diff --git a/.changeset/canonical-site-url.md b/.changeset/canonical-site-url.md new file mode 100644 index 0000000..9969264 --- /dev/null +++ b/.changeset/canonical-site-url.md @@ -0,0 +1,6 @@ +--- +'@directory/web': minor +'@directory/cli': minor +--- + +Use canonical `site.url` for public metadata and auth; migrate `auth.origin` to `site.url`. Default to localhost with the runtime port (development 5173, otherwise 3000). diff --git a/.changeset/local-open-graph-config.md b/.changeset/local-open-graph-config.md new file mode 100644 index 0000000..436d760 --- /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's public branding: title, description, URL, 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/Dockerfile b/apps/web/Dockerfile index c2b25cf..798de18 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -28,7 +28,7 @@ RUN mkdir -p /m/packages/config/dist /m/apps/web/.svelte-kit \ USER ${LOCAL_UID}:${LOCAL_GID} WORKDIR /m/apps/web EXPOSE 5173 -CMD ["sh", "-c", "pnpm --filter @directory/config build && exec node_modules/.bin/vite --host 0.0.0.0 --port 5173 --strictPort"] +CMD ["sh", "-c", "pnpm --filter @directory/config build && exec node_modules/.bin/vite --host 0.0.0.0 --strictPort"] FROM base AS builder diff --git a/apps/web/docker-compose.dev.yml b/apps/web/docker-compose.dev.yml index dc1ee3f..3284ee1 100644 --- a/apps/web/docker-compose.dev.yml +++ b/apps/web/docker-compose.dev.yml @@ -16,10 +16,11 @@ services: required: false environment: NODE_ENV: development + PORT: ${PORT:-5173} CONFIG_PATH: /m/apps/web/config/directory.yaml DIRECTORY_PUBLIC_DIR: /m/apps/web/public ports: - - '127.0.0.1:5173:5173' + - '127.0.0.1:${PORT:-5173}:${PORT:-5173}' volumes: - ./src:/m/apps/web/src:ro - ./static:/m/apps/web/static:ro @@ -35,7 +36,7 @@ services: - CMD - node - --eval - - fetch('http://localhost:5173/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) + - fetch('http://localhost:'+(process.env.PORT || '5173')+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) interval: 30s timeout: 3s start_period: 60s @@ -57,6 +58,7 @@ services: required: false environment: NODE_ENV: development + PORT: ${PORT:-5173} CONFIG_PATH: /m/apps/web/config/directory.yaml DIRECTORY_PUBLIC_DIR: /m/apps/web/public volumes: diff --git a/apps/web/docker-compose.yml b/apps/web/docker-compose.yml index 96f3bf1..e1c3a3d 100644 --- a/apps/web/docker-compose.yml +++ b/apps/web/docker-compose.yml @@ -11,10 +11,13 @@ services: env_file: - path: .env required: false + environment: + PORT: ${PORT:-3000} ports: # Localhost only. Use '3000:3000' for LAN access; restrict it with your firewall. # Change the left port to use a different host port, e.g. '127.0.0.1:8080:3000'. - - '127.0.0.1:3000:3000' + # Set site.url explicitly when the external address differs. + - '127.0.0.1:${PORT:-3000}:${PORT:-3000}' volumes: - ./config:/app/config:ro - ./public:/app/public:ro @@ -31,7 +34,7 @@ services: - CMD - node - --eval - - fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) + - fetch('http://localhost:'+(process.env.PORT || '3000')+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) interval: 30s timeout: 3s start_period: 5s @@ -51,6 +54,8 @@ services: env_file: - path: .env required: false + environment: + PORT: ${PORT:-3000} volumes: - ./config:/app/config:ro read_only: true diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 2720ed5..f997ce8 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -6,8 +6,8 @@ import { credentialsAuth, oidcAuth, password, - privateDescription, privateSentinels, + siteDescription, siteTitle, username } from './support/configuration.js'; @@ -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 }); @@ -275,7 +275,7 @@ test('native SSR credentials and named root signout work without JavaScript', as ); expectPublic(await page.getByRole('alert').innerText()); await signIn(page, server); - await expect(page.getByText(privateDescription)).toBeVisible(); + await expect(page.getByText(siteDescription)).toBeVisible(); const form = page.locator('form', { has: page.getByRole('button', { name: 'Sign out' }) }); await expect(form).toHaveAttribute('method', 'POST'); await expect(form).toHaveAttribute('action', '?/signout'); @@ -293,7 +293,7 @@ test('native SSR credentials and named root signout work without JavaScript', as const data = await context.request.get(`${server.url}/__data.json?x-sveltekit-invalidated=11`); expect(data.status()).toBe(200); const payload = await data.text(); - expect(payload).toContain(privateDescription); + expect(payload).toContain(siteDescription); expect(payload).not.toContain(username); expect(payload).not.toContain(password); expect(payload).not.toContain(secret); @@ -368,14 +368,14 @@ test('SDK rejects wrong, missing and non-string credentials and does not forward const refreshed = sessionCookie(root); expect(refreshed.name).toBe(cookie.name); expect(refreshed.header).not.toMatch(/; Secure(?:;|$)/i); - expect(await root.text()).toContain(privateDescription); + expect(await root.text()).toContain(siteDescription); expectSafeLogs(server, ['PRIVATE_C6_EVIL']); } finally { expect(await server.stop()).toEqual({ code: 0, signal: null }); } }); -test('YAML origin overrides legacy env, return paths and untrusted proxy headers', async ({ +test('site.url overrides legacy env, return paths and untrusted proxy headers', async ({ page }) => { const server = await startServer({ @@ -503,7 +503,7 @@ test('raw auth APIs stay blocked and same-origin checks cover every POST content } }); -test('HTTPS origin sets Secure SDK cookies even when the trusted upstream transport is HTTP', async () => { +test('HTTPS site.url sets Secure SDK cookies even when the trusted upstream transport is HTTP', async () => { const server = await startServer({ configuration: configuration(), origin: 'https://directory.example.test', @@ -540,7 +540,7 @@ test('generated sessions expire on restart even when a legacy AUTH_SECRET is set headers: { cookie: cookie.pair } }); expect(authenticated.status).toBe(200); - expect(await authenticated.text()).toContain(privateDescription); + expect(await authenticated.text()).toContain(siteDescription); expectSafeLogs(server, [cookie.value]); const { port } = server; expect(await server.stop()).toEqual({ code: 0, signal: null }); @@ -555,7 +555,7 @@ test('generated sessions expire on restart even when a legacy AUTH_SECRET is set headers: { cookie: freshCookie.pair } }); expect(freshSession.status).toBe(200); - expect(await freshSession.text()).toContain(privateDescription); + expect(await freshSession.text()).toContain(siteDescription); expectSafeLogs(server, [cookie.value, freshCookie.value]); } finally { expect(await server.stop()).toEqual({ code: 0, signal: null }); @@ -589,7 +589,7 @@ test('tamper, expiry and secret rotation deny sessions while same-secret restart await server.ready(); const preserved = await server.fetch('/__data.json', { headers: { cookie: cookie.pair } }); expect(preserved.status).toBe(200); - expect(await preserved.text()).toContain(privateDescription); + expect(await preserved.text()).toContain(siteDescription); expectSafeLogs(server, [cookie.value]); expect(await server.stop()).toEqual({ code: 0, signal: null }); @@ -609,24 +609,47 @@ test('tamper, expiry and secret rotation deny sessions while same-secret restart } }); -test('auth edits reject the entire reload; restoring auth allows display updates and old providers keep working', async () => { +test('auth and site.url edits reject the entire snapshot; restoring auth allows display updates and old providers keep working', async () => { const server = await startServer({ configuration: configuration() }); try { await server.ready(); const cookie = sessionCookie(await postCredentials(server)); - await server.replace( + const readSnapshot = async () => { + const response = await server.fetch('/config/version', { headers: { cookie: cookie.pair } }); + expect(response.status).toBe(200); + return response.json(); + }; + const snapshot = await readSnapshot(); + for (const rejected of [ configuration( credentialsAuth.replace(password, 'PRIVATE_C6_REJECTED_PASSWORD'), 'Rejected title' + ), + configuration(credentialsAuth, 'Rejected title').replace( + '${TEST_ORIGIN}', + 'https://rejected.example.test' ) - ); - await expect - .poll(() => server.output(), { timeout: 10_000, intervals: [100, 250, 500] }) - .toContain('restart_required'); - const signin = await server.fetch('/signin'); - const html = await signin.text(); - expect(html).toContain(siteTitle); - expect(html).not.toContain('Rejected title'); + ]) { + const rejections = server.output().match(/restart_required/g)?.length ?? 0; + await server.replace(rejected); + await expect + .poll(() => server.output().match(/restart_required/g)?.length ?? 0, { + timeout: 10_000, + intervals: [100, 250, 500] + }) + .toBeGreaterThan(rejections); + expect(await readSnapshot()).toEqual(snapshot); + const signin = await server.fetch('/signin'); + const html = await signin.text(); + expect(html).toContain(siteTitle); + expect(html).not.toContain('Rejected title'); + const root = await server.fetch('/', { headers: { cookie: cookie.pair } }); + expect(root.status).toBe(200); + const rootHtml = await root.text(); + expect(rootHtml).toContain(siteTitle); + expect(rootHtml).not.toContain('Rejected title'); + expect(rootHtml).not.toContain('rejected.example.test'); + } const oldProvider = await postCredentials(server); expect([302, 303]).toContain(oldProvider.status); expect(expectLocalLocation(oldProvider, server).pathname).toBe('/'); @@ -657,7 +680,7 @@ test('auth edits reject the entire reload; restoring auth allows display updates } }); -test('omitting auth needs no AUTH_SECRET, ORIGIN or AUTH_URL and exposes no signout control', async ({ +test('omitting auth needs no operator AUTH_SECRET, ORIGIN or AUTH_URL and exposes no signout control', async ({ browser }) => { const server = await startServer({ @@ -669,13 +692,13 @@ test('omitting auth needs no AUTH_SECRET, ORIGIN or AUTH_URL and exposes no sign const page = await context.newPage(); await page.goto(server.url); await expect(page.getByRole('heading', { name: siteTitle, exact: true })).toBeVisible(); - await expect(page.getByText(privateDescription)).toBeVisible(); + await expect(page.getByText(siteDescription)).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0); await page.goto(`${server.url}/signin`); await expect(page).toHaveURL(`${server.url}/`); const data = await server.fetch('/__data.json'); expect(data.status).toBe(200); - expect(await data.text()).toContain(privateDescription); + expect(await data.text()).toContain(siteDescription); expectSafeLogs(server); } finally { await context.close(); @@ -683,7 +706,7 @@ test('omitting auth needs no AUTH_SECRET, ORIGIN or AUTH_URL and exposes no sign } }); -test('real OIDC discovery and browser roundtrip use YAML origin, PKCE, state and nonce despite legacy env', async ({ +test('real OIDC discovery and browser roundtrip use site.url, PKCE, state and nonce despite legacy env', async ({ browser }) => { const issuer = await startIssuer(); @@ -741,7 +764,7 @@ test('real OIDC discovery and browser roundtrip use YAML origin, PKCE, state and expect(data.status).toBe(200); const payload = await data.text(); expect(payload).toContain(siteTitle); - expect(payload).toContain(privateDescription); + expect(payload).toContain(siteDescription); expect(payload).not.toContain('PRIVATE_C6_IDENTITY'); expect(payload).not.toContain('roles'); for (const value of [ diff --git a/apps/web/e2e/bookmark-logos.e2e.ts b/apps/web/e2e/bookmark-logos.e2e.ts index 16b7a58..581ea9e 100644 --- a/apps/web/e2e/bookmark-logos.e2e.ts +++ b/apps/web/e2e/bookmark-logos.e2e.ts @@ -4,6 +4,7 @@ import { startServer } from './support/server.js'; function configuration(auth = '') { return `site: + url: '\${TEST_ORIGIN}' title: Bookmark logo directory links: photos: 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..219d25e 100644 --- a/apps/web/e2e/config-update.e2e.ts +++ b/apps/web/e2e/config-update.e2e.ts @@ -5,8 +5,8 @@ import { expect, test, type APIRequestContext, type Page, type Route } from '@pl import { configuration, password, - privateDescription, privateSentinels, + siteDescription, siteTitle, username } from './support/configuration.js'; @@ -23,7 +23,7 @@ const initialConfiguration = `; const updatedConfiguration = initialConfiguration .replace(siteTitle, updatedTitle) - .replace(privateDescription, updatedDescription) + .replace(siteDescription, updatedDescription) .replace('#13579b', '#2468ac'); const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const search = (page: Page) => page.getByRole('searchbox', { name: 'Search', exact: true }); @@ -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'; @@ -198,13 +198,13 @@ test('an accepted edit leaves the current document and typed URL alone until nat await expect(page).toHaveTitle(siteTitle); await expect(page.locator('meta[name="description"]')).toHaveAttribute( 'content', - privateDescription + siteDescription ); await expect(search(page)).toHaveValue('tag:PRIVATE_C6_TAG'); 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..07aa375 100644 --- a/apps/web/e2e/configured-images.e2e.ts +++ b/apps/web/e2e/configured-images.e2e.ts @@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test'; import { credentialsAuth, password, username } from './support/configuration.js'; import { startServer, type TestServer } from './support/server.js'; -const privateDescription = 'PRIVATE_IMAGE_DESCRIPTION'; +const siteDescription = 'Useful services with familiar images.'; const privateLogo = 'PRIVATE_IMAGE_BYTES'; const privateBrand = @@ -13,7 +13,6 @@ const privateBrand = const replacementImage = 'Replacement image'; const privateValues = [ - privateDescription, 'PRIVATE_BOOKMARK_DESCRIPTION', 'PRIVATE_IMAGE_BYTES', 'PRIVATE_BRAND_BYTES', @@ -29,8 +28,9 @@ test.setTimeout(60_000); function configuration(auth = credentialsAuth) { return `site: + url: '\${TEST_ORIGIN}' title: Configured images fixture - description: ${privateDescription} + description: ${siteDescription} logo_path: /public/brand logo.svg links: photos: @@ -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/directory.e2e.ts b/apps/web/e2e/directory.e2e.ts index dd59513..35a8eff 100644 --- a/apps/web/e2e/directory.e2e.ts +++ b/apps/web/e2e/directory.e2e.ts @@ -3,12 +3,13 @@ import { credentialsAuth, password, username } from './support/configuration.js' import { startServer } from './support/server.js'; const title = 'Family directory'; -const description = 'Things running at home.'; +const siteDescription = 'Things running at home.'; function configuration(auth = '') { return `site: + url: '\${TEST_ORIGIN}' title: ${title} - description: ${description} + description: ${siteDescription} footer: Ask your household maintainer. links: photos: @@ -92,13 +93,8 @@ test('actual bookmark HTML and Kit data remain private until sign-in', async ({ await server.ready(); const anonymous = await server.fetch('/signin'); const publicHtml = await anonymous.text(); - for (const value of [ - description, - 'photos.example.test', - 'Store your originals.', - username, - password - ]) { + expect(publicHtml).toContain(siteDescription); + for (const value of ['photos.example.test', 'Store your originals.', username, password]) { expect(publicHtml).not.toContain(value); } expect((await server.fetch('/__data.json')).status).toBe(401); diff --git a/apps/web/e2e/favicon.e2e.ts b/apps/web/e2e/favicon.e2e.ts index 31ffb5a..52d085b 100644 --- a/apps/web/e2e/favicon.e2e.ts +++ b/apps/web/e2e/favicon.e2e.ts @@ -5,6 +5,7 @@ import { expect, test } from '@playwright/test'; import { startServer, type TestServer } from './support/server.js'; const title = 'Favicon fixture'; +const siteDescription = 'Useful services with familiar browser icons.'; const username = 'PRIVATE_FAVICON_USER'; const password = 'PRIVATE_FAVICON_PASSWORD'; const sourceA = '/public/café family.svg'; @@ -17,7 +18,6 @@ const png = Buffer.from( const privateValues = [ username, password, - 'PRIVATE_DESCRIPTION', 'PRIVATE_FOOTER', 'PRIVATE_BOOKMARK', 'https://private.example.test', @@ -34,15 +34,15 @@ function configuration(source?: string, auth = true, selectedPassword = password let authentication = ''; if (auth) { authentication = `auth: - origin: '\${TEST_ORIGIN}' credentials: username: ${username} password: ${selectedPassword} `; } return `site: + url: '\${TEST_ORIGIN}' title: ${title} - description: PRIVATE_DESCRIPTION + description: ${siteDescription} footer: PRIVATE_FOOTER logo_path: /public/private-brand.svg ${favicon}links: @@ -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..9e709ae --- /dev/null +++ b/apps/web/e2e/open-graph.e2e.ts @@ -0,0 +1,229 @@ +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, + siteDescription, + 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:/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, description = siteDescription) { + expect(metadata(html)).toEqual({ + 'og:type': 'website', + 'og:title': siteTitle, + ...(description.trim() ? { 'og:description': description } : {}), + '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 configuredUrl of [false, true]) { + test(`no-auth SSR uses ${configuredUrl ? 'configured site.url despite request host and proxy headers' : 'HTTP localhost PORT by default despite PROTOCOL_HEADER'}`, async () => { + const description = configuredUrl ? 'Useful links for "family" & friends.' : siteDescription; + const blankDescription = configuredUrl ? '' : ' '; + let config = configuration('').replace( + JSON.stringify(siteDescription), + JSON.stringify(description) + ); + // Leave site.url absent to exercise shared config's allocated PORT default. + if (!configuredUrl) config = config.replace(" url: '${TEST_ORIGIN}'\n", ''); + const canonicalUrl = 'https://directory.example.test'; + const server = await startServer({ + configuration: config, + publicDir, + ...(configuredUrl ? { origin: canonicalUrl } : {}), + env: { + ORIGIN: 'https://legacy-origin.example.test', + AUTH_URL: 'https://legacy-auth.example.test', + PROTOCOL_HEADER: 'x-forwarded-proto', + HOST_HEADER: 'x-forwarded-host', + PORT_HEADER: 'x-forwarded-port' + } + }); + const headers = { + host: 'foreign.example.test', + 'x-forwarded-host': 'proxy.example.test', + 'x-forwarded-proto': 'https', + 'x-forwarded-port': '443', + forwarded: 'host=forwarded.example.test;proto=https' + }; + try { + await server.ready(); + const html = await ssr(server, '/', headers); + const origin = configuredUrl ? canonicalUrl : server.url; + // Check the wire HTML, including attribute escaping, without DOM decoding. + expectGeneratedMetadata( + html, + origin, + configuredUrl ? 'Useful links for "family" & friends.' : description + ); + await server.replace( + config.replace(JSON.stringify(description), JSON.stringify(blankDescription)) + ); + await expect + .poll(async () => metadata(await ssr(server, '/', headers))) + .not.toHaveProperty('og:description'); + expectGeneratedMetadata(await ssr(server, '/', headers), origin, blankDescription); + } 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..93591b0 100644 --- a/apps/web/e2e/site-header-logo.e2e.ts +++ b/apps/web/e2e/site-header-logo.e2e.ts @@ -6,7 +6,7 @@ import { navigateQuery } from './support/navigation.js'; import { startServer, type TestServer } from './support/server.js'; const title = 'A household directory with a very long unbroken title HouseholdDirectoryForEveryone'; -const description = 'PRIVATE_HEADER_DESCRIPTION'; +const siteDescription = 'Useful services for everyone in the household.'; const username = 'PRIVATE_HEADER_USER'; const password = 'PRIVATE_HEADER_PASSWORD'; const sourceA = '/public/private-brand-a.svg'; @@ -24,15 +24,15 @@ function configuration(source?: string, auth = false) { const logo = source === undefined ? '' : ` logo_path: ${source}\n`; const authentication = auth ? `auth: - origin: '\${TEST_ORIGIN}' credentials: username: ${username} password: ${password} ` : ''; return `site: + url: '\${TEST_ORIGIN}' title: ${title} - description: ${description} + description: ${siteDescription} favicon_path: /public/tab.svg ${logo}links: book: @@ -74,7 +74,7 @@ async function expectBroken(image: Locator) { } function expectNoPrivateData(text: string) { - for (const value of [sourceA, sourceB, description, username, password, icon]) { + for (const value of [sourceA, sourceB, username, password, icon]) { expect(text).not.toContain(value); } } @@ -115,16 +115,16 @@ 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.locator('header .description')).toHaveText(siteDescription); await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0); await expect(page.locator('head link[rel="icon"]')).toHaveJSProperty( 'href', @@ -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( () => @@ -213,7 +213,7 @@ test('without JavaScript a broken configured header leaves title and native navi await expect(page.locator('header h1')).toHaveText(title); await page.locator('a[href="/?link=book"]').click(); await expect(page).toHaveURL(`${app.url}/?link=book`); - await expect(page.locator('header .description')).toHaveText(description); + await expect(page.locator('header .description')).toHaveText(siteDescription); } finally { await context.close(); } @@ -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) @@ -268,7 +268,7 @@ test('source changes retry A to B to repaired A in place, and hydrated errors us } }); -test('logo source and metadata stay private while selected bytes brand sign-in and the narrow header', async ({ +test('logo source and credentials stay private while selected bytes brand sign-in and the narrow header', async ({ browser }) => { const app = await boot(sourceA, true); diff --git a/apps/web/e2e/support/configuration.ts b/apps/web/e2e/support/configuration.ts index 944d3e0..c019afe 100644 --- a/apps/web/e2e/support/configuration.ts +++ b/apps/web/e2e/support/configuration.ts @@ -3,13 +3,12 @@ export const username = 'PRIVATE_C6_USERNAME'; export const password = 'PRIVATE_C6_PASSWORD_!a7'; export const clientId = 'PRIVATE_C6_OIDC_CLIENT'; export const clientSecret = 'PRIVATE_C6_CLIENT_SECRET_!q9'; -export const privateDescription = 'PRIVATE_C6_SITE_DESCRIPTION'; +export const siteDescription = 'Useful services for the C6 family and friends.'; export const privateSentinels = [ username, password, clientId, clientSecret, - privateDescription, 'PRIVATE_C6_FOOTER', 'PRIVATE_C6_SERVICE', 'PRIVATE_C6_LINK_DESCRIPTION', @@ -18,7 +17,6 @@ export const privateSentinels = [ ]; export const credentialsAuth = `auth: - origin: '\${TEST_ORIGIN}' credentials: username: ${JSON.stringify(username)} password: ${JSON.stringify(password)} @@ -26,7 +24,6 @@ export const credentialsAuth = `auth: export function oidcAuth(issuer: string): string { return `auth: - origin: '\${TEST_ORIGIN}' oidc: issuer: ${JSON.stringify(issuer)} client_id: ${JSON.stringify(clientId)} @@ -37,8 +34,9 @@ export function oidcAuth(issuer: string): string { export function configuration(auth = credentialsAuth, title = siteTitle): string { return `version: 1 site: + url: '\${TEST_ORIGIN}' title: ${JSON.stringify(title)} - description: ${privateDescription} + description: ${JSON.stringify(siteDescription)} footer: PRIVATE_C6_FOOTER links: household: diff --git a/apps/web/e2e/support/server.ts b/apps/web/e2e/support/server.ts index d622e48..f4cc615 100644 --- a/apps/web/e2e/support/server.ts +++ b/apps/web/e2e/support/server.ts @@ -32,7 +32,7 @@ interface ServerOptions { publicDir?: string; /** Explicit YAML secret; omitted by default so the runtime generates its own. */ secret?: string; - /** Value for the fixture's ${TEST_ORIGIN}; defaults to the allocated localhost URL. */ + /** Value for an explicit fixture site.url: '${TEST_ORIGIN}'; defaults to the allocated URL. */ origin?: string; port?: number; /** Deliberately injected process env, e.g. hostile legacy auth settings. */ diff --git a/apps/web/e2e/theme-toggle.e2e.ts b/apps/web/e2e/theme-toggle.e2e.ts index f9789c2..985f959 100644 --- a/apps/web/e2e/theme-toggle.e2e.ts +++ b/apps/web/e2e/theme-toggle.e2e.ts @@ -5,9 +5,10 @@ import { join } from 'node:path'; import { credentialsAuth, password, username } from './support/configuration.js'; import { startServer, type TestServer } from './support/server.js'; +const siteDescription = 'Useful household services in your preferred theme.'; const configuration = `site: title: Theme fixture - description: Private theme description + description: ${siteDescription} logo_path: /public/private-theme-logo.svg appearance: primary_light_color: '#13579b' @@ -296,12 +297,8 @@ test('saved theme on public sign-in and sign-out retains the public configured a } else { expect(text).not.toContain('--color-primary'); } - for (const value of [ - 'Private theme description', - '/public/private-theme-logo.svg', - username, - password - ]) { + if (path !== '/__data.json') expect(text).toContain(siteDescription); + for (const value of ['/public/private-theme-logo.svg', username, password]) { expect(text).not.toContain(value); } } 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/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index 8e3f0db..d6f7160 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -71,10 +71,14 @@ function snapshot(title: string, light = '#13579b', dark = '#c4d6e8') { return realSnapshot.prepareSnapshot( validateConfig( { - site: { title, description: `${title} description`, favicon_path: '/public/site.svg' }, + site: { + url: 'https://directory.example.com', + title, + description: `${title} description`, + favicon_path: '/public/site.svg' + }, appearance: { primary_light_color: light, primary_dark_color: dark }, auth: { - origin: 'https://directory.example.com', secret: sentinel, credentials: { username: 'family', password: sentinel } }, diff --git a/apps/web/src/lib/components/DocumentHead.svelte b/apps/web/src/lib/components/DocumentHead.svelte new file mode 100644 index 0000000..cfb6a98 --- /dev/null +++ b/apps/web/src/lib/components/DocumentHead.svelte @@ -0,0 +1,28 @@ + + + + + + + {#if metadata.description.trim()} + + {/if} + + + + + + {#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/appearance.test.ts b/apps/web/src/lib/server/appearance.test.ts index 0bc7599..e9dfec4 100644 --- a/apps/web/src/lib/server/appearance.test.ts +++ b/apps/web/src/lib/server/appearance.test.ts @@ -8,13 +8,13 @@ function snapshot(light: string, dark: string) { validateConfig( { site: { + url: 'https://private-auth.example.test', title: 'PRIVATE_TITLE', description: 'PRIVATE_DESCRIPTION', logo_path: '/public/PRIVATE_LOGO.svg' }, appearance: { primary_light_color: light, primary_dark_color: dark }, auth: { - origin: 'https://private-auth.example.test', secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS', credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } }, diff --git a/apps/web/src/lib/server/application.test.ts b/apps/web/src/lib/server/application.test.ts index 19f9f79..10d613e 100644 --- a/apps/web/src/lib/server/application.test.ts +++ b/apps/web/src/lib/server/application.test.ts @@ -21,8 +21,8 @@ vi.mock('./logger.js', () => ({ reportDirectoryIssue: mocks.report })); const realSnapshot = await vi.importActual('./directory/snapshot.js'); const sentinel = 'PRIVATE-password-env-filename.yml'; +const origin = 'https://directory.example.com'; const credentials = { - origin: 'https://directory.example.com', credentials: { username: 'family', password: sentinel } }; const authentication: Authentication = { @@ -34,11 +34,12 @@ const authentication: Authentication = { function configuration( title = 'boot', - auth?: DirectoryConfiguration['auth'] + auth?: DirectoryConfiguration['auth'], + url = origin ): DirectoryConfiguration { return validateConfig( { - site: { title, logo_path: `/public/${title}.svg` }, + site: { url, title, logo_path: `/public/${title}.svg` }, ...(auth === undefined ? {} : { auth }), links: { photos: { @@ -87,8 +88,11 @@ describe('application startup', () => { const snapshot = app.currentSnapshot(); expect(mocks.prepare).toHaveBeenCalledExactlyOnceWith(initial); expect(snapshot).toBe(mocks.prepare.mock.results[0]!.value); - expect(mocks.authenticate).toHaveBeenCalledExactlyOnceWith(snapshot.configuration.auth); - expect(mocks.authenticate.mock.calls[0]![0]).toBe(snapshot.configuration.auth); + expect(mocks.authenticate).toHaveBeenCalledExactlyOnceWith({ + ...snapshot.configuration.auth, + origin: snapshot.configuration.site.url + }); + expect(snapshot.configuration.auth).not.toHaveProperty('origin'); expect(app.authentication).toBe(authentication); expect(snapshot.configuration.auth?.credentials?.password).toBe(sentinel); expect(snapshot.configuration.auth).not.toHaveProperty('secret'); @@ -165,8 +169,7 @@ describe('application config policy', () => { Object.entries(equivalent.site).reverse() ) as DirectoryConfiguration['site']; equivalent.auth = { - credentials: { password: sentinel, username: 'family' }, - origin: credentials.origin + credentials: { password: sentinel, username: 'family' } }; expect(app.onConfigChange(equivalent, accepted)).toBe(true); expect(app.onConfigChange(configuration('accepted', credentials), equivalent)).toBe(true); @@ -209,11 +212,6 @@ describe('application config policy', () => { it.each([ { change: 'enable auth', before: undefined, after: credentials }, { change: 'disable auth', before: credentials, after: undefined }, - { - change: 'origin', - before: credentials, - after: { ...credentials, origin: 'https://other.example.com' } - }, { change: 'password', before: credentials, @@ -243,6 +241,24 @@ describe('application config policy', () => { expect(mocks.authenticate).toHaveBeenCalledTimes(before === undefined ? 0 : 1); }); + it.each([undefined, credentials])( + 'rejects site.url changes until the canonical URL is restored (auth: %j)', + (auth) => { + const initial = configuration('boot', auth); + const app = createApplication(initial); + const captured = app.currentSnapshot(); + const next = configuration('rejected', auth, 'https://other.example.com'); + expect(app.onConfigChange(next, initial)).toBe(false); + expect(app.currentSnapshot()).toBe(captured); + expect(mocks.prepare).toHaveBeenCalledOnce(); + expect(mocks.report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' }); + expect(mocks.authenticate).toHaveBeenCalledTimes(auth === undefined ? 0 : 1); + expect(app.onConfigChange(configuration('restored', auth), initial)).toBe(true); + expect(app.currentSnapshot().configuration.site.url).toBe(origin); + expect(app.currentSnapshot().display.site.title).toBe('restored'); + } + ); + it('throws a safe preparation failure, retains last good, and recovers using the accepted baseline', () => { const initial = configuration(); const app = createApplication(initial); diff --git a/apps/web/src/lib/server/application.ts b/apps/web/src/lib/server/application.ts index aaddc53..92f1b59 100644 --- a/apps/web/src/lib/server/application.ts +++ b/apps/web/src/lib/server/application.ts @@ -20,7 +20,9 @@ export function createApplication(initial: DirectoryConfiguration) { let authentication: Authentication | undefined; try { const auth = snapshot.configuration.auth; - if (auth !== undefined) authentication = createAuthentication(auth); + if (auth !== undefined) { + authentication = createAuthentication({ ...auth, origin: snapshot.configuration.site.url }); + } } catch { throw new DirectoryError({ code: 'auth_failed' }); } @@ -29,7 +31,7 @@ export function createApplication(initial: DirectoryConfiguration) { currentSnapshot: () => snapshot, authentication, onConfigChange(next: DirectoryConfiguration, previous: DirectoryConfiguration): boolean { - if (!isDeepStrictEqual(next.auth, previous.auth)) { + if (!isDeepStrictEqual(startupSettings(next), startupSettings(previous))) { reportDirectoryIssue({ code: 'restart_required' }); return false; } @@ -40,6 +42,10 @@ export function createApplication(initial: DirectoryConfiguration) { }; } +function startupSettings(config: DirectoryConfiguration) { + return { siteUrl: config.site.url, auth: config.auth }; +} + function prepare(config: DirectoryConfiguration) { try { return prepareSnapshot(config); 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/auth/index.ts b/apps/web/src/lib/server/auth/index.ts index 80bbd4e..d81cef4 100644 --- a/apps/web/src/lib/server/auth/index.ts +++ b/apps/web/src/lib/server/auth/index.ts @@ -31,7 +31,7 @@ const authJsLogger: NonNullable = { debug() {} }; -export function createAuthentication(config: DirectoryAuth): Authentication { +export function createAuthentication(config: DirectoryAuth & { origin: string }): Authentication { const { origin } = config; const secret = config.secret ?? randomBytes(32).toString('base64'); const strategy = config.credentials ? 'credentials' : 'oidc'; diff --git a/apps/web/src/lib/server/auth/providers.test.ts b/apps/web/src/lib/server/auth/providers.test.ts index b160c17..6b5866a 100644 --- a/apps/web/src/lib/server/auth/providers.test.ts +++ b/apps/web/src/lib/server/auth/providers.test.ts @@ -49,7 +49,6 @@ describe('credentials', () => { it('uses the native Credentials provider and captures configuration', async () => { const configured = { ...credentials }; const provider = buildProviders({ - origin: 'https://directory.example', credentials: configured })[0]; expect(provider).toMatchObject({ id: 'credentials', type: 'credentials' }); @@ -70,7 +69,6 @@ describe('credentials', () => { it('explicitly requires OIDC PKCE, state and nonce without adding schema fields', () => { expect( buildProviders({ - origin: 'https://directory.example', oidc: { issuer: 'https://issuer.example', client_id: 'client', client_secret: 'secret' } }) ).toEqual([ diff --git a/apps/web/src/lib/server/boot.process.test.ts b/apps/web/src/lib/server/boot.process.test.ts index e5c27eb..06569a0 100644 --- a/apps/web/src/lib/server/boot.process.test.ts +++ b/apps/web/src/lib/server/boot.process.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'; const serverModule = new URL('../../../build/bootstrap/start.js', import.meta.url).href; const sentinel = 'PRIVATE_CONFIG_CREDENTIAL_LINK'; const bookmarkTitle = 'Visible household bookmark'; -const credentialsConfiguration = `links: {}\nauth:\n origin: http://localhost\n credentials:\n username: ${sentinel}\n password: ${sentinel}`; +const credentialsConfiguration = `site:\n url: http://localhost\nlinks: {}\nauth:\n credentials:\n username: ${sentinel}\n password: ${sentinel}`; const hostileEnvironment = { ORIGIN: `https://foreign.example.com/${sentinel}`, AUTH_URL: `malformed-${sentinel}`, @@ -19,6 +19,7 @@ const hostileEnvironment = { function source(title: string): string { return ` site: + url: http://localhost title: ${title} description: ${title} description links: @@ -115,16 +116,25 @@ describe('production configuration startup', () => { it.each([ { name: 'missing file', code: 'load_failed', contents: undefined }, { name: 'invalid YAML', code: 'load_failed', contents: `links: [\n${sentinel}` }, - { name: 'invalid auth', code: 'load_failed', contents: 'links: {}\nauth: {}' }, + { + name: 'invalid auth', + code: 'load_failed', + contents: 'site:\n url: http://localhost\nlinks: {}\nauth: {}' + }, { name: 'missing environment value', code: 'load_failed', - contents: `links:\n photos:\n title: Photos\n url: '\${${sentinel}}'` + contents: `site:\n url: http://localhost\nlinks:\n photos:\n title: Photos\n url: '\${${sentinel}}'` }, { - name: 'credentials auth missing YAML origin', + name: 'open directory missing site.url with PORT=0', code: 'load_failed', - contents: credentialsConfiguration.replace(' origin: http://localhost\n', '') + contents: 'links: {}' + }, + { + name: 'credentials auth missing site.url with PORT=0', + code: 'load_failed', + contents: credentialsConfiguration.replace('site:\n url: http://localhost\n', '') } ])('rejects $name before opening a socket', async ({ contents, code }) => { const server = await launchServer(contents, hostileEnvironment); @@ -174,7 +184,7 @@ it.each([ name: 'with explicit legacy-named environment references', environment: { ORIGIN: 'http://localhost', AUTH_SECRET: sentinel }, contents: credentialsConfiguration - .replace('origin: http://localhost', 'origin: ${ORIGIN}') + .replace('url: http://localhost', 'url: ${ORIGIN}') .replace(`password: ${sentinel}`, 'password: ${AUTH_SECRET}') } ])('starts YAML credentials auth $name', async ({ environment, contents }) => { @@ -274,12 +284,25 @@ it('polls atomic and ctime-only saves, retains last good across invalid/deleted await vi.waitFor(() => expectPage(origin, 'third'), { timeout: 5000, interval: 50 }); await writeFile( server.path, - `${source('rejected')}\nauth:\n origin: http://localhost\n credentials:\n username: ${sentinel}\n password: ${sentinel}` + source('rejected URL').replace('url: http://localhost', 'url: https://other.example.com') ); await vi.waitFor(() => expect(server.output()).toContain('restart_required'), { timeout: 5000 }); await expectPage(origin, 'third'); await expectHealthy(origin); + const beforeAuthChange = server.output().split('restart_required').length; + await writeFile( + server.path, + `${source('rejected')}\nauth:\n credentials:\n username: ${sentinel}\n password: ${sentinel}` + ); + await vi.waitFor( + () => + expect(server.output().split('restart_required').length).toBeGreaterThan(beforeAuthChange), + { timeout: 5000 } + ); + await expectPage(origin, 'third'); + await expectHealthy(origin); + await writeFile(replacement, source('restored')); await rename(replacement, server.path); await vi.waitFor(() => expectPage(origin, 'restored'), { timeout: 5000, interval: 50 }); diff --git a/apps/web/src/lib/server/boot.test.ts b/apps/web/src/lib/server/boot.test.ts index ae0173c..bac4e1b 100644 --- a/apps/web/src/lib/server/boot.test.ts +++ b/apps/web/src/lib/server/boot.test.ts @@ -27,7 +27,7 @@ const path = '/config/directory.yaml'; const sentinel = 'PRIVATE-config-credential'; const origin = 'https://directory.example.com'; const frameworkEnv = { ORIGIN: origin, AUTH_URL: origin }; -const auth = { origin, credentials: { username: 'family', password: sentinel } }; +const auth = { credentials: { username: 'family', password: sentinel } }; const authentication: Authentication = { strategy: 'credentials', handle: vi.fn(), @@ -36,13 +36,13 @@ const authentication: Authentication = { }; function configuration(title = 'Home', enabled = false): DirectoryConfiguration { - return validateConfig({ site: { title }, links: {}, ...(enabled ? { auth } : {}) }); + return validateConfig({ site: { url: origin, title }, links: {}, ...(enabled ? { auth } : {}) }); } beforeEach(() => { vi.resetAllMocks(); - vi.stubEnv('ORIGIN', undefined); - vi.stubEnv('AUTH_URL', undefined); + vi.stubEnv('ORIGIN', origin); + vi.stubEnv('AUTH_URL', origin); mocks.path.mockReturnValue(path); mocks.publicDir.mockReturnValue('/mounted/public'); mocks.load.mockResolvedValue(configuration()); @@ -113,46 +113,61 @@ describe('application boot wiring', () => { const env = { ORIGIN: sentinel, AUTH_URL: sentinel }; const app = await bootApp(env, frameworkEnv); expect(mocks.load).toHaveBeenCalledExactlyOnceWith(path, { env }); - expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(auth); + expect(mocks.auth).toHaveBeenCalledExactlyOnceWith({ ...auth, origin }); expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan( mocks.monitor.mock.invocationCallOrder[0]! ); expect(app.authentication).toBe(authentication); }); - it.each([ - { name: 'auth added', enabled: true, prepared: undefined, privateEnv: {} }, - { name: 'auth removed', enabled: false, prepared: origin, privateEnv: frameworkEnv }, - { - name: 'prepared process origin changed', - enabled: true, - prepared: 'https://other.example.com', - privateEnv: frameworkEnv - }, - { - name: 'Kit private ORIGIN differs', - enabled: true, - prepared: origin, - privateEnv: { ...frameworkEnv, ORIGIN: sentinel } - }, - { - name: 'Kit private AUTH_URL differs', - enabled: true, - prepared: origin, - privateEnv: { ...frameworkEnv, AUTH_URL: sentinel } - } - ])( - 'fails closed when $name before auth or monitoring', - async ({ enabled, prepared, privateEnv }) => { - vi.stubEnv('ORIGIN', prepared); + describe.each([false, true])('canonical URL consistency (auth enabled: %s)', (enabled) => { + it('boots with the prepared canonical URL regardless of auth state', async () => { mocks.load.mockResolvedValueOnce(configuration('Home', enabled)); - await expect(bootApp({}, privateEnv)).rejects.toMatchObject({ - issue: { code: 'auth_failed' } - }); - expect(mocks.auth).not.toHaveBeenCalled(); - expect(mocks.monitor).not.toHaveBeenCalled(); - } - ); + const app = await bootApp({}, frameworkEnv); + expect(app.currentSnapshot().configuration.site.url).toBe(origin); + expect(app.authentication).toBe(enabled ? authentication : undefined); + }); + + it.each([ + { name: 'process ORIGIN missing', prepared: undefined, privateEnv: frameworkEnv }, + { name: 'process ORIGIN empty', prepared: '', privateEnv: frameworkEnv }, + { + name: 'prepared process origin changed', + prepared: 'https://other.example.com', + privateEnv: frameworkEnv + }, + { name: 'Kit private URLs missing', prepared: origin, privateEnv: {} }, + { + name: 'Kit private ORIGIN differs', + prepared: origin, + privateEnv: { ...frameworkEnv, ORIGIN: sentinel } + }, + { + name: 'Kit private AUTH_URL differs', + prepared: origin, + privateEnv: { ...frameworkEnv, AUTH_URL: sentinel } + }, + { + name: 'site.url changed after preparation', + prepared: origin, + privateEnv: frameworkEnv, + url: 'https://other.example.com' + } + ])( + 'fails closed when $name before auth or monitoring', + async ({ prepared, privateEnv, ...change }) => { + vi.stubEnv('ORIGIN', prepared); + const config = configuration('Home', enabled); + if ('url' in change && change.url) config.site.url = change.url; + mocks.load.mockResolvedValueOnce(config); + await expect(bootApp({}, privateEnv)).rejects.toMatchObject({ + issue: { code: 'auth_failed' } + }); + expect(mocks.auth).not.toHaveBeenCalled(); + expect(mocks.monitor).not.toHaveBeenCalled(); + } + ); + }); it('uses the process environment when no environments are injected', async () => { await bootApp(); diff --git a/apps/web/src/lib/server/boot.ts b/apps/web/src/lib/server/boot.ts index 0934929..6494a6a 100644 --- a/apps/web/src/lib/server/boot.ts +++ b/apps/web/src/lib/server/boot.ts @@ -31,9 +31,9 @@ export async function bootApp( // Reject changes between framework URL preparation and the initial application load. if ( - config.auth?.origin !== (process.env.ORIGIN || undefined) || - config.auth?.origin !== (frameworkEnv.ORIGIN || undefined) || - config.auth?.origin !== (frameworkEnv.AUTH_URL || undefined) + config.site.url !== process.env.ORIGIN || + config.site.url !== frameworkEnv.ORIGIN || + config.site.url !== frameworkEnv.AUTH_URL ) { throw new DirectoryError({ code: 'auth_failed' }); } diff --git a/apps/web/src/lib/server/directory/content.test.ts b/apps/web/src/lib/server/directory/content.test.ts index e304b23..730f23e 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'); @@ -229,11 +229,9 @@ describe('prepareDirectoryContent', () => { it.each([ { - origin: 'https://directory.example.com', credentials: { username: 'private-user', password: '${PRIVATE_VALUE}' } }, { - origin: 'https://directory.example.com', oidc: { issuer: 'https://identity.example.com', client_id: 'private-client', @@ -243,7 +241,7 @@ describe('prepareDirectoryContent', () => { ])('does not copy auth, loading metadata, environment or future fields', (auth) => { const sentinel = 'PRIVATE-SENTINEL-DO-NOT-COPY'; const config = validateConfig( - { links: { photos: destination }, auth }, + { site: { url: 'https://directory.example.com' }, links: { photos: destination }, auth }, { env: { PRIVATE_VALUE: sentinel } } diff --git a/apps/web/src/lib/server/directory/snapshot.test.ts b/apps/web/src/lib/server/directory/snapshot.test.ts index 0b88693..697d99b 100644 --- a/apps/web/src/lib/server/directory/snapshot.test.ts +++ b/apps/web/src/lib/server/directory/snapshot.test.ts @@ -8,9 +8,13 @@ const service = { title: 'Photos', url: 'https://photos.example.com' }; function configuration() { return validateConfig( { - site: { title: 'Home', logo_path: '/public/home.svg', favicon_path: '/public/site-icon.png' }, + site: { + url: 'https://directory.example.com', + title: 'Home', + logo_path: '/public/home.svg', + favicon_path: '/public/site-icon.png' + }, auth: { - origin: 'https://directory.example.com', secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS', credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } }, @@ -107,6 +111,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 +161,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 +170,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 +181,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..2bf5159 --- /dev/null +++ b/apps/web/src/lib/server/document.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { documentMetadata } from './document.js'; + +describe('document metadata', () => { + it('publishes the title, description and generated PNG with its known dimensions', () => { + expect( + documentMetadata( + { title: 'Home & family', description: 'Public site description' }, + 'https://directory.example' + ) + ).toEqual({ + title: 'Home & family', + description: 'Public site description', + url: 'https://directory.example/', + image: { + url: 'https://directory.example/images/og.png', + type: 'image/png', + width: 1200, + height: 630 + } + }); + }); + + it.each([ + { name: 'public text', description: 'Public site description' }, + { name: 'empty text', description: '' }, + { name: 'whitespace', description: ' \t\n ' }, + { + name: 'full, unescaped text', + description: ` & "family" 'notes'\n${'Full text. '.repeat(200)} ` + } + ])('preserves the public site description object with $name', ({ description }) => { + const site = { title: ' & "family"', description }; + const before = { ...site }; + const metadata = documentMetadata(site, 'https://directory.example'); + expect(metadata).toMatchObject(site); + expect(site).toEqual(before); + }); + + 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( + { title: 'Home', description: '' }, + '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..8dae064 --- /dev/null +++ b/apps/web/src/lib/server/document.ts @@ -0,0 +1,38 @@ +export interface DocumentMetadata { + title: string; + description: string; + url: string; + image: { + url: string; + type: string; + width?: number; + height?: number; + }; +} + +export function documentMetadata( + site: { title: string; description: 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: site.title, + description: site.description, + 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..3673e3e --- /dev/null +++ b/apps/web/src/lib/server/images/open-graph.html @@ -0,0 +1,71 @@ + +
+
+
+
{{title}}
+ {{description}} +
+ +
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..d3ddcfd --- /dev/null +++ b/apps/web/src/lib/server/images/render-open-graph.test.ts @@ -0,0 +1,173 @@ +import { Renderer, type Node } from '@takumi-rs/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const branding = { + url: 'https://directory.example.com', + 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('renders the description and refreshes the cache when it changes', async () => { + const generate = await loadRenderOpenGraph(); + const draw = vi.spyOn(Renderer.prototype, 'render'); + const first = await generate({ ...branding, description: 'All your household apps.' }); + expect(await generate({ ...branding, description: 'All your household apps.' })).toBe(first); + expect(textContent(draw.mock.calls[0]![0] as Node)).toBe( + branding.title + 'All your household apps.' + branding.url + ); + expect(await generate({ ...branding, description: 'Photos, recipes and more.' })).not.toEqual( + first + ); + expect(draw).toHaveBeenCalledTimes(2); + }); + + it('renders the canonical URL and refreshes the cache when it changes', async () => { + const generate = await loadRenderOpenGraph(); + const draw = vi.spyOn(Renderer.prototype, 'render'); + const first = await generate(branding); + expect(textContent(draw.mock.calls[0]![0] as Node)).toBe(branding.title + branding.url); + expect(await generate(branding)).toBe(first); + const url = 'https://another.example.com:8443'; + expect(await generate({ ...branding, url })).not.toEqual(first); + expect(textContent(draw.mock.calls[1]![0] as Node)).toBe(branding.title + url); + expect(draw).toHaveBeenCalledTimes(2); + }); + + it('omits blank descriptions without changing the title-only image', async () => { + const generate = await loadRenderOpenGraph(); + const original = await generate(branding); + expect(await generate({ ...branding, description: ' \n\t ' })).toBe(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 branding as escaped text, not HTML or template substitutions', async () => { + const generate = await loadRenderOpenGraph(); + const draw = vi.spyOn(Renderer.prototype, 'render'); + const title = ' & {{description}} $&'; + const description = ' & {{title}} $&'; + const url = 'https://example.test/?value=&template={{title}}&replace=$&'; + await generate({ ...branding, url, title, description }); + expect(textContent(draw.mock.calls[0]![0] as Node)).toBe(title + description + url); + }); + + 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) + '…' + branding.url); + const layout = await new Renderer().measure(call[0], call[1]); + expect(layout.children[1]!.height).toBeLessThanOrEqual(240); + }); + + it('fits a long title, description and URL on the card', async () => { + const generate = await loadRenderOpenGraph(); + const draw = vi.spyOn(Renderer.prototype, 'render'); + const url = `https://${'w'.repeat(63)}.${'w'.repeat(63)}.example.com:8443`; + await generate({ ...branding, url, title: 'W'.repeat(2000), description: 'W'.repeat(2000) }); + const call = draw.mock.calls[0]!; + expect(textContent(call[0] as Node)).toBe('W'.repeat(79) + '…' + 'W'.repeat(159) + '…' + url); + const layout = await new Renderer().measure(call[0], call[1]); + expect(layout.children[1]!.height).toBeLessThanOrEqual(253); + const footer = layout.children[2]!; + const address = footer.children[1]!; + expect(address.height).toBe(28); + expect(address.transform[4] + address.width).toBeLessThanOrEqual(1200 - 88); + expect(footer.transform[5] + footer.height).toBeLessThanOrEqual(630 - 88); + expect(address.transform[5] + address.height).toBe(630 - 40); + }); + + 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..4510cc0 --- /dev/null +++ b/apps/web/src/lib/server/images/render-open-graph.ts @@ -0,0 +1,107 @@ +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 url: string; + readonly title: string; + readonly description?: string | undefined; + 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 = boundedText(branding.title, 80); + const description = boundedText(branding.description ?? '', 160); + const favicon = branding.favicon ?? defaultFavicon; + const key = createHash('sha256') + .update( + JSON.stringify([ + title, + description, + branding.url, + branding.primaryLightColor, + branding.primaryDarkColor + ]) + ) + .update(favicon) + .digest('hex'); + if (cached?.key === key) return cached.image; + + const pending = draw(title, description, 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, + description: 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; + if (description) fontSize -= 16; + const { node, css } = fromHtml( + template + .replaceAll('{{light}}', fullHex(branding.primaryLightColor)) + .replaceAll('{{dark}}', fullHex(branding.primaryDarkColor)) + .replaceAll('{{fontSize}}', String(fontSize)) + .replaceAll('{{gap}}', description ? '20' : '28') + .replace(/\{\{(title|description|url)\}\}/g, (_match, field: string) => { + if (field === 'url') return escapeHtml(branding.url); + if (field === 'title') return escapeHtml(title); + if (!description) return ''; + return `
${escapeHtml(description)}
`; + }) + .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 boundedText(value: string, limit: number): string { + const characters = Array.from(value.replace(/\s+/gu, ' ').trim()); + if (characters.length <= limit) return characters.join(''); + return characters.slice(0, limit - 1).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/lib/server/logger.test.ts b/apps/web/src/lib/server/logger.test.ts index aff50a8..9981857 100644 --- a/apps/web/src/lib/server/logger.test.ts +++ b/apps/web/src/lib/server/logger.test.ts @@ -67,20 +67,20 @@ describe('safe server reporting', () => { expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (prepare_failed).'); }); - it('reports auth schema issue paths without configuration values or legacy hints', () => { + it('reports site and auth schema issue paths without configuration values or legacy hints', () => { const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); reportStartupFailure( new DirectoryError({ code: 'load_failed', issues: new ConfigError([ - { code: 'invalid_value', path: '/auth/origin' }, + { code: 'invalid_value', path: '/site/url' }, { code: 'invalid_value', path: '/auth/secret' } ]).issues.map((detail) => ({ ...detail, message: sentinel })) }) ); expect(output.mock.calls).toEqual([ ['Application startup failed (load_failed).'], - [' /auth/origin (invalid_value)'], + [' /site/url (invalid_value)'], [' /auth/secret (invalid_value)'] ]); }); diff --git a/apps/web/src/lib/server/runtime-environment.test.ts b/apps/web/src/lib/server/runtime-environment.test.ts index 1559c2b..a554c51 100644 --- a/apps/web/src/lib/server/runtime-environment.test.ts +++ b/apps/web/src/lib/server/runtime-environment.test.ts @@ -1,7 +1,10 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DirectoryError } from './directory-error.js'; import { prepareRuntimeEnvironment } from './runtime-environment.js'; @@ -21,8 +24,9 @@ afterEach(async () => { function credentials(extra = ''): string { return `links: {} +site: + url: '${origin}' auth: - origin: '${origin}' credentials: username: family password: test-password @@ -30,7 +34,99 @@ ${extra}`; } describe('framework environment preparation', () => { - it('uses the YAML origin instead of legacy framework settings', async () => { + it('captures the final Vite CLI port before Kit snapshots stale framework URLs', async () => { + const web = fileURLToPath(new URL('../../../', import.meta.url)); + const vite = join(web, 'node_modules/vite/bin/vite.js'); + const host = '127.0.0.1'; + const socket = createServer(); + await new Promise((resolve, reject) => { + socket.once('error', reject); + socket.listen(0, host, resolve); + }); + const address = socket.address(); + await new Promise((resolve, reject) => + socket.close((error) => (error ? reject(error) : resolve())) + ); + if (address === null || typeof address === 'string') throw new Error('Expected a TCP port'); + const port = String(address.port); + + // Run the real Vite/Kit lifecycle without writing generated files into the working app. + await mkdir(join(directory, 'src/routes'), { recursive: true }); + for (const file of ['src/app.html', 'svelte.config.js', 'tsconfig.json']) { + await cp(join(web, file), join(directory, file)); + } + await symlink(join(web, 'node_modules'), join(directory, 'node_modules'), 'dir'); + await writeFile(join(directory, 'package.json'), '{"type":"module"}'); + await writeFile( + join(directory, 'vite.config.mjs'), + `import config from ${JSON.stringify(join(web, 'vite.config.ts'))}; +export default (env) => { + const result = config(env); + return { ...result, cacheDir: ${JSON.stringify(join(directory, '.vite'))}, + server: { ...result.server, fs: { allow: ${JSON.stringify([directory, web])} } } }; +};` + ); + await writeFile(path, 'links: {}'); + await writeFile( + join(directory, '.env'), + 'PORT=2\nORIGIN=https://stale.example.com\nAUTH_URL=https://stale.example.com\n' + ); + await writeFile( + join(directory, 'src/routes/+page.server.js'), + `import { env } from '$env/dynamic/private'; +export const load = () => ({ settings: env.ORIGIN + '|' + env.AUTH_URL + '|' + process.env.PORT });` + ); + await writeFile( + join(directory, 'src/routes/+page.svelte'), + '

{data.settings}

' + ); + const env = { ...process.env }; + for (const key of ['SOCKET_PATH', 'LISTEN_FDS', 'ORIGIN', 'AUTH_URL', 'VITEST']) + delete env[key]; + const child = spawn( + process.execPath, + [vite, '--host', host, '--configLoader', 'runner', '--port', port], + { + cwd: directory, + env: { ...env, NODE_ENV: 'development', PORT: '1', CONFIG_PATH: path }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + let output = ''; + let failure: Error | undefined; + child.stdout.setEncoding('utf8').on('data', (text: string) => (output += text)); + child.stderr.setEncoding('utf8').on('data', (text: string) => (output += text)); + child.once('error', (error) => (failure = error)); + const closed = new Promise((resolve) => child.once('close', () => resolve())); + try { + // Probe HTTP, not Vite's console URL (which changes with CI and ANSI colors). + const html = await vi.waitFor( + async () => { + expect(failure, output).toBeUndefined(); + expect(child.exitCode, output).toBeNull(); + const response = await fetch(`http://${host}:${port}/`, { + headers: { connection: 'close' }, + signal: AbortSignal.timeout(5000) + }); + const html = await response.text(); + expect(response.status, `${output}\n${html}`).toBe(200); + return html; + }, + { timeout: 20_000 } + ); + expect(html).toContain(`http://localhost:${port}|http://localhost:${port}|${port}`); + } finally { + child.kill('SIGTERM'); + const timeout = setTimeout(() => child.kill('SIGKILL'), 2000); + try { + await closed; + } finally { + clearTimeout(timeout); + } + } + }, 30_000); + + it('uses site.url instead of legacy framework settings', async () => { await writeFile(path, credentials()); const env = { CONFIG_PATH: path, @@ -48,22 +144,76 @@ describe('framework environment preparation', () => { expect(env.AUTH_SECRET).toBe(sentinel); }); - it('removes inherited framework URLs for an open directory', async () => { - await writeFile(path, 'links: {}'); - const env = { - CONFIG_PATH: path, - ORIGIN: sentinel, - AUTH_URL: sentinel, - NEXTAUTH_URL: sentinel, - AUTH_REDIRECT_PROXY_URL: sentinel - }; + it.each([ + { settings: {}, expected: 'http://localhost:3000' }, + { settings: { NODE_ENV: 'development' }, expected: 'http://localhost:5173' }, + { settings: { NODE_ENV: 'production', PORT: '8080' }, expected: 'http://localhost:8080' } + ])( + 'sets canonical framework URLs for an open directory: $expected', + async ({ settings, expected }) => { + await writeFile(path, 'links: {}'); + const env = { + CONFIG_PATH: path, + ORIGIN: sentinel, + AUTH_URL: sentinel, + NEXTAUTH_URL: sentinel, + AUTH_REDIRECT_PROXY_URL: sentinel, + ...settings + }; + await prepareRuntimeEnvironment(env); + expect(env.ORIGIN).toBe(expected); + expect(env.AUTH_URL).toBe(expected); + expect(env.NEXTAUTH_URL).toBe(''); + expect(env.AUTH_REDIRECT_PROXY_URL).toBe(''); + } + ); + + it('uses an explicit site.url even with auth disabled and an ephemeral listen port', async () => { + await writeFile(path, `site:\n url: '${origin}'\nlinks: {}`); + const env = { CONFIG_PATH: path, PORT: '0', ORIGIN: sentinel, AUTH_URL: sentinel }; await prepareRuntimeEnvironment(env); - expect(env).not.toHaveProperty('ORIGIN'); - expect(env).not.toHaveProperty('AUTH_URL'); - expect(env.NEXTAUTH_URL).toBe(''); - expect(env.AUTH_REDIRECT_PROXY_URL).toBe(''); + expect(env.ORIGIN).toBe(origin); + expect(env.AUTH_URL).toBe(origin); }); + it('preserves explicit legacy-named environment interpolation in YAML', async () => { + await writeFile(path, credentials().replace(origin, '${ORIGIN}')); + const env = { CONFIG_PATH: path, ORIGIN: origin, AUTH_URL: sentinel }; + await prepareRuntimeEnvironment(env); + expect(env.ORIGIN).toBe(origin); + expect(env.AUTH_URL).toBe(origin); + }); + + it.each([{ PORT: '0' }, { SOCKET_PATH: '/tmp/directory.sock' }, { LISTEN_FDS: '1' }])( + 'fails safely without site.url for a nonstandard listener: %j', + async (settings) => { + await writeFile(path, 'links: {}'); + const env = { + CONFIG_PATH: path, + ORIGIN: sentinel, + AUTH_URL: sentinel, + NEXTAUTH_URL: sentinel, + AUTH_REDIRECT_PROXY_URL: sentinel, + ...settings + }; + const before = { ...env }; + await expect(prepareRuntimeEnvironment(env)).rejects.toMatchObject({ + issue: { + code: 'load_failed', + issues: [ + { + code: 'invalid_value', + path: '/site/url', + message: + 'Set site.url explicitly when using a random port, socket or socket activation.' + } + ] + } + }); + expect(env).toEqual(before); + } + ); + it('fails safely before changing the environment for invalid configuration', async () => { await writeFile(path, credentials(` secret: '${sentinel}'\n`)); const env = { CONFIG_PATH: path, ORIGIN: sentinel, AUTH_URL: sentinel }; diff --git a/apps/web/src/lib/server/runtime-environment.ts b/apps/web/src/lib/server/runtime-environment.ts index ea9a51b..8739db1 100644 --- a/apps/web/src/lib/server/runtime-environment.ts +++ b/apps/web/src/lib/server/runtime-environment.ts @@ -4,9 +4,10 @@ import { DirectoryError } from './directory-error.js'; export async function prepareRuntimeEnvironment( env: NodeJS.ProcessEnv = process.env ): Promise { - let origin: string | undefined; + let origin: string; try { - origin = (await loadConfigFile(undefined, { env })).auth?.origin; + const config = await loadConfigFile(undefined, { env }); + origin = config.site.url; } catch (error) { throw new DirectoryError({ code: 'load_failed', @@ -14,13 +15,8 @@ export async function prepareRuntimeEnvironment( }); } - if (origin === undefined) { - delete env.ORIGIN; - delete env.AUTH_URL; - } else { - env.ORIGIN = origin; - env.AUTH_URL = origin; - } + env.ORIGIN = origin; + env.AUTH_URL = origin; env.NEXTAUTH_URL = ''; env.AUTH_REDIRECT_PROXY_URL = ''; } diff --git a/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts b/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts index ac0b439..4e08589 100644 --- a/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts +++ b/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts @@ -9,9 +9,8 @@ function snapshot(title: string) { return prepareSnapshot( validateConfig( { - site: { title, description: sentinel }, + site: { url: 'https://private-auth.example.test', title, description: sentinel }, auth: { - origin: 'https://private-auth.example.test', secret: `${sentinel}-session-secret`, credentials: { username: sentinel, password: sentinel } }, 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/(protected)/page.test.ts b/apps/web/src/routes/(protected)/page.test.ts index c62bbdb..2e309d2 100644 --- a/apps/web/src/routes/(protected)/page.test.ts +++ b/apps/web/src/routes/(protected)/page.test.ts @@ -7,6 +7,7 @@ import { actions, load } from './+page.server.js'; function eventFor(query = '', withAuth = false) { const configuration: Record = { site: { + ...(withAuth ? { url: 'https://private-auth.example.test' } : {}), title: 'Home Server', description: 'Useful things running at home.', footer: 'Ask the household for help.', @@ -33,7 +34,6 @@ function eventFor(query = '', withAuth = false) { }; if (withAuth) { configuration.auth = { - origin: 'https://private-auth.example.test', secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS', credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } }; 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..0e3690d --- /dev/null +++ b/apps/web/src/routes/(public)/images/og.png/+server.ts @@ -0,0 +1,31 @@ +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({ + url: site.url, + title: site.title, + description: site.description, + 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..e5da7e0 --- /dev/null +++ b/apps/web/src/routes/(public)/images/og.png/server.test.ts @@ -0,0 +1,261 @@ +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 = { + url: 'https://private.example', + title: 'Captured home', + description: 'Useful household services.', + primaryLightColor: '#123456', + primaryDarkColor: '#abc' +}; + +function snapshot(site: Record = {}, colors = appearance) { + return prepareSnapshot( + validateConfig( + { + site: { + url: branding.url, + title: branding.title, + description: branding.description, + footer: 'PRIVATE_FOOTER', + logo_path: '/public/private-logo.png', + ...site + }, + appearance: colors, + auth: { + 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({ + url: 'http://localhost:3000', + title: 'Directory', + description: '', + 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&url=https://attacker.example', + '/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( + { + url: 'https://replacement.example', + title: 'Replacement', + description: 'Another household.', + 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 }], + [ + { + url: 'https://replacement.example', + title: 'Replacement', + description: 'Another household.', + 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 @@