release: v0.20.0
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
102
app/api/admin/__tests__/doctor-zoom.test.ts
Normal file
102
app/api/admin/__tests__/doctor-zoom.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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, '-');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '-');
|
||||
|
||||
@@ -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) } },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
185
app/api/download/[album]/[id]/__tests__/route.test.ts
Normal file
185
app/api/download/[album]/[id]/__tests__/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]) : [];
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
279
app/api/zoom/[album]/[id]/__tests__/route.test.ts
Normal file
279
app/api/zoom/[album]/[id]/__tests__/route.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
181
app/api/zoom/[album]/[id]/route.ts
Normal file
181
app/api/zoom/[album]/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user