Packages
phoenix_kit
1.7.174
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_web/controllers/pdf_viewer_controller.ex
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
`/<prefix>/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