defmodule PhoenixKitWeb.PdfViewerController do @moduledoc """ Serves `phoenix_kit_catalogue`'s vendored PDF.js viewer assets through the router. The catalogue PDF detail page embeds the viewer from `/_pdfjs/...`. Normally those bytes are served by a `Plug.Static` mount that `mix phoenix_kit.install` / `mix phoenix_kit.update` add to the host endpoint. That mount is fragile — a host that bumps the deps without re-running the task (or whose endpoint gets regenerated by a scaffold script) loses it, and the viewer iframe then 404s with a `NoRouteError`. This controller is the router-served fallback. Because it's wired in via `phoenix_kit_routes()` (the same mechanism behind `/file/...` and `//assets/...`), it's present on every host that mounts PhoenixKit at all — no endpoint patch required. When the endpoint `Plug.Static` mount *is* present it runs first (endpoint plugs precede the router), so this only handles the fall-through. Read-only: it streams files from the catalogue app's `priv/static/pdfjs/` and nothing else. Path traversal is rejected via `Path.safe_relative/1` plus an expanded-prefix check. ## Compatibility note The route is mounted at the **root** literal `/_pdfjs/*path` (no URL prefix / locale) so it matches the same URL the catalogue's iframe and the endpoint `Plug.Static` mount use. A host app that already serves its own `/_pdfjs/...` would shadow / be shadowed here — but the path is deliberately underscore-prefixed and module-namespaced to avoid collisions, and the route only compiles in when `phoenix_kit_catalogue` is a dependency. """ use PhoenixKitWeb, :controller @app :phoenix_kit_catalogue @root "priv/static/pdfjs" # Pins the content types the viewer depends on. Most of these also resolve # correctly via `MIME.from_path/1` (the fallback in `content_type/1`); the # genuinely-needed overrides are `.map` → json and `.ftl` → text/plain, which # MIME reports as `application/octet-stream`. The rest are kept explicit so # the viewer's MIME contract is pinned here regardless of the `:mime` version. @content_types %{ ".mjs" => "text/javascript", ".js" => "text/javascript", ".html" => "text/html", ".css" => "text/css", ".json" => "application/json", ".map" => "application/json", ".pdf" => "application/pdf", ".svg" => "image/svg+xml", ".png" => "image/png", ".gif" => "image/gif", ".bcmap" => "application/octet-stream", ".ftl" => "text/plain", ".pfb" => "application/octet-stream", ".otf" => "font/otf", ".ttf" => "font/ttf", ".woff" => "font/woff", ".woff2" => "font/woff2" } @doc """ Streams a single vendored PDF.js asset by its path under `priv/static/pdfjs/`. 404s on traversal attempts, a missing catalogue app, or a non-file target. Sends an `ETag` and honours `If-None-Match` (304) so browsers can revalidate cheaply instead of re-downloading every asset once the `max-age` window lapses. """ def serve(conn, %{"path" => segments}) when is_list(segments) and segments != [] do with {:ok, abs} <- locate(segments), # `lstat` (not `File.regular?/1`) so a symlinked target is rejected # rather than followed — the containment check is purely lexical and # would otherwise let an in-tree symlink escape the vendored dir. {:ok, %File.Stat{type: :regular} = stat} <- File.lstat(abs, time: :posix) do etag = etag_for(stat) conn = put_resp_header(conn, "etag", etag) if fresh?(conn, etag) do send_resp(conn, 304, "") else conn |> put_resp_content_type(content_type(abs)) |> put_resp_header("cache-control", "public, max-age=86400") |> send_file(200, abs) end else _ -> send_resp(conn, 404, "Not found") end end def serve(conn, _params), do: send_resp(conn, 404, "Not found") # Resolve the request path under the catalogue app's vendored dir, # refusing anything that escapes it. defp locate(segments) do with {:ok, rel} <- Path.safe_relative(Path.join(segments)), base when is_binary(base) <- app_root() do abs = Path.join(base, rel) if within?(abs, base), do: {:ok, abs}, else: :error else _ -> :error end end # Platform-agnostic containment check: compare expanded path *segments* # rather than string-prefixing with a hardcoded "/" (which assumes POSIX # separators). `Path.split/1` and `Path.expand/1` are separator-aware, so # this holds on Windows too. Belt-and-suspenders on top of # `Path.safe_relative/1`. defp within?(abs, base) do base_parts = base |> Path.expand() |> Path.split() abs_parts = abs |> Path.expand() |> Path.split() List.starts_with?(abs_parts, base_parts) end defp app_root do Application.app_dir(@app, @root) rescue # Catalogue not loaded — the route shouldn't have been compiled in, # but stay defensive. ArgumentError -> :error end defp content_type(path) do ext = path |> Path.extname() |> String.downcase() Map.get(@content_types, ext) || MIME.from_path(path) end # A strong validator derived from size + mtime — stable for an unchanged file, # changes when the vendored asset is rebuilt. defp etag_for(%File.Stat{size: size, mtime: mtime}) do ~s("#{Integer.to_string(size, 16)}-#{Integer.to_string(mtime, 16)}") end # True when the client already holds a matching representation (so we can 304). defp fresh?(conn, etag) do case get_req_header(conn, "if-none-match") do [] -> false values -> Enum.any?(values, &etag_member?(&1, etag)) end end defp etag_member?(header_value, etag) do header_value |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.any?(&(&1 == etag or &1 == "*")) end end