release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -38,9 +38,30 @@ const config = {
albumPasswords: {},
theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } },
contact: { enabled: true, retentionDays: 90 },
legal: { enabled: true },
privacy: { enabled: true },
siteUrl: null,
// Past the gate, /api/analytics/track would write content/analytics.json in
// the checkout; turned off, it answers without writing. The gate runs first,
// so the locked-site rows still exercise it.
analytics: false,
};
/*
* The contact route stores what it accepts under content/messages/ and pushes
* to contact.notifyUrl. Both are replaced here, so an accepted submission
* touches neither the checkout's content/ directory nor the network;
* validateContact stays real, honeypot and fill-time check included.
*/
const contactStore = vi.hoisted(() => ({
saveMessage: vi.fn(async (fields: Record<string, string>) => ({ id: 'x', ...fields })),
notifyNewMessage: vi.fn(async () => {}),
}));
vi.mock('@/lib/contact', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/contact')>()),
...contactStore,
}));
vi.mock('@/lib/config', () => ({
getConfig: () => config,
getConfigOrNull: () => config,
@@ -99,6 +120,15 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
{ params: Promise.resolve({ album: 'tok', id: 'tok' }) } as never,
),
},
{
// The lightbox zoom's full-resolution file (#467).
name: 'GET /api/zoom/[album]/[id]',
call: async () =>
(await import('../zoom/[album]/[id]/route')).GET(
request('/api/zoom/tok/tok') as never,
{ params: Promise.resolve({ album: 'tok', id: 'tok' }) } as never,
),
},
// Client proofing links: the site password applies to clients as well.
{
name: 'PUT /api/proof/[token]/selection',
@@ -128,11 +158,15 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
),
},
{
// Stores whatever a visitor sends; a locked site accepts nothing from strangers.
name: 'POST /api/contact',
// Writes visitor-chosen paths into content/analytics.json. It used to be
// OPEN "to count the gate", but the layout mounts no tracker on the gate.
name: 'POST /api/analytics/track',
call: async () =>
(await import('../contact/route')).POST(
new NextRequest('http://localhost/api/contact', { method: 'POST', body: '{}' }) as never,
(await import('../analytics/track/route')).POST(
new NextRequest('http://localhost/api/analytics/track', {
method: 'POST',
body: '{"path":"/"}',
}) as never,
),
},
];
@@ -152,7 +186,8 @@ const OPEN: Record<string, string> = {
install: 'the first-run wizard; setup-token gated, refuses once installed',
'install/albums': 'the first-run wizard; setup-token gated, refuses once installed',
webhook: 'server-to-server from Immich, HMAC-verified',
'analytics/track': 'counts a view of the gate itself; returns no content',
contact:
'the Impressum links the form as its second contact channel, and /contact is served to a locked site',
};
/** `GET /api/download/[album]/[id]` → `download/[album]/[id]`. */
@@ -253,6 +288,58 @@ describe('routes that must stay open', () => {
// The password gate is set in the theme's fonts; locking them would render
// the gate itself in fallback fonts. Three public font names give nothing away.
describe('POST /api/contact on a locked site', () => {
/** Own rate-limit bucket per case: the route allows three a minute per IP. */
const submit = (ip: string, fields: Record<string, unknown>) =>
import('../contact/route').then(({ POST }) =>
POST(
new NextRequest('http://localhost/api/contact', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-real-ip': ip },
body: JSON.stringify(fields),
}) as never,
),
);
const valid = () => ({
name: 'Erika',
email: 'erika@example.com',
message: 'Wer betreibt diese Seite?',
website: '',
startedAt: Date.now() - 10_000,
});
beforeEach(() => {
config.sitePassword = 'letmein';
contactStore.saveMessage.mockClear();
});
// The Impressum links the form as its second contact channel, and the
// proxy serves /contact to a locked site; the endpoint has to take the post.
it('accepts a valid message without a session', async () => {
const res = await submit('10.0.0.1', valid());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
expect(contactStore.saveMessage).toHaveBeenCalledTimes(1);
});
it('still drops a filled honeypot and a form sent too fast, unsaved', async () => {
expect((await submit('10.0.0.2', { ...valid(), website: 'spam.example' })).status).toBe(200);
expect((await submit('10.0.0.2', { ...valid(), startedAt: Date.now() })).status).toBe(200);
expect(contactStore.saveMessage).not.toHaveBeenCalled();
});
it('still refuses an invalid message', async () => {
const res = await submit('10.0.0.3', { ...valid(), email: 'not-an-address' });
expect(res.status).toBe(400);
expect(contactStore.saveMessage).not.toHaveBeenCalled();
});
it('still rate-limits at three a minute', async () => {
for (let i = 0; i < 3; i++) expect((await submit('10.0.0.4', valid())).status).toBe(200);
expect((await submit('10.0.0.4', valid())).status).toBe(429);
});
});
it('GET /api/fonts/css answers a locked site', async () => {
config.sitePassword = 'letmein';
const res = await (
@@ -271,3 +358,49 @@ describe('routes that must stay open', () => {
expect(res.status).not.toBe(401);
});
});
/*
* The pages, not the route handlers: proxy.ts gates those. Driven through the
* real lib/auth here, with the password set, rather than through a mocked
* isSiteUnlocked() as in lib/__tests__/proxy.test.ts.
*/
describe('legal and contact pages on a locked site', () => {
const page = async (path: string, cookie?: string) =>
(await import('@/proxy')).proxy(
new NextRequest(`http://localhost${path}`, cookie ? { headers: { cookie } } : {}),
);
const rewrittenTo = (res: Response) => res.headers.get('x-middleware-rewrite');
beforeEach(() => {
config.sitePassword = 'letmein';
config.legal.enabled = true;
config.privacy.enabled = true;
config.contact.enabled = true;
});
// The gate page is public and links both. A German Impressum has to be
// reachable directly, and neither page carries an album name or asset token.
it.each(['/impressum', '/privacy', '/contact'])(
'%s is served without the password',
async (path) => {
expect(rewrittenTo(await page(path))).toBeNull();
},
);
it('every other page is still rewritten to the gate', async () => {
for (const path of ['/', '/japan', '/journal', '/about', '/contact/x', '/impressum/x']) {
expect(rewrittenTo(await page(path)), path).toContain('/gate');
}
});
it('a switched-off legal or contact page is still a 404, not the gate', async () => {
config.legal.enabled = false;
config.privacy.enabled = false;
config.contact.enabled = false;
for (const path of ['/impressum', '/privacy', '/contact']) {
const res = await page(path);
expect(rewrittenTo(res), path).toBeNull();
expect(res.status, path).toBe(404);
}
});
});

View File

@@ -17,9 +17,9 @@ const fsMock = vi.hoisted(() => ({
copyFile: vi.fn(async () => undefined),
readdir: vi.fn(async () => []),
unlink: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
writeFile: vi.fn(async (..._args: unknown[]) => undefined),
rename: vi.fn(async () => undefined),
readFile: vi.fn(async () => {
readFile: vi.fn(async (..._args: unknown[]): Promise<Buffer> => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}),
}));
@@ -43,13 +43,14 @@ const put = (body: unknown) =>
* brand-new file and the save went ahead with no snapshot (#630).
*/
describe('PUT /api/admin/about backup handling', () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
fs.readFile.mockImplementation(async () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
});
it('saves a brand-new file with no backup', async () => {
vi.mocked(fs.access).mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
const res = await put({ meta: { name: 'Me' }, body: 'Hello' });
expect(res.status).toBe(200);
@@ -58,6 +59,7 @@ describe('PUT /api/admin/about backup handling', () => {
});
it('aborts the save when the file exists but the backup copy fails', async () => {
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('---\nname: Old\n---\n'));
vi.mocked(fs.copyFile).mockRejectedValueOnce(
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
);
@@ -65,4 +67,15 @@ describe('PUT /api/admin/about backup handling', () => {
await expect(put({ meta: { name: 'Me' }, body: 'Hello' })).rejects.toThrow('EACCES');
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('takes no backup for a save that changes nothing (QA A-19)', async () => {
await put({ meta: { name: 'Me' }, body: 'Hello' });
const written = vi.mocked(fs.writeFile).mock.calls[0][1] as string;
vi.mocked(fs.writeFile).mockClear();
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from(written));
const res = await put({ meta: { name: 'Me' }, body: 'Hello' });
expect(res.status).toBe(200);
expect(fs.copyFile).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* The doctor's zoom check (#467) samples one photo that zoom serves through
* Immich's full-size rendition. A rendition in a format the zoom route refuses
* (WebP) is not "fine" — the doctor used to say OK because one existed.
*/
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => true,
isAdminAuthenticated: async () => true,
COOKIE_NAME: 'folio_admin_session',
}));
const ALBUM = '11111111-1111-1111-1111-111111111111';
const config = {
needsCredentials: false,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
albums: [ALBUM],
standaloneAlbums: [ALBUM],
standaloneAlbumZoom: {},
zoom: true,
albumOverrides: {},
albumDownloads: {},
subpages: [],
albumPasswords: {},
sitePassword: '',
immich: { apiUrl: 'https://immich.example/api', apiKey: 'key' },
immichTimeoutMs: 1000,
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
contact: { enabled: false, retentionDays: 90 },
privacy: { enabled: true },
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
slugify: (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
}));
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] }));
vi.mock('@/lib/admin/yaml-service', () => ({ readSettingsYaml: async () => null }));
const immichMock = vi.hoisted(() => ({ getAlbum: vi.fn(), streamFullsize: vi.fn() }));
vi.mock('@/lib/immich', () => ({ immich: immichMock }));
import { GET } from '../doctor/route';
import { NextRequest } from 'next/server';
const zoomFinding = async () => {
const body = await (await GET(new NextRequest('http://localhost/api/admin/doctor'))).json();
return body.findings.find((f: { id: string }) => f.id === 'zoom-renditions');
};
const rendition = (contentType: string) => ({
stream: new ReadableStream(),
contentType,
contentLength: '10',
});
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
url.includes('/albums')
? ({ ok: true, json: async () => [{ id: ALBUM, albumName: 'Trip' }] } as Response)
: ({ ok: true, json: async () => ({}) } as Response),
),
);
immichMock.getAlbum.mockResolvedValue({
id: ALBUM,
albumName: 'Trip',
assets: [{ id: 'heic', type: 'IMAGE', originalMimeType: 'image/heic' }],
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe('GET /api/admin/doctor — zoom renditions', () => {
it('is OK for a JPEG rendition', async () => {
immichMock.streamFullsize.mockResolvedValue(rendition('image/jpeg'));
expect((await zoomFinding())?.level).toBe('ok');
expect(immichMock.streamFullsize).toHaveBeenCalledWith('heic');
});
it('warns when Immich has none', async () => {
immichMock.streamFullsize.mockResolvedValue(null);
const finding = await zoomFinding();
expect(finding?.level).toBe('warn');
expect(finding?.title).toMatch(/no full-size preview/);
});
it('warns, asking for JPEG, when the rendition is WebP (review of #830)', async () => {
immichMock.streamFullsize.mockResolvedValue(rendition('image/webp'));
const finding = await zoomFinding();
expect(finding?.level).toBe('warn');
expect(finding?.title).toMatch(/not JPEG/);
});
});

View File

@@ -6,6 +6,7 @@ import path from 'path';
import yaml from 'js-yaml';
import { atomicWrite } from '@/lib/atomicWrite';
import {
needsBackup,
VersionConflictError,
assertVersion,
baseVersionFrom,
@@ -90,15 +91,8 @@ export const PUT = withAdmin(async (request: Request) => {
// brand-new file: the save went ahead with no snapshot taken (#630). Only
// ENOENT means there is nothing to back up; anything else aborts the save
// before it overwrites the live file.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException & { code?: string }).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
// A save that changes nothing takes no backup either (needsBackup).
if (await needsBackup(filePath, content)) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

View File

@@ -3,6 +3,9 @@ import { withAdmin } from '@/lib/admin/withAdmin';
import fs from 'node:fs/promises';
import path from 'node:path';
import { getConfig, slugify } from '@/lib/config';
// From the pure schema module, like lib/auth.ts: tests stub the config barrel.
import { resolveZoom } from '@/lib/config/schema';
import { ZOOM_CONTENT_TYPES, zoomSourceFor } from '@/lib/zoomSource';
import { env } from '@/lib/env';
import { listJournalEntries } from '@/lib/admin/journal-service';
import { readSettingsYaml } from '@/lib/admin/yaml-service';
@@ -11,6 +14,8 @@ import { readPrivacy } from '@/lib/privacy';
import { listPageSlugsSync } from '@/lib/admin/pages-service';
import { takenPageSlugs } from '@/lib/admin/pageSlugs';
import { describeCollision, menuPageSlugs, pageSlugCollision } from '@/lib/pages';
import { immich } from '@/lib/immich';
import { isLocationScrubbable } from '@/lib/locationScrub';
import {
checkAlbumIds,
checkAlbumSlugCollisions,
@@ -20,21 +25,26 @@ import {
checkImmichCalls,
checkContact,
checkContentPages,
checkDownloadMetadata,
checkLegal,
checkPrivacy,
checkSettingValues,
checkPasswords,
checkProxyHops,
checkWritable,
checkZoomRenditions,
countForwardedHops,
PROXY_MARKER_HEADERS,
worstLevel,
type AlbumRef,
type AlbumSlugGroup,
type DoctorFinding,
type DownloadAlbumRef,
type ContactRef,
type LegalRef,
type PasswordRef,
type ZoomAlbumRef,
type ZoomRenditionSample,
} from '@/lib/admin/doctor';
/**
@@ -130,6 +140,81 @@ export const GET = withAdmin(async (request: NextRequest) => {
})),
];
findings.push(checkAlbumSlugCollisions(slugGroups, config.albumOverrides, albums, slugify));
// Originals whose metadata a download cannot clean. Through the album
// cache, like the alt-text report; an album that cannot be read is
// already reported above and is skipped here.
const downloadAlbums: DownloadAlbumRef[] = [];
for (const id of config.albums.filter((albumId) => config.albumDownloads?.[albumId])) {
try {
const album = await immich.getAlbum(id);
if (!album) continue;
const uncleanable = album.assets.filter((asset) => !isLocationScrubbable(asset));
downloadAlbums.push({
id,
albumName: album.albumName,
uncleanable: uncleanable.length,
uncleanableWithLocation: uncleanable.filter(
(asset) => asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null,
).length,
});
} catch {
// Immich unreachable: the connection check says so.
}
}
const downloads = checkDownloadMetadata(downloadAlbums);
if (downloads) findings.push(downloads);
// Lightbox zoom (#467): photos a browser cannot show need Immich's
// full-size rendition. Whether Immich has them is only known by asking,
// so one such photo is sampled. "On" here means on for any route to the
// album, standalone or through an enabled subpage.
const zoomAlbums: ZoomAlbumRef[] = [];
let renditionSample: ZoomRenditionSample | null = null;
try {
const zoomOn = (albumId: string) =>
(config.standaloneAlbums.includes(albumId) && resolveZoom(config, albumId)) ||
config.subpages.some(
(sp) =>
sp.enabled !== false &&
sp.albumIds.includes(albumId) &&
resolveZoom(config, albumId, sp),
);
let sample: string | undefined;
for (const id of config.albums.filter(zoomOn)) {
try {
const album = await immich.getAlbum(id);
if (!album) continue;
const viaRendition = album.assets.filter(
(asset) => zoomSourceFor(asset) === 'fullsize',
);
sample ??= viaRendition[0]?.id;
zoomAlbums.push({ id, albumName: album.albumName, needRendition: viaRendition.length });
} catch {
// Immich unreachable: the connection check says so.
}
}
if (sample) {
try {
const rendition = await immich.streamFullsize(sample);
if (!rendition) {
renditionSample = 'missing';
} else {
await (rendition.stream as ReadableStream).cancel().catch(() => {});
// The zoom route serves only what it can scrub: a WebP rendition
// exists, and is still refused.
const type = rendition.contentType.toLowerCase().split(';')[0].trim();
renditionSample = ZOOM_CONTENT_TYPES.has(type) ? 'ok' : { contentType: type };
}
} catch {
// Unknown; reported as nothing rather than as a false alarm.
}
}
} catch {
// Best-effort, like every check here: no finding rather than no report.
}
const zoomFinding = checkZoomRenditions(zoomAlbums, renditionSample);
if (zoomFinding) findings.push(zoomFinding);
}
}

View File

@@ -5,6 +5,7 @@ import { revalidatePath } from 'next/cache';
import { withAdmin } from '@/lib/admin/withAdmin';
import { atomicWrite } from '@/lib/atomicWrite';
import {
needsBackup,
VersionConflictError,
assertVersion,
baseVersionFrom,
@@ -17,6 +18,8 @@ import {
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { PRIVACY_FILENAME, processingFacts, readPrivacy, starterHeadings } from '@/lib/privacy';
import { listSessions } from '@/lib/proofSessions';
import { webhookUrl } from '@/lib/proofWebhook';
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
const MAX_BACKUPS = 10;
@@ -35,6 +38,12 @@ export const GET = withAdmin(async () => {
!!config.sitePassword ||
config.subpages.some((sp) => !!sp.password) ||
Object.values(config.albumPasswords).some(Boolean),
// An unreadable proofing.json may still hold client data: report it.
hasProofingLinks: await listSessions().then(
(sessions) => sessions.length > 0,
() => true,
),
proofingWebhookUrl: webhookUrl(),
});
// Read before the text: a save landing between the two then makes the
// version stale, which costs a spurious conflict, never a missed one.
@@ -70,15 +79,8 @@ export const PUT = withAdmin(async (request: Request) => {
// Same rule as the about route (#630): only a missing file means there is
// nothing to back up. Any other failure aborts before the live file changes.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
// A save that changes nothing takes no backup either (needsBackup).
if (await needsBackup(filePath, content)) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

View File

@@ -40,6 +40,9 @@ export const GET = withAdmin(async () => {
// Fields an environment variable overrides, as path → variable name. Only
// the names: the values are secrets (a password, an ntfy topic) (#605).
envLocks: resolveEnvLocks(env),
// What an empty title falls back to before the language default, so the
// General section can show the title the site really uses (QA A-15).
siteTitleEnv: env.SITE_TITLE || null,
},
{ headers: { ETag: etag(version) } },
);

View File

@@ -26,7 +26,10 @@ export const GET = withAdmin(
try {
const res = await fetch(
`${config.immich.apiUrl}/assets/${assetId}/thumbnail?size=thumbnail`,
// `edited=true`: the pickers show a photo as edited in Immich, like
// the site does (#831). For a photo that is not edited Immich returns
// the same bytes, so it needs no lookup first.
`${config.immich.apiUrl}/assets/${assetId}/thumbnail?size=thumbnail&edited=true`,
{
headers: {
'x-api-key': config.immich.apiKey,

View File

@@ -10,6 +10,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/config', () => ({
getConfigOrNull: vi.fn(() => ({ analytics: true })),
// The site gate reads it; no sitePassword, so the site is open.
getConfig: () => ({ analytics: true }),
}));
const checkRateLimit = vi.fn(

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { getConfigOrNull } from '@/lib/config';
import { siteLockResponse } from '@/lib/auth';
import { atomicWrite } from '@/lib/atomicWrite';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
@@ -79,6 +80,13 @@ async function saveAnalytics(data: AnalyticsData) {
let writeQueue: Promise<void> = Promise.resolve();
export async function POST(req: NextRequest) {
// A locked site accepts nothing from strangers, the same rule as
// /api/contact: without it anyone could write paths of their choosing into
// analytics.json. The gate page itself is not counted — the layout mounts
// no tracker there — so a visitor who unlocked the site loses nothing.
const locked = siteLockResponse(req);
if (locked) return locked;
try {
const config = getConfigOrNull();
if (config?.analytics === false) {

View File

@@ -8,7 +8,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { siteLockResponse } from '@/lib/auth';
import { notifyNewMessage, saveMessage, validateContact } from '@/lib/contact';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
@@ -24,9 +23,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
// The page sits behind the site password; so does the endpoint it posts to.
const locked = siteLockResponse(request);
if (locked) return locked;
// No site-lock check, on purpose: proxy.ts serves /contact to a locked site,
// because the Impressum links the form as its second contact channel. The
// honeypot, the fill-time check, the rate limit and the inbox cap below are
// what stand between a stranger and content/messages/, locked or not.
const ip = getClientIp(request);
const { success, resetAt } = checkRateLimit(`contact:${ip}`, CONTACT_RPM);

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import {
LAT,
cameraHeic,
cameraJpeg,
chunked,
contains,
exifOf,
heicExif,
latin1,
parseTiff,
asciiOf,
} from '@/lib/__tests__/fixtures/location';
/**
* The single-original download serves the file without its location: GPS is
* removed from JPEG/HEIC/AVIF on the way through, camera data stays, and the
* length (and so Content-Length) does not change.
*/
vi.mock('@/lib/config', () => ({ getConfig: vi.fn() }));
vi.mock('@/lib/tokens', () => ({ decodeAssetId: vi.fn((t: string) => t.replace('tok-', '')) }));
vi.mock('@/lib/immich', () => ({
immich: { getAlbum: vi.fn(), streamAsset: vi.fn() },
ImmichUnavailableError: class ImmichUnavailableError extends Error {},
}));
vi.mock('@/lib/rate-limit', () => ({
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
getClientIp: vi.fn(() => '127.0.0.1'),
retryAfterSeconds: vi.fn(() => 60),
}));
vi.mock('@/lib/auth', () => ({
siteLockResponse: vi.fn(() => null),
isAlbumReachable: vi.fn(() => true),
}));
vi.mock('next/headers', () => ({
cookies: vi.fn(() => Promise.resolve({ get: () => undefined })),
}));
import { GET } from '../route';
import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
const mockGetAlbum = immich.getAlbum as unknown as ReturnType<typeof vi.fn>;
const mockStream = immich.streamAsset as unknown as ReturnType<typeof vi.fn>;
const ALBUM = {
id: 'album',
albumName: 'Album',
assets: [{ id: 'asset', type: 'IMAGE', originalFileName: 'IMG_0001.JPG' }],
};
function original(data: Uint8Array, contentType: string) {
return {
stream: chunked(data, 1000),
contentType,
contentLength: String(data.length),
};
}
const download = () =>
GET(new NextRequest('http://localhost/api/download/tok-album/tok-asset'), {
params: Promise.resolve({ album: 'tok-album', id: 'tok-asset' }),
});
beforeEach(() => {
vi.clearAllMocks();
mockConfig.mockReturnValue({ albums: ['album'], albumDownloads: { album: true } });
mockGetAlbum.mockResolvedValue(ALBUM);
});
describe('GET /api/download/[album]/[id]', () => {
it('serves a JPEG without GPS, camera data and length intact', async () => {
const { file } = cameraJpeg(true);
mockStream.mockResolvedValue(original(file, 'image/jpeg'));
const res = await download();
expect(res.status).toBe(200);
expect(res.headers.get('content-length')).toBe(String(file.length));
expect(res.headers.get('content-disposition')).toContain('IMG_0001.JPG');
const body = new Uint8Array(await res.arrayBuffer());
expect(body.length).toBe(file.length);
const tiff = parseTiff(exifOf(body)!);
expect(tiff.gps).toBeNull();
expect(tiff.ifd0.has(0x8825)).toBe(false);
expect(asciiOf(tiff.ifd0.get(0x0110))).toBe('Canon EOS R5');
expect(contains(body, LAT.value(true))).toBe(false);
expect(latin1(body)).not.toMatch(/exif:GPS/);
expect(latin1(body)).toContain('photoshop:City="Berlin"');
});
it('serves a HEIC without GPS', async () => {
const { file } = cameraHeic();
mockStream.mockResolvedValue(original(file, 'image/heic'));
const body = new Uint8Array(await (await download()).arrayBuffer());
expect(body.length).toBe(file.length);
const tiff = parseTiff(heicExif(body)!);
expect(tiff.gps).toBeNull();
expect(asciiOf(tiff.ifd0.get(0x010f))).toBe('Canon');
});
it('passes a format it does not edit through unchanged', async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
mockStream.mockResolvedValue(original(png, 'image/png'));
const body = new Uint8Array(await (await download()).arrayBuffer());
expect(body).toEqual(png);
});
it('refuses a JPEG whose metadata it cannot locate', async () => {
mockStream.mockResolvedValue({
stream: new ReadableStream({
start(controller) {
// SOI, then an APP1 claiming more bytes than the file has.
controller.enqueue(new Uint8Array([0xff, 0xd8, 0xff, 0xe1, 0x40, 0x00, 0x45, 0x78]));
controller.close();
},
}),
contentType: 'image/jpeg',
contentLength: '8',
});
const res = await download();
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
});
describe('a photo edited in Immich (#831)', () => {
const EDITED = {
id: 'album',
albumName: 'Album',
assets: [{ id: 'asset', type: 'IMAGE', originalFileName: 'IMG_9262.HEIC', isEdited: true }],
};
it('serves the edited rendition, named as the JPEG it is, without GPS', async () => {
mockGetAlbum.mockResolvedValue(EDITED);
// Immich's edited rendition carries no EXIF; one that did must still
// lose its GPS on the way through.
const { file } = cameraJpeg(true);
mockStream.mockResolvedValue(original(file, 'image/jpeg'));
const res = await download();
expect(res.status).toBe(200);
expect(mockStream).toHaveBeenCalledWith('asset', 'original', true);
expect(res.headers.get('content-type')).toBe('image/jpeg');
expect(res.headers.get('content-disposition')).toContain('IMG_9262.jpg');
expect(res.headers.get('content-disposition')).not.toContain('HEIC');
const body = new Uint8Array(await res.arrayBuffer());
expect(parseTiff(exifOf(body)!).gps).toBeNull();
expect(contains(body, LAT.value(true))).toBe(false);
});
it('keeps the name of an edited JPEG', async () => {
mockGetAlbum.mockResolvedValue({
...EDITED,
assets: [{ ...EDITED.assets[0], originalFileName: 'scan.jpeg' }],
});
mockStream.mockResolvedValue(original(cameraJpeg(true).file, 'image/jpeg'));
const res = await download();
expect(res.headers.get('content-disposition')).toContain('scan.jpeg');
});
it('refuses an edited rendition the scrubber would pass through (review of #832)', async () => {
mockGetAlbum.mockResolvedValue(EDITED);
const webp = new TextEncoder().encode('RIFF\x10\x00\x00\x00WEBPVP8 ');
mockStream.mockResolvedValue(original(webp, 'image/webp'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const res = await download();
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
expect(warn.mock.calls.some((args) => String(args[0]).includes('Refused edited asset'))).toBe(
true,
);
warn.mockRestore();
});
it('asks for the original of a photo that is not edited exactly as before', async () => {
mockStream.mockResolvedValue(original(cameraJpeg(true).file, 'image/jpeg'));
await download();
expect(mockStream).toHaveBeenCalledWith('asset', 'original', false);
});
});
});

View File

@@ -15,6 +15,15 @@
*
* The last one is what stops one enabled album's URL being edited into a
* download of any asset in the Immich instance.
*
* The file goes out as Immich stores it, minus its location: GPS coordinates
* are removed from JPEG, HEIC/HEIF and AVIF originals on the way through
* (lib/locationScrub.ts). Camera data, copyright and colour profile stay.
*
* A photo edited in Immich's editor is the exception (#831): it goes out as
* edited, which is Immich's full-resolution rendition of the edit — a JPEG
* without EXIF — named with a matching extension. It passes the same
* scrubber.
*/
import { NextRequest, NextResponse } from 'next/server';
@@ -24,7 +33,8 @@ import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { isAlbumReachable, siteLockResponse } from '@/lib/auth';
import { contentDisposition } from '@/lib/downloadName';
import { contentDisposition, editedDownloadName } from '@/lib/downloadName';
import { scrubLocationStream } from '@/lib/locationScrub';
export const dynamic = 'force-dynamic';
@@ -104,7 +114,9 @@ export async function GET(
let result;
try {
result = await immich.streamAsset(assetId, 'original');
// A photo edited in Immich goes out as edited (#831): Immich's
// full-resolution edited rendition rather than the unedited camera file.
result = await immich.streamAsset(assetId, 'original', asset.isEdited === true);
} catch (error) {
if (error instanceof ImmichUnavailableError) {
return NextResponse.json(
@@ -116,15 +128,50 @@ export async function GET(
}
if (!result) return notFound();
// Location out, everything else in. The head is read before a byte is sent,
// so a file whose metadata cannot be located is refused rather than leaked.
let scrubbed;
try {
scrubbed = await scrubLocationStream(result.stream as ReadableStream<Uint8Array>);
} catch (error) {
console.error(`[Download] Reading original ${assetId} failed:`, error);
return NextResponse.json(
{ error: 'Immich is currently unavailable' },
{ status: 503, headers: { 'Retry-After': '30', 'Cache-Control': 'no-store' } },
);
}
if (!scrubbed.ok) {
console.warn(
`[Download] Refused original ${assetId}: its location metadata could not be removed (${scrubbed.reason}).`,
);
return notFound();
}
// An edited photo is a rendition Immich made, which Folio expects as JPEG
// (#831). One in a format the scrubber passes through untouched (WebP,
// PNG) is not what was measured, and is refused rather than sent with
// whatever metadata it carries — as the zoom route does.
if (asset.isEdited === true && scrubbed.format === 'passthrough') {
await scrubbed.stream.cancel().catch(() => {});
console.warn(
`[Download] Refused edited asset ${assetId}: Immich sent ${result.contentType || 'no type'}, which is not a JPEG or HEIF-family file.`,
);
return notFound();
}
const headers: Record<string, string> = {
'Content-Type': result.contentType || 'application/octet-stream',
'Content-Disposition': contentDisposition(asset.originalFileName),
'Content-Disposition': contentDisposition(
asset.isEdited === true
? editedDownloadName(asset.originalFileName, result.contentType)
: asset.originalFileName,
),
// Private: a download is authorised per visitor, so a shared cache must
// not hand the file to the next one.
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
};
// Scrubbing overwrites in place, so the upstream length still holds.
if (result.contentLength) headers['Content-Length'] = result.contentLength;
return new NextResponse(result.stream, { headers });
return new NextResponse(scrubbed.stream, { headers });
}

View File

@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import {
LAT,
cameraHeic,
cameraJpeg,
chunked,
contains,
latin1,
} from '@/lib/__tests__/fixtures/location';
/**
* The archive route streams originals as a ZIP for either the whole album
@@ -430,3 +438,103 @@ describe('concurrent archives', () => {
mockClientIp.mockReturnValue('127.0.0.1');
});
});
/**
* The ZIP is stored uncompressed, so each entry's bytes appear in it as they
* are: what the scrubber left is exactly what a visitor unpacks.
*/
describe('location metadata', () => {
it('leaves GPS out of every original in the archive and keeps camera data', async () => {
const jpeg = cameraJpeg(false).file;
const heic = cameraHeic({ le: true }).file;
mockStream.mockImplementation((id: string) =>
Promise.resolve({
stream: chunked(id === 'asset-1' ? jpeg : heic, 512),
contentType: id === 'asset-1' ? 'image/jpeg' : 'image/heic',
contentLength: null,
}),
);
const zip = new Uint8Array(await (await GET(getReq(), params)).arrayBuffer());
expect(contains(zip, LAT.value(false))).toBe(false);
expect(contains(zip, LAT.value(true))).toBe(false);
const text = latin1(zip);
expect(text).not.toMatch(/exif:GPS/);
expect(text).not.toContain('52.520095');
expect(text.split('Canon EOS R5').length - 1).toBeGreaterThanOrEqual(2);
expect(text).toContain('photoshop:City="Berlin"');
});
it('leaves out an original whose metadata cannot be located, and goes on', async () => {
const broken = new Uint8Array([0xff, 0xd8, 0xff, 0xe1, 0x40, 0x00, 0x45, 0x78]);
mockStream.mockImplementation((id: string) =>
Promise.resolve(
id === 'asset-1'
? { stream: chunked(broken, 8), contentType: 'image/jpeg', contentLength: null }
: originStream('second-original'),
),
);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const zip = latin1(new Uint8Array(await (await GET(getReq(), params)).arrayBuffer()));
// Visible in the server log: which asset, which album, and how many.
const logged = warn.mock.calls.map((args) => String(args[0]));
expect(logged.some((l) => l.includes('asset-1') && l.includes('Test Album'))).toBe(true);
expect(logged.some((l) => l.includes('missing 1 of 2'))).toBe(true);
warn.mockRestore();
expect(zip).not.toContain('photo-1.jpg');
expect(zip).toContain('photo-2.jpg');
expect(zip).toContain('second-original');
});
it('puts a photo edited in Immich in as edited, renamed, and without GPS (#831)', async () => {
mockGetAlbum.mockResolvedValue({
...ALBUM,
assets: [
{ id: 'asset-1', type: 'IMAGE', originalFileName: 'IMG_9262.HEIC', isEdited: true },
{ id: 'asset-2', type: 'IMAGE', originalFileName: 'photo-2.jpg' },
],
});
const jpeg = cameraJpeg(true).file;
mockStream.mockImplementation(() =>
Promise.resolve({
stream: chunked(jpeg, 512),
contentType: 'image/jpeg',
contentLength: null,
}),
);
const zip = new Uint8Array(await (await GET(getReq(), params)).arrayBuffer());
expect(mockStream).toHaveBeenCalledWith('asset-1', 'original', true);
expect(mockStream).toHaveBeenCalledWith('asset-2', 'original', false);
const text = latin1(zip);
expect(text).toContain('IMG_9262.jpg');
expect(text).not.toContain('IMG_9262.HEIC');
expect(contains(zip, LAT.value(true))).toBe(false);
});
it('leaves out an edited rendition the scrubber would pass through (review of #832)', async () => {
mockGetAlbum.mockResolvedValue({
...ALBUM,
assets: [
{ id: 'asset-1', type: 'IMAGE', originalFileName: 'IMG_9262.HEIC', isEdited: true },
{ id: 'asset-2', type: 'IMAGE', originalFileName: 'photo-2.jpg' },
],
});
const webp = new TextEncoder().encode('RIFF\x10\x00\x00\x00WEBPVP8 edited-webp');
mockStream.mockImplementation((id: string) =>
Promise.resolve(
id === 'asset-1'
? { stream: chunked(webp, 64), contentType: 'image/webp', contentLength: null }
: originStream('second-original'),
),
);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const zip = latin1(new Uint8Array(await (await GET(getReq(), params)).arrayBuffer()));
const logged = warn.mock.calls.map((args) => String(args[0]));
expect(logged.some((l) => l.includes('edited asset asset-1'))).toBe(true);
warn.mockRestore();
expect(zip).not.toContain('edited-webp');
expect(zip).not.toContain('IMG_9262');
expect(zip).toContain('second-original');
});
});

View File

@@ -33,6 +33,9 @@ const STRIP_VERSION = 's1';
*/
const MAX_STRIP_BYTES = 64 * 1024 * 1024;
/** The shape of lib/urls.ts editMarker(): a base-36 timestamp. */
const EDIT_MARKER = /^[0-9a-z]{1,16}$/;
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// ── Rate limiting ──────────────────────────────────
const ip = getClientIp(request);
@@ -86,6 +89,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
request.nextUrl.searchParams.get('w'),
);
// Photos edited in Immich (#831) are always served as edited: the server
// asks for `edited=true` on every request, whatever the URL says, so a URL
// stripped of its marker cannot bring back what a crop removed. For a photo
// that is not edited Immich returns the same bytes (measured on 3.2), and
// the parameter exists in every supported Immich (since 2.5; Folio needs
// 3.0). The marker `e` that lib/urls.ts writes for edited photos is only a
// cache key: it gives the edited rendition a URL (and ETag) of its own, so
// an `immutable` copy of the unedited one is never reused. A value outside
// the marker's shape is ignored, so it cannot carry anything into the ETag.
const editParam = request.nextUrl.searchParams.get('e');
const edit = editParam && EDIT_MARKER.test(editParam) ? editParam : null;
// ── Browser Cache Optimization ─────────────────────
// Use the opaque token to generate a safe ETag without leaking Immich UUIDs.
// IMAGE_CACHE_VERSION participates so that a bump is not defeated by a client
@@ -93,7 +108,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// `immutable`, so this only matters after expiry or a cache eviction, but a
// matching ETag across two different URLs would be wrong either way.
const cacheVersion = request.nextUrl.searchParams.get('v') ?? '';
const etag = `W/"${token}-${size}-${STRIP_VERSION}${cacheVersion ? `-${cacheVersion}` : ''}"`;
const etag = `W/"${token}-${size}-${STRIP_VERSION}${edit ? `-e${edit}` : ''}${cacheVersion ? `-${cacheVersion}` : ''}"`;
if (request.headers.get('if-none-match') === etag) {
return new NextResponse(null, {
status: 304,
@@ -107,7 +122,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
let result;
try {
result = await immich.streamAsset(assetId, size);
result = await immich.streamAsset(assetId, size, true);
} catch (error) {
// An outage must not look like a deleted photo. These URLs are served with
// `immutable` on success, and a bare 404 is heuristically cacheable — so
@@ -177,7 +192,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
console.warn(
`[Image API] Original von ${assetId} (${contentType}) nicht strippbar — liefere Preview.`,
);
const preview = await immich.streamAsset(assetId, 'preview');
const preview = await immich.streamAsset(assetId, 'preview', true);
if (!preview) {
return NextResponse.json(
{ error: 'Asset not found' },

View File

@@ -4,9 +4,12 @@ import { GET } from '../[id]/route';
import { encodeAssetId } from '@/lib/tokens';
import { immich, ImmichUnavailableError } from '@/lib/immich';
const published = vi.hoisted(() => ({ value: true }));
const published = vi.hoisted(() => ({ value: true, asked: [] as string[] }));
vi.mock('@/lib/publishedAssets', () => ({
isPublishedAsset: async () => published.value,
isPublishedAsset: async (id: string) => {
published.asked.push(id);
return published.value;
},
}));
vi.mock('@/lib/admin/auth', () => ({
isAdminAuthenticated: async () => false,
@@ -77,7 +80,72 @@ describe('GET /api/image/[id]', () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const res = await call(encodeAssetId(ASSET_ID));
expect(res.status).toBe(200);
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview');
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview', true);
});
describe('photos edited in Immich (#831)', () => {
beforeEach(() => {
published.asked = [];
});
it('asks Immich for the edit when the URL carries the edit marker', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const res = await call(encodeAssetId(ASSET_ID), '?size=preview&e=mf3k2abc');
expect(res.status).toBe(200);
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview', true);
});
it('asks for the edited thumbnail too', async () => {
mockStream.mockResolvedValue(fakeBody('image/webp'));
await call(encodeAssetId(ASSET_ID), '?size=thumbnail&e=mf3k2abc');
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'thumbnail', true);
});
it('shows the edit to a URL stripped of its marker: the server decides, not the URL', async () => {
// Review of #832: with the marker as the switch, dropping `&e=` from a
// URL brought back what the photographer had cropped out.
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
await call(encodeAssetId(ASSET_ID), '?size=preview&w=1440');
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview', true);
mockStream.mockResolvedValue(fakeBody('image/webp'));
await call(encodeAssetId(ASSET_ID), '?size=thumbnail');
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'thumbnail', true);
expect(mockStream).not.toHaveBeenCalledWith(ASSET_ID, expect.anything(), false);
});
it('is still gated by the published-asset check, which goes by the asset alone', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const res = await call(encodeAssetId(ASSET_ID), '?size=preview&e=mf3k2abc&w=1440');
expect(res.status).toBe(200);
expect(published.asked).toEqual([ASSET_ID]);
published.value = false;
try {
const refused = await call(encodeAssetId(ASSET_ID), '?size=preview&e=mf3k2abc');
expect(refused.status).toBe(404);
} finally {
published.value = true;
}
});
it('gives the edited rendition its own ETag', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const token = encodeAssetId(ASSET_ID);
const plain = (await call(token, '?size=preview')).headers.get('ETag');
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const edited = (await call(token, '?size=preview&e=mf3k2abc')).headers.get('ETag');
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const reEdited = (await call(token, '?size=preview&e=mf9zz001')).headers.get('ETag');
expect(new Set([plain, edited, reEdited]).size).toBe(3);
});
it('ignores a marker that is not one, and keeps it out of the ETag', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const token = encodeAssetId(ASSET_ID);
const res = await call(token, `?size=preview&e=${encodeURIComponent('"x", W/"y')}`);
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview', true);
expect(res.headers.get('ETag')).not.toContain('y');
});
});
// Regression guard for the stored-XSS fix in c2fa8e7. An SVG served as

View File

@@ -3,21 +3,13 @@ import { revalidatePath } from 'next/cache';
import { completeInstall, isInstalled, normalizeApiBase, validateSetupToken } from '@/lib/install';
import { invalidateConfigCache } from '@/lib/config';
import { DEFAULT_PRESET } from '@/lib/config/theme';
import { PRESET_IDS } from '@/lib/config/presets';
import { immich } from '@/lib/immich';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/** Install submissions per minute per IP — only live before install. */
const INSTALL_RPM = 10;
const THEME_PRESETS = [
'studio-modern',
'studio',
'minimal',
'editorial',
'classic',
'noir',
'monograph',
];
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** POST: run the install wizard's final step — verify, then write config. */
@@ -59,7 +51,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid Immich URL' }, { status: 400 });
}
const theme = THEME_PRESETS.includes(body.theme) ? body.theme : DEFAULT_PRESET;
const theme = PRESET_IDS.includes(body.theme) ? body.theme : DEFAULT_PRESET;
// Album selection is optional: an empty gallery is a valid install.
const rawAlbums: unknown[] = Array.isArray(body.albums) ? (body.albums as unknown[]) : [];

View File

@@ -140,7 +140,10 @@ export async function GET(request: NextRequest) {
lat: position.lat,
lng: position.lng,
photoCount,
coverUrl: imageUrl(allowedAlbums[0].coverAssetId, 'thumbnail'),
coverUrl: imageUrl(
{ id: allowedAlbums[0].coverAssetId, ...allowedAlbums[0].coverEdit },
'thumbnail',
),
albums: allowedAlbums.map((a) => ({
name: a.name,
url: a.subpageSlug ? `/${a.subpageSlug}/${a.slug}` : `/${a.slug}`,

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest, NextResponse } from 'next/server';
import {
LAT,
asciiOf,
cameraHeic,
cameraJpeg,
chunked,
contains,
exifOf,
heicExif,
latin1,
parseTiff,
} from '@/lib/__tests__/fixtures/location';
/**
* GET /api/zoom/[album]/[id] (#467): the full-resolution file behind the
* lightbox zoom. Every refusal is the same 404; the route checks are tested
* here, the per-route zoom/password resolution in lib/__tests__/auth-zoom.
*/
vi.mock('@/lib/config', () => ({ getConfig: vi.fn() }));
vi.mock('@/lib/tokens', () => ({
decodeAssetId: vi.fn((t: string) => (t.startsWith('tok-') ? t.replace('tok-', '') : null)),
}));
vi.mock('@/lib/immich', () => ({
immich: { getAlbum: vi.fn(), streamAsset: vi.fn(), streamFullsize: vi.fn() },
ImmichUnavailableError: class ImmichUnavailableError extends Error {},
}));
vi.mock('@/lib/rate-limit', () => ({
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
getClientIp: vi.fn(() => '127.0.0.1'),
retryAfterSeconds: vi.fn(() => 60),
}));
vi.mock('@/lib/auth', () => ({
siteLockResponse: vi.fn(() => null),
isAlbumZoomReachable: vi.fn(() => true),
}));
vi.mock('@/lib/publishedAssets', () => ({ isPublishedAsset: vi.fn(async () => true) }));
vi.mock('next/headers', () => ({
cookies: vi.fn(() => Promise.resolve({ get: () => undefined })),
}));
import { GET } from '../route';
import { getConfig } from '@/lib/config';
import { immich, ImmichUnavailableError } from '@/lib/immich';
import { checkRateLimit } from '@/lib/rate-limit';
import { isAlbumZoomReachable, siteLockResponse } from '@/lib/auth';
import { isPublishedAsset } from '@/lib/publishedAssets';
const mocked = <T>(fn: T) => fn as unknown as ReturnType<typeof vi.fn>;
const asset = (id: string, originalMimeType: string, type = 'IMAGE') => ({
id,
type,
originalMimeType,
originalFileName: `${id}.bin`,
});
const ALBUM = {
id: 'album',
albumName: 'Album',
assets: [
asset('jpeg', 'image/jpeg'),
asset('avif', 'image/avif'),
asset('heic', 'image/heic'),
asset('png', 'image/png'),
asset('webp', 'image/webp'),
asset('mov', 'video/quicktime', 'VIDEO'),
{ ...asset('edited', 'image/jpeg'), isEdited: true },
{ ...asset('edited-heic', 'image/heic'), isEdited: true },
],
};
function upstream(data: Uint8Array, contentType: string) {
return { stream: chunked(data, 1000), contentType, contentLength: String(data.length) };
}
const zoom = (assetToken = 'tok-jpeg', albumToken = 'tok-album') =>
GET(new NextRequest(`http://localhost/api/zoom/${albumToken}/${assetToken}`), {
params: Promise.resolve({ album: albumToken, id: assetToken }),
});
/** A HEIF-family file branded as AVIF, as Lightroom writes one. */
function avifFile() {
const { file } = cameraHeic();
const out = file.slice();
out.set([0x61, 0x76, 0x69, 0x66], 8); // major brand → 'avif'
return out;
}
beforeEach(() => {
vi.clearAllMocks();
mocked(getConfig).mockReturnValue({ albums: ['album'] });
mocked(immich.getAlbum).mockResolvedValue(ALBUM);
});
describe('refusals', () => {
it('answers a locked site with the lock, before anything else', async () => {
mocked(siteLockResponse).mockReturnValueOnce(
NextResponse.json(
{ error: 'locked' },
{ status: 401, headers: { 'Cache-Control': 'no-store' } },
),
);
const res = await zoom();
expect(res.status).toBe(401);
expect(immich.getAlbum).not.toHaveBeenCalled();
});
it('rate-limits in its own bucket', async () => {
mocked(checkRateLimit).mockReturnValueOnce({ success: false, resetAt: Date.now() + 1000 });
const res = await zoom();
expect(res.status).toBe(429);
expect(res.headers.get('cache-control')).toBe('no-store');
expect(res.headers.get('retry-after')).toBe('60');
expect(checkRateLimit).toHaveBeenCalledWith('zoom:127.0.0.1', 20);
});
it.each([
['an undecodable album token', () => zoom('tok-jpeg', 'garbage')],
['an undecodable asset token', () => zoom('garbage')],
])('refuses %s', async (_name, call) => {
const res = await call();
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
});
it('refuses an album that is not on the allowlist', async () => {
mocked(getConfig).mockReturnValue({ albums: ['other'] });
expect((await zoom()).status).toBe(404);
expect(isAlbumZoomReachable).not.toHaveBeenCalled();
});
it('refuses when zoom is off, or the album is locked, hidden behind an offline page …', async () => {
// isAlbumZoomReachable covers each of those (lib/__tests__/auth-zoom).
mocked(isAlbumZoomReachable).mockReturnValueOnce(false);
const res = await zoom();
expect(res.status).toBe(404);
expect(isAlbumZoomReachable).toHaveBeenCalledWith('album', expect.any(Function));
expect(immich.streamAsset).not.toHaveBeenCalled();
});
it('refuses an asset the site no longer publishes', async () => {
mocked(isPublishedAsset).mockResolvedValueOnce(false);
expect((await zoom()).status).toBe(404);
expect(immich.getAlbum).not.toHaveBeenCalled();
});
it('refuses an asset from another album', async () => {
expect((await zoom('tok-elsewhere')).status).toBe(404);
expect(immich.streamAsset).not.toHaveBeenCalled();
});
it.each(['png', 'webp', 'mov'])('never streams a %s', async (id) => {
expect((await zoom(`tok-${id}`)).status).toBe(404);
expect(immich.streamAsset).not.toHaveBeenCalled();
expect(immich.streamFullsize).not.toHaveBeenCalled();
});
it('answers 503 while Immich is down', async () => {
mocked(immich.getAlbum).mockRejectedValueOnce(new ImmichUnavailableError('down'));
const res = await zoom();
expect(res.status).toBe(503);
expect(res.headers.get('cache-control')).toBe('no-store');
});
});
describe('the JPEG original', () => {
it('goes out without GPS, inline, privately cached, length intact', async () => {
const { file } = cameraJpeg(true);
mocked(immich.streamAsset).mockResolvedValue(upstream(file, 'image/jpeg'));
const res = await zoom();
expect(res.status).toBe(200);
expect(immich.streamAsset).toHaveBeenCalledWith('jpeg', 'original');
expect(immich.streamFullsize).not.toHaveBeenCalled();
expect(res.headers.get('content-type')).toBe('image/jpeg');
expect(res.headers.get('content-disposition')).toBe('inline');
expect(res.headers.get('x-content-type-options')).toBe('nosniff');
expect(res.headers.get('cache-control')).toBe('private, max-age=3600');
expect(res.headers.get('content-length')).toBe(String(file.length));
const body = new Uint8Array(await res.arrayBuffer());
expect(body.length).toBe(file.length);
const tiff = parseTiff(exifOf(body)!);
expect(tiff.gps).toBeNull();
expect(asciiOf(tiff.ifd0.get(0x0110))).toBe('Canon EOS R5');
expect(contains(body, LAT.value(true))).toBe(false);
expect(latin1(body)).not.toMatch(/exif:GPS/);
});
it('is refused when its metadata cannot be located', async () => {
mocked(immich.streamAsset).mockResolvedValue({
stream: chunked(new Uint8Array([0xff, 0xd8, 0xff, 0xe1, 0x40, 0x00, 0x45, 0x78]), 8),
contentType: 'image/jpeg',
contentLength: '8',
});
expect((await zoom()).status).toBe(404);
});
it('is refused when the bytes are not a JPEG at all', async () => {
// Labelled JPEG, actually a PNG: the scrubber would pass it through
// untouched, eXIf chunk and all.
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
mocked(immich.streamAsset).mockResolvedValue(upstream(png, 'image/jpeg'));
expect((await zoom()).status).toBe(404);
});
});
describe('the AVIF original', () => {
it('goes out without GPS', async () => {
const file = avifFile();
mocked(immich.streamAsset).mockResolvedValue(upstream(file, 'image/avif'));
const res = await zoom('tok-avif');
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/avif');
const body = new Uint8Array(await res.arrayBuffer());
expect(body.length).toBe(file.length);
expect(parseTiff(heicExif(body)!).gps).toBeNull();
});
});
describe('a HEIC (not browser-displayable)', () => {
it('uses Immich’s full-size rendition, scrubbed', async () => {
const { file } = cameraJpeg(false);
mocked(immich.streamFullsize).mockResolvedValue(upstream(file, 'image/jpeg'));
const res = await zoom('tok-heic');
expect(res.status).toBe(200);
expect(immich.streamFullsize).toHaveBeenCalledWith('heic', false);
expect(immich.streamAsset).not.toHaveBeenCalled();
const body = new Uint8Array(await res.arrayBuffer());
expect(parseTiff(exifOf(body)!).gps).toBeNull();
});
it('is 404 when Immich has no rendition', async () => {
mocked(immich.streamFullsize).mockResolvedValue(null);
const res = await zoom('tok-heic');
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
});
it('never sends the HEIC original itself', async () => {
mocked(immich.streamFullsize).mockResolvedValue(upstream(cameraHeic().file, 'image/heic'));
expect((await zoom('tok-heic')).status).toBe(404);
});
it('refuses a WebP rendition, whose metadata is not scrubbed', async () => {
const webp = new TextEncoder().encode('RIFF\x10\x00\x00\x00WEBPVP8 ');
mocked(immich.streamFullsize).mockResolvedValue(upstream(webp, 'image/webp'));
expect((await zoom('tok-heic')).status).toBe(404);
});
});
describe('a photo edited in Immich (#831)', () => {
it.each(['edited', 'edited-heic'])(
'zooms a %s into the edited full-size rendition, never the original',
async (id) => {
// Immich's edited rendition is a JPEG; given one with GPS here, the
// scrubber still has to take it out.
const { file } = cameraJpeg(true);
mocked(immich.streamFullsize).mockResolvedValue(upstream(file, 'image/jpeg'));
const res = await zoom(`tok-${id}`);
expect(res.status).toBe(200);
expect(immich.streamFullsize).toHaveBeenCalledWith(id, true);
expect(immich.streamAsset).not.toHaveBeenCalled();
const body = new Uint8Array(await res.arrayBuffer());
expect(parseTiff(exifOf(body)!).gps).toBeNull();
expect(contains(body, LAT.value(true))).toBe(false);
},
);
it('is 404 when Immich has no edited rendition', async () => {
mocked(immich.streamFullsize).mockResolvedValue(null);
const res = await zoom('tok-edited');
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
});
});

View File

@@ -0,0 +1,181 @@
/**
* The full-resolution image behind the lightbox zoom (#467).
*
* Shaped like the download route, and for the same reason: this hands out a
* file at full resolution, so it re-checks everything instead of treating the
* asset token as the capability the image proxy does.
*
* - the album must be on the allowlist,
* - the visitor must be able to reach it by a route on which zoom resolves
* on — album, then subpage, then `zoom` in settings.yaml — with every
* password gate on that route satisfied,
* - the asset must currently be published and belong to that album, and
* - it must have a zoomable source (lib/zoomSource.ts).
*
* What it sends:
*
* - JPEG and AVIF originals as they are stored, minus their location: GPS and
* sub-city place names are removed in place on the way through
* (lib/locationScrub.ts), so the length still holds.
* - For formats a browser cannot show (HEIC, RAW, TIFF, …), Immich's
* full-size JPEG rendition, through the same scrubber. When Immich has
* none — full-size previews switched off, or the asset not yet processed —
* the answer is 404 and the lightbox stays on the preview.
* - For a photo edited in Immich, whatever its format, the edited full-size
* rendition (#831), through the same scrubber, so the zoom shows the crop
* and rotation the preview shows.
*
* Whatever the source, only a file the scrubber recognised and cleaned goes
* out, and only as JPEG or AVIF. A format it passes through untouched (a PNG
* labelled as JPEG, a WebP rendition) is refused rather than sent with
* whatever metadata it carries.
*/
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { immich, ImmichUnavailableError } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { isAlbumZoomReachable, siteLockResponse } from '@/lib/auth';
import { isPublishedAsset } from '@/lib/publishedAssets';
import { scrubLocationStream } from '@/lib/locationScrub';
import { ZOOM_CONTENT_TYPES, zoomSourceFor } from '@/lib/zoomSource';
export const dynamic = 'force-dynamic';
/**
* Per client IP and minute. A zoom is one deliberate gesture per photo, and
* each answer is a full-resolution file off the Immich server — 7 MB for a
* 60 MP JPEG. Twenty a minute is a zoom every three seconds, sustained, which
* a visitor checking focus does not reach; a script pulling originals does.
* Below the download route's 30: a download is rarer still, but zooming is
* offered on whole albums that never opted into downloads.
*/
const ZOOM_RPM = 20;
const NO_STORE = { 'Cache-Control': 'no-store' };
/** One 404 for every refusal, so the response never says which check failed. */
const notFound = () =>
NextResponse.json({ error: 'Not found' }, { status: 404, headers: NO_STORE });
const unavailable = () =>
NextResponse.json(
{ error: 'Immich is currently unavailable' },
{ status: 503, headers: { ...NO_STORE, 'Retry-After': '30' } },
);
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ album: string; id: string }> },
) {
const ip = getClientIp(request);
const rl = checkRateLimit(`zoom:${ip}`, ZOOM_RPM);
if (!rl.success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: { ...NO_STORE, 'Retry-After': String(retryAfterSeconds(rl.resetAt)) },
},
);
}
const locked = siteLockResponse(request);
if (locked) return locked;
const { album: albumToken, id: assetToken } = await params;
const albumId = decodeAssetId(albumToken);
const assetId = decodeAssetId(assetToken);
if (!albumId || !assetId) return notFound();
const config = getConfig();
if (!config.albums.includes(albumId)) return notFound();
// Zoom on, and a route to the album the visitor could have taken. A route
// handler is reached without passing through the page, so the page's gates
// are asked again here.
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
if (!isAlbumZoomReachable(albumId, getCookie)) return notFound();
// Same membership test as the image proxy: an album taken offline or an
// asset removed from it stops being served at once, not after the cache.
if (!(await isPublishedAsset(assetId))) return notFound();
let album;
try {
album = await immich.getAlbum(albumId);
} catch (error) {
if (error instanceof ImmichUnavailableError) return unavailable();
throw error;
}
if (!album) return notFound();
// The asset has to be in the album that authorised the zoom.
const asset = album.assets.find((candidate) => candidate.id === assetId);
if (!asset) return notFound();
const source = zoomSourceFor(asset);
if (!source) return notFound();
let result;
try {
// An edited photo (#831) is zoomed into Immich's edited full-size
// rendition, never its original, which is the unedited file. Redirects
// are not followed here either: should Immich send one, it would be to a
// smaller rendition, which must not pass for full resolution.
result =
source === 'original'
? await immich.streamAsset(assetId, 'original')
: await immich.streamFullsize(assetId, source === 'edited');
} catch (error) {
if (error instanceof ImmichUnavailableError) return unavailable();
throw error;
}
// For `fullsize` this is the common "Immich has no rendition" case.
if (!result) return notFound();
const contentType = result.contentType.toLowerCase().split(';')[0].trim();
if (!ZOOM_CONTENT_TYPES.has(contentType)) {
await (result.stream as ReadableStream).cancel().catch(() => {});
console.warn(`[Zoom] Refused ${assetId}: Immich sent ${contentType || 'no type'}.`);
return notFound();
}
let scrubbed;
try {
scrubbed = await scrubLocationStream(result.stream as ReadableStream<Uint8Array>);
} catch (error) {
console.error(`[Zoom] Reading ${source} ${assetId} failed:`, error);
return unavailable();
}
if (!scrubbed.ok) {
console.warn(
`[Zoom] Refused ${assetId}: its location metadata could not be removed (${scrubbed.reason}).`,
);
return notFound();
}
if (scrubbed.format === 'passthrough') {
// Labelled JPEG/AVIF, but not a file the scrubber knows how to clean.
await scrubbed.stream.cancel().catch(() => {});
console.warn(`[Zoom] Refused ${assetId}: not a JPEG or HEIF-family file.`);
return notFound();
}
const headers: Record<string, string> = {
'Content-Type': contentType,
// Shown in the page, never saved by a click.
'Content-Disposition': 'inline',
// Private: the answer depends on this visitor's cookies, so a shared cache
// (a CDN, a proxy) must not hand it to the next one. An hour in the
// visitor's own cache makes a second zoom into the same photo free.
'Cache-Control': 'private, max-age=3600',
'X-Content-Type-Options': 'nosniff',
};
// Scrubbing overwrites in place, so the upstream length still holds.
if (result.contentLength) headers['Content-Length'] = result.contentLength;
return new NextResponse(scrubbed.stream, { headers });
}