diff --git a/.changeset/public-mounted-images.md b/.changeset/public-mounted-images.md
new file mode 100644
index 0000000..06eb0da
--- /dev/null
+++ b/.changeset/public-mounted-images.md
@@ -0,0 +1,5 @@
+---
+'@directory/web': patch
+---
+
+Serve mounted images as public static files without authentication, configuration allowlisting, or content sniffing.
diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts
index f997ce8..4888557 100644
--- a/apps/web/e2e/auth.e2e.ts
+++ b/apps/web/e2e/auth.e2e.ts
@@ -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());
}
}
diff --git a/apps/web/e2e/bookmark-logo-fallback.e2e.ts b/apps/web/e2e/bookmark-logo-fallback.e2e.ts
index d8b4c3a..e746735 100644
--- a/apps/web/e2e/bookmark-logo-fallback.e2e.ts
+++ b/apps/web/e2e/bookmark-logo-fallback.e2e.ts
@@ -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(
diff --git a/apps/web/e2e/configured-images.e2e.ts b/apps/web/e2e/configured-images.e2e.ts
index 07aa375..868745c 100644
--- a/apps/web/e2e/configured-images.e2e.ts
+++ b/apps/web/e2e/configured-images.e2e.ts
@@ -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 =
- '';
+const privateLogo = `
+
+
+]>
+`;
const privateBrand =
'';
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'), 'PRIVATE_IMAGE_BYTES');
- 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');
+ const changed = await bookmarkLogo();
expect(changed).not.toBe(original);
- expect(changed).toContain('>UP');
+ 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({
diff --git a/apps/web/e2e/favicon.e2e.ts b/apps/web/e2e/favicon.e2e.ts
index c8eac3b..3ae3ad3 100644
--- a/apps/web/e2e/favicon.e2e.ts
+++ b/apps/web/e2e/favicon.e2e.ts
@@ -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);
diff --git a/apps/web/e2e/open-graph.e2e.ts b/apps/web/e2e/open-graph.e2e.ts
index 9e709ae..335458f 100644
--- a/apps/web/e2e/open-graph.e2e.ts
+++ b/apps/web/e2e/open-graph.e2e.ts
@@ -14,7 +14,7 @@ const generatedPath = '/images/og.png';
const svgPath = '/public/share.svg';
const nextPath = '/public/next.svg';
const 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 });
}
diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts
index d6f7160..4a3e04d 100644
--- a/apps/web/src/hooks.server.test.ts
+++ b/apps/web/src/hooks.server.test.ts
@@ -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().mockResolvedValue(null);
+ vi.mocked(readMountedImage).mockResolvedValue({
+ bytes: new TextEncoder().encode(''),
+ 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().mockImplementation(async (event) => {
- if (event.route.id === '/(protected)/(endpoints)/public/[...file]') {
+ if (event.route.id === '/(public)/public/[...file]') {
return mountedImage(event as Parameters[0]);
}
return bookmarkLogo(event as Parameters[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(''),
- 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);
}
);
diff --git a/apps/web/src/lib/server/application.test.ts b/apps/web/src/lib/server/application.test.ts
index 10d613e..3335dcd 100644
--- a/apps/web/src/lib/server/application.test.ts
+++ b/apps/web/src/lib/server/application.test.ts
@@ -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();
diff --git a/apps/web/src/lib/server/auth/access.ts b/apps/web/src/lib/server/auth/access.ts
index 4e0c43a..e06bc38 100644
--- a/apps/web/src/lib/server/auth/access.ts
+++ b/apps/web/src/lib/server/auth/access.ts
@@ -60,7 +60,7 @@ export async function requireSignIn(
event.locals.session = null;
const routeId = event.route.id ?? '';
const publicRoute = routeId === '/(public)' || routeId.startsWith('/(public)/');
- if (publicRoute || 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);
}
diff --git a/apps/web/src/lib/server/auth/index.test.ts b/apps/web/src/lib/server/auth/index.test.ts
index b5ce213..bb8ae6f 100644
--- a/apps/web/src/lib/server/auth/index.test.ts
+++ b/apps/web/src/lib/server/auth/index.test.ts
@@ -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[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', () => {
diff --git a/apps/web/src/lib/server/directory/snapshot.test.ts b/apps/web/src/lib/server/directory/snapshot.test.ts
index 697d99b..7eca07e 100644
--- a/apps/web/src/lib/server/directory/snapshot.test.ts
+++ b/apps/web/src/lib/server/directory/snapshot.test.ts
@@ -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');
});
diff --git a/apps/web/src/lib/server/directory/snapshot.ts b/apps/web/src/lib/server/directory/snapshot.ts
index a323218..97f5955 100644
--- a/apps/web/src/lib/server/directory/snapshot.ts
+++ b/apps/web/src/lib/server/directory/snapshot.ts
@@ -9,8 +9,6 @@ export interface PreparedSnapshot {
readonly configuration: Immutable;
readonly display: DirectoryContent;
readonly assets: {
- readonly referencedPaths: readonly string[];
- readonly brandingPaths: readonly string[];
readonly bookmarkLogos: Readonly>;
};
}
@@ -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 {
- const brandingPaths = new Set();
- 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] };
-}
diff --git a/apps/web/src/lib/server/images/image-format.test.ts b/apps/web/src/lib/server/images/image-format.test.ts
deleted file mode 100644
index fa45368..0000000
--- a/apps/web/src/lib/server/images/image-format.test.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { matchesImageFormat } from './image-format.js';
-
-const svg = Buffer.from('');
-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('not an image')]
- ] 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([
- '',
- '\n ',
- '\n',
- "",
- '\ufeff\n\n',
- '',
- ''
- ])('recognizes an SVG document with XML prolog/comments', (text) => {
- expect(matchesImageFormat(Buffer.from(text), '.svg')).toBe(true);
- });
-
- it.each([
- '',
- '',
- '',
- 'not xml ',
- '',
- '',
- '',
- '',
- '',
- ']>',
- '',
- '',
- ''
- ])('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')).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);
- });
-});
diff --git a/apps/web/src/lib/server/images/image-format.ts b/apps/web/src/lib/server/images/image-format.ts
deleted file mode 100644
index 48ac665..0000000
--- a/apps/web/src/lib/server/images/image-format.ts
+++ /dev/null
@@ -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 (/');
- 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('');
- if (end === -1) return false;
- text = text.slice(end + 3).trimStart();
- }
- return /^