tangui

tangui

Hi all!

Since I released plug_http_cache, I’ve been asked a few times how to use it to cache LiveView’s initial responses. I’m not very well versed in LiveView, and I think there are many ways one could shoot himself in the foot doing that. However, it seems to be there are some reasonable use-cases for doing that.

One of them is to use http caching to improve SEO ranking for public pages that have little interactivity whether the user is logged in or not. For instance, one LiveView page could render financial charts in real-time as an aside, while the main content is almost static.

I tried plug_http_cache and it almost works out-of-the box:

  • initial mount replies with a 200 response, which is cached
  • second mount replies with a 101 HTTP response, which is not cacheable. The websocket connection is established

So far so good, but LiveView uses a CSRF token that is cached and sent along the second request - and LiveView WS connection fails when opening the same page in another browser with:

[debug] LiveView session was misconfigured or the user token is outdated.

1) Ensure your session configuration in your endpoint is in a module attribute:

    @session_options [
      ...
    ]

2) Change the `plug Plug.Session` to use said attribute:

    plug Plug.Session, @session_options

3) Also pass the `@session_options` to your LiveView socket:

    socket "/live", Phoenix.LiveView.Socket,
      websocket: [connect_info: [session: @session_options]]

4) Ensure the `protect_from_forgery` plug is in your router pipeline:

    plug :protect_from_forgery

5) Define the CSRF meta tag inside the `<head>` tag in your layout:

    <meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />

6) Pass it forward in your app.js:

    let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
    let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}});

I figured out it’s possible to disable checking of this CSRF token when configuring the live endpoint, at the cost of disabling live sessions:

endpoint.ex before:

socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]

endpoint.ex after:

socket "/live", Phoenix.LiveView.Socket, websocket: []

My first batch of questions would be:

  • is it safe to do so?
  • what are the other side-effects, other than disabling live session?
  • is that possible to have 2 sockets: one where live sessions are disabled for this specific use-case, and another one with session information enabled?

Another thing I noticed is that some information related to LiveView is stored in the HTML upon initial rendering:

<div
data-phx-main
data-phx-session="SFMyNTY.g2gDaAJhBXQAAAAIdwJpZG0AAAAUcGh4LUY1Q0hZRndnM2I0N0lRSkN3B3Nlc3Npb250AAAAAHcKcGFyZW50X3BpZHcDbmlsdwR2aWV3dy1FbGl4aXIuSHR0cENhY2hlV2l0aExpdmV2aWV3V2ViLlVzZXJMaXZlLlNob3d3BnJvdXRlcncmRWxpeGlyLkh0dHBDYWNoZVdpdGhMaXZldmlld1dlYi5Sb3V0ZXJ3DGxpdmVfc2Vzc2lvbmgCdwdkZWZhdWx0bggAt3Fq2ugnkBd3CHJvb3RfcGlkdwNuaWx3CXJvb3Rfdmlld3ctRWxpeGlyLkh0dHBDYWNoZVdpdGhMaXZldmlld1dlYi5Vc2VyTGl2ZS5TaG93bgYA-_cJWYsBYgABUYA.25eSbZuNO6oxbt4AEcignF3HVsHExGqPcoc8BI8E5nI"
data-phx-static="SFMyNTY.g2gDaAJhBXQAAAADdwJpZG0AAAAUcGh4LUY1Q0hZRndnM2I0N0lRSkN3BWZsYXNodAAAAAB3CmFzc2lnbl9uZXdqbgYA-_cJWYsBYgABUYA.k0KyuD5mMq4arpXYUO2poQAdDrnFipHxVJ1D6B32tyI" id="phx-F5CHYFwg3b47IQJC">

After decoding it, it seems it doesn’t contain private session data, which remains stored in the cookie. However, is there any reason this data shouldn’t be served to users other than the one who initiated the request in the first place?

Lastly, I’d like to write guidance on how to handle authenticated LiveView’s with static content. The idea is to render only public data on initial mount, and load private, session-based data only when the LiveView goes live:

def mount(params, session, socket) do
  if not connected?(socket) do
    # Initial request: here we render only **public** data.
    # The response can be cached by shared caches
    products = Products.list(params)

    {:ok, assign(socket, products: products)}
  else
    # Check user authentication
    socket =
      case session do
        %{"user_id" => user_id} ->
          assign(socket, user: Accounts.get_user!(user_id))

        _ ->
          socket
      end

    # Perform stateful stuff that don't depend on the user
    Phoenix.PubSub.subscribe(MyApp.PubSub, "product-updates")

    if socket.assigns.user do
      # Prepare rendering for authenticated users
      socket =
        socket
        |> assign(:pending_orders, User.get_pending_orders!(socket.assigns.user))
        |> assign(:pending_notifications, User.get_pending_notifications(socket.assigns.user))

      {:ok, socket}
    else
      # Prepare rendering for anonymous users
      {:ok, socket}
    end
  end
end

What do you think? Did I miss something? Any security issue I didn’t anticipate?

Cheers!

Showing Posts 18 to 9

tangui

tangui OP

I guess if you can tell your CDN to browse your sitemap then you can cache your pages this way. I was thinking of warming locally the cache by simulating user requests using something like Invoke Phoenix endpoint programmatically at runtime. Will probably write a blog post about it.

By the way, if you’re using a cloud application platform (Gigalixir, render, fly.io…) and your users are always near one of your servers, then maybe you no longer need a CDN to cache pages. It’d be interesting to make measures and have feedback about it.

BartOtten

BartOtten

Nice. I was thinking of a static site generator for the static part so the initial pages can be on a CDN (and socket activated on first interaction?). Seems this lib and the enhancement it brought to the core makes a good base.

tangui

tangui OP

