From 3ca870d9bcba26e688f336c0c4cf6e7f47f9b73d Mon Sep 17 00:00:00 2001 From: Tom Conroy Date: Thu, 25 Jun 2026 15:17:55 +0200 Subject: [PATCH 1/3] FD-773: render a password form for password-protected collections Catch FontduePasswordProtectedError from the collection fetch and render instead of a 404. The runWithPreview middleware already forwards the visitor's node-access token, so no extra wiring; the CDN-cache guard now also skips responses runWithPreview marked no-store, so an unlocked render is never cached for everyone. Requires the FD-773 fontdue-js release; pair with the version bump. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/middleware.ts | 7 +- src/pages/fonts/[slug].astro | 181 +++++++++++++++++++++-------------- 2 files changed, 114 insertions(+), 74 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index 61b2e0f..11b2c52 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -29,10 +29,13 @@ export const onRequest = defineMiddleware(async (context, next) => { const response = await runWithPreview(context.request, next); - // Only public, non-preview HTML gets the long-lived CDN cache. Preview - // responses were already marked uncacheable by runWithPreview. + // Only public HTML gets the long-lived CDN cache. runWithPreview already + // marked per-visitor responses (admin preview, or a collection this visitor + // unlocked via the node-access cookie) `no-store`; don't override that, or an + // unlocked render could be cached and served to someone who hasn't unlocked. if ( previewing || + response.headers.get('cache-control')?.includes('no-store') || response.status !== 200 || context.url.pathname.startsWith('/api/') || !response.headers.get('content-type')?.includes('text/html') diff --git a/src/pages/fonts/[slug].astro b/src/pages/fonts/[slug].astro index b7c4911..d8dd95f 100644 --- a/src/pages/fonts/[slug].astro +++ b/src/pages/fonts/[slug].astro @@ -5,6 +5,8 @@ import CharacterViewer, { loadCharacterViewerQuery, } from 'fontdue-js/CharacterViewer'; import BuyButton, { loadBuyButtonQuery } from 'fontdue-js/BuyButton'; +import NodePasswordForm from 'fontdue-js/NodePasswordForm'; +import { FontduePasswordProtectedError } from 'fontdue-js/server'; import { fetchGraphql } from '../../lib/graphql'; import FontDoc from '../../queries/Font.graphql?raw'; import type { FontQuery, FontQueryVariables } from '../../queries/operations-types'; @@ -18,24 +20,43 @@ if (!slug) return Astro.redirect('/'); // this page resolves even for an unpublished collection and its islands // reveal unpublished styles — preview rides the ambient context, so nothing // is threaded here (see src/lib/graphql.ts). -const [ - fontData, - typeTestersPreload, - characterViewerPreload, - buyButtonPreload, -] = await Promise.all([ - fetchGraphql('Font', FontDoc, { slug }), - loadTypeTestersQuery({ collectionSlug: slug }), - loadCharacterViewerQuery({ collectionSlug: slug }), - loadBuyButtonQuery({ collectionSlug: slug }), -]); - -const collection = fontData.viewer.slug?.fontCollection; -if (!collection) { +// +// A password-protected collection the visitor hasn't unlocked throws +// FontduePasswordProtectedError — render the password form island instead of a +// 404. The node-access token the form stores then rides the ambient context +// (src/middleware.ts) on reload, just like preview, and the collection resolves. +let locked = false; +let collection: NonNullable< + FontQuery['viewer']['slug'] +>['fontCollection'] = null; +let typeTestersPreload; +let characterViewerPreload; +let buyButtonPreload; + +try { + const [fontData, tt, cv, bb] = await Promise.all([ + fetchGraphql('Font', FontDoc, { slug }), + loadTypeTestersQuery({ collectionSlug: slug }), + loadCharacterViewerQuery({ collectionSlug: slug }), + loadBuyButtonQuery({ collectionSlug: slug }), + ]); + collection = fontData.viewer.slug?.fontCollection ?? null; + typeTestersPreload = tt; + characterViewerPreload = cv; + buyButtonPreload = bb; +} catch (error) { + if (error instanceof FontduePasswordProtectedError) { + locked = true; + } else { + throw error; + } +} + +if (!locked && !collection) { return new Response('Not found', { status: 404 }); } -const feature = collection.featureStyle; +const feature = collection?.featureStyle; const featureSrc = feature?.webfontSources?.find((s) => s?.format === 'woff2'); const featureFontFace = feature && featureSrc?.url ? `@font-face { @@ -45,72 +66,88 @@ const featureFontFace = feature && featureSrc?.url }` : ''; -const heroImage = collection.images?.find((img) => img && img.url); -const pageTitle = collection.pageMetadata?.title ?? collection.name; +const heroImage = collection?.images?.find((img) => img && img.url); +const pageTitle = locked + ? 'Password required' + : (collection?.pageMetadata?.title ?? collection?.name); --- - {featureFontFace &&