PhoenixKit.Modules.Storage.URLSigner (phoenix_kit v2.41.0)

Copy Markdown View Source

Token-based URL signing for secure file serving.

Generates and verifies secure tokens that prevent file enumeration attacks. Each file instance receives a unique 4-character token based on MD5 hashing.

Token Generation

Token = first 4 chars of MD5(file_uuid:instance_name + secret_key_base)

This ensures:

  • Prevents file enumeration (can't guess URLs)
  • Each instance has unique token
  • Token changes if secret changes
  • Secure comparison prevents timing attacks
  • No user-guessable patterns

Examples

iex> file_uuid = "018e3c4a-9f6b-7890-abcd-ef1234567890"
iex> PhoenixKit.Modules.Storage.URLSigner.signed_url(file_uuid, "thumbnail")
"/file/018e3c4a-9f6b-7890-abcd-ef1234567890/thumbnail/a3f2"

iex> PhoenixKit.Modules.Storage.URLSigner.verify_token(file_uuid, "thumbnail", "a3f2")
true

iex> PhoenixKit.Modules.Storage.URLSigner.verify_token(file_uuid, "thumbnail", "xxxx")
false

Summary

Functions

Generate the 4-character token for a file instance.

The time-window token for a file instance, expiring at expires_at (unix seconds). Without a secret_key_base there is nothing to key it with, and the token is one no check accepts.

Whether token has the time-window shape (it may still be wrong or expired: see verify_private_token/4).

How long one private URL window is, in seconds: the storage_private_url_window_hours setting (default 12, at least 1).

Conditionally adds a "dzi" deep-zoom manifest URL to a urls map.

Generate a signed URL for a file instance.

Checks a time-window token: :ok, :expired (a real token whose window has passed), or :invalid.

Verify a token is valid for the given file and instance.

The content version a file URL carries: the first 16 hex characters of the served instance's checksum (pass the instance or the checksum).

The expiry a URL minted at now gets: now plus at least a quarter of a window, rounded up to the end of that window.

Functions

generate_token(file_uuid, instance_name)

Generate the 4-character token for a file instance.

Used internally by signed_url/2 and verify_token/4.

Arguments

  • file_uuid (binary) - File UUID v7
  • instance_name (binary) - Variant name

Returns

A 4-character hex string token.

Examples

iex> PhoenixKit.Modules.Storage.URLSigner.generate_token("018e3c4a", "thumbnail")
"abc1"

private_token(file_uuid, instance_name, expires_at)

@spec private_token(String.t(), String.t(), integer()) :: String.t()

The time-window token for a file instance, expiring at expires_at (unix seconds). Without a secret_key_base there is nothing to key it with, and the token is one no check accepts.

private_token?(arg1)

@spec private_token?(term()) :: boolean()

Whether token has the time-window shape (it may still be wrong or expired: see verify_private_token/4).

private_url_window_seconds()

@spec private_url_window_seconds() :: pos_integer()

How long one private URL window is, in seconds: the storage_private_url_window_hours setting (default 12, at least 1).

put_dzi_url(urls, file_uuid, mime_type, opts \\ [])

Conditionally adds a "dzi" deep-zoom manifest URL to a urls map.

Returns the map unchanged unless the file is an image and the variant set of its library makes tiles (V205; tiles: says so for a caller that already knows, such as a grid that looked up a whole page at once with VariantSets.tiles_among/1). The signed manifest URL (/tiles/<token>/<file_uuid>-<version>.dzi) is what Tessera fetches to stream tiles; the token and the version live in the path (not a query string) so they survive Tessera's manifest → tile URL derivation.

Pass version: — the file's original instance, or its checksum — so the tiles are cached for good and an edit moves them to new URLs. Without it the legacy unversioned manifest (<file_uuid>.dzi) is emitted, which the server resolves to the current version and never lets a cache keep.

This is the single source of truth for the "dzi" URL — every viewer that builds a file urls map (media browser, detail page, lightbox) pipes through it so the deep-zoom layer is wired consistently.

signed_url(file_uuid, instance_name, opts \\ [])

Generate a signed URL for a file instance.

Arguments

  • file_uuid (binary) - File UUID v7
  • instance_name (binary) - Variant name (e.g., "thumbnail", "medium", "large")

Returns

A relative URL path with prefix: {url_prefix}/file/{file_uuid}/{instance_name}/{token}

Options

  • :version - the served instance (or its checksum): see version/1
  • :locale - passed to Routes.path/2 (default :none)
  • :private - true for a file in a private library (PhoenixKit.Modules.Storage.Libraries.private?/1): the URL carries a time-window token instead of the permanent one, and stops working when its window ends. The file route refuses a private file's permanent token. Mint these only after checking that the viewer may see the file (Storage.authorized_url/4 does both).

Examples

iex> PhoenixKit.Modules.Storage.URLSigner.signed_url("018e3c4a-9f6b-7890", "thumbnail")
"/phoenix_kit/file/018e3c4a-9f6b-7890/thumbnail/abc1"  # With default prefix

verify_private_token(file_uuid, instance_name, token, now \\ System.os_time(:second))

@spec verify_private_token(String.t(), String.t(), String.t(), integer()) ::
  :ok | :expired | :invalid

Checks a time-window token: :ok, :expired (a real token whose window has passed), or :invalid.

verify_token(file_uuid, instance_name, token)

Verify a token is valid for the given file and instance.

Arguments

  • file_uuid (binary) - File UUID v7
  • instance_name (binary) - Variant name
  • token (binary) - 4-character token from URL

Returns

Boolean indicating if token is valid.

Examples

iex> file_uuid = "018e3c4a-9f6b-7890"
iex> token = PhoenixKit.Modules.Storage.URLSigner.generate_token(file_uuid, "thumbnail")
iex> PhoenixKit.Modules.Storage.URLSigner.verify_token(file_uuid, "thumbnail", token)
true

iex> PhoenixKit.Modules.Storage.URLSigner.verify_token(file_uuid, "thumbnail", "xxxx")
false

version(checksum)

@spec version(term()) :: String.t() | nil

The content version a file URL carries: the first 16 hex characters of the served instance's checksum (pass the instance or the checksum).

A URL with a version is content-addressed: FileController serves it with a year-long immutable lifetime only while it names the bytes the variant holds now, and redirects to the current version otherwise — so a cached copy can never be different bytes (an edited or redacted image). Build URLs with signed_url(uuid, variant, version: instance) wherever the instance is at hand; an unversioned URL still works and is revalidated.

window_end(now, window \\ private_url_window_seconds())

@spec window_end(integer(), pos_integer()) :: integer()

The expiry a URL minted at now gets: now plus at least a quarter of a window, rounded up to the end of that window.