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 =
'';
const privateBrand =
@@ -13,7 +13,6 @@ const privateBrand =
const replacementImage =
'';
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 @@
diff --git a/apps/web/src/routes/(public)/signin/page.test.ts b/apps/web/src/routes/(public)/signin/page.test.ts
index c4fef83..8c63d48 100644
--- a/apps/web/src/routes/(public)/signin/page.test.ts
+++ b/apps/web/src/routes/(public)/signin/page.test.ts
@@ -12,12 +12,12 @@ function eventFor(query = '', strategy: Authentication['strategy'] = 'credential
validateConfig(
{
site: {
+ url: 'https://private-auth.example.test',
title: 'Home Server',
description: 'PRIVATE_DESCRIPTION',
logo_path: '/public/private.svg'
},
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/+layout.server.ts b/apps/web/src/routes/+layout.server.ts
index 01f3eb9..a40a2ab 100644
--- a/apps/web/src/routes/+layout.server.ts
+++ b/apps/web/src/routes/+layout.server.ts
@@ -1,10 +1,23 @@
+import { readMountedImage } from '../lib/server/images/read-mounted-image.js';
+import { documentMetadata } from '../lib/server/document.js';
import type { LayoutServerLoad } from './$types';
-export const load = (({ locals }) => {
+export const load = (async ({ locals }) => {
const { site } = locals.snapshot.configuration;
+ const supplied = site.og_image_path
+ ? await readMountedImage(locals.publicDir, site.og_image_path)
+ : undefined;
let faviconPath = site.favicon_path;
if (faviconPath.startsWith('/public/')) faviconPath = '/favicon';
let logoPath = site.logo_path;
if (logoPath.startsWith('/public/')) logoPath = '/logo';
- return { faviconPath, logoPath };
+ return {
+ faviconPath,
+ logoPath,
+ document: documentMetadata(
+ site,
+ site.url,
+ supplied && { path: site.og_image_path!, contentType: supplied.contentType }
+ )
+ };
}) satisfies LayoutServerLoad;
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte
index 0409b45..9c210ce 100644
--- a/apps/web/src/routes/+layout.svelte
+++ b/apps/web/src/routes/+layout.svelte
@@ -2,6 +2,7 @@
import { onMount, type Snippet } from 'svelte';
import { page } from '$app/state';
import ConfigUpdateNotice from '$lib/components/ConfigUpdateNotice.svelte';
+ import DocumentHead from '$lib/components/DocumentHead.svelte';
import type { LayoutData } from './$types';
import '../lib/styles/reset.css';
import '../lib/styles/theme.css';
@@ -21,9 +22,7 @@
});
-
-
-
+
{@render children()}
diff --git a/apps/web/src/routes/layout.test.ts b/apps/web/src/routes/layout.test.ts
index ac7635f..e7921ff 100644
--- a/apps/web/src/routes/layout.test.ts
+++ b/apps/web/src/routes/layout.test.ts
@@ -1,25 +1,58 @@
import { validateConfig } from '@directory/config';
-import { describe, expect, it } from 'vitest';
+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 { load } from './+layout.server.js';
-function event(site: Record = {}) {
+vi.mock('../lib/server/images/read-mounted-image.js', () => ({ readMountedImage: vi.fn() }));
+
+function event(
+ site: Record = {},
+ {
+ siteUrl = 'https://directory.example.test',
+ url = 'https://internal.example.test/signin?callbackUrl=%2Fprivate&link=PRIVATE_QUERY#PRIVATE_FRAGMENT',
+ withAuth = true,
+ env = {}
+ }: {
+ siteUrl?: string | null;
+ url?: string;
+ withAuth?: boolean;
+ env?: Record;
+ } = {}
+) {
const snapshot = prepareSnapshot(
validateConfig(
{
- site: { title: 'PRIVATE_LAYOUT_TITLE', description: 'PRIVATE_DESCRIPTION', ...site },
- appearance: { primary_light_color: '#13579b', primary_dark_color: '#c4d6e8' },
- auth: {
- origin: 'https://private-auth.example.test',
- secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS',
- credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' }
+ site: {
+ ...(siteUrl === null ? {} : { url: siteUrl }),
+ title: 'Home Server',
+ description: 'Public site description',
+ footer: 'PRIVATE_FOOTER',
+ ...site
},
- links: { private: { title: 'PRIVATE_LINK', url: 'https://private.example.test' } }
+ appearance: { primary_light_color: '#13579b', primary_dark_color: '#c4d6e8' },
+ ...(!withAuth
+ ? {}
+ : {
+ auth: {
+ secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS',
+ credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' }
+ }
+ }),
+ links: {
+ private: {
+ title: 'PRIVATE_LINK',
+ url: 'https://private.example.test',
+ description: 'PRIVATE_LINK_DESCRIPTION',
+ group: 'PRIVATE_GROUP'
+ }
+ }
},
- { env: {} }
+ { env }
)
);
return {
+ url: new URL(url),
locals: {
snapshot,
publicDir: '/PRIVATE_PUBLIC_ROOT',
@@ -30,59 +63,174 @@ function event(site: Record = {}) {
} as unknown as Parameters[0];
}
+function expectedData(faviconPath = '/favicon.png', logoPath = '/favicon.png') {
+ return {
+ faviconPath,
+ logoPath,
+ document: {
+ title: 'Home Server',
+ description: 'Public site description',
+ url: 'https://directory.example.test/',
+ image: {
+ url: 'https://directory.example.test/images/og.png',
+ type: 'image/png',
+ width: 1200,
+ height: 630
+ }
+ }
+ };
+}
+
+beforeEach(() => {
+ vi.mocked(readMountedImage).mockReset();
+});
+
describe('public branding layout data', () => {
- it('returns only constant aliases, with no appearance or private payload', () => {
+ it('publishes only public branding metadata, never private directory data', async () => {
const captured = event();
const before = JSON.stringify(captured.locals);
- const data = load(captured);
- expect(data).toEqual({ faviconPath: '/favicon.svg', logoPath: '/favicon.svg' });
+ const data = await load(captured);
+ expect(data).toEqual(expectedData());
+ for (const privateValue of ['PRIVATE_', 'private.example.test', '2099-01-01', '#13579b']) {
+ expect(JSON.stringify(data)).not.toContain(privateValue);
+ }
expect(JSON.stringify(captured.locals)).toBe(before);
+ expect(readMountedImage).not.toHaveBeenCalled();
});
- it('passes explicit resolved bundled selections through without mounted aliases', () => {
- const captured = event({ logo_path: '/favicon.svg', favicon_path: '/favicon.svg' });
- expect(load(captured)).toEqual({ faviconPath: '/favicon.svg', logoPath: '/favicon.svg' });
+ it.each([
+ { name: 'public text', description: 'Public site description' },
+ { name: 'empty text', description: '' },
+ { name: 'whitespace', description: ' \t\n ' },
+ {
+ name: 'full, unescaped text',
+ description: ` & "family"\n${'Full text. '.repeat(200)} `
+ }
+ ])('publishes the public site description with $name', async ({ description }) => {
+ const data = await load(event({ description }));
+ expect(data).toEqual({
+ ...expectedData(),
+ document: { ...expectedData().document, description }
+ });
+ });
+
+ it('preserves explicit bundled favicon and logo selections', async () => {
+ expect(await load(event({ logo_path: '/favicon.png', favicon_path: '/favicon.png' }))).toEqual(
+ expectedData()
+ );
});
it.each(['/public/PRIVATE_SOURCE.png', '/public/café family.svg'])(
- 'publishes constant aliases, never configured source %s or image bytes',
- (source) => {
- const captured = event({ logo_path: source, favicon_path: source });
- captured.locals.snapshot = {
- ...captured.locals.snapshot,
- assets: {
- referencedPaths: [source],
- brandingPaths: [source],
- bookmarkLogos: { private: 'PRIVATE_SVG_BYTES' }
- }
- };
- const data = load(captured);
- expect(data).toEqual({ faviconPath: '/favicon', logoPath: '/logo' });
+ 'keeps existing favicon/logo aliases for %s unchanged',
+ async (source) => {
+ const data = await load(event({ logo_path: source, favicon_path: source }));
+ expect(data).toEqual(expectedData('/favicon', '/logo'));
expect(JSON.stringify(data)).not.toContain(source);
- expect(JSON.stringify(data)).not.toContain('PRIVATE_');
+ expect(readMountedImage).not.toHaveBeenCalled();
}
);
- it('selects aliases only from captured configuration without reading display data', () => {
- const captured = event({ logo_path: '/public/PRIVATE_LOGO.svg' });
+ it('does not read directory display data or prepared asset registries', async () => {
+ const captured = event();
captured.locals.snapshot = {
...captured.locals.snapshot,
get display(): never {
throw new Error('Root layout must not read display data');
+ },
+ get assets(): never {
+ throw new Error('Root layout must not read image assets');
}
};
- expect(load(captured)).toEqual({ faviconPath: '/favicon.svg', logoPath: '/logo' });
+ expect(await load(captured)).toEqual(expectedData());
});
- it('preserves the immutable request capture when a later request gets a replacement', () => {
+ it.each([
+ {
+ siteUrl: 'https://directory.example.test',
+ url: 'http://internal.test:8080/signin?callbackUrl=%2Fsecret#private',
+ withAuth: true
+ },
+ {
+ siteUrl: 'http://localhost:3000',
+ url: 'https://internal.test/signin?callbackUrl=%2Fsecret#private',
+ withAuth: true
+ },
+ {
+ siteUrl: 'https://public.example.test:8443',
+ url: 'http://localhost:5173/private/path?link=secret#private',
+ withAuth: false
+ },
+ {
+ siteUrl: 'http://public.test:8080',
+ url: 'https://untrusted.test/private?tracking=ignored#fragment',
+ withAuth: false
+ }
+ ])(
+ 'uses configured site URL $siteUrl rather than request $url (auth: $withAuth)',
+ async ({ siteUrl, url, withAuth }) => {
+ const captured = event({}, { siteUrl, url, withAuth });
+ const metadata = (await load(captured)).document;
+ expect(metadata.url).toBe(`${siteUrl}/`);
+ expect(metadata.image.url).toBe(`${siteUrl}/images/og.png`);
+ }
+ );
+
+ it.each([
+ { env: {}, expected: 'http://localhost:3000' },
+ { env: { PORT: '5173' }, expected: 'http://localhost:5173' },
+ { env: { PORT: '8080' }, expected: 'http://localhost:8080' }
+ ])('uses the resolved default site URL $expected', async ({ env, expected }) => {
+ const captured = event(
+ {},
+ {
+ siteUrl: null,
+ withAuth: false,
+ url: 'https://untrusted.test:8443/private?tracking=ignored#fragment',
+ env
+ }
+ );
+ expect(captured.locals.snapshot.configuration.site.url).toBe(expected);
+ const metadata = (await load(captured)).document;
+ expect(metadata.url).toBe(`${expected}/`);
+ expect(metadata.image.url).toBe(`${expected}/images/og.png`);
+ });
+
+ it('uses a readable custom image at its own URL, preserving its actual MIME type', async () => {
+ vi.mocked(readMountedImage).mockResolvedValue({
+ bytes: new TextEncoder().encode(''),
+ contentType: 'image/svg+xml'
+ });
+ const source = '/public/images/café family.svg';
+ const data = await load(event({ og_image_path: source }));
+ expect(readMountedImage).toHaveBeenCalledExactlyOnceWith('/PRIVATE_PUBLIC_ROOT', source);
+ expect(data.document.image.url).toBe(
+ 'https://directory.example.test/public/images/caf%C3%A9%20family.svg'
+ );
+ expect(data.document.image.type).toBe('image/svg+xml');
+ expect(data.document.image).not.toHaveProperty('width');
+ expect(data.document.image).not.toHaveProperty('height');
+ expect(JSON.stringify(data)).not.toContain('