perf(web): generate avatar thumbnails automatically #122
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@when/web': patch
|
||||
---
|
||||
|
||||
Upgrade the Takumi image renderer to 2.19.0.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@when/web': patch
|
||||
---
|
||||
|
||||
Serve the configured avatar through `/avatar/<configured-path>`, 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.
|
||||
@@ -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<void>) {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { avatarThumbnailUrl } from '#lib/avatar.js';
|
||||
import DatePicker from '#lib/components/DatePicker.svelte';
|
||||
import DayTimeline from '#lib/components/DayTimeline.svelte';
|
||||
import WizardPickerControls from '#lib/components/WizardPickerControls.svelte';
|
||||
@@ -128,7 +129,11 @@
|
||||
<header class="page-banner">
|
||||
<a href="/" class="banner-link">
|
||||
{#if data.user.appearance.avatar_path}
|
||||
<img src={data.user.appearance.avatar_path} alt={data.user.name} class="banner-avatar" />
|
||||
<img
|
||||
src={avatarThumbnailUrl(data.user.appearance.avatar_path)}
|
||||
alt={data.user.name}
|
||||
class="banner-avatar"
|
||||
/>
|
||||
{/if}
|
||||
<div class="banner-text">
|
||||
<span class="banner-title">{data.user.appearance.title}</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- Desktop-only sidebar: provider and event summary. Hidden on mobile. -->
|
||||
<script lang="ts">
|
||||
import type { Appearance } from '@when/config';
|
||||
import { avatarThumbnailUrl } from '#lib/avatar.js';
|
||||
|
||||
interface Props {
|
||||
appearance: Appearance;
|
||||
@@ -16,7 +17,7 @@
|
||||
<section class="section">
|
||||
<a href="/" class="provider">
|
||||
{#if appearance.avatar_path}
|
||||
<img src={appearance.avatar_path} alt={providerName} class="avatar" />
|
||||
<img src={avatarThumbnailUrl(appearance.avatar_path)} alt={providerName} class="avatar" />
|
||||
{/if}
|
||||
</a>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { Renderer } from 'takumi-js/node';
|
||||
import { defaultAvatar } from './avatar';
|
||||
|
||||
test('renders a deterministic svg from a seed', () => {
|
||||
@@ -15,3 +16,248 @@ test('applies brand background and text colors', () => {
|
||||
expect(svg).toContain('#166534');
|
||||
expect(svg).toContain('#ededed');
|
||||
});
|
||||
|
||||
describe('thumbnails', () => {
|
||||
const fixtureRenderer = new Renderer();
|
||||
let sources: Record<string, Buffer>;
|
||||
let getAvatarThumbnail: typeof import('./avatar').getAvatarThumbnail;
|
||||
|
||||
beforeAll(async () => {
|
||||
const node = {
|
||||
type: 'container' as const,
|
||||
style: {
|
||||
width: 800,
|
||||
height: 800,
|
||||
backgroundImage: 'linear-gradient(45deg, #166534, #34d399, #2563eb)'
|
||||
}
|
||||
};
|
||||
sources = {};
|
||||
for (const format of ['png', 'jpeg', 'webp'] as const) {
|
||||
sources[`image/${format}`] = await fixtureRenderer.render(node, {
|
||||
width: 800,
|
||||
height: 800,
|
||||
format
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ getAvatarThumbnail } = await import('./avatar'));
|
||||
});
|
||||
|
||||
function sourceFetch(bytes: Uint8Array, contentType: string, etag?: string) {
|
||||
return vi.fn<typeof fetch>().mockImplementation(async () => {
|
||||
const headers = new Headers({ 'content-type': contentType });
|
||||
if (etag) headers.set('etag', etag);
|
||||
return new Response(Uint8Array.from(bytes), { headers });
|
||||
});
|
||||
}
|
||||
|
||||
async function dimensions(bytes: Uint8Array) {
|
||||
const png = await fixtureRenderer.render({ type: 'image', src: bytes }, { format: 'png' });
|
||||
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
test.each(['image/png', 'image/jpeg', 'image/webp'])(
|
||||
'resizes %s to a smaller 216px WebP',
|
||||
async (type) => {
|
||||
const source = sources[type];
|
||||
const image = await getAvatarThumbnail(sourceFetch(source, type), '/public/avatar');
|
||||
expect(image.contentType).toBe('image/webp');
|
||||
expect(image.bytes.byteLength).toBeLessThan(source.byteLength);
|
||||
expect(await dimensions(image.bytes)).toEqual({ width: 216, height: 216 });
|
||||
}
|
||||
);
|
||||
|
||||
test('infers MIME from the configured filename when dev fetch omits its headers', async () => {
|
||||
const fetchSource = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response(Uint8Array.from(sources['image/webp'])));
|
||||
const image = await getAvatarThumbnail(fetchSource, '/public/avatar.webp?revision=2');
|
||||
expect(image.contentType).toBe('image/webp');
|
||||
expect(image.bytes.byteLength).toBeLessThan(sources['image/webp'].byteLength);
|
||||
expect(await dimensions(image.bytes)).toEqual({ width: 216, height: 216 });
|
||||
});
|
||||
|
||||
test('does not override an explicit non-image source type based on its filename', async () => {
|
||||
const fetchSource = sourceFetch(Buffer.from('not an image'), 'text/html');
|
||||
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow(
|
||||
'not an image'
|
||||
);
|
||||
});
|
||||
|
||||
test('SVG avatars remain vectors with unchanged bytes', async () => {
|
||||
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800"/>');
|
||||
const image = await getAvatarThumbnail(sourceFetch(svg, 'image/svg+xml'), '/public/avatar.svg');
|
||||
expect(image.contentType).toBe('image/svg+xml');
|
||||
expect(Buffer.from(image.bytes)).toEqual(svg);
|
||||
});
|
||||
|
||||
test('does not enlarge a small image', async () => {
|
||||
const bytes = await fixtureRenderer.render(
|
||||
{ type: 'container', style: { width: 40, height: 40, backgroundColor: 'green' } },
|
||||
{ width: 40, height: 40, format: 'png' }
|
||||
);
|
||||
const image = await getAvatarThumbnail(sourceFetch(bytes, 'image/png'), '/public/avatar.png');
|
||||
expect(image.contentType).toBe('image/png');
|
||||
expect(Buffer.from(image.bytes)).toEqual(bytes);
|
||||
});
|
||||
|
||||
test('retains a large source if encoding it would increase its bytes', async () => {
|
||||
// A flat 800px lossless WebP is already smaller than the lossy thumbnail.
|
||||
const source = await fixtureRenderer.render(
|
||||
{ type: 'container', style: { width: 800, height: 800, backgroundColor: 'transparent' } },
|
||||
{ width: 800, height: 800, format: 'webp', lossless: true }
|
||||
);
|
||||
const image = await getAvatarThumbnail(
|
||||
sourceFetch(source, 'image/webp'),
|
||||
'/public/avatar.webp'
|
||||
);
|
||||
expect(Buffer.from(image.bytes)).toEqual(source);
|
||||
});
|
||||
|
||||
test.each(['image/gif', 'image/avif', 'image/png'])(
|
||||
'serves original %s bytes if not decodable',
|
||||
async (type) => {
|
||||
const bytes = Buffer.from('not a decodable image');
|
||||
const image = await getAvatarThumbnail(sourceFetch(bytes, type), '/public/avatar');
|
||||
expect(image.contentType).toBe(type);
|
||||
expect(Buffer.from(image.bytes)).toEqual(bytes);
|
||||
}
|
||||
);
|
||||
|
||||
test('reuses the derivative when source validation returns 304', async () => {
|
||||
const fetchSource = sourceFetch(sources['image/png'], 'image/png', '"source-v1"');
|
||||
const first = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
|
||||
fetchSource.mockResolvedValueOnce(new Response(null, { status: 304 }));
|
||||
const second = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
|
||||
expect(second).toBe(first);
|
||||
const options = fetchSource.mock.calls[1][1];
|
||||
expect(new Headers(options?.headers).get('if-none-match')).toBe('"source-v1"');
|
||||
expect(new Headers(options?.headers).get('accept-encoding')).toBe('identity');
|
||||
expect(options?.redirect).toBe('error');
|
||||
});
|
||||
|
||||
test('concurrent requests share the source fetch and resize', async () => {
|
||||
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
|
||||
const [first, second] = await Promise.all([
|
||||
getAvatarThumbnail(fetchSource, '/public/avatar.png'),
|
||||
getAvatarThumbnail(fetchSource, '/public/avatar.png')
|
||||
]);
|
||||
expect(second).toBe(first);
|
||||
expect(fetchSource).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('revalidates between requests but reuses unchanged bytes without validators', async () => {
|
||||
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
|
||||
const first = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
|
||||
const second = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
|
||||
expect(second).toBe(first);
|
||||
expect(fetchSource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('concurrent source failures are shared and a later request can retry', async () => {
|
||||
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
|
||||
fetchSource.mockResolvedValueOnce(new Response(null, { status: 404 }));
|
||||
const attempts = await Promise.allSettled([
|
||||
getAvatarThumbnail(fetchSource, '/public/avatar.png'),
|
||||
getAvatarThumbnail(fetchSource, '/public/avatar.png')
|
||||
]);
|
||||
expect(attempts.map((attempt) => attempt.status)).toEqual(['rejected', 'rejected']);
|
||||
expect(fetchSource).toHaveBeenCalledOnce();
|
||||
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).resolves.toHaveProperty(
|
||||
'bytes'
|
||||
);
|
||||
expect(fetchSource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('refuses ambiguous compressed bytes when a source ignores identity encoding', async () => {
|
||||
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
|
||||
fetchSource.mockResolvedValueOnce(
|
||||
new Response(Uint8Array.from(sources['image/png']), {
|
||||
headers: { 'content-type': 'image/png', 'content-encoding': 'gzip' }
|
||||
})
|
||||
);
|
||||
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow(
|
||||
'identity encoding'
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces the cached derivative when the source changes at the same path', async () => {
|
||||
const fetchSource = sourceFetch(sources['image/png'], 'image/png', '"source-v1"');
|
||||
const first = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
|
||||
const changed = await fixtureRenderer.render(
|
||||
{ type: 'container', style: { width: 800, height: 800, backgroundColor: 'blue' } },
|
||||
{ width: 800, height: 800, format: 'png' }
|
||||
);
|
||||
fetchSource.mockImplementation(sourceFetch(changed, 'image/png', '"source-v2"'));
|
||||
const second = await getAvatarThumbnail(fetchSource, '/public/avatar.png');
|
||||
expect(second.etag).not.toBe(first.etag);
|
||||
});
|
||||
|
||||
test('a changed configured path is fetched without the previous source validator', async () => {
|
||||
const fetchSource = sourceFetch(sources['image/png'], 'image/png', '"source-v1"');
|
||||
await getAvatarThumbnail(fetchSource, '/public/first.png');
|
||||
await getAvatarThumbnail(fetchSource, '/public/second.png');
|
||||
expect(fetchSource.mock.calls[1][0]).toBe('/public/second.png');
|
||||
expect(new Headers(fetchSource.mock.calls[1][1]?.headers).has('if-none-match')).toBe(false);
|
||||
});
|
||||
|
||||
test('a failed source request can be retried', async () => {
|
||||
const fetchSource = sourceFetch(sources['image/png'], 'image/png');
|
||||
fetchSource.mockResolvedValueOnce(new Response(null, { status: 404 }));
|
||||
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow('HTTP 404');
|
||||
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).resolves.toHaveProperty(
|
||||
'bytes'
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects oversized sources with or without Content-Length', async () => {
|
||||
const fetchSource = vi.fn<typeof fetch>();
|
||||
fetchSource.mockResolvedValueOnce(
|
||||
new Response(null, { headers: { 'content-type': 'image/png', 'content-length': '9000000' } })
|
||||
);
|
||||
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow(
|
||||
'source limit'
|
||||
);
|
||||
fetchSource.mockResolvedValueOnce(
|
||||
new Response(new Uint8Array(8 * 1024 * 1024 + 1), {
|
||||
headers: { 'content-type': 'image/png' }
|
||||
})
|
||||
);
|
||||
await expect(getAvatarThumbnail(fetchSource, '/public/avatar.png')).rejects.toThrow(
|
||||
'source limit'
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['webp', 'image/webp'],
|
||||
['apng', 'image/png']
|
||||
] as const)(
|
||||
'preserves a valid animated %s instead of flattening it',
|
||||
async (format, contentType) => {
|
||||
const bytes = await fixtureRenderer.renderAnimation({
|
||||
width: 512,
|
||||
height: 512,
|
||||
fps: 10,
|
||||
format,
|
||||
scenes: ['red', 'blue'].map((color) => ({
|
||||
node: {
|
||||
type: 'container' as const,
|
||||
style: {
|
||||
width: 512,
|
||||
height: 512,
|
||||
backgroundImage: `linear-gradient(45deg, ${color}, green)`
|
||||
}
|
||||
},
|
||||
durationMs: 100
|
||||
}))
|
||||
});
|
||||
expect(await dimensions(bytes)).toEqual({ width: 512, height: 512 });
|
||||
const image = await getAvatarThumbnail(sourceFetch(bytes, contentType), '/public/animated');
|
||||
expect(image.contentType).toBe(contentType);
|
||||
expect(Buffer.from(image.bytes)).toEqual(bytes);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Avatar, Style } from '@dicebear/core';
|
||||
import { Renderer } from 'takumi-js/node';
|
||||
import { lookup } from 'mrmime';
|
||||
// DiceBear "thumbs" style, vendored (CC0 1.0, see meta.license in the JSON).
|
||||
import thumbs from './thumbs.json';
|
||||
|
||||
@@ -29,3 +32,165 @@ export function defaultAvatar(seed: string, colors: AvatarColors = {}): string {
|
||||
})
|
||||
}).toString();
|
||||
}
|
||||
|
||||
const SIZE = 216;
|
||||
const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export interface AvatarThumbnail {
|
||||
bytes: Uint8Array<ArrayBuffer>;
|
||||
contentType: string;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
let renderer: Renderer | undefined;
|
||||
let cachedAvatar: {
|
||||
source: string;
|
||||
contentType: string;
|
||||
sourceDigest: string;
|
||||
sourceEtag: string | null;
|
||||
thumbnail: AvatarThumbnail;
|
||||
} | null = null;
|
||||
const inFlightAvatars = new Map<string, Promise<AvatarThumbnail>>();
|
||||
|
||||
function digest(bytes: Uint8Array): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
async function readSource(response: Response): Promise<Buffer> {
|
||||
if (Number(response.headers.get('content-length')) > MAX_SOURCE_BYTES) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('Avatar exceeds the thumbnail source limit.');
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Avatar has no image body.');
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > MAX_SOURCE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new Error('Avatar exceeds the thumbnail source limit.');
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function isAnimated(bytes: Buffer, contentType: string): boolean {
|
||||
if (contentType === 'image/webp') {
|
||||
return bytes.length > 20 && bytes.toString('ascii', 12, 16) === 'VP8X' && !!(bytes[20] & 2);
|
||||
}
|
||||
if (contentType === 'image/png') {
|
||||
// APNG declares its animation before the first image-data chunk.
|
||||
for (let offset = 8; offset + 12 <= bytes.length; ) {
|
||||
const type = bytes.toString('ascii', offset + 4, offset + 8);
|
||||
if (type === 'acTL') return true;
|
||||
if (type === 'IDAT') return false;
|
||||
offset += 12 + bytes.readUInt32BE(offset);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Return a smaller WebP, or null when the original should be kept. */
|
||||
async function tryResizeAvatar(bytes: Buffer, contentType: string): Promise<Uint8Array | null> {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) return null;
|
||||
if (isAnimated(bytes, contentType)) return null;
|
||||
|
||||
try {
|
||||
renderer ??= new Renderer();
|
||||
const image = { type: 'image' as const, src: bytes };
|
||||
const { width, height } = await renderer.measure(image);
|
||||
if (width <= SIZE && height <= SIZE) return null;
|
||||
|
||||
const size = Math.floor(Math.min(SIZE, width, height));
|
||||
const resized = await renderer.render(
|
||||
{ ...image, width: size, height: size, style: { objectFit: 'cover' } },
|
||||
{ width: size, height: size, format: 'webp', quality: 80 }
|
||||
);
|
||||
return resized.byteLength < bytes.byteLength ? resized : null;
|
||||
} catch {
|
||||
// Unsupported/corrupt inputs keep the original rather than breaking the avatar.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createThumbnail(bytes: Buffer, contentType: string): Promise<AvatarThumbnail> {
|
||||
const resized = await tryResizeAvatar(bytes, contentType);
|
||||
const output = resized ?? bytes;
|
||||
const outputContentType = resized ? 'image/webp' : contentType;
|
||||
return {
|
||||
bytes: Uint8Array.from(output),
|
||||
contentType: outputContentType,
|
||||
etag: `"${digest(output)}"`
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAvatarThumbnail(
|
||||
fetchFn: typeof fetch,
|
||||
source: string
|
||||
): Promise<AvatarThumbnail> {
|
||||
const cacheBeforeFetch = cachedAvatar?.source === source ? cachedAvatar : null;
|
||||
const headers = new Headers({ 'accept-encoding': 'identity' });
|
||||
if (cacheBeforeFetch?.sourceEtag) headers.set('if-none-match', cacheBeforeFetch.sourceEtag);
|
||||
const response = await fetchFn(source, { headers, redirect: 'error' });
|
||||
if (response.status === 304 && cacheBeforeFetch) return cacheBeforeFetch.thumbnail;
|
||||
const encoding = response.headers.get('content-encoding');
|
||||
if (encoding && encoding.toLowerCase() !== 'identity') {
|
||||
await response.body?.cancel();
|
||||
throw new Error('Avatar source ignored identity encoding.');
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`Avatar source returned HTTP ${response.status}.`);
|
||||
}
|
||||
// Kit's dev asset reader can omit MIME headers for files outside static/.
|
||||
const contentType = (
|
||||
response.headers.get('content-type') ??
|
||||
lookup(new URL(source, 'http://when.invalid').pathname) ??
|
||||
''
|
||||
)
|
||||
.split(';', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!contentType.startsWith('image/')) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('Avatar source is not an image.');
|
||||
}
|
||||
const bytes = await readSource(response);
|
||||
const sourceDigest = digest(bytes);
|
||||
const sourceEtag = response.headers.get('etag');
|
||||
const cacheAfterRead = cachedAvatar;
|
||||
if (
|
||||
cacheAfterRead?.source === source &&
|
||||
cacheAfterRead.contentType === contentType &&
|
||||
cacheAfterRead.sourceDigest === sourceDigest
|
||||
) {
|
||||
cacheAfterRead.sourceEtag = sourceEtag;
|
||||
return cacheAfterRead.thumbnail;
|
||||
}
|
||||
const thumbnail = await createThumbnail(bytes, contentType);
|
||||
cachedAvatar = { source, contentType, sourceDigest, sourceEtag, thumbnail };
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
/** Revalidate between requests; concurrent callers share the entire load and resize. */
|
||||
export function getAvatarThumbnail(
|
||||
fetchFn: typeof fetch,
|
||||
source: string
|
||||
): Promise<AvatarThumbnail> {
|
||||
const inFlight = inFlightAvatars.get(source);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const thumbnailPromise = loadAvatarThumbnail(fetchFn, source).finally(() => {
|
||||
inFlightAvatars.delete(source);
|
||||
});
|
||||
inFlightAvatars.set(source, thumbnailPromise);
|
||||
return thumbnailPromise;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import IconShieldCheck from 'virtual:icons/ph/shield-check';
|
||||
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
||||
import { avatarThumbnailUrl } from '#lib/avatar.js';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
@@ -14,7 +15,11 @@
|
||||
<div class="profile">
|
||||
{#if data.user.appearance.avatar_path}
|
||||
<div class="avatar-container">
|
||||
<img src={data.user.appearance.avatar_path} alt={data.user.name} class="avatar" />
|
||||
<img
|
||||
src={avatarThumbnailUrl(data.user.appearance.avatar_path)}
|
||||
alt={data.user.name}
|
||||
class="avatar"
|
||||
/>
|
||||
{#if data.role === 'host'}
|
||||
<a
|
||||
href="/admin"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { lookup } from 'mrmime';
|
||||
import { getAvatarThumbnail, type AvatarThumbnail } from '#lib/server/avatar.js';
|
||||
import { getConfig } from '#lib/server/state.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
async function fetchOriginalAvatar(
|
||||
fetchSource: typeof fetch,
|
||||
source: URL,
|
||||
requestHeaders: Headers
|
||||
): Promise<Response> {
|
||||
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' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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<typeof fetch>(),
|
||||
path = 'public/avatar.jpg'
|
||||
}: {
|
||||
headers?: Record<string, string>;
|
||||
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<typeof GET>[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<typeof fetch>().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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40" fill="green"/></svg>'
|
||||
);
|
||||
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<typeof fetch>()
|
||||
.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<void>((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<typeof fetch>()
|
||||
.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<typeof fetch>()
|
||||
.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<typeof fetch>();
|
||||
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<typeof fetch>();
|
||||
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<typeof fetch>();
|
||||
await expect(GET(createRequestEvent({ fetchSource, path }))).rejects.toMatchObject({
|
||||
status: 404
|
||||
});
|
||||
expect(fetchSource).not.toHaveBeenCalled();
|
||||
expect(mocks.thumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Generated
+54
-54
@@ -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
|
||||
|
||||
Reference in new issue
Block a user