The PR has been merged :partying_face: Thanks to the Phoenix team :heart: The new option will be available as of Phoenix 1.7.15

I’ve updated the instructions and the demo app.

Happy caching!

tangui

tangui OP

PR created: https://github.com/phoenixframework/phoenix/pull/5952

Feel free to comment, improve the idea.

LostKobrakai

LostKobrakai

Seems like I got the actual implementation mixed up with how it works conceptually and what you eventually get in mount/3. But at least in the current implementation of socket you still need a csrf token to get access to the cookie. Iirc earlier versions didn’t allow that at all.

tangui

tangui OP

Unless I’m terribly wrong, this is not the case: only data set via the :session attribute of a live_session is stored in the HTML, and not the session data from the cookie. I have the feeling you assume the websocket connection doesn’t include the cookie, which is wrong - it does. Hence the CSWH issue discussed in the blog post.

Let’s check it out after setting 1) session cookie data 2) setting live_session :default, session: %{"titi" => "toto"} do:

Rendered HTML div root liveview:

<div id="phx-F_F6hHTMdNAciQDC" data-phx-main data-phx-session="SFMyNTY.g2gDaAJhBXQAAAAIdwJpZG0AAAAUcGh4LUZfRjZoSFRNZE5BY2lRREN3B3Nlc3Npb250AAAAAW0AAAAEdGl0aW0AAAAEdG90b3cKcGFyZW50X3BpZHcDbmlsdwZyb3V0ZXJ3I0VsaXhpci5DYWNoZWFibGVMaXZldmlld3NXZWIuUm91dGVydwxsaXZlX3Nlc3Npb25oAncHZGVmYXVsdG4IAIiG5dqDevEXdwR2aWV3dytFbGl4aXIuQ2FjaGVhYmxlTGl2ZXZpZXdzV2ViLk1haW5MaXZlLkluZGV4dwlyb290X3ZpZXd3K0VsaXhpci5DYWNoZWFibGVMaXZldmlld3NXZWIuTWFpbkxpdmUuSW5kZXh3CHJvb3RfcGlkdwNuaWxuBgD5DpazkQFiAAFRgA.mhCxhz3RtfaIh7TRDV1I55h2Ra8h4o5060cFCL_x8RE" data-phx-static="SFMyNTY.g2gDaAJhBXQAAAADdwJpZG0AAAAUcGh4LUZfRjZoSFRNZE5BY2lRREN3BWZsYXNodAAAAAB3CmFzc2lnbl9uZXdqbgYA-g6Ws5EBYgABUYA.M75Zd_M-hrnOsnCrWy15ZFFKwzUJrwkJtX9dXg_iow4"><header class="relative top-0 right-0 left-0 p-1 bg-gray-100">

Decoding both values:

iex(17)> data_phx_session |> String.split(".") |> Enum.map(&Base.url_decode64!(&1, padding: false)) |> Enum.drop(1) |> Enum.take(1) |> Enum.map(&:erlang.binary_to_term/1)
[
  {{5,
    %{
      id: "phx-F_F6hHTMdNAciQDC",
      session: %{"titi" => "toto"},
      parent_pid: nil,
      router: CacheableLiveviewsWeb.Router,
      live_session: {:default, 1725294838991390344},
      view: CacheableLiveviewsWeb.MainLive.Index,
      root_view: CacheableLiveviewsWeb.MainLive.Index,
      root_pid: nil
    }}, 1725294841593, 86400}
]
iex(18)> data_phx_static |> String.split(".") |> Enum.map(&Base.url_decode64!(&1, padding: false)) |> Enum.drop(1) |> Enum.take(1) |> Enum.map(&:erlang.binary_to_term/1)
[
  {{5, %{id: "phx-F_F6hHTMdNAciQDC", flash: %{}, assign_new: []}},
   1725294841594, 86400}
]

There’s no trace of session (cookie) data, only the values set by the :session option of the live session.

LostKobrakai

LostKobrakai

LV sessions are merged data between what is in the plug/http session store and what is provided by the :session option on live_session. The merged data is put on the html tags to be sent back by LV. It’s a bit unfortunate that LV calls that data in the html tag session given it’s technically completely unrelated to plug/http level session data.

This basically affects any data you’d get in mount/3s second parameter. So e.g. (not different to http level caching) needing to deal with logged in users would likely require the usage of the session.

tangui

tangui OP

It’s indeed used to store the additional session data set by the :session option of Phoenix.LiveView.Router.live_session/3. Is this what you are referring to?

Or user data from the session? Both will be discussed in the next blog post :slight_smile:

LostKobrakai

LostKobrakai

Afaik the csrf token is not just used to secure the websocket connection against CSWH, but also to sign the session data put into the html. Though I guess if you want to cache html you cannot put per user data on the html anyways. At that point removing the csrf token is indeed viable and should be supported without needing to adjust any of the 3rd party codebases involved - e.g. I did that here: Do not use use a session cookie · LostKobrakai/hex-bobs-list@f86dde5 · GitHub

Where Next? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
nseaSeb
Hello, I know there is an approach for handling lists that allows for optimized traversal, but I can’t recall the specific method (somet...
New
kpanic
Hi everyone, I am toying with the idea of building a “match maker” for giving personal help to people that wants to start coding. I sta...
New
brecabral
Documentation While reading the Scoped Routes section, I noticed that the documentation currently refers to a problem without explainin...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
asweet-confluent
I recently noticed that Elixir’s Logger defaults its primary log level to :debug when no :logger, :level application configuration is pre...
New
ryanwinchester
apply_graft/2 doesn’t rewrite an add_many sub-workflow’s deps on an add step. Grafted jobs cancel with “upstream job was deleted” Version...
New

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
mhanberg
Hi everyone! The first release candidate for the Expert language server project is now available! We’ve published a press release detai...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews