diff --git a/package-lock.json b/package-lock.json index c118fc1..dfc49df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "astro": "^6.1.8", - "fontdue-js": "3.0.6", + "fontdue-js": "3.1.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, @@ -9866,9 +9866,9 @@ } }, "node_modules/fontdue-js": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fontdue-js/-/fontdue-js-3.0.6.tgz", - "integrity": "sha512-ThSTQ4tue+hxyE9d6TqwQNRGsSKSonixCRQxc2UidQtjOCQMNfAHqFdcXv8yQvUHzw/rmWtHA0b/E+LNWtXpeg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fontdue-js/-/fontdue-js-3.1.0.tgz", + "integrity": "sha512-zFgzLEzJSpq8Eh2ZR3iVEg3jf510gAX2Fefwe2Nur5kTw9kKHub8iQRb12iY3qPWwudCTp+xbWiHXRpD/9GAuw==", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index d511167..1e084f2 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "astro": "^6.1.8", - "fontdue-js": "3.0.6", + "fontdue-js": "3.1.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/src/lib/graphql.ts b/src/lib/graphql.ts index dcb23be..02a6b28 100644 --- a/src/lib/graphql.ts +++ b/src/lib/graphql.ts @@ -5,11 +5,12 @@ import { createFontdueFetch, FontdueNotFoundError } from 'fontdue-js/server'; // error handling, so there's no transport boilerplate at the call sites. // // There's no per-request binding: because src/middleware.ts wraps every request -// in runWithPreview, this fetcher automatically forwards the admin preview token -// when an admin is previewing (revealing unpublished fonts), and sends a -// plain request otherwise. The same is true of every fontdue-js preload helper +// in runWithFontdue, this fetcher automatically forwards the admin preview token +// when an admin is previewing (revealing unpublished fonts) and the visitor's +// node-access token for a collection they've unlocked, and sends a plain request +// otherwise. The same is true of every fontdue-js preload helper // (loadTypeTesterQuery, loadFontdueProviderQuery, …) — call them with just their -// variables and they pick up preview from the ambient context. +// variables and they pick up the ambient context. // // Use it at the top of a page's frontmatter: // diff --git a/src/middleware.ts b/src/middleware.ts index 61b2e0f..1141f5a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,18 +1,22 @@ import { defineMiddleware } from 'astro:middleware'; import { readPreviewToken } from 'fontdue-js/preview'; -import { runWithPreview } from 'fontdue-js/preview/server'; +import { runWithFontdue } from 'fontdue-js/server/middleware'; // Two responsibilities, both per request: // -// 1. Preview. runWithPreview puts the logged-in admin's token (from the -// preview cookie) into an ambient context for the whole render, so every -// GraphQL fetch and fontdue-js preload reveals unpublished ("hidden") fonts -// with no per-page plumbing — and it forces preview responses out of the -// shared cache so an admin render is never served to the public. (This relies -// on middleware running in the same runtime as the render, which is the -// default. If you set `edgeMiddleware: true`, the context can't cross to the -// render — fall back to reading the token here and threading -// previewAuthHeaders(token) into fetches/preloads explicitly.) +// 1. Fontdue request context. runWithFontdue puts two request-scoped tokens +// into an ambient context for the whole render: the logged-in admin's preview +// token (reveals unpublished "hidden" fonts) and the visitor's per-collection +// node-access token (a collection they unlocked with a password). Every +// GraphQL fetch and fontdue-js preload forwards them with no per-page +// plumbing, and it forces a per-visitor response out of the shared cache so +// an admin's — or an unlocked visitor's — render is never served to the +// public. (runWithFontdue is runWithPreview composed with runWithNodeAccess; +// mount either alone if you only need one.) This relies on middleware running +// in the same runtime as the render, which is the default. If you set +// `edgeMiddleware: true`, the context can't cross to the render — fall back to +// reading the cookies here and threading previewAuthHeaders(token) / +// nodeAccessHeadersFromCookie(cookie) into fetches/preloads explicitly. // // 2. CDN caching for public pages. Netlify's edge serves the cached HTML // instantly while regenerating in the background, so the page feels static @@ -27,12 +31,15 @@ export const onRequest = defineMiddleware(async (context, next) => { const previewing = readPreviewToken(context.request.headers.get('cookie')) != null; - const response = await runWithPreview(context.request, next); + const response = await runWithFontdue(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. runWithFontdue 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/api/preview.ts b/src/pages/api/preview.ts index bc3af91..572fd63 100644 --- a/src/pages/api/preview.ts +++ b/src/pages/api/preview.ts @@ -5,7 +5,7 @@ import { handlePreviewRequest } from 'fontdue-js/preview'; // admins by — POSTs a short-lived token here to turn preview // on, and DELETEs to turn it off. handlePreviewRequest sets the preview // cookies (an httpOnly token + a readable marker that the toolbar checks); -// src/middleware.ts wraps each request in runWithPreview, which forwards the +// src/middleware.ts wraps each request in runWithFontdue, which forwards the // token to GraphQL and keeps preview pages out of the shared CDN cache so the // public never sees unpublished fonts. The default path is /api/preview — to // use another path, set config.preview.endpoint on and 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 &&