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 = - 'PRIVATE_IMAGE_BYTES'; +const privateLogo = ` + + +]> + + PRIVATE_IMAGE_BYTES + opaque Illustrator editing metadata +`; const privateBrand = 'PRIVATE_BRAND_BYTES'; 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\nHome & family', - '<hello> & anything', - '' - ])('recognizes an SVG document with XML prolog/comments', (text) => { - expect(matchesImageFormat(Buffer.from(text), '.svg')).toBe(true); - }); - - it.each([ - '', - '', - '', - 'not xml ', - '', - '', - '', - '', - '', - ']>&x;', - '', - '', - '' - ])('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('&unknown;'), '.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 /^]*|\/?)>/.test(text); -} diff --git a/apps/web/src/lib/server/images/read-mounted-image.test.ts b/apps/web/src/lib/server/images/read-mounted-image.test.ts index 8a98c53..20c39c3 100644 --- a/apps/web/src/lib/server/images/read-mounted-image.test.ts +++ b/apps/web/src/lib/server/images/read-mounted-image.test.ts @@ -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('')] - ] 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(''), Buffer.from([0xff]), Buffer.from('')]) - ); - expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined(); - await put('icon.svg', ' '.repeat(MAX_IMAGE_BYTES - 6) + ''); - expect(await readMountedImage(root, '/public/icon.svg')).toBeUndefined(); - await put('icon.svg', ' ({ - readMountedImage: vi.fn() -})); - -const publicDir = '/mounted/private-images'; -const image = { bytes: new TextEncoder().encode(''), 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[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' ? '' : ''); - 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(''); - }); -}); diff --git a/apps/web/src/routes/(protected)/page.test.ts b/apps/web/src/routes/(protected)/page.test.ts index 2e309d2..1a2f3e8 100644 --- a/apps/web/src/routes/(protected)/page.test.ts +++ b/apps/web/src/routes/(protected)/page.test.ts @@ -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); } diff --git a/apps/web/src/routes/(public)/favicon/server.test.ts b/apps/web/src/routes/(public)/favicon/server.test.ts index fea6366..bfddca9 100644 --- a/apps/web/src/routes/(public)/favicon/server.test.ts +++ b/apps/web/src/routes/(public)/favicon/server.test.ts @@ -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); diff --git a/apps/web/src/routes/(public)/images/og.png/server.test.ts b/apps/web/src/routes/(public)/images/og.png/server.test.ts index e5da7e0..17bb961 100644 --- a/apps/web/src/routes/(public)/images/og.png/server.test.ts +++ b/apps/web/src/routes/(public)/images/og.png/server.test.ts @@ -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 () => { diff --git a/apps/web/src/routes/(public)/logo/server.test.ts b/apps/web/src/routes/(public)/logo/server.test.ts index 60c25af..e236e12 100644 --- a/apps/web/src/routes/(public)/logo/server.test.ts +++ b/apps/web/src/routes/(public)/logo/server.test.ts @@ -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); diff --git a/apps/web/src/routes/(protected)/(endpoints)/public/[...file]/+server.ts b/apps/web/src/routes/(public)/public/[...file]/+server.ts similarity index 52% rename from apps/web/src/routes/(protected)/(endpoints)/public/[...file]/+server.ts rename to apps/web/src/routes/(public)/public/[...file]/+server.ts index 2c48de8..3ca98f4 100644 --- a/apps/web/src/routes/(protected)/(endpoints)/public/[...file]/+server.ts +++ b/apps/web/src/routes/(public)/public/[...file]/+server.ts @@ -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); }; diff --git a/apps/web/src/routes/(public)/public/[...file]/server.test.ts b/apps/web/src/routes/(public)/public/[...file]/server.test.ts new file mode 100644 index 0000000..b32c960 --- /dev/null +++ b/apps/web/src/routes/(public)/public/[...file]/server.test.ts @@ -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(''), 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[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' ? '' : ''); + 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(''); + }); +});