Serve mounted images publicly #66

Merged
eleith merged 1 commits from public-mounted-images into main 2026-10-28 17:14:46 +00:00
23 changed files with 224 additions and 578 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
'@directory/web': patch
---
Serve mounted images as public static files without authentication, configuration allowlisting, or content sniffing.
+8 -6
View File
@@ -130,7 +130,7 @@ async function stopOidc(server: TestServer, issuer: TestIssuer) {
}
}
test('enabled authentication gates HTML, actual Kit data, HEAD, unknown routes and future assets', async () => {
test('enabled authentication gates HTML, actual Kit data, HEAD and unknown routes', async () => {
const server = await startServer({ configuration: configuration() });
try {
await server.ready();
@@ -145,7 +145,6 @@ test('enabled authentication gates HTML, actual Kit data, HEAD, unknown routes a
'/',
'/__data.json?x-sveltekit-invalidated=11',
'/unknown',
'/public/private.svg',
'/healthz/extra',
'/signin/extra',
'/favicon-missing.svg'
@@ -203,9 +202,11 @@ test('enabled authentication gates HTML, actual Kit data, HEAD, unknown routes a
expectPublic(body);
}
}
const missingScript = await server.fetch('/_app/immutable/missing.js');
expect(missingScript.status).toBe(404);
expectPublic(await missingScript.text());
for (const path of ['/_app/immutable/missing.js', '/public/private.svg']) {
const missing = await server.fetch(path);
expect(missing.status).toBe(404);
expectPublic(await missing.text());
}
expectSafeLogs(server);
} finally {
expect(await server.stop()).toEqual({ code: 0, signal: null });
@@ -241,7 +242,8 @@ test('native routes reject unsupported verbs instead of serving public or privat
method,
headers: { origin: server.origin, cookie: session }
});
expect(response.status).toBe(session ? 405 : 401);
const expectedStatus = session || path.startsWith('/public/') ? 405 : 401;
expect(response.status).toBe(expectedStatus);
expectPublic(await response.text());
}
}
+2 -2
View File
@@ -179,11 +179,11 @@ test('source changes retry primary URLs in place, including a repaired previousl
await expect
.poll(async () => (await server.fetch('/__data.json')).text())
.toContain('/public/primary-c.svg');
const failedPrimary = page.waitForResponse(
const primaryResponse = page.waitForResponse(
(response) => new URL(response.url()).pathname === '/public/primary-c.svg'
);
await navigateQuery(page, '/?link=valid');
expect((await failedPrimary).status()).toBe(404);
await primaryResponse;
await expectDecoded(image, '/bookmarks/broken/logo');
expect(await page.evaluate(() => Reflect.get(window, 'bookmarkNavigationMarker'))).toBe(true);
expect(
+70 -62
View File
@@ -6,8 +6,15 @@ import { credentialsAuth, password, username } from './support/configuration.js'
import { startServer, type TestServer } from './support/server.js';
const siteDescription = 'Useful services with familiar images.';
const privateLogo =
'<svg xmlns="http://www.w3.org/2000/svg"><text>PRIVATE_IMAGE_BYTES</text></svg>';
const privateLogo = `<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Illustrator export -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
]>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:i="&ns_ai;" viewBox="0 0 128 128">
<g i:extraneous="self"><rect width="128" height="128" fill="#91bdce"/><text>PRIVATE_IMAGE_BYTES</text></g>
<i:aipgf><![CDATA[opaque Illustrator editing metadata]]></i:aipgf>
</svg>`;
const privateBrand =
'<svg xmlns="http://www.w3.org/2000/svg"><text>PRIVATE_BRAND_BYTES</text></svg>';
const replacementImage =
@@ -57,6 +64,9 @@ async function fixtures() {
await writeFile(join(publicDir, 'brand logo.svg'), privateBrand);
await writeFile(join(publicDir, 'photo.svg'), privateLogo);
await writeFile(join(publicDir, 'photo.png'), png);
await writeFile(join(publicDir, 'unreferenced.svg'), privateLogo);
await mkdir(join(publicDir, 'images', 'bookmarks'), { recursive: true });
await writeFile(join(publicDir, 'images', 'bookmarks', 'test-ling.svg'), privateLogo);
return { directory, publicDir };
} catch (error) {
await rm(directory, { recursive: true, force: true });
@@ -99,7 +109,7 @@ async function stop(server: TestServer, directory: string) {
}
}
test('mounted files and generated bookmark logos require a real session, including encoded HTML requests', async () => {
test('mounted files are public while generated bookmark logos require a session, including encoded HTML requests', async () => {
const fixture = await fixtures();
const server = await startServer({
configuration: configuration(),
@@ -109,14 +119,28 @@ test('mounted files and generated bookmark logos require a real session, includi
await server.ready();
for (const method of ['GET', 'HEAD']) {
for (const cookie of ['', 'authjs.session-token=invalid-session']) {
for (const [path, contentType, bytes] of [
['/public/photo.svg', 'image/svg+xml', Buffer.from(privateLogo)],
['/public/photo.png', 'image/png', png],
['/public/brand%20logo.svg', 'image/svg+xml', Buffer.from(privateBrand)],
['/%70ublic/photo.svg', 'image/svg+xml', Buffer.from(privateLogo)],
['/public/photo%2Esvg', 'image/svg+xml', Buffer.from(privateLogo)],
['/public/unreferenced.svg', 'image/svg+xml', Buffer.from(privateLogo)],
['/public/images/bookmarks/test-ling.svg', 'image/svg+xml', Buffer.from(privateLogo)]
] as const) {
const image = await server.fetch(path, {
method,
headers: { accept: 'text/html', cookie }
});
expect(image.status).toBe(200);
expectPrivateHeaders(image);
expect(image.headers.get('content-type')).toBe(contentType);
expect(image.headers.get('content-length')).toBe(String(bytes.length));
expect(Buffer.from(await image.arrayBuffer())).toEqual(
method === 'HEAD' ? Buffer.alloc(0) : bytes
);
}
for (const path of [
'/public/photo.svg',
'/public/photo.png',
'/public/brand%20logo.svg',
'/%70ublic/photo.svg',
'/public/photo%2Esvg',
'/public/unknown.svg',
'/public',
'/bookmarks/photos/logo',
'/bookmarks/missing/logo',
'/%62ookmarks/photos/logo',
@@ -140,11 +164,12 @@ test('mounted files and generated bookmark logos require a real session, includi
expect(response.status).toBe(200);
await expectNoPrivateData(response);
}
for (const path of ['/__data.json', '/public/photo.svg/__data.json']) {
const denied = await server.fetch(path);
expect(denied.status).toBe(401);
await expectNoPrivateData(denied);
}
const denied = await server.fetch('/__data.json');
expect(denied.status).toBe(401);
await expectNoPrivateData(denied);
const imageData = await server.fetch('/public/photo.svg/__data.json');
expect(imageData.status).toBe(404);
await expectNoPrivateData(imageData);
const cookie = await signIn(server);
for (const path of [
@@ -180,23 +205,18 @@ test('mounted files and generated bookmark logos require a real session, includi
}
});
test('open mode serves only exact contained image references, with MIME and bodyless HEAD responses', async () => {
test('open mode serves contained images regardless of references, with MIME and bodyless HEAD responses', async () => {
const fixture = await fixtures();
await writeFile(join(fixture.directory, 'outside.svg'), privateLogo);
await symlink(join(fixture.directory, 'outside.svg'), join(fixture.publicDir, 'escape.svg'));
await symlink(join(fixture.publicDir, 'photo.svg'), join(fixture.publicDir, 'inside.svg'));
await writeFile(join(fixture.publicDir, 'not-image.png'), '<html>PRIVATE_IMAGE_BYTES</html>');
await writeFile(join(fixture.publicDir, 'unreferenced.svg'), privateLogo);
await writeFile(join(fixture.publicDir, 'unsupported.txt'), 'PRIVATE_IMAGE_BYTES');
await writeFile(join(fixture.publicDir, '家 icon.svg'), replacementImage);
const extra = `
extra:
title: Extra
url: https://extra.example.test
logo_path: /public/escape.svg
invalid-image:
title: Invalid image
url: https://invalid-image.example.test
logo_path: /public/not-image.png
inside:
title: Inside
url: https://inside.example.test
@@ -215,6 +235,7 @@ test('open mode serves only exact contained image references, with MIME and body
for (const [path, bytes, contentType] of [
['/public/photo.svg', Buffer.from(privateLogo), 'image/svg+xml'],
['/public/photo.png', png, 'image/png'],
['/public/unreferenced.svg', Buffer.from(privateLogo), 'image/svg+xml'],
['/public/inside.svg', Buffer.from(privateLogo), 'image/svg+xml'],
['/public/%E5%AE%B6%20icon.svg', Buffer.from(replacementImage), 'image/svg+xml']
] as const) {
@@ -242,9 +263,9 @@ test('open mode serves only exact contained image references, with MIME and body
}
for (const path of [
'/public/escape.svg',
'/public/not-image.png',
'/public/unreferenced.svg',
'/public/unsupported.txt',
'/public/missing.svg',
'/public',
'/public/PHOTO.svg',
'/bookmarks/missing/logo',
'/bookmarks/constructor/logo',
@@ -274,65 +295,52 @@ test('open mode serves only exact contained image references, with MIME and body
}
});
test('accepted reloads replace exact references; invalid and auth-changing candidates keep last-good images', async () => {
test('config reloads update generated logos but never gate mounted files on references', async () => {
const fixture = await fixtures();
await writeFile(join(fixture.publicDir, 'replacement.svg'), replacementImage);
const server = await startServer({
configuration: configuration(),
publicDir: fixture.publicDir
});
async function expectPublicFiles() {
for (const [path, bytes] of [
['/public/photo.svg', privateLogo],
['/public/replacement.svg', replacementImage]
] as const) {
const response = await server.fetch(path);
expect(response.status).toBe(200);
expect(await response.text()).toBe(bytes);
}
}
try {
await server.ready();
const cookie = await signIn(server);
const original = await (
await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })
).text();
const bookmarkLogo = async () =>
(await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })).text();
const original = await bookmarkLogo();
await expectPublicFiles();
const replacement = configuration()
.replace('/public/photo.svg', '/public/replacement.svg')
.replace('title: Photos', 'title: Updated Photos');
await server.replace(replacement);
await expect
.poll(
async () => (await server.fetch('/public/replacement.svg', { headers: { cookie } })).status
)
.toBe(200);
expect((await server.fetch('/public/photo.svg', { headers: { cookie } })).status).toBe(404);
const changed = await (
await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })
).text();
await expect.poll(bookmarkLogo).toContain('>UP</text>');
const changed = await bookmarkLogo();
expect(changed).not.toBe(original);
expect(changed).toContain('>UP</text>');
await expectPublicFiles();
await server.replace('version: [invalid');
await expect.poll(() => server.output()).toContain('load_failed');
expect(
await (await server.fetch('/public/replacement.svg', { headers: { cookie } })).text()
).toBe(replacementImage);
expect((await server.fetch('/public/photo.svg', { headers: { cookie } })).status).toBe(404);
expect(
await (await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })).text()
).toBe(changed);
await expectPublicFiles();
expect(await bookmarkLogo()).toBe(changed);
await server.replace(configuration().replace(password, 'CHANGED_PASSWORD'));
await expect.poll(() => server.output()).toContain('restart_required');
expect(
await (await server.fetch('/public/replacement.svg', { headers: { cookie } })).text()
).toBe(replacementImage);
expect((await server.fetch('/public/photo.svg', { headers: { cookie } })).status).toBe(404);
expect(
await (await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })).text()
).toBe(changed);
await expectPublicFiles();
expect(await bookmarkLogo()).toBe(changed);
await server.replace(configuration());
await expect
.poll(async () => (await server.fetch('/public/photo.svg', { headers: { cookie } })).status)
.toBe(200);
expect((await server.fetch('/public/replacement.svg', { headers: { cookie } })).status).toBe(
404
);
expect(
await (await server.fetch('/bookmarks/photos/logo', { headers: { cookie } })).text()
).toBe(original);
await expect.poll(bookmarkLogo).toBe(original);
await expectPublicFiles();
} finally {
await stop(server, fixture.directory);
}
@@ -363,7 +371,7 @@ test('missing and atomically replaced bytes recover without changing the prepare
}
});
test('a missing public directory is valid at startup and recovers when its referenced file appears', async () => {
test('a missing public directory is valid at startup and recovers when an image appears', async () => {
const fixture = await fixtures();
await rm(fixture.publicDir, { recursive: true });
const server = await startServer({
+1 -33
View File
@@ -120,34 +120,6 @@ async function expectNotFound(server: TestServer) {
}
}
async function expectRawPrivate(server: TestServer) {
for (const method of ['GET', 'HEAD']) {
for (const cookie of ['', 'authjs.session-token=invalid-favicon-cookie']) {
for (const path of [
encodeURI(sourceA),
sourceB,
'/public/private-brand.svg',
'/%70ublic/book.png',
'/public/book%2Epng',
'/public/unlisted.svg',
'/bookmarks/book/logo',
'/__data.json'
]) {
const response = await server.fetch(path, {
method,
headers: { cookie, accept: 'text/html' }
});
expect(response.status, path).toBe(401);
expectPrivateHeaders(response);
expect(response.headers.has('location')).toBe(false);
const text = await response.text();
if (method === 'HEAD') expect(text).toBe('');
expectNoPrivateData(text);
}
}
}
}
async function signIn(server: TestServer) {
const response = await server.fetch('/signin', {
method: 'POST',
@@ -174,7 +146,7 @@ for (const [source, bytes, contentType] of [
[sourceA, Buffer.from(svg), 'image/svg+xml'],
[sourceB, png, 'image/png']
] as const) {
test(`selected ${contentType} is public only through the alias, including before sign-in without JS`, async ({
test(`selected ${contentType} is public through the alias, including before sign-in without JS`, async ({
browser
}) => {
const fixture = await fixtures();
@@ -205,7 +177,6 @@ for (const [source, bytes, contentType] of [
for (const session of ['', 'authjs.session-token=invalid-favicon-cookie', cookie]) {
await expectImage(server, bytes, contentType, session);
}
await expectRawPrivate(server);
for (const query of [
'?file=/public/private-brand.svg&source=/public/book.png',
'?path=%2Fpublic%2Finvalid.png&publicDir=%2Foutside',
@@ -257,7 +228,6 @@ test('valid reloads keep the alias but change bytes; invalid and auth-changing c
.poll(async () => (await server.fetch('/favicon')).headers.get('content-type'))
.toBe('image/png');
await expectImage(server, png, 'image/png');
await expectRawPrivate(server);
const page = await context.newPage();
await page.goto(`${server.url}/signin`);
await expect(page.locator('head link[rel="icon"]')).toHaveAttribute('href', '/favicon');
@@ -268,7 +238,6 @@ test('valid reloads keep the alias but change bytes; invalid and auth-changing c
await server.replace(configuration(sourceA, true, 'REJECTED_FAVICON_PASSWORD'));
await expect.poll(() => server.output()).toContain('restart_required');
await expectImage(server, png, 'image/png');
await expectRawPrivate(server);
expectNoPrivateData(server.output(), ['REJECTED_FAVICON_PASSWORD']);
await server.replace(configuration());
@@ -279,7 +248,6 @@ test('valid reloads keep the alias but change bytes; invalid and auth-changing c
const bundled = await server.fetch('/favicon.png');
expect(bundled.status).toBe(200);
expect(bundled.headers.get('content-type')).toContain('image/png');
await expectRawPrivate(server);
} finally {
await context.close();
await stop(server, fixture.directory);
+15 -21
View File
@@ -14,7 +14,7 @@ const generatedPath = '/images/og.png';
const svgPath = '/public/share.svg';
const nextPath = '/public/next.svg';
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h32v32H0z"/></svg>';
const privatePaths = [
const mountedPaths = [
'/public/logo.svg',
'/public/link.svg',
'/public/tab.svg',
@@ -27,16 +27,16 @@ function brandedConfiguration(image: string) {
return configuration()
.replace(
'site:\n',
`site:\n og_image_path: ${image}\n logo_path: ${privatePaths[0]}\n favicon_path: ${privatePaths[2]}\n`
`site:\n og_image_path: ${image}\n logo_path: ${mountedPaths[0]}\n favicon_path: ${mountedPaths[2]}\n`
)
.replace(
' household:\n',
` household:\n group: PRIVATE_OG_GROUP\n logo_path: ${privatePaths[1]}\n`
` household:\n group: PRIVATE_OG_GROUP\n logo_path: ${mountedPaths[1]}\n`
);
}
function expectNoPrivateData(text: string) {
for (const value of [...privateSentinels, 'PRIVATE_OG_GROUP', ...privatePaths, publicDir]) {
for (const value of [...privateSentinels, 'PRIVATE_OG_GROUP', ...mountedPaths, publicDir]) {
expect(text).not.toContain(value);
}
}
@@ -84,17 +84,13 @@ async function expectStatic(server: TestServer, path: string) {
expect(await response.text()).toBe(svg);
}
async function expectPrivateImages(server: TestServer, extra: string[] = []) {
for (const path of [...privatePaths, '/bookmarks/household/logo', ...extra]) {
for (const method of ['GET', 'HEAD']) {
const response = await server.fetch(path, { method });
expect(response.status, `${method} ${path}`).toBe(401);
expect(response.headers.has('location')).toBe(false);
const body = await response.text();
if (method === 'HEAD') expect(body).toBe('');
expectNoPrivateData(body);
}
async function expectPublicAssets(server: TestServer) {
for (const path of [...mountedPaths, svgPath, nextPath]) {
await expectStatic(server, path);
}
const generatedLogo = await server.fetch('/bookmarks/household/logo');
expect(generatedLogo.status).toBe(401);
expectNoPrivateData(await generatedLogo.text());
}
test.setTimeout(60_000);
@@ -102,7 +98,7 @@ test.beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'directory-open-graph-'));
publicDir = join(directory, 'public');
await mkdir(publicDir);
for (const path of [...privatePaths, nextPath]) {
for (const path of [...mountedPaths, nextPath]) {
await writeFile(join(publicDir, path.slice('/public/'.length)), svg);
}
});
@@ -146,7 +142,7 @@ test('auth redirects home while sign-in SSR and generated PNG GET/HEAD remain pu
}
});
test('missing static image recovers without reload; one reload moves only its public access', async () => {
test('missing static image recovers without reload; selection changes metadata, not public file access', async () => {
const server = await startServer({ configuration: brandedConfiguration(svgPath), publicDir });
try {
await server.ready();
@@ -160,16 +156,14 @@ test('missing static image recovers without reload; one reload moves only its pu
});
expect(metadata(html)).not.toHaveProperty('og:image:width');
expect(metadata(html)).not.toHaveProperty('og:image:height');
await expectStatic(server, svgPath);
await expectPrivateImages(server, [nextPath]);
await expectPublicAssets(server);
await server.replace(brandedConfiguration(nextPath));
await expect
.poll(async () => metadata(await ssr(server))['og:image'])
.toBe(`${server.origin}${nextPath}`);
await expectStatic(server, nextPath);
// Selecting another OG image must not widen the static-image privacy scope.
await expectPrivateImages(server, [svgPath]);
// Both selected and unselected mounted files remain public.
await expectPublicAssets(server);
} finally {
expect(await server.stop()).toEqual({ code: 0, signal: null });
}
+21 -30
View File
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } fr
import type { Authentication } from './lib/server/auth/index.js';
import { requireSignIn } from './lib/server/auth/access.js';
import { readMountedImage } from './lib/server/images/read-mounted-image.js';
import { GET as mountedImage } from './routes/(protected)/(endpoints)/public/[...file]/+server.js';
import { GET as mountedImage } from './routes/(public)/public/[...file]/+server.js';
import { GET as bookmarkLogo } from './routes/(protected)/(endpoints)/bookmarks/[id]/logo/+server.js';
import type { Application } from './lib/server/application.js';
@@ -297,9 +297,13 @@ describe('server hooks', () => {
);
it.each(['GET', 'HEAD'])(
'never reaches an image reader before authentication for %s, including the configured favicon',
'serves public mounted %s images but authenticates generated bookmark logos',
async (method) => {
const session = vi.fn<App.Locals['auth']>().mockResolvedValue(null);
vi.mocked(readMountedImage).mockResolvedValue({
bytes: new TextEncoder().encode('<svg/>'),
contentType: 'image/svg+xml'
});
const authentication: Authentication = {
strategy: 'credentials',
handle: async (input) => {
@@ -317,25 +321,17 @@ describe('server hooks', () => {
const hooks = await import('./hooks.server.js');
await hooks.init();
const resolve = vi.fn<Resolve>().mockImplementation(async (event) => {
if (event.route.id === '/(protected)/(endpoints)/public/[...file]') {
if (event.route.id === '/(public)/public/[...file]') {
return mountedImage(event as Parameters<typeof mountedImage>[0]);
}
return bookmarkLogo(event as Parameters<typeof bookmarkLogo>[0]);
});
for (const cookie of ['', 'authjs.session-token=invalid-session']) {
for (const [path, routeId, params] of [
['/public/photo.svg', '/(protected)/(endpoints)/public/[...file]', { file: 'photo.svg' }],
['/public/site.svg', '/(protected)/(endpoints)/public/[...file]', { file: 'site.svg' }],
[
'/%70ublic/photo.svg',
'/(protected)/(endpoints)/public/[...file]',
{ file: 'photo.svg' }
],
[
'/public/unknown.svg',
'/(protected)/(endpoints)/public/[...file]',
{ file: 'unknown.svg' }
],
['/public/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }],
['/public/site.svg', '/(public)/public/[...file]', { file: 'site.svg' }],
['/%70ublic/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }],
['/public/unknown.svg', '/(public)/public/[...file]', { file: 'unknown.svg' }],
[
'/bookmarks/photos/logo',
'/(protected)/(endpoints)/bookmarks/[id]/logo',
@@ -356,33 +352,28 @@ describe('server hooks', () => {
isDataRequest: false,
locals: {}
} as unknown as RequestEvent;
expect((await hooks.handle({ event: request, resolve })).status).toBe(401);
expect((await hooks.handle({ event: request, resolve })).status).toBe(
routeId === '/(public)/public/[...file]' ? 200 : 401
);
expect(request.locals.publicDir).toBe(ready.publicDir);
}
}
expect(session).toHaveBeenCalledTimes(12);
expect(resolve).not.toHaveBeenCalled();
expect(readMountedImage).not.toHaveBeenCalled();
expect(session).toHaveBeenCalledTimes(4);
expect(resolve).toHaveBeenCalledTimes(8);
expect(readMountedImage).toHaveBeenCalledTimes(8);
session.mockResolvedValue({ user: { name: 'Directory' }, expires: '2030-01-01' });
vi.mocked(readMountedImage).mockResolvedValue({
bytes: new TextEncoder().encode('<svg/>'),
contentType: 'image/svg+xml'
});
const url = new URL('https://directory.example/public/photo.svg');
const url = new URL('https://directory.example/bookmarks/photos/logo');
const authorized = {
url,
request: new Request(url, { method }),
route: { id: '/(protected)/(endpoints)/public/[...file]' },
params: { file: 'photo.svg' },
route: { id: '/(protected)/(endpoints)/bookmarks/[id]/logo' },
params: { id: 'photos' },
isDataRequest: false,
locals: {}
} as unknown as RequestEvent;
expect((await hooks.handle({ event: authorized, resolve })).status).toBe(200);
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(
ready.publicDir,
'/public/photo.svg'
);
expect(readMountedImage).toHaveBeenCalledTimes(8);
}
);
@@ -97,10 +97,6 @@ describe('application startup', () => {
expect(snapshot.configuration.auth?.credentials?.password).toBe(sentinel);
expect(snapshot.configuration.auth).not.toHaveProperty('secret');
expect(snapshot.display.groups[0]?.links[0]?.tags).toEqual(['photos', 'backup']);
expect(snapshot.assets.referencedPaths).toEqual([
'/public/boot.svg',
'/public/boot-photos.svg'
]);
expectRedacted(snapshot.display);
expect(mocks.report).not.toHaveBeenCalled();
});
@@ -141,16 +137,11 @@ describe('application config policy', () => {
expect(current.configuration.site.title).toBe('next');
expect(current.display.site.title).toBe('next');
expect(current.display.groups[0]?.links[0]?.title).toBe('next photos');
expect(current.assets.referencedPaths).toEqual(['/public/next.svg', '/public/next-photos.svg']);
expect(current.assets.bookmarkLogos.photos).not.toBe(captured.assets.bookmarkLogos.photos);
expect(current.display.snapshotId).not.toBe(captured.display.snapshotId);
expect(captured.configuration.site.title).toBe('boot');
expect(captured.display.site.title).toBe('boot');
expect(captured.display.groups[0]?.links[0]?.title).toBe('boot photos');
expect(captured.assets.referencedPaths).toEqual([
'/public/boot.svg',
'/public/boot-photos.svg'
]);
expectRedacted(current.display);
expect(app.authentication).toBe(authentication);
expect(mocks.authenticate).toHaveBeenCalledOnce();
+1 -10
View File
@@ -60,7 +60,7 @@ export async function requireSignIn(
event.locals.session = null;
const routeId = event.route.id ?? '';
const publicRoute = routeId === '/(public)' || routeId.startsWith('/(public)/');
if (publicRoute || isConfiguredShareImage(event)) return resolve(event);
if (publicRoute) return resolve(event);
try {
event.locals.session = await event.locals.auth();
} catch {
@@ -80,15 +80,6 @@ export async function requireSignIn(
return denied(401);
}
function isConfiguredShareImage(event: RequestEvent): boolean {
if (event.route.id !== '/(protected)/(endpoints)/public/[...file]') return false;
if (event.isDataRequest) return false;
if (!['GET', 'HEAD'].includes(event.request.method)) return false;
const selectedPath = event.locals.snapshot.configuration.site.og_image_path;
if (selectedPath === undefined) return false;
return selectedPath === `/public/${event.params.file}`;
}
function isUnsafeMethod(event: RequestEvent): boolean {
return !['GET', 'HEAD', 'OPTIONS'].includes(event.request.method);
}
+8 -95
View File
@@ -4,7 +4,6 @@ import { isHttpError, isRedirect, redirect, type Handle, type RequestEvent } fro
import type { Session, SvelteKitAuthConfig } from '@auth/sveltekit';
import { validateConfig } from '@directory/config';
import { prepareSnapshot } from '../directory/snapshot.js';
import { GET as mountedImage } from '../../../routes/(protected)/(endpoints)/public/[...file]/+server.js';
import { createAuthentication } from './index.js';
const mocks = vi.hoisted(() => ({
@@ -163,113 +162,27 @@ describe('bookmark logo API access', () => {
});
});
describe('mounted image access', () => {
describe('public mounted image access', () => {
it.each(['GET', 'HEAD'])(
'requires a session for every mounted %s path, even configured site favicons and HTML Accept',
'lets configured and unreferenced %s images reach the public route without reading a session',
async (method) => {
for (const path of [
'/public/site.svg',
'/public/site-logo.svg',
'/public/logos/photos.svg',
'/public/bookmark-favicon.svg',
'/public/unreferenced.svg',
'/public',
'/public/'
'/%70ublic/site.svg',
'/public/site%2Esvg'
]) {
const response = await responseFor(
eventFor(path, { method, routeId: '/(protected)/(endpoints)/public/[...file]' })
);
expect(response.status).toBe(401);
expect(response.headers.has('location')).toBe(false);
const event = eventFor(path, { method, routeId: '/(public)/public/[...file]' });
const response = await responseFor(event);
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('private, no-store');
expect(mocks.resolve).toHaveBeenLastCalledWith(event);
}
expect(mocks.resolve).not.toHaveBeenCalled();
}
);
it.each(['GET', 'HEAD'])(
'uses the matched mounted route for encoded %s aliases',
async (method) => {
for (const path of ['/%70ublic/site.svg', '/public/site%2Esvg', '/public/caf%C3%A9.svg']) {
const event = eventFor(path, { method });
event.route.id = '/(protected)/(endpoints)/public/[...file]';
expect((await responseFor(event)).status).toBe(401);
}
expect(mocks.resolve).not.toHaveBeenCalled();
}
);
it.each(['GET', 'HEAD'])(
'keeps an unreferenced former bookmark favicon private, then returns 404 after sign-in for %s',
async (method) => {
const path = '/public/bookmark-favicon.svg';
expect(snapshot.assets.referencedPaths).not.toContain(path);
const event = eventFor(path, {
method,
routeId: '/(protected)/(endpoints)/public/[...file]'
});
event.params = { file: 'bookmark-favicon.svg' };
mocks.resolve.mockImplementation((current: RequestEvent) =>
mountedImage(current as Parameters<typeof mountedImage>[0])
);
expect((await responseFor(event)).status).toBe(401);
expect(mocks.resolve).not.toHaveBeenCalled();
mocks.session.mockResolvedValueOnce(signedIn);
expect((await responseFor(event)).status).toBe(404);
expect(mocks.resolve).toHaveBeenCalledExactlyOnceWith(event);
}
);
it.each(['/public/site.svg', '/public/logos/photos.svg'])(
'permits a signed-in request for %s',
async (path) => {
mocks.session.mockResolvedValueOnce(signedIn);
const event = eventFor(path);
event.route.id = '/(protected)/(endpoints)/public/[...file]';
expect((await responseFor(event)).status).toBe(200);
expect(mocks.resolve).toHaveBeenCalledExactlyOnceWith(event);
}
);
});
describe('configured public share image access', () => {
it.each(['GET', 'HEAD'])(
'permits the selected static OG image for %s without a session',
async (method) => {
const event = eventFor('/public/images/share.svg?ignored=1', {
method,
routeId: '/(protected)/(endpoints)/public/[...file]'
});
event.params = { file: 'images/share.svg' };
event.locals.snapshot = prepareSnapshot(
validateConfig(
{ site: { og_image_path: '/public/images/share.svg' }, links: {} },
{ env: {} }
)
);
expect((await responseFor(event)).status).toBe(200);
expect(mocks.resolve).toHaveBeenCalledExactlyOnceWith(event);
expect(mocks.session).not.toHaveBeenCalled();
}
);
it.each(['other.svg', 'images/share.svg/other'])(
'keeps %s outside the public selection',
async (file) => {
const event = eventFor(`/public/${file}`, {
routeId: '/(protected)/(endpoints)/public/[...file]'
});
event.params = { file };
event.locals.snapshot = prepareSnapshot(
validateConfig(
{ site: { og_image_path: '/public/images/share.svg' }, links: {} },
{ env: {} }
)
);
expect((await responseFor(event)).status).toBe(401);
expect(mocks.resolve).not.toHaveBeenCalled();
}
);
});
describe('boot factory', () => {
@@ -71,7 +71,6 @@ describe('private prepared snapshots', () => {
expect(Object.isFrozen(snapshot.configuration.auth?.credentials)).toBe(true);
expect(Object.isFrozen(snapshot.display)).toBe(true);
expect(Object.isFrozen(snapshot.assets)).toBe(true);
expect(Object.isFrozen(snapshot.assets.referencedPaths)).toBe(true);
expect(Object.isFrozen(snapshot.assets.bookmarkLogos)).toBe(true);
expect(Reflect.set(snapshot.assets.bookmarkLogos, 'photos', 'Changed')).toBe(false);
input.site.title = 'Changed';
@@ -84,16 +83,8 @@ describe('private prepared snapshots', () => {
expect(Reflect.set(snapshot.configuration.site, 'title', 'Changed')).toBe(false);
});
it('collects only site images and primary logos, deduplicating shared branding references', () => {
it('retains configured image paths in display while preparing generated bookmark logos', () => {
const snapshot = prepareSnapshot(configuration());
expect(snapshot.assets.brandingPaths).toEqual(['/public/home.svg', '/public/site-icon.png']);
expect(snapshot.assets.referencedPaths).toEqual([
'/public/home.svg',
'/public/site-icon.png',
'/public/photos.svg',
'/public/shared.png',
'/public/photos-icon.png'
]);
const links = snapshot.display.groups.flatMap((group) => group.links);
expect(links).toHaveLength(4);
expect(Object.keys(snapshot.assets.bookmarkLogos)).toEqual([
@@ -102,8 +93,6 @@ describe('private prepared snapshots', () => {
'album',
'home'
]);
expect(snapshot.assets.brandingPaths).not.toContain('/public/shared.png');
expect(snapshot.assets.brandingPaths).not.toContain('/public/photos-icon.png');
for (const link of links) {
expect(link.logo_path).toBe(snapshot.configuration.links[link.id]!.logo_path);
expect(link).not.toHaveProperty('favicon_path');
@@ -111,12 +100,11 @@ describe('private prepared snapshots', () => {
}
});
it('tracks a configured Open Graph image as branding without adding its path to display data', () => {
it('keeps a configured Open Graph image in configuration, not directory display data', () => {
const config = configuration();
config.site.og_image_path = '/public/share.png';
const snapshot = prepareSnapshot(config);
expect(snapshot.assets.brandingPaths).toContain('/public/share.png');
expect(snapshot.assets.referencedPaths).toContain('/public/share.png');
expect(snapshot.configuration.site.og_image_path).toBe('/public/share.png');
expect(JSON.stringify(snapshot.display)).not.toContain('/public/share.png');
});
@@ -162,12 +150,10 @@ describe('private prepared snapshots', () => {
});
it.each([{}, { logo_path: '/favicon.png', favicon_path: '/favicon.png' }])(
'excludes default and explicit bundled site images from mounted assets: %j',
'retains default and explicit bundled site images in display: %j',
(site) => {
const snapshot = prepareSnapshot(validateConfig({ site, links: {} }, { env: {} }));
expect(snapshot.assets).toEqual({
referencedPaths: [],
brandingPaths: [],
bookmarkLogos: {}
});
expect(snapshot.display.site.logo_path).toBe('/favicon.png');
@@ -177,7 +163,7 @@ describe('private prepared snapshots', () => {
}
);
it('captures only mounted references when site selections mix bundled and mounted images', () => {
it('retains selected paths when site images mix bundled and mounted images', () => {
const snapshot = prepareSnapshot(
validateConfig(
{
@@ -190,8 +176,6 @@ describe('private prepared snapshots', () => {
{ env: {} }
)
);
expect(snapshot.assets.brandingPaths).toEqual(['/public/site.png']);
expect(snapshot.assets.referencedPaths).toEqual(['/public/site.png', '/public/photos.png']);
expect(snapshot.display.groups[0]?.links[0]?.logo_path).toBe('/public/photos.png');
expect(snapshot.display.groups[0]?.links[1]?.logo_path).toBe('/bookmarks/plain/logo');
});
@@ -9,8 +9,6 @@ export interface PreparedSnapshot {
readonly configuration: Immutable<DirectoryConfiguration>;
readonly display: DirectoryContent;
readonly assets: {
readonly referencedPaths: readonly string[];
readonly brandingPaths: readonly string[];
readonly bookmarkLogos: Readonly<Record<string, string>>;
};
}
@@ -19,22 +17,7 @@ export function prepareSnapshot(config: DirectoryConfiguration): PreparedSnapsho
const configuration = structuredClone(config);
const display = prepareDirectoryContent(configuration, randomUUID());
const assets = {
...collectAssetPaths(configuration),
bookmarkLogos: renderBookmarkLogos(configuration)
};
return freezeData({ configuration, display, assets });
}
function collectAssetPaths(
config: DirectoryConfiguration
): Pick<PreparedSnapshot['assets'], 'referencedPaths' | 'brandingPaths'> {
const brandingPaths = new Set<string>();
for (const path of [config.site.logo_path, config.site.favicon_path, config.site.og_image_path]) {
if (path?.startsWith('/public/')) brandingPaths.add(path);
}
const referencedPaths = new Set(brandingPaths);
for (const link of Object.values(config.links)) {
if (link.logo_path !== undefined) referencedPaths.add(link.logo_path);
}
return { referencedPaths: [...referencedPaths], brandingPaths: [...brandingPaths] };
}
@@ -1,79 +0,0 @@
import { describe, expect, it } from 'vitest';
import { matchesImageFormat } from './image-format.js';
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>');
const pngHeader = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=',
'base64'
).subarray(0, 33);
const jpegHeader = Buffer.from([0xff, 0xd8, 0xff]);
const gifHeader = Buffer.concat([Buffer.from('GIF89a'), Buffer.from([1, 0, 1, 0])]);
const webpHeader = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP')]);
describe('image format recognition', () => {
it.each([
['.png', svg],
['.jpg', gifHeader],
['.gif', webpHeader],
['.webp', pngHeader],
['.png', pngHeader.subarray(0, 30)],
['.gif', gifHeader.subarray(0, 9)],
['.jpeg', jpegHeader.subarray(0, 2)],
['.webp', webpHeader.subarray(0, 11)],
['.png', Buffer.from('<html>not an image</html>')]
] as const)('rejects mismatched bytes or an incomplete %s header', (extension, bytes) => {
expect(matchesImageFormat(bytes, extension)).toBe(false);
});
it.each([
['.png', pngHeader],
['.jpeg', jpegHeader],
['.gif', gifHeader],
['.webp', webpHeader]
] as const)('recognizes %s without requiring complete codec data', (extension, bytes) => {
expect(matchesImageFormat(bytes, extension)).toBe(true);
});
it.each([
'<svg/>',
'<!-- first -->\n<!-- second --> <svg/>',
'<?xml version="1.0"?>\n<svg/>',
"<?xml version='1.0' encoding='utf-8'?><svg/>",
'\ufeff<?xml version="1.0" encoding="UTF-8"?>\n<!-- icon -->\n<svg xmlns="http://www.w3.org/2000/svg"><text>Home &amp; family</text></svg>',
'<svg><text><![CDATA[<hello> & anything]]></text></svg><!-- end & -->',
'<svg viewBox="0 0 1 1"><path d="M0 0" /></svg>'
])('recognizes an SVG document with XML prolog/comments', (text) => {
expect(matchesImageFormat(Buffer.from(text), '.svg')).toBe(true);
});
it.each([
'<html><svg/></html>',
'<?xml version="1.0"?><!-- icon --><html><svg/></html>',
'<!-- <svg/> -->',
'not xml <svg/>',
'<svgish/>',
'<svg',
'<!-- unclosed <svg/>',
'<?xml version="1.0" <svg/>',
'<!DOCTYPE svg><svg/>',
'<!doctype svg><svg/>',
'<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///private">]><svg>&x;</svg>',
'<svg><!ENTITY x "value"></svg>',
'<?xml version="1.0" encoding="ISO-8859-1"?><svg/>',
'<svg><script/></svg><!DOCTYPE html>'
])('rejects non-SVG roots, unsupported encodings and DTD/entity markup', (text) => {
expect(matchesImageFormat(Buffer.from(text), '.svg')).toBe(false);
});
it('recognizes an SVG root without validating descendant XML grammar', () => {
expect(matchesImageFormat(Buffer.from('<svg><path bad=value>&unknown;</svg>'), '.svg')).toBe(
true
);
});
it('compares magic bytes without masking their high bits', () => {
const disguised = Buffer.from(gifHeader);
disguised[0] = disguised[0]! | 0x80;
expect(matchesImageFormat(disguised, '.gif')).toBe(false);
});
});
@@ -1,55 +0,0 @@
export function matchesImageFormat(bytes: Buffer, extension: string): boolean {
switch (extension) {
case '.png':
return (
bytes.length >= 33 &&
bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
bytes.readUInt32BE(8) === 13 &&
bytes.toString('latin1', 12, 16) === 'IHDR' &&
bytes.readUInt32BE(16) > 0 &&
bytes.readUInt32BE(20) > 0
);
case '.jpg':
case '.jpeg':
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
case '.gif':
return (
bytes.length >= 10 &&
['GIF87a', 'GIF89a'].includes(bytes.toString('latin1', 0, 6)) &&
bytes.readUInt16LE(6) > 0 &&
bytes.readUInt16LE(8) > 0
);
case '.webp':
return (
bytes.length >= 12 &&
bytes.toString('latin1', 0, 4) === 'RIFF' &&
bytes.toString('latin1', 8, 12) === 'WEBP'
);
case '.svg':
return isSvg(bytes);
default:
return false;
}
}
// Recognize trusted operator SVG, not XML validity or safe active content.
function isSvg(bytes: Buffer): boolean {
let text = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trimStart();
if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(text)) return false;
if (/^<\?xml[\t\n\r ]/.test(text)) {
const end = text.indexOf('?>');
if (end === -1) return false;
const declaration = text.slice(0, end);
const encoding = /\bencoding\s*=\s*(["'])(.*?)\1/i.exec(declaration);
if (/\bencoding\s*=/i.test(declaration) && encoding?.[2]?.toLowerCase() !== 'utf-8') {
return false;
}
text = text.slice(end + 2).trimStart();
}
while (text.startsWith('<!--')) {
const end = text.indexOf('-->');
if (end === -1) return false;
text = text.slice(end + 3).trimStart();
}
return /^<svg(?:[\t\n\r ][^<>]*|\/?)>/.test(text);
}
@@ -80,6 +80,14 @@ describe('bounded mounted images', () => {
}
);
it('returns file bytes unchanged with an extension-based MIME type', async () => {
const bytes = Buffer.from('mounted file bytes\xff', 'latin1');
await put('icon.svg', bytes);
const image = await readMountedImage(root, '/public/icon.svg');
expect(image?.contentType).toBe('image/svg+xml');
expect(image?.bytes).toEqual(new Uint8Array(bytes));
});
it('accepts decoded Unicode/spaces, and nested paths without decoding again', async () => {
await mkdir(join(root, 'family'));
await put('family/café icon.svg');
@@ -160,6 +168,8 @@ describe('bounded mounted images', () => {
'rejects an ordinary concurrent %s on the same opened descriptor',
async (change) => {
await put('icon.svg');
// Same-size writes can share a filesystem clock tick.
if (change === 'rewrite') await fs.utimes(join(root, 'icon.svg'), 0, 0);
const normalOpen = vi.mocked(open).getMockImplementation()!;
vi.mocked(open).mockImplementationOnce(async (...args) => {
const file = await normalOpen(...args);
@@ -307,26 +317,3 @@ describe('bounded mounted images', () => {
expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined();
});
});
describe('image format checks during file reads', () => {
it.each([
['icon.png', svg],
['icon.svg', png],
['icon.svg', Buffer.from('<!DOCTYPE svg><svg/>')]
] as const)('rejects mismatched bytes or unsupported SVG markup in %s', async (name, bytes) => {
await put(name, bytes);
expect(await readMountedImage(root, `/public/${name}`)).toBeUndefined();
});
it('rejects invalid UTF-8 and bounded long non-SVG prefixes', async () => {
await put(
'icon.svg',
Buffer.concat([Buffer.from('<svg>'), Buffer.from([0xff]), Buffer.from('</svg>')])
);
expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined();
await put('icon.svg', ' '.repeat(MAX_IMAGE_BYTES - 6) + '<html>');
expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined();
await put('icon.svg', '<svg' + ' '.repeat(MAX_IMAGE_BYTES - 4));
expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined();
});
});
@@ -2,7 +2,6 @@ import { constants } from 'node:fs';
import { open, realpath, stat, type FileHandle } from 'node:fs/promises';
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
import type { BigIntStats } from 'node:fs';
import { matchesImageFormat } from './image-format.js';
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
@@ -96,7 +95,6 @@ export async function readMountedImage(
return undefined;
}
const bytes = buffer.subarray(0, length);
if (!matchesImageFormat(bytes, extension)) return undefined;
image = { bytes: new Uint8Array(bytes), contentType };
} catch {
return undefined;
@@ -1,100 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { readMountedImage } from '../../../../../lib/server/images/read-mounted-image.js';
import { GET, HEAD } from './+server.js';
vi.mock('../../../../../lib/server/images/read-mounted-image.js', () => ({
readMountedImage: vi.fn()
}));
const publicDir = '/mounted/private-images';
const image = { bytes: new TextEncoder().encode('<svg/>'), contentType: 'image/svg+xml' };
const snapshot = (path = '/public/icon.svg') =>
Object.freeze({ assets: Object.freeze({ referencedPaths: Object.freeze([path]) }) });
function event(file: string, method = 'GET', captured = snapshot()) {
return {
params: { file },
locals: { snapshot: captured, publicDir },
request: new Request(`http://localhost/public/${encodeURI(file)}`, { method })
} as Parameters<typeof GET>[0];
}
beforeEach(() => {
vi.mocked(readMountedImage).mockReset().mockResolvedValue(image);
});
describe('referenced mounted image route', () => {
it.each(['GET', 'HEAD'])('reads eligible images for %s', async (method) => {
const response = await (method === 'HEAD' ? HEAD : GET)(event('icon.svg', method));
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/icon.svg');
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe(image.contentType);
expect(response.headers.get('content-length')).toBe(String(image.bytes.byteLength));
expect(await response.text()).toBe(method === 'HEAD' ? '' : '<svg/>');
if (method === 'HEAD') expect(response.body).toBeNull();
});
it.each(['GET', 'HEAD'])(
'requires exact captured membership before I/O for %s',
async (method) => {
for (const file of [
'unlisted.svg',
'ICON.svg',
'icon.svg/extra',
'../icon.svg',
'%2e%2e/icon.svg',
'%252e%252e/icon.svg',
'icon.svg?x',
''
]) {
const response = await (method === 'HEAD' ? HEAD : GET)(event(file, method));
expect(response.status).toBe(404);
expect(await response.text()).toBe(method === 'HEAD' ? '' : 'Not found');
}
expect(readMountedImage).not.toHaveBeenCalled();
}
);
it('passes Kit-decoded paths unchanged, without a second decode', async () => {
const captured = snapshot('/public/café family.svg');
expect((await GET(event('café family.svg', 'GET', captured))).status).toBe(200);
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/café family.svg');
vi.mocked(readMountedImage).mockClear();
expect((await GET(event('caf%C3%A9%20family.svg', 'GET', captured))).status).toBe(404);
expect(readMountedImage).not.toHaveBeenCalled();
});
it('keeps old captured eligibility while a replacement removes the reference', async () => {
const captured = event('icon.svg');
const replacement = snapshot('/public/replacement.svg');
expect((await GET(captured)).status).toBe(200);
expect((await GET(event('icon.svg', 'GET', replacement))).status).toBe(404);
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/icon.svg');
});
it.each(['GET', 'HEAD'])(
'maps an unavailable reader result to a generic %s 404',
async (method) => {
vi.mocked(readMountedImage).mockResolvedValue(undefined);
const response = await (method === 'HEAD' ? HEAD : GET)(event('icon.svg', method));
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/icon.svg');
expect(response.status).toBe(404);
expect(response.headers.get('content-length')).toBe('9');
expect(await response.text()).toBe(method === 'HEAD' ? '' : 'Not found');
}
);
it('ignores conditional and range headers', async () => {
const request = event('icon.svg');
request.request = new Request(request.request, {
headers: {
'if-none-match': '*',
'if-modified-since': new Date().toUTCString(),
range: 'bytes=0-2'
}
});
const response = await GET(request);
expect(response.status).toBe(200);
expect(await response.text()).toBe('<svg/>');
});
});
+1 -5
View File
@@ -127,8 +127,6 @@ describe('directory page data', () => {
for (const field of [
'configuration',
'assets',
'referencedPaths',
'brandingPaths',
'session',
'authentication',
'auth',
@@ -181,13 +179,11 @@ describe('directory page data', () => {
...privateSnapshot,
display,
assets: {
referencedPaths: ['PRIVATE_ASSET_BYTES'],
brandingPaths: [],
bookmarkLogos: { photos: 'PRIVATE_SVG_BYTES' }
}
};
expect(load(event)).toEqual({ directory: display, selectedLinkId: 'photos', signedIn: false });
for (const id of ['hidden', 'PRIVATE_ASSET_BYTES']) {
for (const id of ['hidden', 'PRIVATE_SVG_BYTES']) {
event.url.search = `?link=${id}`;
expect(() => load(event)).toThrowError(notFound);
}
@@ -98,7 +98,7 @@ describe('public favicon alias', () => {
captured.locals.snapshot = {
...captured.locals.snapshot,
display: snapshot('/public/display.svg').display,
assets: { referencedPaths: [], brandingPaths: [], bookmarkLogos: {} }
assets: { bookmarkLogos: {} }
};
const replacement = event('GET', snapshot('/public/replacement.png'));
await GET(captured);
@@ -209,7 +209,7 @@ describe('public generated Open Graph image', () => {
captured.locals.snapshot = {
...captured.locals.snapshot,
display: replacement.locals.snapshot.display,
assets: { referencedPaths: [], brandingPaths: [], bookmarkLogos: {} }
assets: { bookmarkLogos: {} }
};
const replacementFavicon = { bytes: new Uint8Array([1, 2, 3]), contentType: 'image/png' };
vi.mocked(readMountedImage).mockImplementationOnce(async () => {
@@ -97,7 +97,7 @@ describe('public selected-logo alias', () => {
captured.locals.snapshot = {
...captured.locals.snapshot,
display: snapshot('/public/display.svg').display,
assets: { referencedPaths: [], brandingPaths: [], bookmarkLogos: {} }
assets: { bookmarkLogos: {} }
};
const replacement = event('GET', snapshot('/public/replacement.png'));
await GET(captured);
@@ -1,12 +1,9 @@
import { readMountedImage } from '../../../../../lib/server/images/read-mounted-image.js';
import { imageResponse } from '../../../../../lib/server/images/image-response.js';
import { readMountedImage } from '../../../../lib/server/images/read-mounted-image.js';
import { imageResponse } from '../../../../lib/server/images/image-response.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals, params, request }) => {
const publicPath = `/public/${params.file}`;
if (!locals.snapshot.assets.referencedPaths.includes(publicPath)) {
return imageResponse(request.method);
}
const image = await readMountedImage(locals.publicDir, publicPath);
return imageResponse(request.method, image);
};
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { readMountedImage } from '../../../../lib/server/images/read-mounted-image.js';
import { GET, HEAD } from './+server.js';
vi.mock('../../../../lib/server/images/read-mounted-image.js', () => ({
readMountedImage: vi.fn()
}));
const publicDir = '/mounted/public-images';
const image = { bytes: new TextEncoder().encode('<svg/>'), contentType: 'image/svg+xml' };
function event(file: string, method = 'GET') {
return {
params: { file },
// Mounted files are public independently of configuration and sessions.
locals: { publicDir },
request: new Request(`http://localhost/public/${encodeURI(file)}`, { method })
} as Parameters<typeof GET>[0];
}
beforeEach(() => {
vi.mocked(readMountedImage).mockReset().mockResolvedValue(image);
});
describe('public mounted image route', () => {
it.each(['GET', 'HEAD'])(
'reads unreferenced images for %s without a snapshot',
async (method) => {
const response = await (method === 'HEAD' ? HEAD : GET)(event('unlisted.svg', method));
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/unlisted.svg');
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe(image.contentType);
expect(response.headers.get('content-length')).toBe(String(image.bytes.byteLength));
expect(await response.text()).toBe(method === 'HEAD' ? '' : '<svg/>');
if (method === 'HEAD') expect(response.body).toBeNull();
}
);
it.each(['café family.svg', 'caf%C3%A9%20family.svg', '../icon.svg', 'icon.svg?x', ''])(
'passes Kit-decoded paths unchanged to the validating reader, without a second decode: %s',
async (file) => {
await GET(event(file));
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, `/public/${file}`);
}
);
it.each(['GET', 'HEAD'])(
'maps an unavailable reader result to a generic %s 404',
async (method) => {
vi.mocked(readMountedImage).mockResolvedValue(undefined);
const response = await (method === 'HEAD' ? HEAD : GET)(event('icon.svg', method));
expect(readMountedImage).toHaveBeenCalledExactlyOnceWith(publicDir, '/public/icon.svg');
expect(response.status).toBe(404);
expect(response.headers.get('content-length')).toBe('9');
expect(await response.text()).toBe(method === 'HEAD' ? '' : 'Not found');
}
);
it('ignores conditional and range headers', async () => {
const request = event('icon.svg');
request.request = new Request(request.request, {
headers: {
'if-none-match': '*',
'if-modified-since': new Date().toUTCString(),
range: 'bytes=0-2'
}
});
const response = await GET(request);
expect(response.status).toBe(200);
expect(await response.text()).toBe('<svg/>');
});
});