From 129ece987e93e6c3ba032f6123b0a33e53679c6a Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 27 Oct 2026 07:24:01 -0700 Subject: [PATCH 1/2] Add public Open Graph previews with generated branding --- .changeset/local-open-graph-config.md | 6 + apps/web/e2e/auth.e2e.ts | 2 +- apps/web/e2e/bootstrap.e2e.ts | 4 +- apps/web/e2e/config-update.e2e.ts | 4 +- apps/web/e2e/configured-images.e2e.ts | 2 +- apps/web/e2e/favicon.e2e.ts | 6 +- apps/web/e2e/open-graph.e2e.ts | 188 +++++++++++++ apps/web/e2e/signin.e2e.ts | 14 +- apps/web/e2e/site-header-logo.e2e.ts | 16 +- apps/web/package.json | 2 + apps/web/src/app.d.ts | 5 + .../src/lib/components/DocumentHead.svelte | 25 ++ apps/web/src/lib/search/engine.test.ts | 4 +- apps/web/src/lib/server/auth/access.ts | 11 +- apps/web/src/lib/server/auth/index.test.ts | 42 ++- .../src/lib/server/directory/content.test.ts | 6 +- .../src/lib/server/directory/snapshot.test.ts | 17 +- apps/web/src/lib/server/directory/snapshot.ts | 4 +- apps/web/src/lib/server/document.test.ts | 31 +++ apps/web/src/lib/server/document.ts | 32 +++ .../web/src/lib/server/images/open-graph.html | 41 +++ .../server/images/render-open-graph.test.ts | 121 +++++++++ .../lib/server/images/render-open-graph.ts | 84 ++++++ apps/web/src/routes/(protected)/+page.svelte | 2 +- .../routes/(public)/favicon/server.test.ts | 4 +- .../routes/(public)/images/og.png/+server.ts | 29 ++ .../(public)/images/og.png/server.test.ts | 250 ++++++++++++++++++ .../src/routes/(public)/logo/server.test.ts | 4 +- .../src/routes/(public)/signin/+page.svelte | 2 +- apps/web/src/routes/+layout.server.ts | 28 +- apps/web/src/routes/+layout.svelte | 5 +- apps/web/src/routes/layout.test.ts | 218 ++++++++++++--- apps/web/static/favicon.png | Bin 0 -> 135645 bytes apps/web/static/favicon.svg | 1 - docs/config.md | 5 +- docs/directory.example.yaml | 2 + packages/config/src/config.schema.json | 12 +- packages/config/src/editor-schema.test.ts | 12 +- packages/config/src/load.test.ts | 105 +++++++- packages/config/src/schema.test.ts | 29 +- packages/config/src/schema.ts | 14 +- pnpm-lock.yaml | 123 +++++++++ pnpm-workspace.yaml | 12 + 43 files changed, 1400 insertions(+), 124 deletions(-) create mode 100644 .changeset/local-open-graph-config.md create mode 100644 apps/web/e2e/open-graph.e2e.ts create mode 100644 apps/web/src/lib/components/DocumentHead.svelte create mode 100644 apps/web/src/lib/server/document.test.ts create mode 100644 apps/web/src/lib/server/document.ts create mode 100644 apps/web/src/lib/server/images/open-graph.html create mode 100644 apps/web/src/lib/server/images/render-open-graph.test.ts create mode 100644 apps/web/src/lib/server/images/render-open-graph.ts create mode 100644 apps/web/src/routes/(public)/images/og.png/+server.ts create mode 100644 apps/web/src/routes/(public)/images/og.png/server.test.ts create mode 100644 apps/web/static/favicon.png delete mode 100644 apps/web/static/favicon.svg diff --git a/.changeset/local-open-graph-config.md b/.changeset/local-open-graph-config.md new file mode 100644 index 0000000..121cdd3 --- /dev/null +++ b/.changeset/local-open-graph-config.md @@ -0,0 +1,6 @@ +--- +'@directory/web': minor +'@directory/cli': minor +--- + +Add public Open Graph previews with a generated 1200×630 PNG using the site title, favicon and appearance colors. Support an optional `site.og_image_path` that links directly to a supplied local image. Keep private directory content out of previews and publish metadata on sign-in pages. diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 2720ed5..21e9c29 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -191,7 +191,7 @@ test('enabled authentication gates HTML, actual Kit data, HEAD, unknown routes a expect(scriptPath).toBeDefined(); for (const method of ['GET', 'HEAD']) { for (const [path, contentType] of [ - ['/favicon.svg', 'image/svg+xml'], + ['/favicon.png', 'image/png'], [`/${scriptPath!}`, 'javascript'] ] as const) { const asset = await server.fetch(path, { method }); diff --git a/apps/web/e2e/bootstrap.e2e.ts b/apps/web/e2e/bootstrap.e2e.ts index 6c8ab1a..bfb2698 100644 --- a/apps/web/e2e/bootstrap.e2e.ts +++ b/apps/web/e2e/bootstrap.e2e.ts @@ -16,8 +16,8 @@ test.describe('without JavaScript', () => { await expect(page.getByRole('heading', { name: 'Directory' })).toBeVisible(); await expect(page.getByText("A small directory for your family's homelab.")).toBeVisible(); await expect(page.getByText('No bookmarks yet.')).toBeVisible(); - const favicon = await request.get('/favicon.svg'); + const favicon = await request.get('/favicon.png'); expect(favicon.ok()).toBe(true); - expect(favicon.headers()['content-type']).toContain('image/svg+xml'); + expect(favicon.headers()['content-type']).toContain('image/png'); }); }); diff --git a/apps/web/e2e/config-update.e2e.ts b/apps/web/e2e/config-update.e2e.ts index 1729935..d35ba5d 100644 --- a/apps/web/e2e/config-update.e2e.ts +++ b/apps/web/e2e/config-update.e2e.ts @@ -174,7 +174,7 @@ test('an accepted edit leaves the current document and typed URL alone until nat await expect(search(page)).toBeEnabled(); await expect.poll(() => versionRequests.length).toBe(1); const original = await readVersion(page.request); - await expect(page.locator('header img')).toHaveAttribute('src', '/favicon.svg'); + await expect(page.locator('header img')).toHaveAttribute('src', '/favicon.png'); await expect.poll(() => imageRequests.length).toBe(1); await page.evaluate(() => { document.body.dataset.updateDocument = 'original'; @@ -204,7 +204,7 @@ test('an accepted edit leaves the current document and typed URL alone until nat await expect(search(page)).toBeFocused(); await expect(search(page).locator('..')).toHaveCSS('outline-color', 'rgb(19, 87, 155)'); await expect(page.locator('body')).toHaveAttribute('data-update-document', 'original'); - await expect(page.locator('header img')).toHaveAttribute('src', '/favicon.svg'); + await expect(page.locator('header img')).toHaveAttribute('src', '/favicon.png'); expect(imageRequests).toHaveLength(1); await expect(page).toHaveURL(address); const checksAtNotice = versionRequests.length; diff --git a/apps/web/e2e/configured-images.e2e.ts b/apps/web/e2e/configured-images.e2e.ts index 2ab2fd1..63d0979 100644 --- a/apps/web/e2e/configured-images.e2e.ts +++ b/apps/web/e2e/configured-images.e2e.ts @@ -135,7 +135,7 @@ test('mounted files and generated bookmark logos require a real session, includi } } } - for (const path of ['/healthz', '/favicon.svg', '/signin']) { + for (const path of ['/healthz', '/favicon.png', '/signin']) { const response = await server.fetch(path); expect(response.status).toBe(200); await expectNoPrivateData(response); diff --git a/apps/web/e2e/favicon.e2e.ts b/apps/web/e2e/favicon.e2e.ts index 31ffb5a..3ac1302 100644 --- a/apps/web/e2e/favicon.e2e.ts +++ b/apps/web/e2e/favicon.e2e.ts @@ -276,10 +276,10 @@ test('valid reloads keep the alias but change bytes; invalid and auth-changing c await expect.poll(async () => (await server.fetch('/favicon')).status).toBe(404); await expectNotFound(server); await page.reload(); - await expect(page.locator('head link[rel="icon"]')).toHaveAttribute('href', '/favicon.svg'); - const bundled = await server.fetch('/favicon.svg'); + await expect(page.locator('head link[rel="icon"]')).toHaveAttribute('href', '/favicon.png'); + const bundled = await server.fetch('/favicon.png'); expect(bundled.status).toBe(200); - expect(bundled.headers.get('content-type')).toContain('image/svg+xml'); + expect(bundled.headers.get('content-type')).toContain('image/png'); await expectRawPrivate(server); } finally { await context.close(); diff --git a/apps/web/e2e/open-graph.e2e.ts b/apps/web/e2e/open-graph.e2e.ts new file mode 100644 index 0000000..f7f349a --- /dev/null +++ b/apps/web/e2e/open-graph.e2e.ts @@ -0,0 +1,188 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { configuration, privateSentinels, siteTitle } from './support/configuration.js'; +import { startServer, type TestServer } from './support/server.js'; + +const generatedPath = '/images/og.png'; +const svgPath = '/public/share.svg'; +const nextPath = '/public/next.svg'; +const svg = ''; +const privatePaths = [ + '/public/logo.svg', + '/public/link.svg', + '/public/tab.svg', + '/public/other.svg' +]; +let directory: string; +let publicDir: string; + +function brandedConfiguration(image: string) { + return configuration() + .replace( + 'site:\n', + `site:\n og_image_path: ${image}\n logo_path: ${privatePaths[0]}\n favicon_path: ${privatePaths[2]}\n` + ) + .replace( + ' household:\n', + ` household:\n group: PRIVATE_OG_GROUP\n logo_path: ${privatePaths[1]}\n` + ); +} + +function expectNoPrivateData(text: string) { + for (const value of [...privateSentinels, 'PRIVATE_OG_GROUP', ...privatePaths, publicDir]) { + expect(text).not.toContain(value); + } +} + +// Read the actual SSR head, not a hydrated DOM. +function metadata(html: string) { + const head = html.match(/]*>([\s\S]*?)<\/head>/i)?.[1]; + expect(head).toBeDefined(); + expect(head).not.toMatch(/]*(?:name|property)=["'](?:twitter:|og:description)/i); + const entries = [ + ...head!.matchAll(/]*property="(og:[^"]*)"[^>]*content="([^"]*)"[^>]*>/g) + ].map((match) => [match[1], match[2]]); + expect(new Set(entries.map(([property]) => property)).size).toBe(entries.length); + const tags = Object.fromEntries(entries); + expectNoPrivateData(JSON.stringify(tags)); + return tags; +} + +async function ssr(server: TestServer, path = '/signin', headers: Record = {}) { + const response = await server.fetch(path, { headers: { accept: 'text/html', ...headers } }); + expect(response.status).toBe(200); + return response.text(); +} + +function expectGeneratedMetadata(html: string, origin: string) { + expect(metadata(html)).toEqual({ + 'og:type': 'website', + 'og:title': siteTitle, + 'og:site_name': siteTitle, + 'og:url': `${origin}/`, + 'og:image': `${origin}${generatedPath}`, + 'og:image:type': 'image/png', + 'og:image:alt': `Preview of ${siteTitle}`, + 'og:image:width': '1200', + 'og:image:height': '630', + ...(origin.startsWith('https:') ? { 'og:image:secure_url': `${origin}${generatedPath}` } : {}) + }); +} + +async function expectStatic(server: TestServer, path: string) { + const response = await server.fetch(path); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/svg+xml'); + expect(await response.text()).toBe(svg); +} + +async function expectPrivateImages(server: TestServer, extra: string[] = []) { + for (const path of [...privatePaths, '/bookmarks/household/logo', ...extra]) { + for (const method of ['GET', 'HEAD']) { + const response = await server.fetch(path, { method }); + expect(response.status, `${method} ${path}`).toBe(401); + expect(response.headers.has('location')).toBe(false); + const body = await response.text(); + if (method === 'HEAD') expect(body).toBe(''); + expectNoPrivateData(body); + } + } +} + +test.setTimeout(60_000); +test.beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'directory-open-graph-')); + publicDir = join(directory, 'public'); + await mkdir(publicDir); + for (const path of [...privatePaths, nextPath]) { + await writeFile(join(publicDir, path.slice('/public/'.length)), svg); + } +}); +test.afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +test('auth redirects home while sign-in SSR and generated PNG GET/HEAD remain public', async () => { + const origin = 'https://directory.example.test'; + const server = await startServer({ configuration: configuration(), publicDir, origin }); + try { + await server.ready(); + const home = await server.fetch('/', { headers: { accept: 'text/html' } }); + expect(home.status).toBe(303); + expect(new URL(home.headers.get('location')!, server.url).pathname).toBe('/signin'); + expectNoPrivateData(await home.text()); + const html = await ssr(server); + expectNoPrivateData(html); + expect(html).not.toMatch(/]*name=["']description/i); + expect(html).not.toContain(server.path); + expectGeneratedMetadata(html, origin); + + const response = await server.fetch(generatedPath); + expect(response.status).toBe(200); + const bytes = Buffer.from(await response.arrayBuffer()); + expect(bytes.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(bytes.toString('ascii', 12, 16)).toBe('IHDR'); + expect(bytes.readUInt32BE(16)).toBe(1200); + expect(bytes.readUInt32BE(20)).toBe(630); + const head = await server.fetch(generatedPath, { method: 'HEAD' }); + expect(head.status).toBe(200); + for (const result of [response, head]) { + expect(result.headers.get('content-type')).toBe('image/png'); + expect(result.headers.get('content-length')).toBe(String(bytes.length)); + expect(result.headers.get('x-content-type-options')).toBe('nosniff'); + expect(result.headers.has('location')).toBe(false); + } + expect(await head.text()).toBe(''); + } finally { + expect(await server.stop()).toEqual({ code: 0, signal: null }); + } +}); + +test('missing static image recovers without reload; one reload moves only its public access', async () => { + const server = await startServer({ configuration: brandedConfiguration(svgPath), publicDir }); + try { + await server.ready(); + expectGeneratedMetadata(await ssr(server), server.origin); + await writeFile(join(publicDir, 'share.svg'), svg); + const html = await ssr(server); + expectNoPrivateData(html); + expect(metadata(html)).toMatchObject({ + 'og:image': `${server.origin}${svgPath}`, + 'og:image:type': 'image/svg+xml' + }); + expect(metadata(html)).not.toHaveProperty('og:image:width'); + expect(metadata(html)).not.toHaveProperty('og:image:height'); + await expectStatic(server, svgPath); + await expectPrivateImages(server, [nextPath]); + + await server.replace(brandedConfiguration(nextPath)); + await expect + .poll(async () => metadata(await ssr(server))['og:image']) + .toBe(`${server.origin}${nextPath}`); + await expectStatic(server, nextPath); + // Selecting another OG image must not widen the static-image privacy scope. + await expectPrivateImages(server, [svgPath]); + } finally { + expect(await server.stop()).toEqual({ code: 0, signal: null }); + } +}); + +for (const trustedProtocol of [false, true]) { + test(`no-auth SSR uses ${trustedProtocol ? 'HTTPS from the explicitly trusted header' : 'HTTP by default'}`, async () => { + const server = await startServer({ + configuration: configuration(''), + publicDir, + ...(trustedProtocol ? { env: { PROTOCOL_HEADER: 'x-forwarded-proto' } } : {}) + }); + try { + await server.ready(); + const html = await ssr(server, '/', { 'x-forwarded-proto': 'https' }); + const origin = trustedProtocol ? server.url.replace('http:', 'https:') : server.url; + expectGeneratedMetadata(html, origin); + } finally { + expect(await server.stop()).toEqual({ code: 0, signal: null }); + } + }); +} diff --git a/apps/web/e2e/signin.e2e.ts b/apps/web/e2e/signin.e2e.ts index eaf4c05..02aaa13 100644 --- a/apps/web/e2e/signin.e2e.ts +++ b/apps/web/e2e/signin.e2e.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, test, type Locator } from '@playwright/test'; @@ -368,7 +368,7 @@ for (const source of [null, '/public/missing.svg', '/public/invalid.svg']) { await expect(image).toHaveAttribute('src', '/logo'); await expect(image).toHaveJSProperty('complete', true); await expect(image).toHaveJSProperty('naturalWidth', 0); - } else await expectDecoded(image, '/favicon.svg'); + } else await expectDecoded(image, '/favicon.png'); expect(logoRequests).toBe(source === null ? 0 : 1); await expect(page.getByRole('heading')).toHaveText(`Sign in to ${siteTitle}`); await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeEnabled(); @@ -376,7 +376,9 @@ for (const source of [null, '/public/missing.svg', '/public/invalid.svg']) { 'href', `${app.url}/favicon` ); - expect(await (await app.fetch('/favicon.svg')).text()).toContain('🗂️'); + expect(Buffer.from(await (await app.fetch('/favicon.png')).arrayBuffer())).toEqual( + await readFile(new URL('../static/favicon.png', import.meta.url)) + ); expect((await app.fetch('/logo')).status).toBe(404); } finally { await context.close(); @@ -416,15 +418,15 @@ test('sign-in catches a logo failure before hydration and stops after a failed f await expect(image).toHaveJSProperty('complete', true); await expect(image).toHaveJSProperty('naturalWidth', 0); releaseScript(); - await expectDecoded(image, '/favicon.svg'); + await expectDecoded(image, '/favicon.png'); await page.unroute('**/_app/**/*.js'); let fallbackRequests = 0; - await page.route('**/favicon.svg', async (route) => { + await page.route('**/favicon.png', async (route) => { fallbackRequests++; await route.abort(); }); await page.reload(); - await expect(image).toHaveAttribute('src', '/favicon.svg'); + await expect(image).toHaveAttribute('src', '/favicon.png'); await expect(image).toHaveJSProperty('complete', true); await expect(image).toHaveJSProperty('naturalWidth', 0); await page.waitForTimeout(200); diff --git a/apps/web/e2e/site-header-logo.e2e.ts b/apps/web/e2e/site-header-logo.e2e.ts index 127d295..a84240c 100644 --- a/apps/web/e2e/site-header-logo.e2e.ts +++ b/apps/web/e2e/site-header-logo.e2e.ts @@ -115,14 +115,14 @@ for (const javaScriptEnabled of [true, false]) { const html = await (await app.fetch('/')).text(); expect(html).toMatch( new RegExp( - `]*src="${source ?? '/favicon.svg'}"[^>]*alt=""[^>]*width="48"[^>]*height="48"[^>]*loading="eager"` + `]*src="${source ?? '/favicon.png'}"[^>]*alt=""[^>]*width="48"[^>]*height="48"[^>]*loading="eager"` ) ); const context = await browser.newContext({ javaScriptEnabled }); try { const page = await context.newPage(); await page.goto(app.url); - await expectDecoded(page.locator('header img'), source ?? '/favicon.svg'); + await expectDecoded(page.locator('header img'), source ?? '/favicon.png'); await expect(page.locator('header h1')).toHaveText(title); await expect(page.locator('header .description')).toHaveText(description); await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0); @@ -160,7 +160,7 @@ test('a configured image that fails before hydration falls back once in the same await expect.poll(() => heldScripts).toBeGreaterThan(0); const ssrImage = await image.elementHandle(); scripts.resolve(); - await expectDecoded(image, '/favicon.svg'); + await expectDecoded(image, '/favicon.png'); expect(await image.evaluate((node, previous) => node === previous, ssrImage)).toBe(true); await ssrImage?.dispose(); } finally { @@ -176,18 +176,18 @@ for (const source of [sourceA, undefined]) { await writeFile(join(publicDir, 'private-brand-a.svg'), 'not an image'); const app = await boot(source); let requests = 0; - await page.route('**/favicon.svg', async (route) => { + await page.route('**/favicon.png', async (route) => { requests++; await route.abort(); }); const failed = page.waitForEvent( 'requestfailed', - (request) => new URL(request.url()).pathname === '/favicon.svg' + (request) => new URL(request.url()).pathname === '/favicon.png' ); await page.goto(app.url); await failed; const image = page.locator('header img'); - await expect(image).toHaveAttribute('src', '/favicon.svg'); + await expect(image).toHaveAttribute('src', '/favicon.png'); await expectBroken(image); await page.evaluate( () => @@ -230,7 +230,7 @@ test('source changes retry A to B to repaired A in place, and hydrated errors us }); await page.goto(app.url); const image = page.locator('header img'); - await expectDecoded(image, '/favicon.svg'); + await expectDecoded(image, '/favicon.png'); expect(primaryARequests).toBe(1); await page.evaluate(() => Reflect.set(window, 'headerNavigationMarker', true)); const originalImage = await image.elementHandle(); @@ -258,7 +258,7 @@ test('source changes retry A to B to repaired A in place, and hydrated errors us ); await navigateQuery(page, '/?link=book'); expect((await failedPrimary).status()).toBe(404); - await expectDecoded(image, '/favicon.svg'); + await expectDecoded(image, '/favicon.png'); expect(await page.evaluate(() => Reflect.get(window, 'headerNavigationMarker'))).toBe(true); expect( await image.evaluate((node, previous) => node === previous && node.isConnected, originalImage) diff --git a/apps/web/package.json b/apps/web/package.json index 7e5eeb1..f354873 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,8 @@ "dependencies": { "@dicebear/core": "10.4.0", "@directory/config": "workspace:*", + "@takumi-rs/core": "2.18.2", + "@takumi-rs/helpers": "2.18.2", "bits-ui": "catalog:", "fuse.js": "catalog:", "tslog": "5.2.0" diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 2694a61..04c2e41 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,3 +1,4 @@ +import type { IncomingMessage } from 'node:http'; import type { Session } from '@auth/sveltekit'; import type { DirectoryContent } from './lib/display/types.js'; import type { Authentication } from './lib/server/auth/index.js'; @@ -11,6 +12,10 @@ declare global { } namespace App { + interface Platform { + req?: IncomingMessage; + } + interface PageData { directory?: DirectoryContent; } diff --git a/apps/web/src/lib/components/DocumentHead.svelte b/apps/web/src/lib/components/DocumentHead.svelte new file mode 100644 index 0000000..13eeea9 --- /dev/null +++ b/apps/web/src/lib/components/DocumentHead.svelte @@ -0,0 +1,25 @@ + + + + + + + + + + + + {#if metadata.image.width !== undefined} + + {/if} + {#if metadata.image.height !== undefined} + + {/if} + {#if metadata.image.url.startsWith('https:')} + + {/if} + diff --git a/apps/web/src/lib/search/engine.test.ts b/apps/web/src/lib/search/engine.test.ts index aca1042..1e2e537 100644 --- a/apps/web/src/lib/search/engine.test.ts +++ b/apps/web/src/lib/search/engine.test.ts @@ -23,8 +23,8 @@ function directory(groups: readonly DisplayGroup[]): DirectoryContent { title: 'Common site masthead', description: '', footer: '', - logo_path: '/favicon.svg', - favicon_path: '/favicon.svg', + logo_path: '/favicon.png', + favicon_path: '/favicon.png', open_links_in_new_tab: true }), appearance: Object.freeze({ primary_light_color: '#111', primary_dark_color: '#eee' }), diff --git a/apps/web/src/lib/server/auth/access.ts b/apps/web/src/lib/server/auth/access.ts index e06bc38..4e0c43a 100644 --- a/apps/web/src/lib/server/auth/access.ts +++ b/apps/web/src/lib/server/auth/access.ts @@ -60,7 +60,7 @@ export async function requireSignIn( event.locals.session = null; const routeId = event.route.id ?? ''; const publicRoute = routeId === '/(public)' || routeId.startsWith('/(public)/'); - if (publicRoute) return resolve(event); + if (publicRoute || isConfiguredShareImage(event)) return resolve(event); try { event.locals.session = await event.locals.auth(); } catch { @@ -80,6 +80,15 @@ export async function requireSignIn( return denied(401); } +function isConfiguredShareImage(event: RequestEvent): boolean { + if (event.route.id !== '/(protected)/(endpoints)/public/[...file]') return false; + if (event.isDataRequest) return false; + if (!['GET', 'HEAD'].includes(event.request.method)) return false; + const selectedPath = event.locals.snapshot.configuration.site.og_image_path; + if (selectedPath === undefined) return false; + return selectedPath === `/public/${event.params.file}`; +} + function isUnsafeMethod(event: RequestEvent): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(event.request.method); } diff --git a/apps/web/src/lib/server/auth/index.test.ts b/apps/web/src/lib/server/auth/index.test.ts index 1c40324..b5ce213 100644 --- a/apps/web/src/lib/server/auth/index.test.ts +++ b/apps/web/src/lib/server/auth/index.test.ts @@ -232,6 +232,46 @@ describe('mounted image access', () => { ); }); +describe('configured public share image access', () => { + it.each(['GET', 'HEAD'])( + 'permits the selected static OG image for %s without a session', + async (method) => { + const event = eventFor('/public/images/share.svg?ignored=1', { + method, + routeId: '/(protected)/(endpoints)/public/[...file]' + }); + event.params = { file: 'images/share.svg' }; + event.locals.snapshot = prepareSnapshot( + validateConfig( + { site: { og_image_path: '/public/images/share.svg' }, links: {} }, + { env: {} } + ) + ); + expect((await responseFor(event)).status).toBe(200); + expect(mocks.resolve).toHaveBeenCalledExactlyOnceWith(event); + expect(mocks.session).not.toHaveBeenCalled(); + } + ); + + it.each(['other.svg', 'images/share.svg/other'])( + 'keeps %s outside the public selection', + async (file) => { + const event = eventFor(`/public/${file}`, { + routeId: '/(protected)/(endpoints)/public/[...file]' + }); + event.params = { file }; + event.locals.snapshot = prepareSnapshot( + validateConfig( + { site: { og_image_path: '/public/images/share.svg' }, links: {} }, + { env: {} } + ) + ); + expect((await responseFor(event)).status).toBe(401); + expect(mocks.resolve).not.toHaveBeenCalled(); + } + ); +}); + describe('boot factory', () => { it('constructs one SDK instance and seven-day native JWT pipeline per application', async () => { const authentication = authenticate(); @@ -313,7 +353,7 @@ describe('whole-directory gate', () => { } }); - it.each(['/signin', '/healthz', '/favicon.svg', '/_app/immutable/missing.js'])( + it.each(['/signin', '/healthz', '/favicon.png', '/_app/immutable/missing.js'])( 'does not make unresolved URLs public: %s', async (path) => { const response = await responseFor( diff --git a/apps/web/src/lib/server/directory/content.test.ts b/apps/web/src/lib/server/directory/content.test.ts index e304b23..2dc59a6 100644 --- a/apps/web/src/lib/server/directory/content.test.ts +++ b/apps/web/src/lib/server/directory/content.test.ts @@ -82,8 +82,8 @@ describe('prepareDirectoryContent', () => { title: 'Directory', description: '', footer: '', - logo_path: '/favicon.svg', - favicon_path: '/favicon.svg', + logo_path: '/favicon.png', + favicon_path: '/favicon.png', open_links_in_new_tab: true }, appearance: { primary_light_color: '#0f766e', primary_dark_color: '#5eead4' }, @@ -155,7 +155,7 @@ describe('prepareDirectoryContent', () => { const display = prepareDirectoryContent(config, 'independent'); expect(config).toEqual(original); expect(display.site).toHaveProperty('favicon_path', '/public/site.png'); - expect(display.site).toHaveProperty('logo_path', '/favicon.svg'); + expect(display.site).toHaveProperty('logo_path', '/favicon.png'); const link = display.groups[0]!.links[0]!; expect(link).toHaveProperty('description', ''); expect(link).toHaveProperty('logo_path', '/public/photo.svg'); diff --git a/apps/web/src/lib/server/directory/snapshot.test.ts b/apps/web/src/lib/server/directory/snapshot.test.ts index 0b88693..e1d7ed6 100644 --- a/apps/web/src/lib/server/directory/snapshot.test.ts +++ b/apps/web/src/lib/server/directory/snapshot.test.ts @@ -107,6 +107,15 @@ describe('private prepared snapshots', () => { } }); + it('tracks a configured Open Graph image as branding without adding its path to display data', () => { + const config = configuration(); + config.site.og_image_path = '/public/share.png'; + const snapshot = prepareSnapshot(config); + expect(snapshot.assets.brandingPaths).toContain('/public/share.png'); + expect(snapshot.assets.referencedPaths).toContain('/public/share.png'); + expect(JSON.stringify(snapshot.display)).not.toContain('/public/share.png'); + }); + it('keeps authentication private while assigning a fresh non-secret display identity', () => { const input = configuration(); const first = prepareSnapshot(input); @@ -148,7 +157,7 @@ describe('private prepared snapshots', () => { expect(generate).toHaveBeenCalledTimes(2); }); - it.each([{}, { logo_path: '/favicon.svg', favicon_path: '/favicon.svg' }])( + it.each([{}, { logo_path: '/favicon.png', favicon_path: '/favicon.png' }])( 'excludes default and explicit bundled site images from mounted assets: %j', (site) => { const snapshot = prepareSnapshot(validateConfig({ site, links: {} }, { env: {} })); @@ -157,8 +166,8 @@ describe('private prepared snapshots', () => { brandingPaths: [], bookmarkLogos: {} }); - expect(snapshot.display.site.logo_path).toBe('/favicon.svg'); - expect(snapshot.display.site.favicon_path).toBe('/favicon.svg'); + expect(snapshot.display.site.logo_path).toBe('/favicon.png'); + expect(snapshot.display.site.favicon_path).toBe('/favicon.png'); expect(Object.isFrozen(snapshot.display.site)).toBe(true); expect(snapshot.display.groups.flatMap((group) => group.links)).toEqual([]); } @@ -168,7 +177,7 @@ describe('private prepared snapshots', () => { const snapshot = prepareSnapshot( validateConfig( { - site: { logo_path: '/favicon.svg', favicon_path: '/public/site.png' }, + site: { logo_path: '/favicon.png', favicon_path: '/public/site.png' }, links: { photos: { ...service, logo_path: '/public/photos.png' }, plain: service diff --git a/apps/web/src/lib/server/directory/snapshot.ts b/apps/web/src/lib/server/directory/snapshot.ts index 94c858d..a323218 100644 --- a/apps/web/src/lib/server/directory/snapshot.ts +++ b/apps/web/src/lib/server/directory/snapshot.ts @@ -29,8 +29,8 @@ function collectAssetPaths( config: DirectoryConfiguration ): Pick { const brandingPaths = new Set(); - for (const path of [config.site.logo_path, config.site.favicon_path]) { - if (path.startsWith('/public/')) brandingPaths.add(path); + for (const path of [config.site.logo_path, config.site.favicon_path, config.site.og_image_path]) { + if (path?.startsWith('/public/')) brandingPaths.add(path); } const referencedPaths = new Set(brandingPaths); for (const link of Object.values(config.links)) { diff --git a/apps/web/src/lib/server/document.test.ts b/apps/web/src/lib/server/document.test.ts new file mode 100644 index 0000000..1deee91 --- /dev/null +++ b/apps/web/src/lib/server/document.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { documentMetadata } from './document.js'; + +describe('document metadata', () => { + it('publishes the title and generated PNG with its known dimensions', () => { + expect(documentMetadata('Home & family', 'https://directory.example')).toEqual({ + title: 'Home & family', + url: 'https://directory.example/', + image: { + url: 'https://directory.example/images/og.png', + type: 'image/png', + width: 1200, + height: 630 + } + }); + }); + + it.each(['png', 'jpeg', 'svg+xml', 'gif', 'webp'])( + 'links a supplied image with its actual MIME type image/%s and no guessed dimensions', + (format) => { + const metadata = documentMetadata('Home', 'http://localhost:3000', { + path: '/public/images/café family.svg', + contentType: `image/${format}` + }); + expect(metadata.image).toEqual({ + url: 'http://localhost:3000/public/images/caf%C3%A9%20family.svg', + type: `image/${format}` + }); + } + ); +}); diff --git a/apps/web/src/lib/server/document.ts b/apps/web/src/lib/server/document.ts new file mode 100644 index 0000000..fe1c3e3 --- /dev/null +++ b/apps/web/src/lib/server/document.ts @@ -0,0 +1,32 @@ +export interface DocumentMetadata { + title: string; + url: string; + image: { + url: string; + type: string; + width?: number; + height?: number; + }; +} + +export function documentMetadata( + title: string, + origin: string, + supplied?: { path: string; contentType: string } +): DocumentMetadata { + let image: DocumentMetadata['image']; + if (supplied) { + image = { + url: new URL(supplied.path, origin).href, + type: supplied.contentType + }; + } else { + image = { + url: new URL('/images/og.png', origin).href, + type: 'image/png', + width: 1200, + height: 630 + }; + } + return { title, url: new URL('/', origin).href, image }; +} diff --git a/apps/web/src/lib/server/images/open-graph.html b/apps/web/src/lib/server/images/open-graph.html new file mode 100644 index 0000000..3b111b6 --- /dev/null +++ b/apps/web/src/lib/server/images/open-graph.html @@ -0,0 +1,41 @@ + +
+
+
{{title}}
+
+
diff --git a/apps/web/src/lib/server/images/render-open-graph.test.ts b/apps/web/src/lib/server/images/render-open-graph.test.ts new file mode 100644 index 0000000..be8999e --- /dev/null +++ b/apps/web/src/lib/server/images/render-open-graph.test.ts @@ -0,0 +1,121 @@ +import { Renderer, type Node } from '@takumi-rs/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const branding = { + title: 'Home Server', + primaryLightColor: '#0f766e', + primaryDarkColor: '#5eead4' +}; + +beforeEach(() => vi.resetModules()); +afterEach(() => vi.restoreAllMocks()); + +async function loadRenderOpenGraph() { + return (await import('./render-open-graph.js')).renderOpenGraph; +} + +function expectPng(bytes: Uint8Array) { + const png = Buffer.from(bytes); + expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(png.readUInt32BE(16)).toBe(1200); + expect(png.readUInt32BE(20)).toBe(630); +} + +function textContent(node: Node): string { + if (node.type === 'text') return node.text; + if (node.type === 'container') return (node.children ?? []).map(textContent).join(''); + return ''; +} + +describe('generated Open Graph images', () => { + it('renders a PNG with the same bundled folder favicon used by the app', async () => { + const generate = await loadRenderOpenGraph(); + const bytes = await generate(branding); + expectPng(bytes); + const pixels = await new Renderer().render( + { type: 'image', src: bytes, width: 1200, height: 630 }, + { width: 1200, height: 630, format: 'raw' } + ); + let yellow = 0; + for (let offset = 0; offset < pixels.length; offset += 4) { + if (pixels[offset]! > 230 && pixels[offset + 1]! > 150 && pixels[offset + 2]! < 130) yellow++; + } + expect(yellow).toBeGreaterThan(1000); + }); + + it('uses supplied favicon bytes and both appearance colors', async () => { + const generate = await loadRenderOpenGraph(); + const original = await generate(branding); + const favicon = new TextEncoder().encode( + '' + ); + expect(await generate({ ...branding, favicon })).not.toEqual(original); + expect(await generate({ ...branding, primaryLightColor: '#b45309' })).not.toEqual(original); + expect(await generate({ ...branding, primaryDarkColor: '#fbbf24' })).not.toEqual(original); + }); + + it('deduplicates simultaneous renders and retains only the most recent image', async () => { + const generate = await loadRenderOpenGraph(); + const draw = vi.spyOn(Renderer.prototype, 'render'); + const [first, second] = await Promise.all([generate(branding), generate(branding)]); + expect(first).toBe(second); + expect(await generate(branding)).toBe(first); + expect(draw).toHaveBeenCalledTimes(1); + await generate({ ...branding, title: 'Another home' }); + await generate(branding); + expect(draw).toHaveBeenCalledTimes(3); + }); + + it('invalidates by favicon bytes rather than filesystem path or config revision', async () => { + const generate = await loadRenderOpenGraph(); + const draw = vi.spyOn(Renderer.prototype, 'render'); + const icon = (color: string) => + new TextEncoder().encode( + `` + ); + const first = await generate({ ...branding, favicon: icon('#ec4899') }); + expect(await generate({ ...branding, favicon: icon('#ec4899') })).toBe(first); + expect(await generate({ ...branding, favicon: icon('#2563eb') })).not.toEqual(first); + expect(draw).toHaveBeenCalledTimes(2); + }); + + it('retries with the bundled mark when the renderer rejects a supplied favicon', async () => { + const generate = await loadRenderOpenGraph(); + const draw = vi.spyOn(Renderer.prototype, 'render'); + draw.mockRejectedValueOnce(new Error('PRIVATE_DECODER_ERROR')); + expectPng(await generate({ ...branding, favicon: new Uint8Array([1, 2, 3]) })); + expect(draw).toHaveBeenCalledTimes(2); + }); + + it('does not permanently cache a failed render', async () => { + const generate = await loadRenderOpenGraph(); + vi.spyOn(Renderer.prototype, 'render').mockRejectedValueOnce(new Error('Rendering failed')); + await expect(generate(branding)).rejects.toThrow('Rendering failed'); + expectPng(await generate(branding)); + }); + + it('passes the title as escaped text, not HTML or template substitutions', async () => { + const generate = await loadRenderOpenGraph(); + const draw = vi.spyOn(Renderer.prototype, 'render'); + const title = ' & {{dark}} $&'; + await generate({ ...branding, title }); + expect(textContent(draw.mock.calls[0]![0] as Node)).toBe(title); + }); + + it('bounds long titles and keeps their layout on the card', async () => { + const generate = await loadRenderOpenGraph(); + const draw = vi.spyOn(Renderer.prototype, 'render'); + await generate({ ...branding, title: 'W'.repeat(2000) }); + const call = draw.mock.calls[0]!; + expect(textContent(call[0] as Node)).toBe('W'.repeat(79) + '…'); + const layout = await new Renderer().measure(call[0], call[1]); + expect(layout.children[1]!.height).toBeLessThanOrEqual(240); + }); + + it('does not fetch remote images or fonts', async () => { + const fetch = vi.spyOn(globalThis, 'fetch'); + const generate = await loadRenderOpenGraph(); + await generate(branding); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/server/images/render-open-graph.ts b/apps/web/src/lib/server/images/render-open-graph.ts new file mode 100644 index 0000000..e76f215 --- /dev/null +++ b/apps/web/src/lib/server/images/render-open-graph.ts @@ -0,0 +1,84 @@ +import { createHash } from 'node:crypto'; +import type { Renderer } from '@takumi-rs/core'; +import { fromHtml } from '@takumi-rs/helpers/html'; +import bundledFavicon from '../../../../static/favicon.png?inline'; +import template from './open-graph.html?raw'; + +interface OpenGraphBranding { + readonly title: string; + readonly primaryLightColor: string; + readonly primaryDarkColor: string; + readonly favicon?: Uint8Array | undefined; +} + +const defaultFavicon = Buffer.from(bundledFavicon.slice(bundledFavicon.indexOf(',') + 1), 'base64'); +let renderer: Promise | undefined; +let cached: { key: string; image: Promise> } | undefined; + +export async function renderOpenGraph( + branding: OpenGraphBranding +): Promise> { + const title = boundedTitle(branding.title); + const favicon = branding.favicon ?? defaultFavicon; + const key = createHash('sha256') + .update(JSON.stringify([title, branding.primaryLightColor, branding.primaryDarkColor])) + .update(favicon) + .digest('hex'); + if (cached?.key === key) return cached.image; + + const pending = draw(title, branding, favicon); + cached = { key, image: pending }; + try { + return await pending; + } catch (error) { + if (cached?.image === pending) cached = undefined; + throw error; + } +} + +async function draw(title: string, branding: OpenGraphBranding, favicon: Uint8Array) { + const engine = await (renderer ??= import('@takumi-rs/core').then( + ({ Renderer }) => new Renderer({ cacheMaxBytes: 8 * 1024 * 1024 }) + )); + let fontSize = 88; + if (title.length > 40) fontSize = 52; + else if (title.length > 20) fontSize = 68; + const { node, css } = fromHtml( + template + .replaceAll('{{light}}', fullHex(branding.primaryLightColor)) + .replaceAll('{{dark}}', fullHex(branding.primaryDarkColor)) + .replaceAll('{{fontSize}}', String(fontSize)) + .replace('{{title}}', () => escapeHtml(title)) + .replace(/>\s+<') + .trim() + ); + const render = (icon: Uint8Array) => + engine.render(node, { + width: 1200, + height: 630, + format: 'png', + css, + images: [{ src: 'favicon', data: icon }] + }); + try { + return await render(favicon); + } catch (error) { + if (favicon === defaultFavicon) throw error; + return render(defaultFavicon); + } +} + +function boundedTitle(title: string): string { + const characters = Array.from(title.replace(/\s+/gu, ' ').trim()); + if (characters.length <= 80) return characters.join(''); + return characters.slice(0, 79).join('') + '…'; +} + +function fullHex(color: string): string { + if (color.length === 4) return '#' + color.slice(1).replace(/./g, '$&$&'); + return color; +} + +function escapeHtml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} diff --git a/apps/web/src/routes/(protected)/+page.svelte b/apps/web/src/routes/(protected)/+page.svelte index d9c9a2f..f715959 100644 --- a/apps/web/src/routes/(protected)/+page.svelte +++ b/apps/web/src/routes/(protected)/+page.svelte @@ -199,7 +199,7 @@
diff --git a/apps/web/src/routes/(public)/favicon/server.test.ts b/apps/web/src/routes/(public)/favicon/server.test.ts index f9356c9..fea6366 100644 --- a/apps/web/src/routes/(public)/favicon/server.test.ts +++ b/apps/web/src/routes/(public)/favicon/server.test.ts @@ -48,8 +48,8 @@ describe('public favicon alias', () => { it.each([ ['GET', undefined], ['HEAD', undefined], - ['GET', '/favicon.svg'], - ['HEAD', '/favicon.svg'] + ['GET', '/favicon.png'], + ['HEAD', '/favicon.png'] ] as const)('returns 404 before I/O for %s with bundled selection %s', async (method, source) => { const response = await GET(event(method, snapshot(source))); expect(readMountedImage).not.toHaveBeenCalled(); diff --git a/apps/web/src/routes/(public)/images/og.png/+server.ts b/apps/web/src/routes/(public)/images/og.png/+server.ts new file mode 100644 index 0000000..48508df --- /dev/null +++ b/apps/web/src/routes/(public)/images/og.png/+server.ts @@ -0,0 +1,29 @@ +import { readMountedImage } from '../../../../lib/server/images/read-mounted-image.js'; +import { imageResponse } from '../../../../lib/server/images/image-response.js'; +import { renderOpenGraph } from '../../../../lib/server/images/render-open-graph.js'; +import { logger } from '../../../../lib/server/logger.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ locals, request }) => { + const { site, appearance } = locals.snapshot.configuration; + const favicon = site.favicon_path.startsWith('/public/') + ? await readMountedImage(locals.publicDir, site.favicon_path) + : undefined; + try { + const bytes = await renderOpenGraph({ + title: site.title, + primaryLightColor: appearance.primary_light_color, + primaryDarkColor: appearance.primary_dark_color, + favicon: favicon?.bytes + }); + return imageResponse(request.method, { bytes, contentType: 'image/png' }); + } catch { + logger.error('Open Graph image rendering failed (og_render_failed).'); + return new Response(request.method === 'HEAD' ? null : 'Image unavailable', { + status: 503, + headers: { 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' } + }); + } +}; + +export const HEAD = GET; diff --git a/apps/web/src/routes/(public)/images/og.png/server.test.ts b/apps/web/src/routes/(public)/images/og.png/server.test.ts new file mode 100644 index 0000000..4899dc1 --- /dev/null +++ b/apps/web/src/routes/(public)/images/og.png/server.test.ts @@ -0,0 +1,250 @@ +import { validateConfig } from '@directory/config'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { prepareSnapshot } from '../../../../lib/server/directory/snapshot.js'; +import { readMountedImage } from '../../../../lib/server/images/read-mounted-image.js'; +import { renderOpenGraph } from '../../../../lib/server/images/render-open-graph.js'; +import { logger } from '../../../../lib/server/logger.js'; +import { GET, HEAD } from './+server.js'; + +vi.mock('../../../../lib/server/images/read-mounted-image.js', () => ({ + readMountedImage: vi.fn() +})); +vi.mock('../../../../lib/server/images/render-open-graph.js', () => ({ renderOpenGraph: vi.fn() })); +vi.mock('../../../../lib/server/logger.js', () => ({ logger: { error: vi.fn() } })); + +const publicDir = '/PRIVATE_MOUNTED_ROOT'; +const faviconPath = '/public/café family.svg'; +const favicon = { bytes: new TextEncoder().encode(''), contentType: 'image/svg+xml' }; +const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 255, 0]); +const appearance = { primary_light_color: '#123456', primary_dark_color: '#abc' }; +const branding = { + title: 'Captured home', + primaryLightColor: '#123456', + primaryDarkColor: '#abc' +}; + +function snapshot(site: Record = {}, colors = appearance) { + return prepareSnapshot( + validateConfig( + { + site: { + title: branding.title, + description: 'PRIVATE_SITE_DESCRIPTION', + footer: 'PRIVATE_FOOTER', + logo_path: '/public/private-logo.png', + ...site + }, + appearance: colors, + auth: { + origin: 'https://private.example', + secret: 'PRIVATE_SESSION_SECRET_12345678901234567890', + credentials: { username: 'PRIVATE_USER', password: 'PRIVATE_PASSWORD' } + }, + links: { + private: { + title: 'PRIVATE_LINK', + url: 'https://private.example/secret', + description: 'PRIVATE_LINK_DESCRIPTION', + group: 'PRIVATE_GROUP', + tags: ['PRIVATE_TAG'], + logo_path: '/public/private-link.png', + requires_auth: true, + internal_only: true, + admin_only: true + } + } + }, + { env: {} } + ) + ); +} + +function event(method: 'GET' | 'HEAD', captured = snapshot({ favicon_path: faviconPath })) { + return { + params: {}, + locals: { snapshot: captured, publicDir, session: null, authentication: undefined }, + url: new URL('http://localhost/images/og.png'), + request: new Request('http://localhost/images/og.png', { method }) + } as Parameters[0]; +} + +async function expectPNG(response: Response, method: 'GET' | 'HEAD') { + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + expect(response.headers.get('content-length')).toBe(String(png.byteLength)); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('x-content-type-options')).toBe('nosniff'); + if (method === 'HEAD') expect(response.body).toBeNull(); + expect(new Uint8Array(await response.arrayBuffer())).toEqual( + method === 'HEAD' ? new Uint8Array() : png + ); +} + +beforeEach(() => { + vi.mocked(readMountedImage).mockReset().mockResolvedValue(favicon); + vi.mocked(renderOpenGraph).mockReset().mockResolvedValue(png); + vi.mocked(logger.error).mockReset(); +}); + +describe('public generated Open Graph image', () => { + it.each(['GET', 'HEAD'] as const)( + 'reads only the configured favicon and generates a PNG for %s', + async (method) => { + const response = await (method === 'HEAD' ? HEAD : GET)(event(method)); + expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, faviconPath); + expect(renderOpenGraph).toHaveBeenCalledExactlyOnceWith({ + ...branding, + favicon: favicon.bytes + }); + expect(vi.mocked(renderOpenGraph).mock.calls[0]?.[0].favicon).toBe(favicon.bytes); + await expectPNG(response, method); + expect(logger.error).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['GET', undefined], + ['HEAD', undefined], + ['GET', '/favicon.png'], + ['HEAD', '/favicon.png'] + ] as const)( + 'generates %s without reading the bundled favicon selection %s', + async (method, path) => { + const config = + path === undefined ? { links: {} } : { site: { favicon_path: path }, links: {} }; + const captured = prepareSnapshot(validateConfig(config, { env: {} })); + await expectPNG(await (method === 'HEAD' ? HEAD : GET)(event(method, captured)), method); + expect(readMountedImage).not.toHaveBeenCalled(); + expect(renderOpenGraph).toHaveBeenCalledExactlyOnceWith({ + title: 'Directory', + primaryLightColor: '#0f766e', + primaryDarkColor: '#5eead4', + favicon: undefined + }); + } + ); + + it.each(['GET', 'HEAD'] as const)( + 'passes an unavailable favicon as undefined for %s', + async (method) => { + vi.mocked(readMountedImage).mockResolvedValue(undefined); + await expectPNG(await (method === 'HEAD' ? HEAD : GET)(event(method)), method); + expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, faviconPath); + expect(renderOpenGraph).toHaveBeenCalledExactlyOnceWith({ ...branding, favicon: undefined }); + } + ); + + it.each([ + ['GET', '/favicon.png'], + ['HEAD', '/favicon.png'], + ['GET', faviconPath], + ['HEAD', faviconPath] + ] as const)( + 'always generates %s, even with a static OG image and favicon %s', + async (method, path) => { + const captured = snapshot({ favicon_path: path, og_image_path: '/public/static-share.jpg' }); + await expectPNG(await (method === 'HEAD' ? HEAD : GET)(event(method, captured)), method); + expect(vi.mocked(readMountedImage).mock.calls).toEqual( + path === '/favicon.png' ? [] : [[publicDir, faviconPath]] + ); + expect(renderOpenGraph).toHaveBeenCalledExactlyOnceWith({ + ...branding, + favicon: path === '/favicon.png' ? undefined : favicon.bytes + }); + } + ); + + it.each(['GET', 'HEAD'] as const)( + 'ignores request path, queries and headers for %s', + async (method) => { + for (const path of [ + '/images/og.png?file=/public/other.jpg&publicDir=/outside&title=ATTACKER', + '/images/og.png?favicon_path=/public/other.svg&og_image_path=/public/other.jpg&primary_light_color=%23fff&primary_dark_color=%23000', + '/images/other.png?source=%252e%252e%252fsecret.svg&title=OTHER' + ]) { + const request = event(method); + request.url = new URL(path, 'http://attacker.example'); + request.request = new Request(request.url, { + method, + headers: { + 'x-public-dir': '/outside', + 'x-favicon-path': '/public/other.svg', + 'x-title': 'HEADER_TITLE', + 'x-primary-light-color': '#fff', + 'x-primary-dark-color': '#000', + accept: 'image/jpeg', + range: 'bytes=0-1', + 'if-none-match': '*' + } + }); + await expectPNG(await (method === 'HEAD' ? HEAD : GET)(request), method); + } + expect(vi.mocked(readMountedImage).mock.calls).toEqual( + Array.from({ length: 3 }, () => [publicDir, faviconPath]) + ); + expect(vi.mocked(renderOpenGraph).mock.calls).toEqual( + Array.from({ length: 3 }, () => [{ ...branding, favicon: favicon.bytes }]) + ); + } + ); + + it('keeps captured branding coherent across an async read, excluding display, assets and private data', async () => { + const replacement = event( + 'GET', + snapshot( + { title: 'Replacement', favicon_path: '/public/replacement.png' }, + { primary_light_color: '#fed', primary_dark_color: '#654321' } + ) + ); + const captured = event('GET'); + captured.locals.snapshot = { + ...captured.locals.snapshot, + display: replacement.locals.snapshot.display, + assets: { referencedPaths: [], brandingPaths: [], bookmarkLogos: {} } + }; + const replacementFavicon = { bytes: new Uint8Array([1, 2, 3]), contentType: 'image/png' }; + vi.mocked(readMountedImage).mockImplementationOnce(async () => { + captured.locals.snapshot = replacement.locals.snapshot; + return favicon; + }); + await expectPNG(await GET(captured), 'GET'); + vi.mocked(readMountedImage).mockResolvedValue(replacementFavicon); + await expectPNG(await GET(replacement), 'GET'); + expect(vi.mocked(readMountedImage).mock.calls).toEqual([ + [publicDir, faviconPath], + [publicDir, '/public/replacement.png'] + ]); + expect(vi.mocked(renderOpenGraph).mock.calls).toEqual([ + [{ ...branding, favicon: favicon.bytes }], + [ + { + title: 'Replacement', + primaryLightColor: '#fed', + primaryDarkColor: '#654321', + favicon: replacementFavicon.bytes + } + ] + ]); + }); + + it.each(['GET', 'HEAD'] as const)( + 'returns a generic 503 and fixed log message on %s rendering failure', + async (method) => { + const error = new Error(`PRIVATE_RENDER_SECRET ${publicDir}`); + error.stack = 'PRIVATE_STACK_TRACE'; + vi.mocked(renderOpenGraph).mockRejectedValue(error); + const response = await (method === 'HEAD' ? HEAD : GET)(event(method)); + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('x-content-type-options')).toBe('nosniff'); + if (method === 'HEAD') expect(response.body).toBeNull(); + expect(await response.text()).toBe(method === 'HEAD' ? '' : 'Image unavailable'); + expect(logger.error).toHaveBeenCalledExactlyOnceWith( + 'Open Graph image rendering failed (og_render_failed).' + ); + expect( + JSON.stringify([...response.headers, ...vi.mocked(logger.error).mock.calls]) + ).not.toContain('PRIVATE'); + } + ); +}); diff --git a/apps/web/src/routes/(public)/logo/server.test.ts b/apps/web/src/routes/(public)/logo/server.test.ts index 1718e3f..60c25af 100644 --- a/apps/web/src/routes/(public)/logo/server.test.ts +++ b/apps/web/src/routes/(public)/logo/server.test.ts @@ -48,8 +48,8 @@ describe('public selected-logo alias', () => { it.each([ ['GET', undefined], ['HEAD', undefined], - ['GET', '/favicon.svg'], - ['HEAD', '/favicon.svg'] + ['GET', '/favicon.png'], + ['HEAD', '/favicon.png'] ] as const)('returns 404 before I/O for %s with bundled selection %s', async (method, source) => { const response = await GET(event(method, snapshot(source))); expect(readMountedImage).not.toHaveBeenCalled(); diff --git a/apps/web/src/routes/(public)/signin/+page.svelte b/apps/web/src/routes/(public)/signin/+page.svelte index bec8c8d..323d6f9 100644 --- a/apps/web/src/routes/(public)/signin/+page.svelte +++ b/apps/web/src/routes/(public)/signin/+page.svelte @@ -26,7 +26,7 @@