Add Open Graph previews and canonical site URL #56
No files matched your search
@@ -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).
|
||||
@@ -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.
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+48
-25
@@ -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 [
|
||||
|
||||
@@ -4,6 +4,7 @@ import { startServer } from './support/server.js';
|
||||
|
||||
function configuration(auth = '') {
|
||||
return `site:
|
||||
url: '\${TEST_ORIGIN}'
|
||||
title: Bookmark logo directory
|
||||
links:
|
||||
photos:
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><text>PRIVATE_IMAGE_BYTES</text></svg>';
|
||||
const privateBrand =
|
||||
@@ -13,7 +13,6 @@ const privateBrand =
|
||||
const replacementImage =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><text>Replacement image</text></svg>';
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h32v32H0z"/></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(/<head\b[^>]*>([\s\S]*?)<\/head>/i)?.[1];
|
||||
expect(head).toBeDefined();
|
||||
expect(head).not.toMatch(/<meta\b[^>]*(?:name|property)=["']twitter:/i);
|
||||
const entries = [
|
||||
...head!.matchAll(/<meta\b[^>]*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<string, string> = {}) {
|
||||
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(/<meta\b[^>]*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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
`<img[^>]*src="${source ?? '/favicon.svg'}"[^>]*alt=""[^>]*width="48"[^>]*height="48"[^>]*loading="eager"`
|
||||
`<img[^>]*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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import type { DocumentMetadata } from '$lib/server/document.js';
|
||||
|
||||
let { metadata, faviconPath }: { metadata: DocumentMetadata; faviconPath: string } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={faviconPath} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={metadata.title} />
|
||||
{#if metadata.description.trim()}
|
||||
<meta property="og:description" content={metadata.description} />
|
||||
{/if}
|
||||
<meta property="og:site_name" content={metadata.title} />
|
||||
<meta property="og:url" content={metadata.url} />
|
||||
<meta property="og:image" content={metadata.image.url} />
|
||||
<meta property="og:image:alt" content={`Preview of ${metadata.title}`} />
|
||||
<meta property="og:image:type" content={metadata.image.type} />
|
||||
{#if metadata.image.width !== undefined}
|
||||
<meta property="og:image:width" content={String(metadata.image.width)} />
|
||||
{/if}
|
||||
{#if metadata.image.height !== undefined}
|
||||
<meta property="og:image:height" content={String(metadata.image.height)} />
|
||||
{/if}
|
||||
{#if metadata.image.url.startsWith('https:')}
|
||||
<meta property="og:image:secure_url" content={metadata.image.url} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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' }
|
||||
},
|
||||
|
||||
@@ -21,8 +21,8 @@ vi.mock('./logger.js', () => ({ reportDirectoryIssue: mocks.report }));
|
||||
const realSnapshot =
|
||||
await vi.importActual<typeof import('./directory/snapshot.js')>('./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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -31,7 +31,7 @@ const authJsLogger: NonNullable<SvelteKitAuthConfig['logger']> = {
|
||||
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';
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,8 +29,8 @@ function collectAssetPaths(
|
||||
config: DirectoryConfiguration
|
||||
): Pick<PreparedSnapshot['assets'], 'referencedPaths' | 'brandingPaths'> {
|
||||
const brandingPaths = new Set<string>();
|
||||
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)) {
|
||||
|
||||
@@ -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: ` <Home> & "family" 'notes'\n${'Full text. '.repeat(200)} `
|
||||
}
|
||||
])('preserves the public site description object with $name', ({ description }) => {
|
||||
const site = { title: '<Home> & "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}`
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<style>
|
||||
.card {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 88px;
|
||||
gap: {{gap}}px;
|
||||
background: radial-gradient(ellipse at 100% 0%, {{light}}80, transparent 70%), radial-gradient(ellipse at 100% 100%, {{dark}}26, transparent 65%), #101719;
|
||||
color: #f5f7f7;
|
||||
}
|
||||
.favicon {
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 30px;
|
||||
background: #ffffff0d;
|
||||
border: 1px solid #ffffff26;
|
||||
box-shadow: 0 16px 48px #00000026;
|
||||
}
|
||||
img { width: 88px; height: 88px; object-fit: contain; }
|
||||
.copy { display: flex; flex-direction: column; gap: 14px; width: 100%; flex-shrink: 0; }
|
||||
.description {
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
color: #cbd5d2;
|
||||
max-height: 72px;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
.title {
|
||||
font-size: {{fontSize}}px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -2px;
|
||||
line-height: 1.12;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
.footer { display: flex; align-items: center; gap: 24px; width: 100%; height: 28px; flex-shrink: 0; }
|
||||
.accent { width: 64px; height: 5px; flex-shrink: 0; border-radius: 3px; background: {{dark}}; }
|
||||
.url {
|
||||
position: absolute;
|
||||
left: 88px;
|
||||
right: 88px;
|
||||
bottom: 40px;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
color: #a8bab5;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<div class="card">
|
||||
<div class="favicon"><img src="favicon" /></div>
|
||||
<div class="copy">
|
||||
<div class="title">{{title}}</div>
|
||||
{{description}}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="accent"></div>
|
||||
<div class="url">{{url}}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#c026d3"/></svg>'
|
||||
);
|
||||
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(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="${color}" d="M0 0h64v64H0z"/></svg>`
|
||||
);
|
||||
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 = '<img src="https://example.test"> & {{description}} $&';
|
||||
const description = '<script>not HTML</script> & {{title}} $&';
|
||||
const url = 'https://example.test/?value=<img>&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();
|
||||
});
|
||||
});
|
||||
@@ -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<ArrayBuffer> | undefined;
|
||||
}
|
||||
|
||||
const defaultFavicon = Buffer.from(bundledFavicon.slice(bundledFavicon.indexOf(',') + 1), 'base64');
|
||||
let renderer: Promise<Renderer> | undefined;
|
||||
let cached: { key: string; image: Promise<Uint8Array<ArrayBuffer>> } | undefined;
|
||||
|
||||
export async function renderOpenGraph(
|
||||
branding: OpenGraphBranding
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
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<ArrayBuffer>
|
||||
) {
|
||||
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 `<div class="description">${escapeHtml(description)}</div>`;
|
||||
})
|
||||
.replace(/>\s+</g, '><')
|
||||
.trim()
|
||||
);
|
||||
const render = (icon: Uint8Array<ArrayBuffer>) =>
|
||||
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('>', '>');
|
||||
}
|
||||
@@ -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)']
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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<void>((resolve, reject) => {
|
||||
socket.once('error', reject);
|
||||
socket.listen(0, host, resolve);
|
||||
});
|
||||
const address = socket.address();
|
||||
await new Promise<void>((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'),
|
||||
'<script>let { data } = $props();</script><p>{data.settings}</p>'
|
||||
);
|
||||
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<void>((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 };
|
||||
|
||||
@@ -4,9 +4,10 @@ import { DirectoryError } from './directory-error.js';
|
||||
export async function prepareRuntimeEnvironment(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<void> {
|
||||
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 = '';
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
<div class="site-brand">
|
||||
<Logo
|
||||
source={data.directory.site.logo_path}
|
||||
fallback="/favicon.svg"
|
||||
fallback="/favicon.png"
|
||||
size={48}
|
||||
loading="eager"
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { actions, load } from './+page.server.js';
|
||||
function eventFor(query = '', withAuth = false) {
|
||||
const configuration: Record<string, unknown> = {
|
||||
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' }
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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('<svg/>'), 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<string, string> = {}, 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<typeof GET>[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');
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="signin-container">
|
||||
<main class="signin-card">
|
||||
<header>
|
||||
<Logo source={data.logoPath} fallback="/favicon.svg" size={48} loading="eager" />
|
||||
<Logo source={data.logoPath} fallback="/favicon.png" size={48} loading="eager" />
|
||||
<h1>Sign in to {data.title}</h1>
|
||||
<p>Your family's directory of services.</p>
|
||||
</header>
|
||||
|
||||
@@ -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' }
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={data.faviconPath} />
|
||||
</svelte:head>
|
||||
<DocumentHead metadata={data.document} faviconPath={data.faviconPath} />
|
||||
|
||||
<ConfigUpdateNotice snapshotId={page.data.directory?.snapshotId} />
|
||||
{@render children()}
|
||||
@@ -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<string, string> = {}) {
|
||||
vi.mock('../lib/server/images/read-mounted-image.js', () => ({ readMountedImage: vi.fn() }));
|
||||
|
||||
function event(
|
||||
site: Record<string, string> = {},
|
||||
{
|
||||
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<string, string | undefined>;
|
||||
} = {}
|
||||
) {
|
||||
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<string, string> = {}) {
|
||||
} as unknown as Parameters<typeof load>[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: ` <Home> & "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('<svg/>'),
|
||||
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('<svg');
|
||||
});
|
||||
|
||||
it('selects the generated PNG when the configured file is unavailable, then recovers', async () => {
|
||||
const captured = event({ og_image_path: '/public/share.png' });
|
||||
expect(await load(captured)).toEqual(expectedData());
|
||||
vi.mocked(readMountedImage).mockResolvedValue({
|
||||
bytes: new Uint8Array([1]),
|
||||
contentType: 'image/png'
|
||||
});
|
||||
expect((await load(captured)).document.image.url).toBe(
|
||||
'https://directory.example.test/public/share.png'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the captured snapshot immutable across later config publications', async () => {
|
||||
const captured = event({ favicon_path: '/public/PRIVATE_ICON.svg' });
|
||||
const snapshot = captured.locals.snapshot;
|
||||
const before = JSON.stringify(snapshot);
|
||||
const data = load(captured);
|
||||
const replacement = event();
|
||||
expect(load(replacement)).not.toEqual(data);
|
||||
expect(load(captured)).toEqual(data);
|
||||
expect(data).toEqual({ faviconPath: '/favicon', logoPath: '/favicon.svg' });
|
||||
const data = await load(captured);
|
||||
expect((await load(event({ title: 'Updated title' }))).document.title).toBe('Updated title');
|
||||
expect(await load(captured)).toEqual(data);
|
||||
expect(captured.locals.snapshot).toBe(snapshot);
|
||||
expect(Object.isFrozen(snapshot)).toBe(true);
|
||||
expect(JSON.stringify(snapshot)).toBe(before);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="32" y="50" font-size="52" text-anchor="middle">🗂️</text></svg>
|
||||
|
Before Width: | Height: | Size: 137 B |
+19
-6
@@ -3,17 +3,30 @@ import { defineConfig, loadEnv } from 'vite';
|
||||
import { prepareRuntimeEnvironment } from './src/lib/server/runtime-environment.js';
|
||||
import Icons from 'unplugin-icons/vite';
|
||||
|
||||
export default defineConfig(async ({ command, mode, isPreview }) => {
|
||||
if (command === 'serve' && !isPreview) {
|
||||
export default defineConfig(({ command, mode, isPreview }) => {
|
||||
const development = command === 'serve' && !isPreview;
|
||||
if (development) {
|
||||
Object.assign(process.env, loadEnv(mode, process.cwd(), ''));
|
||||
await prepareRuntimeEnvironment();
|
||||
// Empty overrides stop Vite from restoring legacy URL values from dotenv files.
|
||||
process.env.ORIGIN ??= '';
|
||||
process.env.AUTH_URL ??= '';
|
||||
}
|
||||
const port = Number(process.env.PORT ?? 5173);
|
||||
return {
|
||||
server: { port, strictPort: true },
|
||||
optimizeDeps: { include: ['fuse.js'] },
|
||||
plugins: [
|
||||
{
|
||||
name: 'directory-dev-environment',
|
||||
enforce: 'pre',
|
||||
config: {
|
||||
order: 'pre',
|
||||
async handler(config) {
|
||||
if (!development) return;
|
||||
// CLI overrides are merged before config hooks. Kit snapshots env in its own
|
||||
// config hook, so configResolved would leave $env/dynamic/private stale.
|
||||
process.env.PORT = String(config.server?.port ?? 5173);
|
||||
await prepareRuntimeEnvironment();
|
||||
}
|
||||
}
|
||||
},
|
||||
sveltekit(),
|
||||
Icons({ compiler: 'svelte' }),
|
||||
// Kit's SSR loader does not run import.meta.hot.dispose.
|
||||
|
||||
+8
-2
@@ -7,10 +7,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
|
||||
@@ -27,7 +30,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
|
||||
@@ -39,9 +42,12 @@ services:
|
||||
platform: linux/amd64
|
||||
profiles:
|
||||
- tools
|
||||
# Optional values for ${VAR} references in directory.yaml.
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
PORT: ${PORT:-3000}
|
||||
volumes:
|
||||
- ./config:/app/config:ro
|
||||
read_only: true
|
||||
|
||||
+21
-19
@@ -20,14 +20,16 @@ Defaults apply when you leave a field out. Unknown fields and `null` are errors.
|
||||
|
||||
### site
|
||||
|
||||
| Field | Value | Default |
|
||||
| ----------------------- | ------------------------------------------------------------------- | -------------- |
|
||||
| `title` | Nonblank text, e.g. `Home`. | `Directory` |
|
||||
| `description` | Text below the title. | Empty |
|
||||
| `footer` | Text at the bottom of the page. | Empty |
|
||||
| `logo_path` | [Image path](#images), e.g. `/public/house.svg`, or `/favicon.svg`. | `/favicon.svg` |
|
||||
| `favicon_path` | Browser tab icon. Same path options as `logo_path`. | `/favicon.svg` |
|
||||
| `open_links_in_new_tab` | `true` or `false`. Applies to every destination. | `true` |
|
||||
| Field | Value | Default |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `url` | Public HTTP(S) origin; no credentials, path, query, fragment or trailing slash. Restart after changing. | `http://localhost:PORT` (dev `5173`, otherwise `3000`) |
|
||||
| `title` | Nonblank text, e.g. `Home`. | `Directory` |
|
||||
| `description` | Text below the title; public in Open Graph previews. | Empty |
|
||||
| `footer` | Text at the bottom of the page. | Empty |
|
||||
| `logo_path` | [Image path](#images), e.g. `/public/house.svg`, or `/favicon.png`. | `/favicon.png` |
|
||||
| `favicon_path` | Browser tab icon. Same path options as `logo_path`. | `/favicon.png` |
|
||||
| `og_image_path` | Public Open Graph [image path](#images), e.g. `/public/share.png`. | Auto-generated |
|
||||
| `open_links_in_new_tab` | `true` or `false`. Applies to every destination. | `true` |
|
||||
|
||||
### appearance
|
||||
|
||||
@@ -73,18 +75,18 @@ Omit this section to share your directory with the world.
|
||||
Use `credentials` for a basic username and password or `oidc` to turn it up to
|
||||
11 and wire into your authentication fiefdom. Choose one.
|
||||
|
||||
| Field | Value | Default |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
|
||||
| `origin` | Your directory's public origin, e.g. `https://home.example.com`. No credentials, path, query, fragment, or trailing slash. | Required when auth is enabled |
|
||||
| `secret` | Optional random string of at least 32 characters. Keeps sessions across restarts. | Generated at each startup |
|
||||
| `credentials.username` | Shared username, e.g. `family`. | Required for credentials |
|
||||
| `credentials.password` | Password, or `'${DIRECTORY_PASSWORD}'`. | Required for credentials |
|
||||
| `oidc.issuer` | Your provider’s full HTTP(S) issuer URL. | Required for OIDC |
|
||||
| `oidc.client_id` | Client ID registered with your provider. | Required for OIDC |
|
||||
| `oidc.client_secret` | Secret, or `'${DIRECTORY_OIDC_CLIENT_SECRET}'`. | Required for OIDC |
|
||||
| Field | Value | Default |
|
||||
| ---------------------- | --------------------------------------------------------------------------------- | ------------------------- |
|
||||
| `secret` | Optional random string of at least 32 characters. Keeps sessions across restarts. | Generated at each startup |
|
||||
| `credentials.username` | Shared username, e.g. `family`. | Required for credentials |
|
||||
| `credentials.password` | Password, or `'${DIRECTORY_PASSWORD}'`. | Required for credentials |
|
||||
| `oidc.issuer` | Your provider’s full HTTP(S) issuer URL. | Required for OIDC |
|
||||
| `oidc.client_id` | Client ID registered with your provider. | Required for OIDC |
|
||||
| `oidc.client_secret` | Secret, or `'${DIRECTORY_OIDC_CLIENT_SECRET}'`. | Required for OIDC |
|
||||
|
||||
Use HTTPS, except for local testing at `http://localhost:3000` (or your dev port).
|
||||
OIDC's callback is your `origin` followed by `/auth/callback/oidc`.
|
||||
With auth enabled, `site.url` must use HTTPS, except for local testing at
|
||||
`http://localhost:3000` (or your dev port).
|
||||
OIDC's callback is your `site.url` followed by `/auth/callback/oidc`.
|
||||
|
||||
By default, restarting Directory signs everyone out. Set `secret` if you'd rather
|
||||
keep sessions; generate it once with `openssl rand -base64 32`. Changing it signs
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
# Sign-in is disabled. Uncomment one auth example below to enable it.
|
||||
version: 1
|
||||
site:
|
||||
# Set your public origin when deploying beyond localhost (no trailing slash).
|
||||
# url: https://directory.example.com
|
||||
title: Home Server
|
||||
description: Useful things running at home.
|
||||
footer: Something not working? Ask your household maintainer.
|
||||
# Optional public Open Graph image; generated when omitted.
|
||||
# og_image_path: /public/share.png
|
||||
|
||||
appearance:
|
||||
primary_light_color: '#0f766e'
|
||||
primary_dark_color: '#5eead4'
|
||||
|
||||
# Shared sign-in. Use your directory's HTTPS address, with no trailing slash.
|
||||
# Shared sign-in. Set site.url above to your directory's HTTPS origin.
|
||||
# HTTP localhost is allowed for local testing. Restart after changing auth.
|
||||
# auth:
|
||||
# origin: https://directory.example.com
|
||||
# credentials:
|
||||
# username: admin
|
||||
# password: replace-with-your-own-password
|
||||
@@ -21,7 +24,6 @@ appearance:
|
||||
# Or SSO. Register this callback with your provider:
|
||||
# https://directory.example.com/auth/callback/oidc
|
||||
# auth:
|
||||
# origin: https://directory.example.com
|
||||
# oidc:
|
||||
# issuer: https://identity.example.com
|
||||
# client_id: directory
|
||||
|
||||
@@ -2,7 +2,7 @@ import { inspect } from 'node:util';
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest';
|
||||
import { ConfigError } from './errors.js';
|
||||
import { validateConfig } from './load.js';
|
||||
import type { DirectoryAuth } from './types.js';
|
||||
import type { DirectoryAuth, DirectorySite } from './types.js';
|
||||
|
||||
const origin = 'https://directory.example.com';
|
||||
const secret = 'test-only-secret-0123456789abcdef';
|
||||
@@ -25,9 +25,16 @@ const acceptedOrigins = [
|
||||
'http://localhost',
|
||||
'http://localhost:3000',
|
||||
'http://localhost:443',
|
||||
'http://localhost:0',
|
||||
'http://localhost:65535'
|
||||
];
|
||||
const insecureAuthOrigins = [
|
||||
'http://directory.example.com',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://[::1]:3000',
|
||||
'http://localhost.example.com',
|
||||
'http://sub.localhost',
|
||||
'http://localhost.'
|
||||
];
|
||||
const rejectedOrigins = [
|
||||
'',
|
||||
' ',
|
||||
@@ -38,12 +45,6 @@ const rejectedOrigins = [
|
||||
'javascript:alert(1)',
|
||||
'https://',
|
||||
'https://[broken',
|
||||
'http://directory.example.com',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://[::1]:3000',
|
||||
'http://localhost.example.com',
|
||||
'http://sub.localhost',
|
||||
'http://localhost.',
|
||||
'https://user:password@directory.example.com',
|
||||
'https://@directory.example.com',
|
||||
'https://directory.example.com/',
|
||||
@@ -66,6 +67,8 @@ const rejectedOrigins = [
|
||||
'https://directory.example.com:443',
|
||||
'http://localhost:80',
|
||||
'http://localhost:03000',
|
||||
'http://localhost:0',
|
||||
'https://directory.example.com:0',
|
||||
'http://localhost:',
|
||||
'http://localhost:65536',
|
||||
'https://directory.example.com:008443',
|
||||
@@ -81,17 +84,72 @@ const rejectedOrigins = [
|
||||
];
|
||||
|
||||
// These fields share validators across strategies; exercise URL attack classes once.
|
||||
describe('canonical auth origins', () => {
|
||||
describe('canonical auth site URLs', () => {
|
||||
it.each(acceptedOrigins)('accepts %j', (origin) => {
|
||||
expect(validateConfig({ links: {}, auth: { origin, credentials } }).auth?.origin).toBe(origin);
|
||||
expect(
|
||||
validateConfig({ links: {}, site: { url: origin }, auth: { credentials } }).site.url
|
||||
).toBe(origin);
|
||||
});
|
||||
|
||||
it.each([...rejectedOrigins, null, false, 3000, [], {}])(
|
||||
it.each([...insecureAuthOrigins, ...rejectedOrigins, null, false, 3000, [], {}])(
|
||||
'rejects rather than normalizing %j',
|
||||
(origin) => {
|
||||
expect(() => validateConfig({ links: {}, auth: { origin, credentials } })).toThrow(
|
||||
ConfigError
|
||||
);
|
||||
expect(() =>
|
||||
validateConfig({ links: {}, site: { url: origin }, auth: { credentials } })
|
||||
).toThrow(ConfigError);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('canonical open site URLs', () => {
|
||||
it.each([
|
||||
...acceptedOrigins,
|
||||
...insecureAuthOrigins,
|
||||
'http://example.com:8080',
|
||||
'http://[2001:db8::1]'
|
||||
])('accepts canonical HTTP(S) without authentication: %j', (url) => {
|
||||
expect(validateConfig({ links: {}, site: { url } }, { env: {} }).site.url).toBe(url);
|
||||
});
|
||||
|
||||
it.each([
|
||||
...rejectedOrigins,
|
||||
'http://example.com:80',
|
||||
'http://example.com/',
|
||||
'http://EXAMPLE.com',
|
||||
null,
|
||||
false,
|
||||
3000,
|
||||
[],
|
||||
{}
|
||||
])('rejects rather than normalizing %j', (url) => {
|
||||
expect(() => validateConfig({ links: {}, site: { url } }, { env: {} })).toThrow(ConfigError);
|
||||
});
|
||||
|
||||
it.each(strategies)('rejects old auth.origin for $name, even with site.url', ({ strategy }) => {
|
||||
for (const site of [undefined, { url: origin }]) {
|
||||
expect(() =>
|
||||
validateConfig(
|
||||
{ links: {}, ...(site ? { site } : {}), auth: { origin, ...strategy } },
|
||||
{ env: {} }
|
||||
)
|
||||
).toThrow(ConfigError);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(strategies)(
|
||||
'reports HTTPS security violations at /site/url for $name',
|
||||
({ strategy }) => {
|
||||
for (const url of insecureAuthOrigins) {
|
||||
try {
|
||||
validateConfig({ links: {}, site: { url }, auth: strategy }, { env: {} });
|
||||
expect.fail('Expected HTTPS requirement');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ConfigError);
|
||||
expect((error as ConfigError).issues).toMatchObject([
|
||||
{ code: 'invalid_value', path: '/site/url' }
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -101,7 +159,8 @@ describe('optional auth secrets', () => {
|
||||
'preserves a supplied secret verbatim',
|
||||
(secret) => {
|
||||
expect(
|
||||
validateConfig({ links: {}, auth: { origin, secret, credentials } }).auth?.secret
|
||||
validateConfig({ links: {}, site: { url: origin }, auth: { secret, credentials } }).auth
|
||||
?.secret
|
||||
).toBe(secret);
|
||||
}
|
||||
);
|
||||
@@ -109,16 +168,17 @@ describe('optional auth secrets', () => {
|
||||
it.each([null, false, 32, [], {}, '', 'x'.repeat(31), ' '.repeat(32)])(
|
||||
'rejects invalid explicit secret %j',
|
||||
(secret) => {
|
||||
expect(() => validateConfig({ links: {}, auth: { origin, secret, credentials } })).toThrow(
|
||||
ConfigError
|
||||
);
|
||||
expect(() =>
|
||||
validateConfig({ links: {}, site: { url: origin }, auth: { secret, credentials } })
|
||||
).toThrow(ConfigError);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('auth strategy boundary', () => {
|
||||
it('retains required origin and optional secret types', () => {
|
||||
expectTypeOf<DirectoryAuth['origin']>().toEqualTypeOf<string>();
|
||||
it('retains required site URL and originless auth with optional secret types', () => {
|
||||
expectTypeOf<DirectorySite['url']>().toEqualTypeOf<string>();
|
||||
expectTypeOf<DirectoryAuth>().not.toHaveProperty('origin');
|
||||
expectTypeOf<DirectoryAuth['secret']>().toEqualTypeOf<string | undefined>();
|
||||
});
|
||||
|
||||
@@ -129,17 +189,20 @@ describe('auth strategy boundary', () => {
|
||||
});
|
||||
|
||||
it.each(strategies)(
|
||||
'requires an explicit origin for $name and never generates a secret',
|
||||
'defaults site URL for $name and never generates a secret',
|
||||
({ strategy }) => {
|
||||
const env = { ORIGIN: origin, AUTH_SECRET: secret };
|
||||
expect(() => validateConfig({ links: {}, auth: strategy }, { env })).toThrow(ConfigError);
|
||||
const raw = { links: {}, auth: { origin, ...strategy } };
|
||||
expect(validateConfig({ links: {}, auth: strategy }, { env }).site.url).toBe(
|
||||
'http://localhost:3000'
|
||||
);
|
||||
const raw = { links: {}, site: { url: origin }, auth: { ...strategy } };
|
||||
const before = structuredClone(raw);
|
||||
expect(validateConfig(raw, { env }).auth).toEqual(raw.auth);
|
||||
expect(validateConfig(raw, { env }).auth).not.toHaveProperty('secret');
|
||||
expect(raw).toEqual(before);
|
||||
expect(validateConfig({ links: {}, auth: { origin, secret, ...strategy } }).auth).toEqual({
|
||||
origin,
|
||||
expect(
|
||||
validateConfig({ links: {}, site: { url: origin }, auth: { secret, ...strategy } }).auth
|
||||
).toEqual({
|
||||
secret,
|
||||
...strategy
|
||||
});
|
||||
@@ -148,10 +211,10 @@ describe('auth strategy boundary', () => {
|
||||
|
||||
it('applies the shared origin and secret constraints to OIDC too', () => {
|
||||
expect(() =>
|
||||
validateConfig({ links: {}, auth: { origin: 'http://directory.example.com', oidc } })
|
||||
validateConfig({ links: {}, site: { url: 'http://directory.example.com' }, auth: { oidc } })
|
||||
).toThrow(ConfigError);
|
||||
expect(() =>
|
||||
validateConfig({ links: {}, auth: { origin, secret: 'x'.repeat(31), oidc } })
|
||||
validateConfig({ links: {}, site: { url: origin }, auth: { secret: 'x'.repeat(31), oidc } })
|
||||
).toThrow(ConfigError);
|
||||
});
|
||||
});
|
||||
@@ -160,23 +223,26 @@ describe('auth interpolation and safe errors', () => {
|
||||
it('expands origin and secret before validation without changing authored input', () => {
|
||||
const raw = {
|
||||
links: {},
|
||||
auth: { origin: '${PUBLIC_ORIGIN:-http://localhost:3000}', secret: '${SECRET}', credentials }
|
||||
site: { url: '${PUBLIC_ORIGIN:-http://localhost:3000}' },
|
||||
auth: { secret: '${SECRET}', credentials }
|
||||
};
|
||||
const before = structuredClone(raw);
|
||||
expect(validateConfig(raw, { env: { PUBLIC_ORIGIN: origin, SECRET: secret } }).site.url).toBe(
|
||||
origin
|
||||
);
|
||||
expect(validateConfig(raw, { env: { SECRET: secret } }).site.url).toBe('http://localhost:3000');
|
||||
expect(validateConfig(raw, { env: { PUBLIC_ORIGIN: origin, SECRET: secret } }).auth).toEqual({
|
||||
origin,
|
||||
secret,
|
||||
credentials
|
||||
});
|
||||
expect(validateConfig(raw, { env: { SECRET: secret } }).auth).toEqual({
|
||||
origin: 'http://localhost:3000',
|
||||
secret,
|
||||
credentials
|
||||
});
|
||||
expect(raw).toEqual(before);
|
||||
});
|
||||
|
||||
it.each(['origin', 'secret'] as const)(
|
||||
it.each(['url', 'secret'] as const)(
|
||||
'redacts invalid literal, expanded and missing %s',
|
||||
(field) => {
|
||||
const sentinel = 'SENTINEL-NEVER-LOG-THIS';
|
||||
@@ -187,7 +253,11 @@ describe('auth interpolation and safe errors', () => {
|
||||
]) {
|
||||
try {
|
||||
validateConfig(
|
||||
{ links: {}, auth: { origin, secret, credentials, [field]: value } },
|
||||
{
|
||||
links: {},
|
||||
site: { url: field === 'url' ? value : origin },
|
||||
auth: { secret: field === 'secret' ? value : secret, credentials }
|
||||
},
|
||||
{ env }
|
||||
);
|
||||
expect.fail('Expected invalid auth');
|
||||
@@ -206,7 +276,7 @@ describe('auth interpolation and safe errors', () => {
|
||||
expect(error).not.toHaveProperty('cause');
|
||||
if (value.startsWith('${') && !env.PRIVATE_VALUE) {
|
||||
expect((error as ConfigError).issues).toMatchObject([
|
||||
{ code: 'missing_environment', path: `/auth/${field}` }
|
||||
{ code: 'missing_environment', path: field === 'url' ? '/site/url' : '/auth/secret' }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"AuthOrigin": {
|
||||
"description": "The canonical public origin of Directory. HTTPS is required except for http://localhost[:port]. No credentials, path, trailing slash, query, fragment, or noncanonical URL spelling.",
|
||||
"SiteUrl": {
|
||||
"description": "The canonical public HTTP(S) origin of Directory. No credentials, path, trailing slash, query, fragment, or noncanonical URL spelling. Authentication requires HTTPS except for http://localhost[:port]. Omitted values use localhost with PORT, or 5173 in development and 3000 otherwise.",
|
||||
"type": "string",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^(?:https://(?:[^\\s/\\\\?#@:\\[\\]A-Z%]+|\\[[0-9a-f:.]+\\])(?::(?!443(?![\\s\\S]))[0-9]+)?|http://localhost(?::(?!80(?![\\s\\S]))[0-9]+)?)(?![\\s\\S])"
|
||||
"pattern": "^(?:https://(?:[^\\s/\\\\?#@:\\[\\]A-Z%]+|\\[[0-9a-f:.]+\\])(?::(?!443(?![\\s\\S]))[1-9][0-9]*)?|http://(?:[^\\s/\\\\?#@:\\[\\]A-Z%]+|\\[[0-9a-f:.]+\\])(?::(?!80(?![\\s\\S]))[1-9][0-9]*)?)(?![\\s\\S])"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
@@ -74,13 +74,13 @@
|
||||
]
|
||||
},
|
||||
"SiteImagePath": {
|
||||
"description": "A mounted image path or the bundled /favicon.svg mark.",
|
||||
"description": "A mounted image path or the bundled /favicon.png mark.",
|
||||
"anyOf": [
|
||||
{ "$ref": "#/$defs/LocalImagePath" },
|
||||
{
|
||||
"type": "string",
|
||||
"anyOf": [
|
||||
{ "type": "string", "const": "/favicon.svg" },
|
||||
{ "type": "string", "const": "/favicon.png" },
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "(?:^|[^$])(?:\\$\\$)*\\$\\{[A-Z_][A-Z0-9_]*(?::-[^}]*)?\\}"
|
||||
@@ -166,6 +166,7 @@
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "default": "http://localhost:3000", "$ref": "#/$defs/SiteUrl" },
|
||||
"title": {
|
||||
"default": "Directory",
|
||||
"type": "string",
|
||||
@@ -178,8 +179,12 @@
|
||||
]
|
||||
},
|
||||
"description": { "default": "", "type": "string" },
|
||||
"logo_path": { "default": "/favicon.svg", "$ref": "#/$defs/SiteImagePath" },
|
||||
"favicon_path": { "default": "/favicon.svg", "$ref": "#/$defs/SiteImagePath" },
|
||||
"logo_path": { "default": "/favicon.png", "$ref": "#/$defs/SiteImagePath" },
|
||||
"favicon_path": { "default": "/favicon.png", "$ref": "#/$defs/SiteImagePath" },
|
||||
"og_image_path": {
|
||||
"description": "Optional public Open Graph image, including when authentication is enabled. Use a /public/ image path, not an external URL; PNG or JPEG at 1200×630 is recommended. Omit or supply a missing image to generate a 1200×630 PNG from the site title, favicon and appearance only.",
|
||||
"$ref": "#/$defs/LocalImagePath"
|
||||
},
|
||||
"footer": { "default": "", "type": "string" },
|
||||
"open_links_in_new_tab": {
|
||||
"default": true,
|
||||
@@ -258,9 +263,8 @@
|
||||
"additionalProperties": false,
|
||||
"title": "OIDC",
|
||||
"type": "object",
|
||||
"required": ["origin", "oidc"],
|
||||
"required": ["oidc"],
|
||||
"properties": {
|
||||
"origin": { "$ref": "#/$defs/AuthOrigin" },
|
||||
"secret": { "$ref": "#/$defs/AuthSecret" },
|
||||
"oidc": { "$ref": "#/$defs/OidcAuth" },
|
||||
"credentials": { "not": {} }
|
||||
@@ -270,9 +274,8 @@
|
||||
"additionalProperties": false,
|
||||
"title": "Shared username and password",
|
||||
"type": "object",
|
||||
"required": ["origin", "credentials"],
|
||||
"required": ["credentials"],
|
||||
"properties": {
|
||||
"origin": { "$ref": "#/$defs/AuthOrigin" },
|
||||
"secret": { "$ref": "#/$defs/AuthSecret" },
|
||||
"credentials": { "$ref": "#/$defs/CredentialsAuth" },
|
||||
"oidc": { "not": {} }
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
configurationReferences,
|
||||
HexColorSchema,
|
||||
HttpUrlSchema,
|
||||
AuthOriginSchema,
|
||||
SiteUrlSchema,
|
||||
LocalImagePathSchema,
|
||||
DirectoryConfigurationSchema
|
||||
} from './schema.js';
|
||||
@@ -62,23 +62,24 @@ describe('default-aware editor schema', () => {
|
||||
'client_secret'
|
||||
]);
|
||||
const authBranches = definition(editor, 'Auth').anyOf as ObjectSchema[];
|
||||
expect(authBranches.map((branch) => branch.required)).toEqual([
|
||||
['origin', 'oidc'],
|
||||
['origin', 'credentials']
|
||||
]);
|
||||
expect(authBranches.map((branch) => branch.required)).toEqual([['oidc'], ['credentials']]);
|
||||
expect(authBranches.every((branch) => branch.additionalProperties === false)).toBe(true);
|
||||
expect(property(editor, 'links').additionalProperties).toBe(false);
|
||||
expect(object((definition(editor, 'HttpUrl').anyOf as unknown[])[0]).format).toBe('uri');
|
||||
});
|
||||
|
||||
it('keeps auth origin required and secret optional, constrained and without defaults', () => {
|
||||
it('defaults site.url and keeps auth secret optional, constrained and without defaults', () => {
|
||||
const editor = createEditorSchema();
|
||||
for (const branch of definition(editor, 'Auth').anyOf as ObjectSchema[]) {
|
||||
expect(property(branch, 'origin')).toEqual({ $ref: '#/$defs/AuthOrigin' });
|
||||
expect(object(branch.properties)).not.toHaveProperty('origin');
|
||||
expect(property(branch, 'secret')).toEqual({ $ref: '#/$defs/AuthSecret' });
|
||||
expect(branch.required).not.toContain('secret');
|
||||
}
|
||||
const origin = definition(editor, 'AuthOrigin');
|
||||
const origin = definition(editor, 'SiteUrl');
|
||||
expect(property(definition(editor, 'Site'), 'url')).toEqual({
|
||||
$ref: '#/$defs/SiteUrl',
|
||||
default: 'http://localhost:3000'
|
||||
});
|
||||
const secret = definition(editor, 'AuthSecret');
|
||||
expect(secret).not.toHaveProperty('default');
|
||||
expect((secret.anyOf as ObjectSchema[])[0]).toEqual({
|
||||
@@ -95,7 +96,7 @@ describe('default-aware editor schema', () => {
|
||||
for (const field of ['logo_path', 'favicon_path']) {
|
||||
expect(property(definition(editor, 'Site'), field)).toEqual({
|
||||
$ref: '#/$defs/SiteImagePath',
|
||||
default: '/favicon.svg'
|
||||
default: '/favicon.png'
|
||||
});
|
||||
}
|
||||
expect(property(definition(editor, 'Link'), 'logo_path')).toEqual({
|
||||
@@ -106,11 +107,19 @@ describe('default-aware editor schema', () => {
|
||||
expect(choices[0]).toEqual({ $ref: '#/$defs/LocalImagePath' });
|
||||
expect((choices[1]!.anyOf as ObjectSchema[])[0]).toEqual({
|
||||
type: 'string',
|
||||
const: '/favicon.svg'
|
||||
const: '/favicon.png'
|
||||
});
|
||||
expect(interpolationPattern(choices[1]!).test('${SITE_IMAGE}')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the Open Graph image optional, local-only and without a default', () => {
|
||||
const site = definition(createEditorSchema(), 'Site');
|
||||
const image = property(site, 'og_image_path');
|
||||
expect(image.$ref).toBe('#/$defs/LocalImagePath');
|
||||
expect(image).not.toHaveProperty('default');
|
||||
expect(site.required).toBeUndefined();
|
||||
});
|
||||
|
||||
it('produces only resolvable local references, with no invented publication URL', () => {
|
||||
const editor = createEditorSchema();
|
||||
expect(editor.$id).toBeUndefined();
|
||||
@@ -203,12 +212,7 @@ describe('default-aware editor schema', () => {
|
||||
|
||||
it('separates literal constraints from the environment-token branch', () => {
|
||||
const editor = createEditorSchema();
|
||||
for (const canonical of [
|
||||
HttpUrlSchema,
|
||||
AuthOriginSchema,
|
||||
LocalImagePathSchema,
|
||||
HexColorSchema
|
||||
]) {
|
||||
for (const canonical of [HttpUrlSchema, SiteUrlSchema, LocalImagePathSchema, HexColorSchema]) {
|
||||
const adapted = definition(editor, canonical.$id!);
|
||||
const literal: ObjectSchema = { type: 'string', pattern: canonical.pattern };
|
||||
if (canonical.format) literal.format = 'uri';
|
||||
@@ -352,7 +356,7 @@ describe('default-aware editor schema', () => {
|
||||
});
|
||||
|
||||
it('leaves canonical validation strict for placeholders and expanded values', () => {
|
||||
for (const schema of [HttpUrlSchema, AuthOriginSchema, LocalImagePathSchema, HexColorSchema]) {
|
||||
for (const schema of [HttpUrlSchema, SiteUrlSchema, LocalImagePathSchema, HexColorSchema]) {
|
||||
expect(Value.Check(schema, '${VALUE}')).toBe(false);
|
||||
expect(Value.Check(schema, false)).toBe(false);
|
||||
expect(Value.Check(schema, 1)).toBe(false);
|
||||
|
||||
@@ -112,7 +112,7 @@ function adaptSchemaForEditor(
|
||||
resolveReference(node.$ref, context);
|
||||
node.$ref = context.referenceTargets.get(node.$ref) ?? node.$ref;
|
||||
}
|
||||
if (node.format === 'directory-http-url' || node.format === 'directory-auth-origin') {
|
||||
if (node.format === 'directory-http-url' || node.format === 'directory-site-url') {
|
||||
node.format = 'uri';
|
||||
}
|
||||
removeDefaultedPropertiesFromRequired(node, context);
|
||||
|
||||
@@ -23,9 +23,16 @@ describe('redacted configuration errors', () => {
|
||||
{ code: 'invalid_value', path: '/properties' },
|
||||
{ code: 'invalid_value', path: '/Site' },
|
||||
{ code: 'invalid_value', path: '/Directory' },
|
||||
{ code: 'invalid_value', path: '/site/order' }
|
||||
{ code: 'invalid_value', path: '/site/order' },
|
||||
{ code: 'invalid_value', path: '/auth/origin' }
|
||||
]);
|
||||
expect(error.issues.map((issue) => issue.path)).toEqual([
|
||||
'/*',
|
||||
'/*',
|
||||
'/*',
|
||||
'/site/*',
|
||||
'/auth/*'
|
||||
]);
|
||||
expect(error.issues.map((issue) => issue.path)).toEqual(['/*', '/*', '/*', '/site/*']);
|
||||
});
|
||||
|
||||
it('formats fixed messages with their redacted paths', () => {
|
||||
|
||||
@@ -16,7 +16,9 @@ export interface ConfigIssue {
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
type IssueLocation = Pick<ConfigIssue, 'code' | 'path'>;
|
||||
type IssueLocation = Pick<ConfigIssue, 'code' | 'path'> & {
|
||||
reason?: 'explicit_site_url_required';
|
||||
};
|
||||
|
||||
const schemaFieldNames = new Set(
|
||||
[DirectoryConfigurationSchema, ...configurationReferences].flatMap(getSchemaFieldNames)
|
||||
@@ -51,11 +53,11 @@ function getSchemaFieldNames(schema: TSchema): string[] {
|
||||
function redactIssue(location: IssueLocation): ConfigIssue {
|
||||
let code = location.code;
|
||||
if (!Object.hasOwn(issueMessages, code)) code = 'invalid_value';
|
||||
return Object.freeze({
|
||||
code,
|
||||
path: redactIssuePath(location.path),
|
||||
message: issueMessages[code]
|
||||
});
|
||||
let message: string = issueMessages[code];
|
||||
if (code === 'invalid_value' && location.reason === 'explicit_site_url_required') {
|
||||
message = 'Set site.url explicitly when using a random port, socket or socket activation.';
|
||||
}
|
||||
return Object.freeze({ code, path: redactIssuePath(location.path), message });
|
||||
}
|
||||
|
||||
function redactIssuePath(path: string): string {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { inspect } from 'node:util';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { createEditorSchema } from './editor-schema.js';
|
||||
import { ConfigError } from './errors.js';
|
||||
import { configurationReferences, DirectoryConfigurationSchema } from './schema.js';
|
||||
@@ -68,11 +68,12 @@ describe('default, interpolate, then validate', () => {
|
||||
const config = validateConfig(raw, { env: {} });
|
||||
expect(config.version).toBe(1);
|
||||
expect(config.site).toEqual({
|
||||
url: 'http://localhost:3000',
|
||||
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
|
||||
});
|
||||
expect(config.appearance).toEqual({
|
||||
@@ -131,19 +132,20 @@ describe('default, interpolate, then validate', () => {
|
||||
{ title: 'Home' },
|
||||
{ logo_path: '/public/home.svg' },
|
||||
{ favicon_path: '/public/site.png' },
|
||||
{ logo_path: '/favicon.svg' },
|
||||
{ favicon_path: '/favicon.svg' },
|
||||
{ logo_path: '/favicon.svg', favicon_path: '/favicon.svg' }
|
||||
{ logo_path: '/favicon.png' },
|
||||
{ favicon_path: '/favicon.png' },
|
||||
{ logo_path: '/favicon.png', favicon_path: '/favicon.png' }
|
||||
])('normalizes independent site image selections without mutating input: %j', (site) => {
|
||||
const raw = { site, links: { photos: link } };
|
||||
const before = structuredClone(raw);
|
||||
const config = validateConfig(raw, { env: {} });
|
||||
expect(config.site).toEqual({
|
||||
url: 'http://localhost:3000',
|
||||
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,
|
||||
...site
|
||||
});
|
||||
@@ -151,7 +153,7 @@ describe('default, interpolate, then validate', () => {
|
||||
expect(config.links.photos).not.toHaveProperty('favicon_path');
|
||||
config.site.logo_path = '/public/changed.svg';
|
||||
expect(validateConfig(raw, { env: {} }).site.favicon_path).toBe(
|
||||
site.favicon_path ?? '/favicon.svg'
|
||||
site.favicon_path ?? '/favicon.png'
|
||||
);
|
||||
expect(raw).toEqual(before);
|
||||
});
|
||||
@@ -199,11 +201,11 @@ describe('default, interpolate, then validate', () => {
|
||||
|
||||
it('interpolates site image selections before applying the strict image boundary', () => {
|
||||
const raw = { site: { logo_path: '${LOGO}', favicon_path: '${ICON}' }, links: {} };
|
||||
const config = validateConfig(raw, { env: { LOGO: '/favicon.svg', ICON: '/public/icon.png' } });
|
||||
expect(config.site.logo_path).toBe('/favicon.svg');
|
||||
const config = validateConfig(raw, { env: { LOGO: '/favicon.png', ICON: '/public/icon.png' } });
|
||||
expect(config.site.logo_path).toBe('/favicon.png');
|
||||
expect(config.site.favicon_path).toBe('/public/icon.png');
|
||||
expect(() =>
|
||||
validateConfig(raw, { env: { LOGO: '/favicon.svg?x', ICON: '/public/icon.png' } })
|
||||
validateConfig(raw, { env: { LOGO: '/favicon.png?x', ICON: '/public/icon.png' } })
|
||||
).toThrow(ConfigError);
|
||||
expect(() => validateConfig(raw, { env: {} })).toThrow(ConfigError);
|
||||
});
|
||||
@@ -275,25 +277,27 @@ describe('default, interpolate, then validate', () => {
|
||||
|
||||
it('loads either complete auth strategy and never fills missing credentials', () => {
|
||||
const credentials = validateConfig(
|
||||
{ links: {}, auth: { origin, credentials: { username: 'family', password: '${PASSWORD}' } } },
|
||||
{
|
||||
links: {},
|
||||
site: { url: origin },
|
||||
auth: { credentials: { username: 'family', password: '${PASSWORD}' } }
|
||||
},
|
||||
{ env: { PASSWORD: sentinel } }
|
||||
);
|
||||
expect(credentials.auth).toEqual({
|
||||
origin,
|
||||
credentials: { username: 'family', password: sentinel }
|
||||
});
|
||||
const oidc = validateConfig(
|
||||
{
|
||||
links: {},
|
||||
site: { url: origin },
|
||||
auth: {
|
||||
origin,
|
||||
oidc: { issuer: '${ISSUER}', client_id: 'directory', client_secret: '${SECRET}' }
|
||||
}
|
||||
},
|
||||
{ env: { ISSUER: 'https://identity.example.com', SECRET: sentinel } }
|
||||
);
|
||||
expect(oidc.auth).toEqual({
|
||||
origin,
|
||||
oidc: {
|
||||
issuer: 'https://identity.example.com',
|
||||
client_id: 'directory',
|
||||
@@ -327,19 +331,20 @@ describe('default, interpolate, then validate', () => {
|
||||
{ links: {}, site: { favicon_path: false } },
|
||||
{ links: {}, auth: {} },
|
||||
{ links: {}, auth: null },
|
||||
{ links: {}, auth: { origin, credentials: {} } },
|
||||
{ links: {}, site: { url: origin }, auth: { credentials: {} } },
|
||||
{
|
||||
links: {},
|
||||
auth: { origin, oidc: { issuer: 'https://identity.example.com', client_id: 'directory' } }
|
||||
site: { url: origin },
|
||||
auth: { oidc: { issuer: 'https://identity.example.com', client_id: 'directory' } }
|
||||
},
|
||||
{
|
||||
links: {},
|
||||
auth: { origin, credentials: { username: 'family', password: 'test' }, oidc: null }
|
||||
site: { url: origin },
|
||||
auth: { credentials: { username: 'family', password: 'test' }, oidc: null }
|
||||
},
|
||||
{
|
||||
links: {},
|
||||
auth: {
|
||||
origin,
|
||||
credentials: { username: 'family', password: 'test' },
|
||||
oidc: {
|
||||
issuer: 'https://identity.example.com',
|
||||
@@ -380,7 +385,11 @@ describe('default, interpolate, then validate', () => {
|
||||
expect(invalid.issues[0]?.path).toBe('/links/*/url');
|
||||
const badAuth = configurationError(() =>
|
||||
validateConfig(
|
||||
{ links: {}, auth: { origin, credentials: { username: sentinel, password: {} } } },
|
||||
{
|
||||
links: {},
|
||||
site: { url: origin },
|
||||
auth: { credentials: { username: sentinel, password: {} } }
|
||||
},
|
||||
{ env: {} }
|
||||
)
|
||||
);
|
||||
@@ -394,6 +403,204 @@ describe('default, interpolate, then validate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime site URL defaults', () => {
|
||||
it.each([
|
||||
{ env: {}, url: 'http://localhost:3000' },
|
||||
{ env: { NODE_ENV: 'production' }, url: 'http://localhost:3000' },
|
||||
{ env: { NODE_ENV: 'test' }, url: 'http://localhost:3000' },
|
||||
{ env: { NODE_ENV: 'development' }, url: 'http://localhost:5173' },
|
||||
{ env: { PORT: '8080' }, url: 'http://localhost:8080' },
|
||||
{ env: { PORT: '65535', NODE_ENV: 'development' }, url: 'http://localhost:65535' },
|
||||
{ env: { PORT: '1' }, url: 'http://localhost:1' },
|
||||
{ env: { PORT: '80' }, url: 'http://localhost' },
|
||||
{
|
||||
env: { HOST: 'public.example.com', LISTEN_FDS: '0', SOCKET_PATH: '' },
|
||||
url: 'http://localhost:3000'
|
||||
}
|
||||
])('resolves omissions using only the supplied environment: $env', ({ env, url }) => {
|
||||
for (const site of [undefined, {}, { title: 'Home' }]) {
|
||||
const raw = { links: {}, ...(site ? { site } : {}) };
|
||||
const before = structuredClone(raw);
|
||||
const environment = Object.freeze({ ...env });
|
||||
expect(validateConfig(raw, { env: environment }).site.url).toBe(url);
|
||||
expect(validateConfig(raw, { env: environment }).site.url).toBe(url);
|
||||
expect(raw).toEqual(before);
|
||||
expect(environment).toEqual(env);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses process.env consistently, without leaking it into an injected environment', () => {
|
||||
try {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('PORT', undefined);
|
||||
vi.stubEnv('SOCKET_PATH', undefined);
|
||||
vi.stubEnv('LISTEN_FDS', undefined);
|
||||
expect(validateConfig({ links: {} }).site.url).toBe('http://localhost:5173');
|
||||
expect(validateConfig({ links: {} }, { env: {} }).site.url).toBe('http://localhost:3000');
|
||||
vi.stubEnv('PORT', '8080');
|
||||
expect(validateConfig({ links: {} }).site.url).toBe('http://localhost:8080');
|
||||
expect(validateConfig({ links: {} }, { env: process.env }).site.url).toBe(
|
||||
'http://localhost:8080'
|
||||
);
|
||||
expect(process.env.PORT).toBe('8080');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'',
|
||||
' ',
|
||||
'-1',
|
||||
'+3000',
|
||||
'65536',
|
||||
'1.5',
|
||||
'1e3',
|
||||
'0x10',
|
||||
'NaN',
|
||||
'Infinity',
|
||||
'3000\n',
|
||||
sentinel
|
||||
])('rejects invalid fallback PORT %j with a redacted site URL error', (PORT) => {
|
||||
const error = configurationError(() => validateConfig({ links: {} }, { env: { PORT } }));
|
||||
expect(error.issues).toMatchObject([{ code: 'invalid_value', path: '/site/url' }]);
|
||||
expectRedacted(error);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ PORT: '0' },
|
||||
{ PORT: '00' },
|
||||
{ SOCKET_PATH: '/private/socket' },
|
||||
{ LISTEN_FDS: '1' },
|
||||
{ LISTEN_FDS: '2', PORT: '3000' }
|
||||
])('requires an explicit URL for random ports or sockets: %j', (env) => {
|
||||
const error = configurationError(() => validateConfig({ links: {} }, { env }));
|
||||
expect(error.issues).toMatchObject([{ code: 'invalid_value', path: '/site/url' }]);
|
||||
expect(error.message).toContain('Set site.url explicitly');
|
||||
expect(error.message).not.toContain('/private/socket');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ PORT: '0' },
|
||||
{ PORT: sentinel },
|
||||
{ SOCKET_PATH: '/private/socket' },
|
||||
{ LISTEN_FDS: '3', PORT: '0' },
|
||||
{ NODE_ENV: 'development', PORT: '8080' }
|
||||
])(
|
||||
'preserves explicit and interpolated URLs regardless of listener settings: %j',
|
||||
async (env) => {
|
||||
for (const url of [origin, '${PUBLIC_URL}']) {
|
||||
const config = await loadYaml(`site:\n url: '${url}'\nlinks: {}\n`, {
|
||||
...env,
|
||||
PUBLIC_URL: origin
|
||||
});
|
||||
expect(config.site.url).toBe(origin);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([null, '', false, 8080, [], {}, 'not an origin'])(
|
||||
'does not treat an explicit invalid URL as omitted: %j',
|
||||
(url) => {
|
||||
const error = configurationError(() =>
|
||||
validateConfig({ links: {}, site: { url } }, { env: { PORT: '0' } })
|
||||
);
|
||||
expect(error.issues.length).toBeGreaterThan(0);
|
||||
expect(error.issues.every((issue) => issue.path === '/site/url')).toBe(true);
|
||||
expect(error.message).not.toContain('Set site.url explicitly');
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps defaults stable across repeated file loads and resolved configuration validation', async () => {
|
||||
const env = Object.freeze({ NODE_ENV: 'development', PORT: '8080' });
|
||||
const initial = await loadYaml('links: {}', env);
|
||||
const reloaded = await loadYaml('links: {}', env);
|
||||
expect(reloaded).toEqual(initial);
|
||||
expect(validateConfig(initial, { env: { PORT: '0', SOCKET_PATH: '/private/socket' } })).toEqual(
|
||||
initial
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional Open Graph image', () => {
|
||||
it.each([undefined, {}, { title: 'Home' }])(
|
||||
'leaves an omitted image absent after defaults: %j',
|
||||
(site) => {
|
||||
const config = validateConfig({ links: {}, ...(site ? { site } : {}) }, { env: {} });
|
||||
expect(config.site).not.toHaveProperty('og_image_path');
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
'/public/share.png',
|
||||
'/public/cards/home.JPEG',
|
||||
'/public/family photo.jpg',
|
||||
'/public/图标/家.webp',
|
||||
'/public/share.svg',
|
||||
'/public/share.gif',
|
||||
'/public/missing.png'
|
||||
])('preserves a local image without requiring the file to exist: %s', (og_image_path) => {
|
||||
const raw = { site: { og_image_path }, links: {} };
|
||||
const before = structuredClone(raw);
|
||||
expect(validateConfig(raw, { env: {} }).site.og_image_path).toBe(og_image_path);
|
||||
expect(raw).toEqual(before);
|
||||
});
|
||||
|
||||
it.each(
|
||||
[
|
||||
null,
|
||||
false,
|
||||
1,
|
||||
[],
|
||||
{},
|
||||
'',
|
||||
'/favicon.png',
|
||||
'https://example.com/share.png',
|
||||
'//example.com/share.png',
|
||||
'data:image/png;base64,AAAA',
|
||||
'file:///public/share.png',
|
||||
'public/share.png',
|
||||
'/share.png',
|
||||
'/public/../share.png',
|
||||
'/public/./share.png',
|
||||
'/public//share.png',
|
||||
'/public/%2e%2e/share.png',
|
||||
'/public/a\\b.png',
|
||||
'/public/share.png?size=large',
|
||||
'/public/share.png#image',
|
||||
'/public/share.html',
|
||||
'/public/share.png\n',
|
||||
'/public/share\u0000.png'
|
||||
].map((og_image_path) => ({ og_image_path }))
|
||||
)('rejects invalid, remote and bundled image selections: $og_image_path', ({ og_image_path }) => {
|
||||
const error = configurationError(() =>
|
||||
validateConfig({ site: { og_image_path }, links: {} }, { env: {} })
|
||||
);
|
||||
expect(error.issues).toMatchObject([{ path: '/site/og_image_path' }]);
|
||||
});
|
||||
|
||||
it('validates interpolated paths with the same local-only boundary', () => {
|
||||
const raw = { site: { og_image_path: '${IMAGE}' }, links: {} };
|
||||
expect(validateConfig(raw, { env: { IMAGE: '/public/share.png' } }).site.og_image_path).toBe(
|
||||
'/public/share.png'
|
||||
);
|
||||
for (const env of [{}, { IMAGE: '/favicon.png' }, { IMAGE: 'https://example.com/share.png' }]) {
|
||||
expect(() => validateConfig(raw, { env })).toThrow(ConfigError);
|
||||
}
|
||||
});
|
||||
|
||||
it('loads an optional image from YAML without changing other site defaults', async () => {
|
||||
const config = await loadYaml('site:\n og_image_path: /public/share.png\nlinks: {}\n');
|
||||
expect(config.site).toEqual({
|
||||
...validateConfig({ links: {} }, { env: {} }).site,
|
||||
og_image_path: '/public/share.png'
|
||||
});
|
||||
await expect(loadYaml('site: {og_image_path: null}\nlinks: {}\n')).rejects.toBeInstanceOf(
|
||||
ConfigError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('YAML file loader', () => {
|
||||
it.each([
|
||||
{ site: '', expected: true },
|
||||
@@ -419,11 +626,12 @@ describe('YAML file loader', () => {
|
||||
expect(config.links).toEqual({});
|
||||
expect(config).not.toHaveProperty('auth');
|
||||
expect(config.site).toEqual({
|
||||
url: 'http://localhost:3000',
|
||||
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
|
||||
});
|
||||
expect(config.appearance).toEqual({
|
||||
@@ -444,8 +652,9 @@ describe('YAML file loader', () => {
|
||||
const secret = 'test-only-secret-0123456789abcdef';
|
||||
const config = await loadYaml(
|
||||
`links: {}
|
||||
site:
|
||||
url: '\${PUBLIC_ORIGIN}'
|
||||
auth:
|
||||
origin: '\${PUBLIC_ORIGIN}'
|
||||
secret: '\${SESSION_SECRET}'
|
||||
credentials:
|
||||
username: family
|
||||
@@ -453,8 +662,8 @@ auth:
|
||||
`,
|
||||
{ PUBLIC_ORIGIN: origin, SESSION_SECRET: secret, PASSWORD: sentinel }
|
||||
);
|
||||
expect(config.site.url).toBe(origin);
|
||||
expect(config.auth).toEqual({
|
||||
origin,
|
||||
secret,
|
||||
credentials: { username: 'family', password: sentinel }
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { assertSafeInput } from './input.js';
|
||||
import { interpolate } from './interpolate.js';
|
||||
import { resolveConfigPath } from './paths.js';
|
||||
import { configurationReferences, DirectoryConfigurationSchema } from './schema.js';
|
||||
import type { ConfigLoadOptions, DirectoryConfiguration } from './types.js';
|
||||
import type { ConfigEnvironment, ConfigLoadOptions, DirectoryConfiguration } from './types.js';
|
||||
|
||||
export async function loadConfigFile(
|
||||
path?: string,
|
||||
@@ -29,14 +29,16 @@ export function validateConfig(
|
||||
): DirectoryConfiguration {
|
||||
assertSafeInput(raw);
|
||||
try {
|
||||
const copy: unknown = structuredClone(raw);
|
||||
const copy = structuredClone(raw) as Record<string, unknown>;
|
||||
const env = options.env ?? process.env;
|
||||
defaultSiteUrl(copy, env);
|
||||
const defaulted: unknown = Value.Default(
|
||||
DirectoryConfigurationSchema,
|
||||
[...configurationReferences],
|
||||
copy
|
||||
);
|
||||
assertArrayShapesPreserved(raw, defaulted);
|
||||
const expanded = interpolate(defaulted, options.env ?? process.env);
|
||||
const expanded = interpolate(defaulted, env);
|
||||
return checkSchema(expanded);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) throw error;
|
||||
@@ -44,6 +46,27 @@ export function validateConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve only omissions before TypeBox applies its static/editor defaults.
|
||||
function defaultSiteUrl(copy: Record<string, unknown>, env: ConfigEnvironment): void {
|
||||
if (!Object.hasOwn(copy, 'site')) copy.site = {};
|
||||
const site = copy.site;
|
||||
if (typeof site !== 'object' || site === null || Array.isArray(site)) return;
|
||||
if (Object.hasOwn(site, 'url')) return;
|
||||
const port = env.PORT ?? (env.NODE_ENV === 'development' ? '5173' : '3000');
|
||||
const portNumber = Number(port);
|
||||
const isDecimal = port.length > 0 && !/[^0-9]/.test(port);
|
||||
if ((isDecimal && portNumber === 0) || env.SOCKET_PATH || Number(env.LISTEN_FDS) > 0) {
|
||||
throw new ConfigError([
|
||||
{ code: 'invalid_value', path: '/site/url', reason: 'explicit_site_url_required' }
|
||||
]);
|
||||
}
|
||||
if (!isDecimal || !Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
|
||||
throw new ConfigError([{ code: 'invalid_value', path: '/site/url' }]);
|
||||
}
|
||||
(site as Record<string, unknown>).url =
|
||||
portNumber === 80 ? 'http://localhost' : `http://localhost:${portNumber}`;
|
||||
}
|
||||
|
||||
function assertArrayShapesPreserved(raw: unknown, defaulted: unknown, path = ''): void {
|
||||
if (typeof raw !== 'object' || raw === null) return;
|
||||
// TypeBox defaults can merge arrays into object defaults, or objects into array defaults.
|
||||
@@ -98,5 +121,12 @@ function checkSchema(value: unknown): DirectoryConfiguration {
|
||||
locations.push({ code: 'invalid_value', path: error.path });
|
||||
}
|
||||
if (locations.length > 0) throw new ConfigError(locations);
|
||||
return value as DirectoryConfiguration;
|
||||
const config = value as DirectoryConfiguration;
|
||||
if (config.auth) {
|
||||
const url = new URL(config.site.url);
|
||||
if (url.protocol !== 'https:' && url.hostname !== 'localhost') {
|
||||
throw new ConfigError([{ code: 'invalid_value', path: '/site/url' }]);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import type { DirectoryConfiguration, DirectoryLink, DirectorySite } from './types.js';
|
||||
|
||||
const link = { title: 'Photos', url: 'https://photos.example.com' };
|
||||
const origin = 'https://directory.example.com';
|
||||
const raw = { links: { photos: link } };
|
||||
const linkDefaults = { tags: [], requires_auth: false, internal_only: false, admin_only: false };
|
||||
|
||||
@@ -37,11 +36,12 @@ describe('configuration defaults and inferred types', () => {
|
||||
expect(value).toEqual({
|
||||
version: 1,
|
||||
site: {
|
||||
url: 'http://localhost:3000',
|
||||
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' },
|
||||
@@ -56,6 +56,7 @@ describe('configuration defaults and inferred types', () => {
|
||||
const value = defaultFixture({ links: {} });
|
||||
expect(accepts({ links: {} })).toBe(true);
|
||||
expect(value).not.toHaveProperty('auth');
|
||||
expect(value).not.toHaveProperty('site.og_image_path');
|
||||
expect(defaultFixture(raw)).not.toHaveProperty('links.photos.group');
|
||||
expect(accepts({})).toBe(false);
|
||||
});
|
||||
@@ -88,7 +89,10 @@ describe('configuration defaults and inferred types', () => {
|
||||
expectTypeOf<DirectoryConfiguration['site']['title']>().toEqualTypeOf<string>();
|
||||
expectTypeOf<DirectoryConfiguration['site']['logo_path']>().toEqualTypeOf<string>();
|
||||
expectTypeOf<DirectoryConfiguration['site']['favicon_path']>().toEqualTypeOf<string>();
|
||||
expectTypeOf<DirectorySite['url']>().toEqualTypeOf<string>();
|
||||
expect(SiteSchema.required).toContain('url');
|
||||
expectTypeOf<DirectorySite['open_links_in_new_tab']>().toEqualTypeOf<boolean>();
|
||||
expectTypeOf<DirectorySite['og_image_path']>().toEqualTypeOf<string | undefined>();
|
||||
expectTypeOf<DirectoryLink['logo_path']>().toEqualTypeOf<string | undefined>();
|
||||
expectTypeOf<DirectoryConfiguration['auth']>().toBeNullable();
|
||||
expectTypeOf<DirectoryLink['tags']>().toEqualTypeOf<string[]>();
|
||||
@@ -103,7 +107,6 @@ describe('configuration defaults and inferred types', () => {
|
||||
const missingTitle: DirectoryLink = { url: link.url, ...linkDefaults };
|
||||
// @ts-expect-error both auth strategies are forbidden statically too
|
||||
const both: NonNullable<DirectoryConfiguration['auth']> = {
|
||||
origin,
|
||||
credentials: { username: 'family', password: 'fixture' },
|
||||
oidc: { issuer: 'https://id.example.com', client_id: 'directory', client_secret: 'fixture' }
|
||||
};
|
||||
@@ -168,7 +171,7 @@ describe('strict configuration boundary', () => {
|
||||
},
|
||||
{ unknown: {} }
|
||||
].map((auth) => ({
|
||||
auth: auth && !Array.isArray(auth) && typeof auth === 'object' ? { origin, ...auth } : auth
|
||||
auth
|
||||
}))
|
||||
)('rejects invalid or ambiguous auth: $auth', ({ auth }) => {
|
||||
expect(accepts({ ...raw, auth })).toBe(false);
|
||||
@@ -178,7 +181,7 @@ describe('strict configuration boundary', () => {
|
||||
{ credentials: { username: 'family', password: 'fixture' } },
|
||||
{ oidc: { issuer: 'https://id.example.com', client_id: 'directory', client_secret: 'fixture' } }
|
||||
])('accepts exactly one complete auth strategy', (auth) => {
|
||||
expect(accepts({ ...raw, auth: { origin, ...auth } })).toBe(true);
|
||||
expect(accepts({ ...raw, auth })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -246,7 +249,7 @@ describe('strict configuration boundary', () => {
|
||||
{ ...raw, appearance: { background_color: '#fff' } },
|
||||
{
|
||||
...raw,
|
||||
auth: { origin, credentials: { username: 'family', password: 'fixture', extra: true } }
|
||||
auth: { credentials: { username: 'family', password: 'fixture', extra: true } }
|
||||
}
|
||||
])('rejects unknown structural fields and invalid explicit values', (value) => {
|
||||
expect(accepts(value)).toBe(false);
|
||||
@@ -349,15 +352,16 @@ describe('URL, image and color constraints', () => {
|
||||
'https://example.com/logo.svg',
|
||||
'//example.com/logo.svg',
|
||||
'/favicon.svg',
|
||||
'/favicon.svg/',
|
||||
'/favicon.svg?x',
|
||||
'/favicon.svg#x',
|
||||
'/favicon%2esvg',
|
||||
'/Favicon.svg',
|
||||
'/favicon.svg\n',
|
||||
'/favicon.png',
|
||||
'/favicon.png/',
|
||||
'/favicon.png?x',
|
||||
'/favicon.png#x',
|
||||
'/favicon%2epng',
|
||||
'/Favicon.png',
|
||||
'/favicon.png\n',
|
||||
'data:image/svg+xml,test',
|
||||
'file:///favicon.svg',
|
||||
'favicon.svg',
|
||||
'file:///favicon.png',
|
||||
'favicon.png',
|
||||
'/public/logo\u0000.svg',
|
||||
'/public/logo\t.svg',
|
||||
'/public/logo\u007f.svg',
|
||||
@@ -374,7 +378,7 @@ describe('URL, image and color constraints', () => {
|
||||
'/public/logo.svg\n'
|
||||
])('rejects unsafe/non-image path %j', (path) => {
|
||||
expect(Value.Check(LocalImagePathSchema, path)).toBe(false);
|
||||
if (path !== '/favicon.svg') {
|
||||
if (path !== '/favicon.png') {
|
||||
expect(Value.Check(SiteImagePathSchema, configurationReferences, path)).toBe(false);
|
||||
for (const field of ['logo_path', 'favicon_path']) {
|
||||
expect(accepts({ ...raw, site: { [field]: path } })).toBe(false);
|
||||
@@ -383,9 +387,9 @@ describe('URL, image and color constraints', () => {
|
||||
});
|
||||
it('accepts the bundled mark only for site images, not bookmark logos', () => {
|
||||
for (const field of ['logo_path', 'favicon_path']) {
|
||||
expect(accepts({ ...raw, site: { [field]: '/favicon.svg' } })).toBe(true);
|
||||
expect(accepts({ ...raw, site: { [field]: '/favicon.png' } })).toBe(true);
|
||||
}
|
||||
expect(accepts({ links: { photos: { ...link, logo_path: '/favicon.svg' } } })).toBe(false);
|
||||
expect(accepts({ links: { photos: { ...link, logo_path: '/favicon.png' } } })).toBe(false);
|
||||
});
|
||||
it.each(['#fff', '#0f766e', '#ABCDEF'])('accepts hex color %s', (color) => {
|
||||
expect(Value.Check(HexColorSchema, color)).toBe(true);
|
||||
|
||||
@@ -22,11 +22,12 @@ FormatRegistry.Set('directory-http-url', (value) => {
|
||||
}
|
||||
});
|
||||
|
||||
FormatRegistry.Set('directory-auth-origin', (value) => {
|
||||
FormatRegistry.Set('directory-site-url', (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
(url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost')) &&
|
||||
(url.protocol === 'https:' || url.protocol === 'http:') &&
|
||||
url.port !== '0' &&
|
||||
value === url.origin
|
||||
);
|
||||
} catch {
|
||||
@@ -47,13 +48,13 @@ export const HttpUrlSchema = Type.String({
|
||||
description: 'An absolute HTTP or HTTPS URL. Relative and executable URLs are not accepted.'
|
||||
});
|
||||
|
||||
export const AuthOriginSchema = Type.String({
|
||||
$id: 'AuthOrigin',
|
||||
format: 'directory-auth-origin',
|
||||
export const SiteUrlSchema = Type.String({
|
||||
$id: 'SiteUrl',
|
||||
format: 'directory-site-url',
|
||||
// Editors cannot run the URL canonicalization check, but can enforce the origin shape.
|
||||
pattern: `^(?:https://(?:[^\\s/\\\\?#@:\\[\\]A-Z%]+|\\[[0-9a-f:.]+\\])(?::(?!443${end})[0-9]+)?|http://localhost(?::(?!80${end})[0-9]+)?)${end}`,
|
||||
pattern: `^(?:https://(?:[^\\s/\\\\?#@:\\[\\]A-Z%]+|\\[[0-9a-f:.]+\\])(?::(?!443${end})[1-9][0-9]*)?|http://(?:[^\\s/\\\\?#@:\\[\\]A-Z%]+|\\[[0-9a-f:.]+\\])(?::(?!80${end})[1-9][0-9]*)?)${end}`,
|
||||
description:
|
||||
'The canonical public origin of Directory. HTTPS is required except for http://localhost[:port]. No credentials, path, trailing slash, query, fragment, or noncanonical URL spelling.'
|
||||
'The canonical public HTTP(S) origin of Directory. No credentials, path, trailing slash, query, fragment, or noncanonical URL spelling. Authentication requires HTTPS except for http://localhost[:port]. Omitted values use localhost with PORT, or 5173 in development and 3000 otherwise.'
|
||||
});
|
||||
|
||||
export const AuthSecretSchema = Type.String({
|
||||
@@ -72,8 +73,8 @@ export const LocalImagePathSchema = Type.String({
|
||||
});
|
||||
|
||||
export const SiteImagePathSchema = Type.Union(
|
||||
[ref(LocalImagePathSchema), Type.Literal('/favicon.svg')],
|
||||
{ $id: 'SiteImagePath', description: 'A mounted image path or the bundled /favicon.svg mark.' }
|
||||
[ref(LocalImagePathSchema), Type.Literal('/favicon.png')],
|
||||
{ $id: 'SiteImagePath', description: 'A mounted image path or the bundled /favicon.png mark.' }
|
||||
);
|
||||
|
||||
export const HexColorSchema = Type.String({
|
||||
@@ -116,10 +117,17 @@ export const LinkSchema = Type.Object(
|
||||
|
||||
export const SiteSchema = Type.Object(
|
||||
{
|
||||
url: ref(SiteUrlSchema, { default: 'http://localhost:3000' }),
|
||||
title: Type.String({ ...nonEmptyText, default: 'Directory' }),
|
||||
description: Type.String({ default: '' }),
|
||||
logo_path: ref(SiteImagePathSchema, { default: '/favicon.svg' }),
|
||||
favicon_path: ref(SiteImagePathSchema, { default: '/favicon.svg' }),
|
||||
logo_path: ref(SiteImagePathSchema, { default: '/favicon.png' }),
|
||||
favicon_path: ref(SiteImagePathSchema, { default: '/favicon.png' }),
|
||||
og_image_path: Type.Optional(
|
||||
ref(LocalImagePathSchema, {
|
||||
description:
|
||||
'Optional public Open Graph image, including when authentication is enabled. Use a /public/ image path, not an external URL; PNG or JPEG at 1200×630 is recommended. Omit or supply a missing image to generate a 1200×630 PNG from the site title, favicon and appearance only.'
|
||||
})
|
||||
),
|
||||
footer: Type.String({ default: '' }),
|
||||
open_links_in_new_tab: Type.Boolean({
|
||||
default: true,
|
||||
@@ -158,7 +166,6 @@ export const AuthSchema = Type.Union(
|
||||
[
|
||||
Type.Object(
|
||||
{
|
||||
origin: ref(AuthOriginSchema),
|
||||
secret: Type.Optional(ref(AuthSecretSchema)),
|
||||
oidc: ref(OidcAuthSchema),
|
||||
credentials: Type.Optional(Type.Never())
|
||||
@@ -167,7 +174,6 @@ export const AuthSchema = Type.Union(
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
origin: ref(AuthOriginSchema),
|
||||
secret: Type.Optional(ref(AuthSecretSchema)),
|
||||
credentials: ref(CredentialsAuthSchema),
|
||||
oidc: Type.Optional(Type.Never())
|
||||
@@ -194,7 +200,7 @@ export const DirectoryConfigurationSchema = Type.Object(
|
||||
|
||||
export const configurationReferences: TSchema[] = [
|
||||
HttpUrlSchema,
|
||||
AuthOriginSchema,
|
||||
SiteUrlSchema,
|
||||
AuthSecretSchema,
|
||||
LocalImagePathSchema,
|
||||
SiteImagePathSchema,
|
||||
|
||||
Generated
+123
@@ -153,6 +153,12 @@ importers:
|
||||
'@directory/config':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/config
|
||||
'@takumi-rs/core':
|
||||
specifier: 2.18.2
|
||||
version: 2.18.2(preact@10.24.3)
|
||||
'@takumi-rs/helpers':
|
||||
specifier: 2.18.2
|
||||
version: 2.18.2(preact@10.24.3)
|
||||
bits-ui:
|
||||
specifier: 'catalog:'
|
||||
version: 2.18.1(@internationalized/date@3.12.4)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))
|
||||
@@ -982,6 +988,79 @@ packages:
|
||||
'@swc/helpers@0.5.23':
|
||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||
|
||||
'@takumi-rs/core-darwin-arm64@2.18.2':
|
||||
resolution: {integrity: sha512-7JjXUdT1ywBGghWAR8nim7emczQRsHV4TkLspITL3a853AahtNIwLTMMb/uK0KVAPwQR0plFEqzVU23iAzRBWQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@takumi-rs/core-darwin-x64@2.18.2':
|
||||
resolution: {integrity: sha512-YwExtQ81c91GLVKDnECruM5bM41a8flnXtQ/KTMSVovX74xzUZCe98XJjcyvTDdksF+bGMG9aqsmwX833HiMQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@takumi-rs/core-linux-arm64-gnu@2.18.2':
|
||||
resolution: {integrity: sha512-NQcTa0mfIGAuGSLiTyJSTWn85uSj9Y7+adp+2yIaV89SZ1/thYg7HwGHMwpHQyYCZ2mTFWOwlh264nqYO1RvLw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@takumi-rs/core-linux-arm64-musl@2.18.2':
|
||||
resolution: {integrity: sha512-5oaEkOZfyVolD6KxQ77aFeSHI1W7W8Oai4W0vBGzXyyXVLeONfWZkBFWPpXO0ojnPsKa2JVGL5mKFqRiwKAEQg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@takumi-rs/core-linux-x64-gnu@2.18.2':
|
||||
resolution: {integrity: sha512-UG95eda/4ySoJqV1QGl7ykH7AMKySvSkpafSVuIHefzu6fiQbyCOE9GRx/BWx0wKIE3ozQXteIP3ydnHQPJBJw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@takumi-rs/core-linux-x64-musl@2.18.2':
|
||||
resolution: {integrity: sha512-eTufsMdBMk8tvER1hBfh2RkE2Qv6ov05r9UEIXFDwZLVlXoHr1DbEp/k8Ze1a1DdLpZJizVQ9BKFKBUk8+SWnQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@takumi-rs/core-win32-arm64-msvc@2.18.2':
|
||||
resolution: {integrity: sha512-6C6ONbCg65smpkYRik9o3shON/8B8OP3VFpYXvSVHiW59boI+9SQKmOysTQBrZpSlUG5ZPAyoJHCfcFR5nawWA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@takumi-rs/core-win32-x64-msvc@2.18.2':
|
||||
resolution: {integrity: sha512-ui0aYjmhx9vsNlNc2ktLwZsk308132HwaQyp6OR3mQ40/svfnASJDxMyTdmh9O1Vv0ynph9idoj4eEh/0YQAjw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@takumi-rs/core@2.18.2':
|
||||
resolution: {integrity: sha512-9g+oXNTAKLp6ZliXiQ0BkMXVPrXgaX34wjY7jO9osxbgFtBi3QLhnrDMyKFpSvXuOcbjdSCi1LkJ0YzN95bMQw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
csstype: '*'
|
||||
peerDependenciesMeta:
|
||||
csstype:
|
||||
optional: true
|
||||
|
||||
'@takumi-rs/helpers@2.18.2':
|
||||
resolution: {integrity: sha512-HHVdYmGxLos+Jn67mYU64gc+nW372zP8pF6zCnLzn3JMbGtcX5jV7a0N5uZseXkGrfGR07tsdWQbTkpO4Ms+WA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
preact: ^10.0.0 || ^11.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
preact:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@tybys/wasm-util@0.10.4':
|
||||
resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==}
|
||||
|
||||
@@ -2683,6 +2762,50 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@takumi-rs/core-darwin-arm64@2.18.2':
|
||||
optional: true
|
||||
|
||||
'@takumi-rs/core-darwin-x64@2.18.2':
|
||||
optional: true
|
||||
|
||||
'@takumi-rs/core-linux-arm64-gnu@2.18.2':
|
||||
optional: true
|
||||
|
||||
'@takumi-rs/core-linux-arm64-musl@2.18.2':
|
||||
optional: true
|
||||
|
||||
'@takumi-rs/core-linux-x64-gnu@2.18.2':
|
||||
optional: true
|
||||
|
||||
'@takumi-rs/core-linux-x64-musl@2.18.2':
|
||||
optional: true
|
||||
|
||||
'@takumi-rs/core-win32-arm64-msvc@2.18.2':
|
||||
optional: true
|
||||
|
||||
'@takumi-rs/core-win32-x64-msvc@2.18.2':
|
||||
optional: true
|
||||
|
||||
'@takumi-rs/core@2.18.2(preact@10.24.3)':
|
||||
dependencies:
|
||||
'@takumi-rs/helpers': 2.18.2(preact@10.24.3)
|
||||
optionalDependencies:
|
||||
'@takumi-rs/core-darwin-arm64': 2.18.2
|
||||
'@takumi-rs/core-darwin-x64': 2.18.2
|
||||
'@takumi-rs/core-linux-arm64-gnu': 2.18.2
|
||||
'@takumi-rs/core-linux-arm64-musl': 2.18.2
|
||||
'@takumi-rs/core-linux-x64-gnu': 2.18.2
|
||||
'@takumi-rs/core-linux-x64-musl': 2.18.2
|
||||
'@takumi-rs/core-win32-arm64-msvc': 2.18.2
|
||||
'@takumi-rs/core-win32-x64-msvc': 2.18.2
|
||||
transitivePeerDependencies:
|
||||
- preact
|
||||
- react
|
||||
|
||||
'@takumi-rs/helpers@2.18.2(preact@10.24.3)':
|
||||
optionalDependencies:
|
||||
preact: 10.24.3
|
||||
|
||||
'@tybys/wasm-util@0.10.4':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -31,3 +31,15 @@ catalog:
|
||||
unplugin-icons: 23.0.1
|
||||
vite: 8.0.16
|
||||
vitest: 4.1.7
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@takumi-rs/core-darwin-arm64@2.18.2'
|
||||
- '@takumi-rs/core-darwin-x64@2.18.2'
|
||||
- '@takumi-rs/core-linux-arm64-gnu@2.18.2'
|
||||
- '@takumi-rs/core-linux-arm64-musl@2.18.2'
|
||||
- '@takumi-rs/core-linux-x64-gnu@2.18.2'
|
||||
- '@takumi-rs/core-linux-x64-musl@2.18.2'
|
||||
- '@takumi-rs/core-win32-arm64-msvc@2.18.2'
|
||||
- '@takumi-rs/core-win32-x64-msvc@2.18.2'
|
||||
- '@takumi-rs/core@2.18.2'
|
||||
- '@takumi-rs/helpers@2.18.2'
|
||||
Reference in new issue
Block a user