Skip to content

Media References — stable URLs for embedded documents

When an editor drops an image or a PDF into a block, the block stores a document reference, not a URL. Granit.Cms.Documents resolves those references — at publish time — into stable, site-scoped asset URLs, so the public SSR renderer never has to mint a (rotating, signed) public link at request time. It is a thin layer over Granit.Documents resolution.

Document public links rotate and expire. If the renderer minted them per request, every page view would hit the document service and leak short-lived signed URLs into the ISR cache. So the resolution moves to publish time, behind a stable indirection:

flowchart LR
    Publish["Page publish"] -->|walk block JSON| P["Reference processor"]
    P -->|resolve| DR["Granit.Documents resolver"]
    P -->|store mapping| Store[("cms_documents_assets")]
    Renderer["SSR renderer"] -->|GET /api/cms/assets/{site}/{doc}| EP["Asset endpoint"]
    EP -->|302| Public["current public link"]

A PublishedDocumentReferenceProcessor runs in the publish pipeline. Guided by each block’s schema, it walks the opaque page JSON, collects every DocumentReference field (top-level, in lists, nested), batch-resolves them through IDocumentRenderResolver, and:

  • persists the (siteId, documentId) → current public URL mapping (IPublishedAssetStore), and
  • injects a _resolved_<field> sibling carrying the stable path /api/cms/assets/{siteId}/{documentId} into the published JSON — never the rotating link.

The renderer reads the stable path and the anonymous endpoint redirects to the current link:

GET /api/cms/assets/{siteId}/{documentId}
→ 302 Found (current public link) | 404 if the document isn't in the site's published content

The endpoint never mints a link — it only redirects to one already resolved at publish. A missing mapping is a 404, not an on-the-fly resolution.

Public links expire, so a nightly job (cms-documents-renew-links, 03:00 UTC) re-resolves stored URLs before they lapse and updates only the rows whose URL changed. The resolver’s cache throttles re-minting to roughly once per link, a few days ahead of expiry — unchanged links are skipped, so the sweep stays cheap.

builder.AddGranitCmsDocumentsEntityFrameworkCore(db => db.UseNpgsql(connectionString));
app.MapGranitCmsDocuments(); // the anonymous /api/cms/assets endpoint

The mapping lives in cms_documents_assets (unique on (SiteId, DocumentId)); the module depends on Granit.Documents resolution for the actual link minting.

  • Granit.Documents — the document/asset module this resolves against
  • Blocks — where DocumentReference fields are declared
  • SEO — the OG image resolves through the same document path