Skip to main content

lib/omni/ui/files/plug.ex

defmodule Omni.UI.Files.Plug do
  @moduledoc """
  Plug that serves session files over HTTP with signed token authorization.

  Mount in your router with `forward`:

      forward "/omni_files", Omni.UI.Files.Plug

  File URLs use signed tokens that encode the session ID, so only the
  LiveView that created the token can authorize access to a session's files.
  Tokens are generated via `Omni.UI.Files.URL.file_url/3`.

  ## URL format

      GET /omni_files/{token}/{filename}

  ## Options

    * `:max_age` — maximum token age in seconds (default: 86400 = 24 hours)
  """

  @behaviour Plug

  import Plug.Conn

  alias Omni.Tools.Files.FS
  alias Omni.UI.Files.URL

  @default_max_age 86_400

  @impl Plug
  def init(opts) do
    %{
      max_age: Keyword.get(opts, :max_age, @default_max_age)
    }
  end

  @impl Plug
  def call(%Plug.Conn{method: "GET", path_info: [token, raw_filename]} = conn, %{max_age: max_age}) do
    endpoint = conn.private[:phoenix_endpoint]
    filename = URI.decode(raw_filename)

    with {:ok, session_id} <- URL.verify_token(endpoint, token, max_age: max_age),
         fs = FS.new(base_dir: Omni.UI.Sessions.session_files_dir(session_id), nested: false),
         {:ok, path} <- FS.resolve(fs, filename),
         true <- File.regular?(path) do
      content_type = MIME.from_path(filename)

      conn
      |> put_cors_headers()
      |> put_resp_content_type(content_type, nil)
      |> put_resp_header("content-disposition", content_disposition(filename, content_type))
      |> put_resp_header("cache-control", "no-store")
      |> send_file(200, path)
    else
      {:error, reason} when reason in [:invalid, :expired] ->
        send_resp(conn, 401, "Unauthorized")

      {:error, _reason} ->
        send_resp(conn, 404, "Not Found")

      false ->
        send_resp(conn, 404, "Not Found")
    end
  end

  def call(%Plug.Conn{method: "OPTIONS", path_info: [_token, _filename]} = conn, _opts) do
    conn
    |> put_cors_headers()
    |> send_resp(204, "")
  end

  def call(conn, _opts) do
    send_resp(conn, 400, "Bad Request")
  end

  defp put_cors_headers(conn) do
    conn
    |> put_resp_header("access-control-allow-origin", "*")
    |> put_resp_header("access-control-allow-methods", "GET, OPTIONS")
    |> put_resp_header("access-control-allow-headers", "content-type")
  end

  defp content_disposition(filename, content_type) do
    if inline?(content_type) do
      "inline"
    else
      escaped = String.replace(filename, ~S("), ~S(\"))
      ~s(attachment; filename="#{escaped}")
    end
  end

  defp inline?(type) do
    String.starts_with?(type, "text/") or
      String.starts_with?(type, "image/") or
      type in ["application/json", "application/pdf"]
  end
end