This document describes the implementation boundaries maintainers and adapter authors need to understand. It is not a second setup guide; application usage belongs in the README and public API reference.
Controller dispatch
OgEx.Controller.__using__/1 imports the og_card declaration macros,
installs an early query-image plug, removes Phoenix's imported render/3, and
defines a controller-local replacement.
Each declaration compiles into controller-owned metadata and loader dispatch:
controller.__og_ex_declaration__(action)
controller.__og_ex_load__(action, conn, params)The generated loader dispatch calls an explicit declaration loader when one
exists and otherwise delegates to Card.load/2.
For a normal declared request:
- Phoenix calls the page action.
- The action calls its ordinary
render/3without an:ogassign. - the controller replacement finds the declaration through
conn.private.phoenix_action; OgEx.ConfigBuilder.build/4creates anOgEx.Config;OgEx.Head.put_config/2registers metadata injection;- Phoenix renders the normal template.
For a query image request, the generated controller plug checks __og_ex,
loads image assigns, sends the image, and halts before Phoenix invokes the
action.
The legacy render(..., og: declaration) branch remains supported. In that
branch the controller action has already run before dispatch reaches
render/3.
Path integrations
OgEx.Dispatcher is shared by both path integration options.
OgEx.Router.og_ex_routes/1 adds a final unmatched-path route. Application
routes therefore take priority. The handler recognizes
opengraph-image/SIGNATURE and twitter-image/SIGNATURE, recovers the original
page path, resolves it with Phoenix.Router.route_info/4, and dispatches the
controller declaration.
The endpoint alternative:
plug OgEx, router: MyAppWeb.Routerperforms the candidate check before Phoenix routing. Requests that do not resolve to an OgEx declaration pass through unchanged.
These integrations are mutually exclusive. Endpoint initialization inspects a
compiled configured router and logs a warning when its route table already
contains OgEx.Router.
Before loading, the dispatcher stores trusted originating controller, action,
role, page path, signature, and normalized parameters through
OgEx.Request.put_origin/6. Card code accesses those values through stable
top-level helpers rather than the internal Phoenix handler action.
Configuration strategies
Generated cards use:
%OgEx.Config{
strategy: {:generated, CardModule},
card: CardModule,
assigns: assigns,
width: width,
height: height,
format: format
}Direct images use strategy: :existing and retain verified resources or
normalized external sources in :image and :twitter_image.
Consumers branch on :strategy; optional fields should not be used to infer
the lifecycle.
Signatures
The public URL carries a 128-bit truncated HMAC encoded as 22 base64url characters. The signature binds:
- deterministic image identity;
- Open Graph or Twitter image role;
- request path.
Path mode places the signature after an opengraph-image or twitter-image
suffix. Query mode places it in __og_ex. Generated cards emit distinct
role-bound URLs even when both roles render the same card.
The key is domain-separated from Phoenix's secret_key_base. Card assigns,
private paths, and metadata are not serialized into the URL.
OgEx.ConfigBuilder.verify/2 rebuilds role candidates and compares equal-length
signatures with Plug.Crypto.secure_compare/2.
Head injection
OgEx.Head registers a Plug.Conn.register_before_send/2 callback.
OgEx.Meta.to_html/1 escapes dynamic values and creates the tag set.
The callback inserts tags before the first case-insensitive closing </head>.
Non-HTML, streaming, compressed, or otherwise unsupported response shapes are left unchanged.
Generated-card pipeline
OgEx.ImageResponse performs:
- signature verification;
OgEx.HTML.render/1;OgEx.Resources.load/2;- generated-image cache lookup;
- renderer invocation on a miss;
- cache insertion after a complete successful render;
- immutable response headers.
OgEx.HTML converts HEEx safe data to a binary and wraps it in a complete
viewport-sized document. Card-local <style> elements remain in that document.
OgEx.Resources uses Floki to discover unique <img src> values. Each resource
is normalized and loaded, and the returned content fingerprints are sorted for
cache identity.
Image-source boundary
OgEx.Image.normalize/2 classifies source values and constrains local paths.
Path validation rejects:
- absolute paths;
- null bytes;
.and..segments;- symlinks at any traversed segment;
- missing or non-regular files.
The default private root is priv/og_ex inside the endpoint's OTP application.
The public root is that application's priv/static.
OgEx.Image.load/2 calls the configured resource loader and emits resource
telemetry.
Byte verification
OgEx.ResourceLoader.Default.from_bytes/2 calls
OgEx.Native.inspect_image/1. The NIF detects PNG, JPEG, WebP, GIF, and SVG
from content, decodes intrinsic dimensions with Takumi's image stack, and
rejects unsupported or zero-sized images.
Elixir then applies maximum dimension and pixel limits, checks SVG active content, and calculates the SHA-256 content fingerprint.
Remote loading
OgEx.ResourceLoader.Remote validates every request and redirect hop:
- parse the URI;
- require HTTPS unless HTTP is explicitly enabled;
- match the hostname allowlist;
- resolve IPv4 and IPv6 answers;
- reject the hostname if any answer is unsafe;
- rewrite the connection URL to one validated address;
- retain the original hostname for the Host header, TLS SNI, and certificate verification;
- stream under the byte limit and total timeout;
- validate response media type and bytes.
The loader does not reuse headers from the page request. Only conditional ETag and Last-Modified validators retained for the same resource are sent.
Caches
Final images
OgEx.Cache.ETS is a public concurrent-read ETS table owned by a GenServer.
The final key contains the card, version, viewport, format, and resource
fingerprints.
The cache behaviour deliberately uses {:ok, value} | :error, matching
Map.fetch/2. “Miss” appears in telemetry naming, not as a return value.
Remote resources
OgEx.ResourceCache is a protected concurrent-read ETS table. Entries contain
an expiry time, verified resource, and encoded byte count. Expired entries are
retained for conditional revalidation.
Insertion is serialized through the GenServer so entry and byte bounds can be updated consistently. When a bound would be exceeded, the table is cleared.
Native renderer
The Elixir-to-Rust boundary consists of:
OgEx.Native.render_html(html, options)
OgEx.Native.inspect_image(bytes)Both NIFs run on Rustler's dirty CPU scheduler.
The render options map contains primitive viewport values, font binaries, and image binaries. Rust:
- parses HTML with Takumi;
- extracts and parses card-local stylesheets;
- registers fonts;
- decodes registered image resources;
- performs layout and painting;
- writes PNG, JPEG, WebP, or SVG bytes;
- copies the completed result into an Erlang-managed binary.
Rust performs no filesystem or HTTP operations.
Response and error semantics
Valid generated and private image responses receive one-year immutable caching and an ETag.
Invalid signatures return an empty 404. Resource, HTML, or renderer failures
return an empty 503 with Cache-Control: no-store. Failure responses are not
inserted into the final cache.
Direct local resources are currently loaded while the HTML configuration is
built. Missing or invalid files therefore raise ArgumentError at that
boundary. The planned lazy failure isolation is documented in
todo/image_plan.md
and must not be described as current behavior.
Telemetry
The implementation emits:
[:og_ex, :resource, :stop];[:og_ex, :cache, :hit];[:og_ex, :cache, :miss];[:og_ex, :render, :stop];[:og_ex, :render, :exception].
Do not add complete signed URLs, query strings, private paths, request headers, or image bodies to telemetry metadata.