From 9691ea7bce9c7b9029bb68afcf36130a065bdaf4 Mon Sep 17 00:00:00 2001 From: eleith Date: Mon, 2 Nov 2026 15:40:08 -0800 Subject: [PATCH] perf(web): generate avatar thumbnails automatically --- .changeset/upgrade-takumi.md | 5 + .changeset/web-avatar-thumbnail.md | 7 + apps/web/e2e/avatar.e2e.ts | 156 ++++++++++ apps/web/package.json | 2 +- apps/web/src/lib/avatar.test.ts | 27 ++ apps/web/src/lib/avatar.ts | 7 + .../lib/components/AppointmentWizard.svelte | 7 +- .../src/lib/components/WizardContext.svelte | 3 +- apps/web/src/lib/server/avatar.test.ts | 248 +++++++++++++++- apps/web/src/lib/server/avatar.ts | 165 +++++++++++ apps/web/src/routes/(app)/+page.svelte | 7 +- .../src/routes/avatar/[...path]/+server.ts | 93 ++++++ .../routes/avatar/[...path]/server.test.ts | 276 ++++++++++++++++++ docs/config.md | 25 ++ pnpm-lock.yaml | 108 +++---- 15 files changed, 1077 insertions(+), 59 deletions(-) create mode 100644 .changeset/upgrade-takumi.md create mode 100644 .changeset/web-avatar-thumbnail.md create mode 100644 apps/web/e2e/avatar.e2e.ts create mode 100644 apps/web/src/lib/avatar.test.ts create mode 100644 apps/web/src/lib/avatar.ts create mode 100644 apps/web/src/routes/avatar/[...path]/+server.ts create mode 100644 apps/web/src/routes/avatar/[...path]/server.test.ts diff --git a/.changeset/upgrade-takumi.md b/.changeset/upgrade-takumi.md new file mode 100644 index 00000000..0b5e42fa --- /dev/null +++ b/.changeset/upgrade-takumi.md @@ -0,0 +1,5 @@ +--- +'@when/web': patch +--- + +Upgrade the Takumi image renderer to 2.19.0. diff --git a/.changeset/web-avatar-thumbnail.md b/.changeset/web-avatar-thumbnail.md new file mode 100644 index 00000000..bcb2b709 --- /dev/null +++ b/.changeset/web-avatar-thumbnail.md @@ -0,0 +1,7 @@ +--- +'@when/web': patch +--- + +Serve the configured avatar through `/avatar/`, with `?thumbnail=true` providing a cached, automatically resized web avatar without a separate image or new configuration. The requested path is only validated against configuration; it never selects another source. Keep original images for share cards, preserve SVGs and animations, and use the original when resizing cannot reduce the download. Document Takumi's existing EXIF-orientation limitation. + +Share concurrent source loading and resizing, and forward originals without mismatched compression headers. diff --git a/apps/web/e2e/avatar.e2e.ts b/apps/web/e2e/avatar.e2e.ts new file mode 100644 index 00000000..853ad13e --- /dev/null +++ b/apps/web/e2e/avatar.e2e.ts @@ -0,0 +1,156 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { parse, stringify } from 'yaml'; +import { Renderer } from 'takumi-js/node'; +import { avatarThumbnailUrl } from '../src/lib/avatar.ts'; +import { expect, test, type Page } from './support/test.ts'; +import { workerDirectory } from './support/paths.ts'; +import { seedAppointment } from './support/seed.ts'; + +const SOURCE_PATH = '/public/avatar source test.png'; +const ORIGINAL_URL = '/avatar/public/avatar%20source%20test.png'; +const THUMBNAIL_URL = `${ORIGINAL_URL}?thumbnail=true`; +const DEFAULT_AVATAR_URL = '/avatar/assets/images/avatar.svg?thumbnail=true'; + +test.use({ javaScriptEnabled: false }); + +async function setAvatarSource(page: Page, sourcePath: string) { + const configPath = join(workerDirectory(), 'config/when.yaml'); + const config = parse(await readFile(configPath, 'utf8')); + config.user.appearance = { ...config.user.appearance, avatar_path: sourcePath }; + await writeFile(configPath, stringify(config)); + const expectedUrl = avatarThumbnailUrl(sourcePath).replaceAll('&', '&'); + await expect.poll(async () => (await page.request.get('/')).text()).toContain(expectedUrl); +} + +async function withRasterAvatar(page: Page, run: (source: Buffer) => Promise) { + const directory = workerDirectory(); + const configPath = join(directory, 'config/when.yaml'); + const originalConfig = await readFile(configPath, 'utf8'); + const sourceFile = join(directory, 'public/avatar source test.png'); + const source = await new Renderer().render( + { type: 'container', style: { width: 800, height: 800, backgroundColor: '#166534' } }, + { width: 800, height: 800, format: 'png' } + ); + await mkdir(join(directory, 'public'), { recursive: true }); + try { + await writeFile(sourceFile, source); + await setAvatarSource(page, SOURCE_PATH); + await run(source); + } finally { + await writeFile(configPath, originalConfig); + await expect + .poll(async () => (await page.request.get('/')).text()) + .toContain(DEFAULT_AVATAR_URL); + await rm(sourceFile, { force: true }); + } +} + +async function expectPageAvatars(page: Page, count: number) { + const avatars = page.getByRole('img', { name: 'E2E Fixture', exact: true, includeHidden: true }); + await expect(avatars).toHaveCount(count); + for (const avatar of await avatars.all()) { + await expect(avatar).toHaveAttribute('src', THUMBNAIL_URL); + } + await expect + .poll(() => + avatars.evaluateAll((images) => + images.every((image) => { + const img = image as HTMLImageElement; + return img.complete && img.naturalWidth === 216 && img.naturalHeight === 216; + }) + ) + ) + .toBe(true); +} + +test('default SVG avatars stay vector images', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('img', { name: 'E2E Fixture', exact: true })).toHaveAttribute( + 'src', + DEFAULT_AVATAR_URL + ); + const response = await page.request.get(DEFAULT_AVATAR_URL); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('image/svg+xml'); +}); + +test('original and thumbnail variants preserve their bytes and cache validators', async ({ + page +}) => { + await withRasterAvatar(page, async (source) => { + const thumbnail = await page.request.get(THUMBNAIL_URL); + expect(thumbnail.status()).toBe(200); + expect(thumbnail.headers()['content-type']).toBe('image/webp'); + expect((await thumbnail.body()).byteLength).toBeLessThan(source.byteLength); + + const original = await page.request.get(ORIGINAL_URL); + expect(original.headers()['content-type']).toBe('image/png'); + expect(await original.body()).toEqual(source); + const explicitOriginal = await page.request.get(`${ORIGINAL_URL}?thumbnail=false`); + expect(await explicitOriginal.body()).toEqual(source); + + for (const [url, response] of [ + [ORIGINAL_URL, original], + [THUMBNAIL_URL, thumbnail] + ] as const) { + const notModified = await page.request.get(url, { + headers: { 'if-none-match': response.headers()['etag'] } + }); + expect(notModified.status()).toBe(304); + } + }); +}); + +test('pages download thumbnails without JavaScript on mobile and desktop', async ({ page }) => { + await withRasterAvatar(page, async () => { + const imageRequests: string[] = []; + page.on('request', (request) => { + if (request.resourceType() === 'image') imageRequests.push(new URL(request.url()).pathname); + }); + const appointment = await seedAppointment(); + const pages = [ + { path: '/', avatars: 1 }, + { path: '/schedule/chat', avatars: 2 }, + { + path: `/appointment/${appointment.id}/reschedule?token=${appointment.cancel_token}`, + avatars: 2 + } + ]; + for (const width of [390, 1280]) { + await page.setViewportSize({ width, height: 900 }); + for (const scenario of pages) { + await page.goto(scenario.path); + await expectPageAvatars(page, scenario.avatars); + } + } + expect(imageRequests).toContain(ORIGINAL_URL); + expect(imageRequests).not.toContain('/public/avatar%20source%20test.png'); + }); +}); + +test('avatar paths cannot select another public image or recurse into this route', async ({ + page +}) => { + await withRasterAvatar(page, async (source) => { + const otherFile = join(workerDirectory(), 'public/not-configured.png'); + try { + await writeFile(otherFile, source); + expect((await page.request.get('/public/not-configured.png')).status()).toBe(200); + for (const search of ['', '?thumbnail=true']) { + expect( + (await page.request.get(`/avatar/public/not-configured.png${search}`)).status() + ).toBe(404); + } + expect((await page.request.get('/avatar')).status()).toBe(404); + + await setAvatarSource(page, '/%61vatar'); + for (const search of ['', '?thumbnail=true']) { + const rejected = await page.request.get(`/avatar/%61vatar${search}`, { timeout: 5_000 }); + expect(rejected.status()).toBe(404); + } + } finally { + await rm(otherFile, { force: true }); + } + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 574cc034..441fa465 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,7 +65,7 @@ "mrmime": "2.0.1", "openworkflow": "0.11.0", "pino": "10.3.1", - "takumi-js": "2.2.0", + "takumi-js": "2.19.0", "temporal-polyfill": "1.0.1", "ts-ics": "2.4.4", "yaml": "2.9.0" diff --git a/apps/web/src/lib/avatar.test.ts b/apps/web/src/lib/avatar.test.ts new file mode 100644 index 00000000..16b7f254 --- /dev/null +++ b/apps/web/src/lib/avatar.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'vitest'; +import { avatarThumbnailUrl } from './avatar'; + +test('uses the configured path as the thumbnail URL path, not a query parameter', () => { + expect(avatarThumbnailUrl('/public/photo.webp')).toBe('/avatar/public/photo.webp?thumbnail=true'); +}); + +test('preserves source query parameters and SVG fragments', () => { + expect(avatarThumbnailUrl('/public/photo.svg?revision=2#face')).toBe( + '/avatar/public/photo.svg?revision=2&thumbnail=true#face' + ); +}); + +test('encodes filenames without encoding their path separators', () => { + expect(avatarThumbnailUrl('/public/profile photo.webp')).toBe( + '/avatar/public/profile%20photo.webp?thumbnail=true' + ); + expect(avatarThumbnailUrl('/public/profile%20photo.webp')).toBe( + '/avatar/public/profile%20photo.webp?thumbnail=true' + ); +}); + +test('the thumbnail selector cannot be overridden by the configured source query', () => { + expect(avatarThumbnailUrl('/public/photo.webp?thumbnail=false')).toBe( + '/avatar/public/photo.webp?thumbnail=true' + ); +}); diff --git a/apps/web/src/lib/avatar.ts b/apps/web/src/lib/avatar.ts new file mode 100644 index 00000000..eca89825 --- /dev/null +++ b/apps/web/src/lib/avatar.ts @@ -0,0 +1,7 @@ +/** Put the configured path in the URL for cache identity, not source selection. */ +export function avatarThumbnailUrl(avatarPath: string): string { + const url = new URL(avatarPath, 'http://when.invalid'); + url.pathname = `/avatar${url.pathname}`; + url.searchParams.set('thumbnail', 'true'); + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/apps/web/src/lib/components/AppointmentWizard.svelte b/apps/web/src/lib/components/AppointmentWizard.svelte index 1cd5da38..01d9e84e 100644 --- a/apps/web/src/lib/components/AppointmentWizard.svelte +++ b/apps/web/src/lib/components/AppointmentWizard.svelte @@ -1,5 +1,6 @@ @@ -14,7 +15,11 @@
{#if data.user.appearance.avatar_path}
- {data.user.name} + {data.user.name} {#if data.role === 'host'} { + const headers = new Headers({ 'accept-encoding': 'identity' }); + for (const name of ['if-none-match', 'if-modified-since']) { + const value = requestHeaders.get(name); + if (value) headers.set(name, value); + } + const original = await fetchSource(`${source.pathname}${source.search}`, { + headers, + redirect: 'error' + }); + // Native fetch decodes compressed bodies but retains their encoding/length headers. + // If the source ignores identity, let the browser load it directly instead. + const encoding = original.headers.get('content-encoding'); + if (encoding && encoding.toLowerCase() !== 'identity') { + await original.body?.cancel(); + throw new Error('Avatar source ignored identity encoding.'); + } + const responseHeaders = new Headers(original.headers); + if (!responseHeaders.has('content-type')) { + const type = lookup(source.pathname); + if (type) responseHeaders.set('content-type', type); + } + return new Response(original.body, { status: original.status, headers: responseHeaders }); +} + +function thumbnailResponse(image: AvatarThumbnail, ifNoneMatch: string | null): Response { + const headers = new Headers({ + 'content-type': image.contentType, + 'cache-control': 'public, max-age=3600', + etag: image.etag + }); + const matches = ifNoneMatch?.split(',').some((tag) => { + const value = tag.trim(); + return value === '*' || value.replace(/^W\//, '') === image.etag; + }); + if (matches) return new Response(null, { status: 304, headers }); + headers.set('content-length', String(image.bytes.byteLength)); + return new Response(image.bytes, { headers }); +} + +export const GET: RequestHandler = async ({ fetch, request, url, params }) => { + const thumbnail = url.searchParams.get('thumbnail'); + if (thumbnail !== null && thumbnail !== 'true' && thumbnail !== 'false') { + error(400, 'thumbnail must be true or false.'); + } + const avatarPath = getConfig().user.appearance.avatar_path; + const source = new URL(avatarPath, url); + // Only the configured same-origin asset is eligible. + if ( + !avatarPath.startsWith('/') || + source.pathname.startsWith('//') || + source.origin !== url.origin + ) { + error(404); + } + let sourcePathname: string; + try { + sourcePathname = decodeURIComponent(source.pathname).replace(/\/+$/, ''); + } catch { + error(404); + } + // The avatar route cannot fetch itself as its source. + if (sourcePathname === '/avatar' || sourcePathname.startsWith('/avatar/')) { + error(404); + } + // The request path only validates cache identity; it never selects a source. + if (!params.path || `/${params.path}` !== sourcePathname) { + error(404); + } + const sourcePath = `${source.pathname}${source.search}`; + try { + if (thumbnail !== 'true') return await fetchOriginalAvatar(fetch, source, request.headers); + + const image = await getAvatarThumbnail(fetch, sourcePath); + return thumbnailResponse(image, request.headers.get('if-none-match')); + } catch { + // A transient failure must not hide an otherwise usable original or cache a redirect. + return new Response(null, { + status: 307, + headers: { location: sourcePath, 'cache-control': 'no-store' } + }); + } +}; diff --git a/apps/web/src/routes/avatar/[...path]/server.test.ts b/apps/web/src/routes/avatar/[...path]/server.test.ts new file mode 100644 index 00000000..33cb5b8f --- /dev/null +++ b/apps/web/src/routes/avatar/[...path]/server.test.ts @@ -0,0 +1,276 @@ +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { gzipSync } from 'node:zlib'; +import { beforeEach, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + source: '/public/avatar.jpg', + thumbnail: vi.fn() +})); +vi.mock('#lib/server/state.js', () => ({ + getConfig: () => ({ user: { appearance: { avatar_path: mocks.source } } }) +})); +vi.mock('#lib/server/avatar.js', () => ({ getAvatarThumbnail: mocks.thumbnail })); + +import { GET } from './+server'; + +function createRequestEvent({ + headers = {}, + search = '?thumbnail=true', + fetchSource = vi.fn(), + path = 'public/avatar.jpg' +}: { + headers?: Record; + search?: string; + fetchSource?: typeof fetch; + path?: string; +} = {}) { + const url = new URL(`https://when.example/avatar/${path}${search}`); + return { + url, + params: { path: decodeURIComponent(path) }, + request: new Request(url, { headers }), + fetch: fetchSource + } as unknown as Parameters[0]; +} + +beforeEach(() => { + mocks.source = '/public/avatar.jpg'; + mocks.thumbnail.mockReset().mockResolvedValue({ + bytes: new Uint8Array([1, 2, 3]), + contentType: 'image/webp', + etag: '"thumbnail"' + }); +}); + +test.each(['', '?thumbnail=false'])( + 'serves the original without resizing for %s', + async (search) => { + const fetchSource = vi.fn().mockResolvedValue( + new Response(new Uint8Array([4, 5, 6]), { + headers: { + 'content-type': 'image/jpeg', + 'cache-control': 'public, max-age=3600', + etag: '"original"' + } + }) + ); + const response = await GET(createRequestEvent({ search, fetchSource })); + expect(fetchSource).toHaveBeenCalledWith( + '/public/avatar.jpg', + expect.objectContaining({ redirect: 'error' }) + ); + expect(mocks.thumbnail).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/jpeg'); + expect(response.headers.get('cache-control')).toBe('public, max-age=3600'); + expect(response.headers.get('etag')).toBe('"original"'); + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([4, 5, 6]); + } +); + +test.each([ + { behavior: 'honors identity encoding', ignoreIdentity: false }, + { behavior: 'ignores identity encoding', ignoreIdentity: true } +])('safely forwards an upstream that $behavior', async ({ ignoreIdentity }) => { + const source = Buffer.from( + '' + ); + const compressed = gzipSync(source); + const encodings: (string | undefined)[] = []; + const server = createServer((request, response) => { + encodings.push(request.headers['accept-encoding']); + const useGzip = ignoreIdentity || request.headers['accept-encoding'] !== 'identity'; + const body = useGzip ? compressed : source; + response.writeHead(200, { + 'content-type': 'image/svg+xml', + 'content-length': String(body.byteLength), + ...(useGzip ? { 'content-encoding': 'gzip' } : {}) + }); + response.end(body); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected a TCP address'); + const sourceUrl = `http://127.0.0.1:${address.port}/image.svg`; + // Reproduce native fetch's decoded body with still-compressed metadata. + const probe = await fetch(sourceUrl, { headers: { 'accept-encoding': 'gzip' } }); + expect(probe.headers.get('content-encoding')).toBe('gzip'); + expect(Buffer.from(await probe.arrayBuffer())).toEqual(source); + + mocks.source = '/public/avatar.svg'; + const fetchSource = vi + .fn() + .mockImplementation((_input, init) => fetch(sourceUrl, init)); + const response = await GET( + createRequestEvent({ search: '', path: 'public/avatar.svg', fetchSource }) + ); + expect(encodings.at(-1)).toBe('identity'); + expect(mocks.thumbnail).not.toHaveBeenCalled(); + if (ignoreIdentity) { + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('/public/avatar.svg'); + expect(response.headers.get('cache-control')).toBe('no-store'); + } else { + expect(response.status).toBe(200); + expect(response.headers.get('content-encoding')).toBeNull(); + expect(response.headers.get('content-length')).toBe(String(source.byteLength)); + expect(Buffer.from(await response.arrayBuffer())).toEqual(source); + } + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test('restores the full image MIME when dev fetch omits its headers', async () => { + const fetchSource = vi + .fn() + .mockResolvedValue(new Response(new Uint8Array([4, 5, 6]))); + const response = await GET(createRequestEvent({ search: '', fetchSource })); + expect(response.headers.get('content-type')).toBe('image/jpeg'); + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([4, 5, 6]); +}); + +test('forwards original-image validators and preserves a source 304 response', async () => { + const fetchSource = vi + .fn() + .mockResolvedValue(new Response(null, { status: 304, headers: { etag: '"original"' } })); + const response = await GET( + createRequestEvent({ + search: '', + headers: { + 'if-none-match': '"original"', + 'if-modified-since': 'Mon, 02 Nov 2026 00:00:00 GMT' + }, + fetchSource + }) + ); + const headers = new Headers(fetchSource.mock.calls[0][1]?.headers); + expect(headers.get('if-none-match')).toBe('"original"'); + expect(headers.get('if-modified-since')).toBe('Mon, 02 Nov 2026 00:00:00 GMT'); + expect(response.status).toBe(304); + expect(response.headers.get('etag')).toBe('"original"'); + expect(await response.text()).toBe(''); + expect(mocks.thumbnail).not.toHaveBeenCalled(); +}); + +test.each(['', '1', 'full'])('rejects an unsupported thumbnail parameter: %s', async (value) => { + await expect(GET(createRequestEvent({ search: `?thumbnail=${value}` }))).rejects.toMatchObject({ + status: 400 + }); + expect(mocks.thumbnail).not.toHaveBeenCalled(); +}); + +test('serves the configured avatar derivative with cache validators', async () => { + const requestEvent = createRequestEvent(); + const response = await GET(requestEvent); + expect(mocks.thumbnail).toHaveBeenCalledWith(requestEvent.fetch, '/public/avatar.jpg'); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/webp'); + expect(response.headers.get('content-length')).toBe('3'); + expect(response.headers.get('cache-control')).toBe('public, max-age=3600'); + expect(response.headers.get('etag')).toBe('"thumbnail"'); + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([1, 2, 3]); +}); + +test.each(['"thumbnail"', 'W/"thumbnail"', '"another", "thumbnail"', '*'])( + 'returns 304 for a matching If-None-Match: %s', + async (etag) => { + const response = await GET(createRequestEvent({ headers: { 'if-none-match': etag } })); + expect(response.status).toBe(304); + expect(response.headers.get('etag')).toBe('"thumbnail"'); + expect(response.headers.has('content-length')).toBe(false); + expect(await response.text()).toBe(''); + } +); + +test('a changed derivative does not match a stale client validator', async () => { + const response = await GET( + createRequestEvent({ headers: { 'if-none-match': '"old-thumbnail"' } }) + ); + expect(response.status).toBe(200); +}); + +test('preserves query parameters on a configured local source', async () => { + mocks.source = '/public/avatar.jpg?revision=2'; + const requestEvent = createRequestEvent(); + await GET(requestEvent); + expect(mocks.thumbnail).toHaveBeenCalledWith(requestEvent.fetch, mocks.source); +}); + +test('query parameters cannot select an arbitrary image source', async () => { + const requestEvent = createRequestEvent(); + requestEvent.url.searchParams.set('source', 'https://elsewhere.example/image.jpg'); + await GET(requestEvent); + expect(mocks.thumbnail).toHaveBeenCalledWith(requestEvent.fetch, '/public/avatar.jpg'); +}); + +test('source failures redirect to the original without caching the failure', async () => { + mocks.thumbnail.mockRejectedValueOnce(new Error('source unavailable')); + const response = await GET(createRequestEvent()); + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('/public/avatar.jpg'); + expect(response.headers.get('cache-control')).toBe('no-store'); +}); + +test.each(['', '?thumbnail=true'])( + 'rejects a mismatched or missing validation path for %s', + async (search) => { + const fetchSource = vi.fn(); + for (const path of ['public/another.jpg', '']) { + await expect(GET(createRequestEvent({ search, fetchSource, path }))).rejects.toMatchObject({ + status: 404 + }); + } + expect(fetchSource).not.toHaveBeenCalled(); + expect(mocks.thumbnail).not.toHaveBeenCalled(); + } +); + +test('validates a decoded filename but fetches the configured encoded source', async () => { + mocks.source = '/public/profile photo.jpg?revision=2'; + const requestEvent = createRequestEvent({ path: 'public/profile%20photo.jpg' }); + await GET(requestEvent); + expect(mocks.thumbnail).toHaveBeenCalledWith( + requestEvent.fetch, + '/public/profile%20photo.jpg?revision=2' + ); +}); + +test.each(['', '?thumbnail=true'])( + 'rejects configured sources in the avatar route namespace for %s', + async (search) => { + const fetchSource = vi.fn(); + for (const source of ['/%61vatar', '/avatar/public/avatar.jpg']) { + mocks.source = source; + await expect( + GET(createRequestEvent({ search, fetchSource, path: source.slice(1) })) + ).rejects.toMatchObject({ status: 404 }); + } + expect(fetchSource).not.toHaveBeenCalled(); + expect(mocks.thumbnail).not.toHaveBeenCalled(); + } +); + +test.each([ + 'https://elsewhere.example/avatar.jpg', + '/\t/elsewhere.example/avatar.jpg', + '/folder/..//elsewhere.example/avatar.png', + '/avatar', + '/avatar?thumbnail=true', + '/%61vatar', + '/avatar/' +])('rejects off-origin or recursive configured sources: %s', async (source) => { + mocks.source = source; + const path = new URL(source, 'https://when.example').pathname.slice(1); + const fetchSource = vi.fn(); + await expect(GET(createRequestEvent({ fetchSource, path }))).rejects.toMatchObject({ + status: 404 + }); + expect(fetchSource).not.toHaveBeenCalled(); + expect(mocks.thumbnail).not.toHaveBeenCalled(); +}); diff --git a/docs/config.md b/docs/config.md index 6a85f606..5d4f3f2e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -154,6 +154,31 @@ All `*_path` values must be root-relative paths served by this app. Remote URLs are rejected. Put custom files in `./public/`, alongside `config/` and `data/`, and reference them as `/public/logo.png`. `WHEN_PUBLIC_DIR` overrides that directory. +For an `avatar_path` of `/public/photo.webp`, `/avatar/public/photo.webp` serves +the original and `/avatar/public/photo.webp?thumbnail=true` serves a thumbnail. +The path is only checked against the configured avatar: mismatches return 404, +and the app always loads the source selected by configuration, never by the +request path. Changing the configured path changes the browser's cache key +without embedding a path in a query parameter. + +Web pages request thumbnails automatically, resizing raster avatars to at most +216 × 216 pixels (enough for the largest 72-pixel avatar at 3× density). +`thumbnail=false` also serves the full image; other values are rejected. Keep +setting only `avatar_path`; no separate thumbnail or image tool is needed. +Generated share cards continue to load the original image. + +Thumbnail responses preserve SVGs, small images and animations. It uses WebP +only when the result is smaller, and falls back to the original on conversion +failure. Sources above 8 MiB are served directly instead of processed. The app +caches the current derivative in memory and revalidates its source when the route +is requested. Concurrent requests share source loading and resizing. Browser +caching lasts up to one hour; changing `avatar_path` also +changes the web thumbnail's cache key. The source file is never overwritten. + +**Known limitation:** Takumi currently ignores EXIF orientation metadata. A photo +that relies on that metadata may appear rotated in the generated thumbnail. This +also occurs with Takumi 2.19.0; it is not corrected by this optimization. + Colors accept three- or six-digit hex values. Each primary color generates a muted tonal scale for its mode; background and text colors override the surfaces and text. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d8729c7..df67a8b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,8 +168,8 @@ importers: specifier: 10.3.1 version: 10.3.1 takumi-js: - specifier: 2.2.0 - version: 2.2.0(preact@10.24.3) + specifier: 2.19.0 + version: 2.19.0(preact@10.24.3) temporal-polyfill: specifier: 1.0.1 version: 1.0.1 @@ -1207,60 +1207,60 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@takumi-rs/core-darwin-arm64@2.2.0': - resolution: {integrity: sha512-6HhAaf63mrjX+q3yQXX0SZVU18kSuzr35nAcTsqqKGIr/jsoUMAygiLRDTaufpSlCo7lL4TkUmcIi8/vl3fHVg==} + '@takumi-rs/core-darwin-arm64@2.19.0': + resolution: {integrity: sha512-W/SI7Zrxo70dIaKNo+/kOLVr97S3ezkqsi7OM0RWz2CtP+cFgdBTPHXNsrB561gJFPHsuWN/LZ0u8oZxeAYtGw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@takumi-rs/core-darwin-x64@2.2.0': - resolution: {integrity: sha512-JSv1m+9m3xxCBfJFQ29ACU+ITHV0avDj5LQTOLdmIifRBv4x+JogjWw2ZRq5PisJ8/vKA/QklDqOGeJhxHcw2g==} + '@takumi-rs/core-darwin-x64@2.19.0': + resolution: {integrity: sha512-xO/eTwPLrfUZRybMnG4lb6fDa7BjFryFi1TSkRF5KjPhkv21Lr5ovFEYz1y2P5k/4psrk66FJbU2EJDOEjdXiw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@takumi-rs/core-linux-arm64-gnu@2.2.0': - resolution: {integrity: sha512-fXWs3q8iJiSToCvgjv2/9h8/3FxmBB5ba32mNOoFP+zD8kEQDNYoQ/TfLUqDWoGTVPvjGL+SLwp3PxydFa1Nfw==} + '@takumi-rs/core-linux-arm64-gnu@2.19.0': + resolution: {integrity: sha512-0PvL5bNTZTkLExjKveI3pOz3mtfjJtECqK49f/KGnp7J0bBmdoi2muszCh/dQQEEFKXcWNe27yfK1tzsO5fm0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] libc: [glibc] - '@takumi-rs/core-linux-arm64-musl@2.2.0': - resolution: {integrity: sha512-O+FBMMAXK7r57oVH6dFhZ+4clWRtZ6LeIpzLsAz8FFYU4c9cwGoTBJ8BideNJzt2S78GDQaG9qy5wM+pvkveYA==} + '@takumi-rs/core-linux-arm64-musl@2.19.0': + resolution: {integrity: sha512-nwKZMGcNVdmCP+U7+uINtXpgOeyk5m7g5D9EAtttpWc6ImjJm7MaqNK4Q2YBkOFeAIbRkY5dDL8Xcu19ATSj0w==} engines: {node: '>=18'} cpu: [arm64] os: [linux] libc: [musl] - '@takumi-rs/core-linux-x64-gnu@2.2.0': - resolution: {integrity: sha512-zRy83L9R0SIr0t4AcRzpmbRhCQsB/XZzgjDJ/szA3dytX9jDtVP1WpcoyFRBJFTrmXDFsAIEBoKTobV5vs427w==} + '@takumi-rs/core-linux-x64-gnu@2.19.0': + resolution: {integrity: sha512-gf/U6qxWZk6uX5MFnl5/PTOzxBbISj89UQwbliHa1sflcq2d+SeITDqnGtkUueF/4TlgPVV7JvrPuhAbINy2Gg==} engines: {node: '>=18'} cpu: [x64] os: [linux] libc: [glibc] - '@takumi-rs/core-linux-x64-musl@2.2.0': - resolution: {integrity: sha512-K+14jFA7XQXdrMu3gOToIzZvcP6DK0f0FZMH3HQ7bQfRn1Hx9gS1KesILjpH/C4CF+ZK5kQhxwVjoxeUzBSeYA==} + '@takumi-rs/core-linux-x64-musl@2.19.0': + resolution: {integrity: sha512-zHD0EdIA2ZMCnKV/KtMgEerUcdvjYU4hGiru9I/x96u+BJXaQGMtHCxXt9Srhj+dlqhPyWdQTMAE3wpflN+0nw==} engines: {node: '>=18'} cpu: [x64] os: [linux] libc: [musl] - '@takumi-rs/core-win32-arm64-msvc@2.2.0': - resolution: {integrity: sha512-CPIVzIMCcTP6Bd9TDn1eB6NNwMViIRjyOCexH2fdJj3/rzfp0/A1EDkfFMXMmItFomwGpe7f/2I0A5Vvujnzbg==} + '@takumi-rs/core-win32-arm64-msvc@2.19.0': + resolution: {integrity: sha512-v1CrPsmzhIe0+v+kIAdaSwrNMqiOpUXgMxHLUo0xDQdPMNML0EefDsL9ADtKDpT1pjhY/r9tvwVl3RhKsdottw==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@takumi-rs/core-win32-x64-msvc@2.2.0': - resolution: {integrity: sha512-IATwTX+ScVtHECIAUw7+xXZmu/lCZylHHCNzpIQUnzAxRjT3iu9E8WHQvNlCtxDnYq1L3AhuuwmJoh+2cT7BaA==} + '@takumi-rs/core-win32-x64-msvc@2.19.0': + resolution: {integrity: sha512-9HJb2LTDSlehFrGfPw+uKgxM3XCnpy/PYnwtyzcgAiSFJVjwv0zCLl3IvJREakj5Y9rJCg0mMtPje9hlPbJL/A==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@takumi-rs/core@2.2.0': - resolution: {integrity: sha512-Ex393O45Su8Xm0DqqtFPDnhF93neUzUX5JlVmJ4R95dVY7NQyJkAJ2kZ/Uok8EyWI92G5AEFlkglarrkP3/jwQ==} + '@takumi-rs/core@2.19.0': + resolution: {integrity: sha512-cYxOTb4pOu25Y8GBm3JTyIwenHNAZF11pxEmTxA3Q7VabC0pqeWDo/nOjTc2slTHORaglkvwK4SMmnpzab5BjQ==} engines: {node: '>=18'} peerDependencies: csstype: '*' @@ -1268,11 +1268,11 @@ packages: csstype: optional: true - '@takumi-rs/helpers@2.2.0': - resolution: {integrity: sha512-U9Cr4x/BN/GDFcmIOQs3XdiBQvidbv7rsSIjpol/ICN2EOvNaSnXMm9wwtBr4UgkQ2IhMzcTmkgrCMx8LROjwg==} + '@takumi-rs/helpers@2.19.0': + resolution: {integrity: sha512-3ycCjYCP31HACXBvlpv4fMLw9vneCVGuttFBfZr4jpL0nHNCt2wlXvt0XFt05FscLMUY2s+5PGKPE6EatXI/mg==} engines: {node: '>=18'} peerDependencies: - preact: ^10.0.0 + preact: ^10.0.0 || ^11.0.0 react: ^18.0.0 || ^19.0.0 peerDependenciesMeta: preact: @@ -1280,9 +1280,9 @@ packages: react: optional: true - '@takumi-rs/wasm@2.2.0': - resolution: {integrity: sha512-6xizlgurJNkynwFZBI/ercSf2mlYryVgp0kl0KK6yV10rahs5ECVGH8TcSD9gtzi3YBnxCNkdD6oAw1K5+ApNA==} - engines: {node: '>=18'} + '@takumi-rs/wasm@2.19.0': + resolution: {integrity: sha512-V9LKPOFVHKZ7lANVQ94WmOkGvgW0EqXUeLEpsWB2Wnnx7WpOWHnbAI9Yb0VnAYeyRicp7nAfG3vF/M7vsw0s2w==} + engines: {node: '>=20.19'} peerDependencies: csstype: '*' peerDependenciesMeta: @@ -2322,9 +2322,9 @@ packages: tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - takumi-js@2.2.0: - resolution: {integrity: sha512-EGw1bJ1U/GfISJi5hs6G98dLfk1QId90z+buXrRTFs8MAJTHTxdno+0jX0eamzXueT8AGLfdI0nSq47Dw1uSUQ==} - engines: {node: '>=18'} + takumi-js@2.19.0: + resolution: {integrity: sha512-WUgzukIpAtoDUeFkdKvT0o4pterWoyM9qcXpIRUQIqUZHriIBdPdPZV42UyzEbNyXvg/1aUaVhBGEyivraT8NQ==} + engines: {node: '>=20.19'} tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -3100,53 +3100,53 @@ snapshots: dependencies: tslib: 2.8.1 - '@takumi-rs/core-darwin-arm64@2.2.0': + '@takumi-rs/core-darwin-arm64@2.19.0': optional: true - '@takumi-rs/core-darwin-x64@2.2.0': + '@takumi-rs/core-darwin-x64@2.19.0': optional: true - '@takumi-rs/core-linux-arm64-gnu@2.2.0': + '@takumi-rs/core-linux-arm64-gnu@2.19.0': optional: true - '@takumi-rs/core-linux-arm64-musl@2.2.0': + '@takumi-rs/core-linux-arm64-musl@2.19.0': optional: true - '@takumi-rs/core-linux-x64-gnu@2.2.0': + '@takumi-rs/core-linux-x64-gnu@2.19.0': optional: true - '@takumi-rs/core-linux-x64-musl@2.2.0': + '@takumi-rs/core-linux-x64-musl@2.19.0': optional: true - '@takumi-rs/core-win32-arm64-msvc@2.2.0': + '@takumi-rs/core-win32-arm64-msvc@2.19.0': optional: true - '@takumi-rs/core-win32-x64-msvc@2.2.0': + '@takumi-rs/core-win32-x64-msvc@2.19.0': optional: true - '@takumi-rs/core@2.2.0(preact@10.24.3)': + '@takumi-rs/core@2.19.0(preact@10.24.3)': dependencies: - '@takumi-rs/helpers': 2.2.0(preact@10.24.3) + '@takumi-rs/helpers': 2.19.0(preact@10.24.3) optionalDependencies: - '@takumi-rs/core-darwin-arm64': 2.2.0 - '@takumi-rs/core-darwin-x64': 2.2.0 - '@takumi-rs/core-linux-arm64-gnu': 2.2.0 - '@takumi-rs/core-linux-arm64-musl': 2.2.0 - '@takumi-rs/core-linux-x64-gnu': 2.2.0 - '@takumi-rs/core-linux-x64-musl': 2.2.0 - '@takumi-rs/core-win32-arm64-msvc': 2.2.0 - '@takumi-rs/core-win32-x64-msvc': 2.2.0 + '@takumi-rs/core-darwin-arm64': 2.19.0 + '@takumi-rs/core-darwin-x64': 2.19.0 + '@takumi-rs/core-linux-arm64-gnu': 2.19.0 + '@takumi-rs/core-linux-arm64-musl': 2.19.0 + '@takumi-rs/core-linux-x64-gnu': 2.19.0 + '@takumi-rs/core-linux-x64-musl': 2.19.0 + '@takumi-rs/core-win32-arm64-msvc': 2.19.0 + '@takumi-rs/core-win32-x64-msvc': 2.19.0 transitivePeerDependencies: - preact - react - '@takumi-rs/helpers@2.2.0(preact@10.24.3)': + '@takumi-rs/helpers@2.19.0(preact@10.24.3)': optionalDependencies: preact: 10.24.3 - '@takumi-rs/wasm@2.2.0(preact@10.24.3)': + '@takumi-rs/wasm@2.19.0(preact@10.24.3)': dependencies: - '@takumi-rs/helpers': 2.2.0(preact@10.24.3) + '@takumi-rs/helpers': 2.19.0(preact@10.24.3) transitivePeerDependencies: - preact - react @@ -4154,11 +4154,11 @@ snapshots: tabbable@6.4.0: {} - takumi-js@2.2.0(preact@10.24.3): + takumi-js@2.19.0(preact@10.24.3): dependencies: - '@takumi-rs/core': 2.2.0(preact@10.24.3) - '@takumi-rs/helpers': 2.2.0(preact@10.24.3) - '@takumi-rs/wasm': 2.2.0(preact@10.24.3) + '@takumi-rs/core': 2.19.0(preact@10.24.3) + '@takumi-rs/helpers': 2.19.0(preact@10.24.3) + '@takumi-rs/wasm': 2.19.0(preact@10.24.3) transitivePeerDependencies: - csstype - preact -- 2.54.0