release: v0.17.0 (#677)
See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
@@ -81,6 +81,14 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
|
||||
name: 'GET /api/og',
|
||||
call: async () => (await import('../og/route')).GET(request('/api/og?title=x') as never),
|
||||
},
|
||||
{
|
||||
name: 'GET /api/download/[album]/archive',
|
||||
call: async () =>
|
||||
(await import('../download/[album]/archive/route')).GET(
|
||||
request('/api/download/tok/archive') as never,
|
||||
{ params: Promise.resolve({ album: 'tok' }) } as never,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
describe('public content routes behind a locked site', () => {
|
||||
|
||||
61
app/api/admin/__tests__/auth-cookie.test.ts
Normal file
61
app/api/admin/__tests__/auth-cookie.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* The admin session cookie is `Secure` only when the visitor came over HTTPS
|
||||
* (#664). Keyed on NODE_ENV it was always `Secure` in the Docker image, so an
|
||||
* instance opened at `http://host:7211` logged in with a 200 and then lost the
|
||||
* cookie — every admin request after that was a 401.
|
||||
*/
|
||||
|
||||
vi.mock('@/lib/admin/auth', () => ({
|
||||
verifyAdminPassword: vi.fn(async () => true),
|
||||
createAdminToken: vi.fn(() => 'session-token'),
|
||||
isAdminAuthenticated: vi.fn(async () => true),
|
||||
isAdminEnabled: vi.fn(() => true),
|
||||
COOKIE_NAME: 'folio_admin_session',
|
||||
SESSION_DURATION_MS: 24 * 60 * 60 * 1000,
|
||||
MAX_PASSWORD_LENGTH: 1024,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
|
||||
getClientIp: vi.fn(() => '127.0.0.1'),
|
||||
retryAfterSeconds: vi.fn(() => 60),
|
||||
}));
|
||||
|
||||
import { POST, DELETE } from '../auth/route';
|
||||
|
||||
function login(url: string, headers: Record<string, string> = {}) {
|
||||
return new NextRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ password: 'hunter2' }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('admin session cookie', () => {
|
||||
it('is not Secure when the admin is reached over plain HTTP', async () => {
|
||||
const res = await POST(login('http://192.168.1.10:7211/api/admin/auth'));
|
||||
expect(res.status).toBe(200);
|
||||
const cookie = res.headers.get('set-cookie') ?? '';
|
||||
expect(cookie).toContain('folio_admin_session=session-token');
|
||||
expect(cookie.toLowerCase()).not.toContain('secure');
|
||||
});
|
||||
|
||||
it('is Secure behind an HTTPS reverse proxy', async () => {
|
||||
const res = await POST(
|
||||
login('http://folio:3000/api/admin/auth', { 'x-forwarded-proto': 'https' }),
|
||||
);
|
||||
expect((res.headers.get('set-cookie') ?? '').toLowerCase()).toContain('secure');
|
||||
});
|
||||
|
||||
it('clears the cookie with the same flag it was set with', async () => {
|
||||
const res = await DELETE(
|
||||
new NextRequest('http://192.168.1.10:7211/api/admin/auth', { method: 'DELETE' }),
|
||||
);
|
||||
const cookie = res.headers.get('set-cookie') ?? '';
|
||||
expect(cookie).toContain('folio_admin_session=');
|
||||
expect(cookie.toLowerCase()).not.toContain('secure');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MAX_PASSWORD_LENGTH,
|
||||
} from '@/lib/admin/auth';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
import { isHttpsRequest } from '@/lib/auth';
|
||||
|
||||
/** Admin login attempts per minute per IP — the highest-value credential in the app. */
|
||||
const ADMIN_AUTH_RPM = 5;
|
||||
@@ -53,7 +54,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
response.cookies.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: isHttpsRequest(request),
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: SESSION_DURATION_MS / 1000,
|
||||
@@ -73,11 +74,11 @@ export async function GET() {
|
||||
}
|
||||
|
||||
/** DELETE: Logout. */
|
||||
export async function DELETE() {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.set(COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: isHttpsRequest(request),
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
|
||||
@@ -107,7 +107,10 @@ export const GET = withAdmin(async (request: NextRequest) => {
|
||||
// it), which is only known once Immich has answered (#632).
|
||||
const slugGroups: AlbumSlugGroup[] = [
|
||||
{ context: 'gallery.yaml albums', albumIds: config.standaloneAlbums },
|
||||
...config.subpages.map((sp) => ({ context: `subpage "${sp.name}"`, albumIds: sp.albumIds })),
|
||||
...config.subpages.map((sp) => ({
|
||||
context: `subpage "${sp.name}"`,
|
||||
albumIds: sp.albumIds,
|
||||
})),
|
||||
];
|
||||
findings.push(checkAlbumSlugCollisions(slugGroups, config.albumOverrides, albums, slugify));
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ export async function POST(req: NextRequest) {
|
||||
{ error: 'Too many requests' },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(retryAfterSeconds(resetAt)), 'Cache-Control': 'no-store' },
|
||||
headers: {
|
||||
'Retry-After': String(retryAfterSeconds(resetAt)),
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -111,7 +114,10 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// An already-tracked path may still increment past the cap; only a
|
||||
// brand-new key is refused once the day is full.
|
||||
if (dayObj.pages[pagePath] !== undefined || Object.keys(dayObj.pages).length < MAX_PAGES_PER_DAY) {
|
||||
if (
|
||||
dayObj.pages[pagePath] !== undefined ||
|
||||
Object.keys(dayObj.pages).length < MAX_PAGES_PER_DAY
|
||||
) {
|
||||
dayObj.pages[pagePath] = (dayObj.pages[pagePath] || 0) + 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticate, isProtected, type ProtectedType } from '@/lib/auth';
|
||||
import { authenticate, isHttpsRequest, isProtected, type ProtectedType } from '@/lib/auth';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
|
||||
/** Tight limit for auth attempts — 10 per minute per IP. */
|
||||
@@ -71,7 +71,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const setCookie = await authenticate(slug, password, type);
|
||||
const setCookie = await authenticate(slug, password, type, isHttpsRequest(request));
|
||||
if (!setCookie) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
|
||||
313
app/api/download/[album]/archive/__tests__/route.test.ts
Normal file
313
app/api/download/[album]/archive/__tests__/route.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* The archive route streams originals as a ZIP for either the whole album
|
||||
* (GET) or a proofing selection (POST). It shares the single-asset download's
|
||||
* checks — allowlist, `download: true`, password gates, asset membership — and
|
||||
* adds one of its own: a selected token must decode to an asset that actually
|
||||
* belongs to the album it names.
|
||||
*/
|
||||
|
||||
vi.mock('@/lib/config', () => ({ getConfig: vi.fn(), getConfigOrNull: vi.fn() }));
|
||||
vi.mock('@/lib/tokens', () => ({ decodeAssetId: vi.fn() }));
|
||||
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, POST } from '../route';
|
||||
import { getConfig, getConfigOrNull } from '@/lib/config';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { immich } from '@/lib/immich';
|
||||
|
||||
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockConfigOrNull = getConfigOrNull as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockDecode = decodeAssetId 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-uuid',
|
||||
albumName: 'Test Album',
|
||||
assets: [
|
||||
{ id: 'asset-1', type: 'IMAGE', originalFileName: 'photo-1.jpg' },
|
||||
{ id: 'asset-2', type: 'IMAGE', originalFileName: 'photo-2.jpg' },
|
||||
],
|
||||
};
|
||||
|
||||
/** A config where the album is on the allowlist and opted into downloads. */
|
||||
function optedIn() {
|
||||
return { albums: ['album-uuid'], albumDownloads: { 'album-uuid': true } };
|
||||
}
|
||||
|
||||
/** Decode album/asset tokens into the UUIDs under test; unknown → null. */
|
||||
function decodeMap() {
|
||||
mockDecode.mockImplementation((token: string) => {
|
||||
if (token === 'album-token') return 'album-uuid';
|
||||
if (token === 'asset-token-1') return 'asset-1';
|
||||
if (token === 'asset-token-2') return 'asset-2';
|
||||
if (token === 'asset-token-foreign') return 'asset-foreign';
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/** A tiny originals stream, standing in for Immich's response body. */
|
||||
function originStream(bytes: string) {
|
||||
const data = new TextEncoder().encode(bytes);
|
||||
return {
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(data);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
contentType: 'image/jpeg',
|
||||
contentLength: null,
|
||||
};
|
||||
}
|
||||
|
||||
const getReq = () => new NextRequest('http://localhost/api/download/album-token/archive');
|
||||
const params = { params: Promise.resolve({ album: 'album-token' }) };
|
||||
|
||||
function postReq(tokens: unknown[]) {
|
||||
return new NextRequest('http://localhost/api/download/album-token/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ assets: tokens }),
|
||||
});
|
||||
}
|
||||
|
||||
/** The form POST the proofing modal makes — one `assets` field per token. */
|
||||
function formReq(tokens: string[]) {
|
||||
const body = tokens.map((t) => `assets=${encodeURIComponent(t)}`).join('&');
|
||||
return new NextRequest('http://localhost/api/download/album-token/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
const browsesHtml = (referer?: string) =>
|
||||
new NextRequest('http://localhost/api/download/album-token/archive', {
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml',
|
||||
...(referer ? { Referer: referer } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
decodeMap();
|
||||
mockConfig.mockReturnValue(optedIn());
|
||||
mockConfigOrNull.mockReturnValue(null);
|
||||
mockGetAlbum.mockResolvedValue(ALBUM);
|
||||
// A fresh stream per call — a real Immich fetch never hands back the same
|
||||
// body twice, and a shared web stream would be locked by the first consumer.
|
||||
mockStream.mockImplementation(() => Promise.resolve(originStream('image-bytes')));
|
||||
});
|
||||
|
||||
describe('GET /api/download/[album]/archive', () => {
|
||||
it('streams a ZIP of the whole album when opted in', async () => {
|
||||
const res = await GET(getReq(), params);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/zip');
|
||||
expect(res.headers.get('content-disposition')).toContain('attachment');
|
||||
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
expect(bytes[0]).toBe(0x50); // 'P'
|
||||
expect(bytes[1]).toBe(0x4b); // 'K'
|
||||
});
|
||||
|
||||
it('refuses an album that has not opted into downloads', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const res = await GET(getReq(), params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses an album that is not on the allowlist', async () => {
|
||||
mockConfig.mockReturnValue({ albums: [], albumDownloads: { 'album-uuid': true } });
|
||||
const res = await GET(getReq(), params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses a token that does not decode', async () => {
|
||||
mockDecode.mockImplementation(() => null);
|
||||
const res = await GET(getReq(), params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/download/[album]/archive', () => {
|
||||
it('streams only the selected assets', async () => {
|
||||
mockStream
|
||||
.mockResolvedValueOnce(originStream('one'))
|
||||
.mockResolvedValueOnce(originStream('two'));
|
||||
const res = await POST(postReq(['asset-token-1', 'asset-token-2']), params);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/zip');
|
||||
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
expect(bytes[0]).toBe(0x50);
|
||||
expect(bytes[1]).toBe(0x4b);
|
||||
expect(mockStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('refuses a token that decodes to an asset outside the album', async () => {
|
||||
const res = await POST(postReq(['asset-token-foreign']), params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses an empty selection', async () => {
|
||||
const res = await POST(postReq([]), params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses a malformed body', async () => {
|
||||
const req = new NextRequest('http://localhost/api/download/album-token/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: 'not json',
|
||||
});
|
||||
const res = await POST(req, params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('accepts the form body the proofing modal posts', async () => {
|
||||
const res = await POST(formReq(['asset-token-1', 'asset-token-2']), params);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/zip');
|
||||
// Draining the body drives the background streaming loop to completion.
|
||||
await res.arrayBuffer();
|
||||
expect(mockStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* `byId.get()` is a lookup, not a consume, so without a `seen` set the same
|
||||
* token could be appended once per occurrence — a 2 GB video repeated 800
|
||||
* times is 1.6 TB off the Immich server.
|
||||
*/
|
||||
it('appends a repeated token only once', async () => {
|
||||
const res = await POST(
|
||||
postReq(['asset-token-1', 'asset-token-1', 'asset-token-1', 'asset-token-1']),
|
||||
params,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
await res.arrayBuffer();
|
||||
expect(mockStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refuses a selection past the absolute ceiling', async () => {
|
||||
const assets = Array.from({ length: 1001 }, (_, i) => ({
|
||||
id: `asset-${i}`,
|
||||
type: 'IMAGE',
|
||||
originalFileName: `photo-${i}.jpg`,
|
||||
}));
|
||||
mockGetAlbum.mockResolvedValue({ ...ALBUM, assets });
|
||||
mockDecode.mockImplementation((token: string) =>
|
||||
token === 'album-token' ? 'album-uuid' : token.replace('token-', ''),
|
||||
);
|
||||
const tokens = assets.map((a) => `token-${a.id}`);
|
||||
|
||||
const res = await POST(postReq(tokens), params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses a body past the size cap', async () => {
|
||||
const big = JSON.stringify({ assets: Array(30000).fill('asset-token-1') });
|
||||
const req = new NextRequest('http://localhost/api/download/album-token/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: big,
|
||||
});
|
||||
const res = await POST(req, params);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refusals', () => {
|
||||
it('renders HTML for a browser navigation instead of raw JSON', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const res = await GET(browsesHtml(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.get('content-type')).toContain('text/html');
|
||||
});
|
||||
|
||||
it("speaks the site's language", async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
mockConfigOrNull.mockReturnValue({ lang: 'de' });
|
||||
const html = await (await GET(browsesHtml(), params)).text();
|
||||
expect(html).toContain('<html lang="de">');
|
||||
expect(html).toContain('Download nicht möglich');
|
||||
expect(html).toContain('Zurück zur Galerie');
|
||||
});
|
||||
|
||||
it('links back to the page the download started from', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const html = await (
|
||||
await GET(browsesHtml('http://localhost/deutschland/kloster-chorin?fav=abc'), params)
|
||||
).text();
|
||||
expect(html).toContain('href="/deutschland/kloster-chorin?fav=abc"');
|
||||
});
|
||||
|
||||
it('does not follow a foreign referer', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const html = await (await GET(browsesHtml('https://evil.example/phish'), params)).text();
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('keeps JSON for an API caller', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const res = await GET(getReq(), params);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* An original that is still arriving when the visitor gives up must be let go:
|
||||
* the loop stopping is not enough, because the upstream body keeps its socket
|
||||
* out of undici's pool until something cancels it (#635).
|
||||
*/
|
||||
describe('abort', () => {
|
||||
/** An original that sends one chunk and then never finishes. */
|
||||
function stallingStream(onCancel: () => void) {
|
||||
return {
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('first-chunk'));
|
||||
},
|
||||
cancel: onCancel,
|
||||
}),
|
||||
contentType: 'image/jpeg',
|
||||
contentLength: null,
|
||||
};
|
||||
}
|
||||
|
||||
it('cancels the original in flight when the download is aborted', async () => {
|
||||
const cancelled = vi.fn();
|
||||
mockStream.mockImplementation(() => Promise.resolve(stallingStream(cancelled)));
|
||||
|
||||
const res = await GET(getReq(), params);
|
||||
const reader = res.body!.getReader();
|
||||
await reader.read(); // the entry is under way
|
||||
await reader.cancel();
|
||||
|
||||
await vi.waitFor(() => expect(cancelled).toHaveBeenCalled());
|
||||
// The loop stopped rather than moving on to the next original.
|
||||
expect(mockStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
422
app/api/download/[album]/archive/route.ts
Normal file
422
app/api/download/[album]/archive/route.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Archive download route — streams an album, or a selection of its assets, as
|
||||
* a ZIP of the originals.
|
||||
*
|
||||
* Sits beside the single-asset download and re-checks the exact same things:
|
||||
*
|
||||
* - the album must be on the allowlist,
|
||||
* - the album must have opted in with `download: true`,
|
||||
* - every password gate on a route to it must be satisfied, and
|
||||
* - every asset requested must actually belong to that album.
|
||||
*
|
||||
* A `GET` returns the whole album. A `POST` returns a selection, accepting
|
||||
* either `application/json` (`{ "assets": [<token>, …] }`) or the `assets=…`
|
||||
* form body the proofing modal posts — the form matters because the browser
|
||||
* then streams the response straight to disk instead of buffering the whole ZIP
|
||||
* in a blob.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Readable } from 'node:stream';
|
||||
import archiver from 'archiver';
|
||||
import { cookies } from 'next/headers';
|
||||
import { immich, ImmichUnavailableError, type ImmichAsset } from '@/lib/immich';
|
||||
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, safeDownloadName } from '@/lib/downloadName';
|
||||
import { getDictionary } from '@/lib/i18n';
|
||||
import { getLocale, getServerDictionary } from '@/lib/i18n/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Far below even the single-asset limit: one request may pull down an entire
|
||||
* album of originals, so it must be a deliberate, rare action per visitor.
|
||||
*/
|
||||
const ARCHIVE_RPM = 5;
|
||||
|
||||
/**
|
||||
* Absolute ceiling on one selection, independent of album size. Together with
|
||||
* the token de-duplication in POST it bounds the upstream egress a single
|
||||
* request can ask for: without it, one 2 GB video's token repeated a thousand
|
||||
* times would stream 2 TB out of Immich.
|
||||
*/
|
||||
const MAX_SELECTION_ASSETS = 1000;
|
||||
|
||||
/**
|
||||
* Cap on the POST body, enforced while reading it rather than after. The token
|
||||
* list is the only payload a POST carries, so 256 KB comfortably fits the
|
||||
* ceiling above and a short body can never become a large parse.
|
||||
*/
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
/** Escape a value interpolated into the refusal page. */
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) =>
|
||||
char === '&'
|
||||
? '&'
|
||||
: char === '<'
|
||||
? '<'
|
||||
: char === '>'
|
||||
? '>'
|
||||
: char === '"'
|
||||
? '"'
|
||||
: ''',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal, rendered for whoever asked.
|
||||
*
|
||||
* A download begins as a navigation — the header link, or the proofing modal's
|
||||
* form POST — and only a success carries `Content-Disposition: attachment`. A
|
||||
* bare JSON body would therefore replace the gallery with
|
||||
* `{"error":"Too many requests"}` on a rate limit, which is the likely failure
|
||||
* (5 rpm, and the ZIP shows nothing until the first byte, so people click
|
||||
* again). Browsers get a page with a way back; callers asking for JSON keep it.
|
||||
*
|
||||
* The page speaks the site's language; the JSON stays English, like every other
|
||||
* API error.
|
||||
*/
|
||||
function refusal(
|
||||
request: NextRequest,
|
||||
status: number,
|
||||
reason: RefusalReason,
|
||||
retryAfter?: number,
|
||||
): NextResponse {
|
||||
const headers: Record<string, string> = { 'Cache-Control': 'no-store' };
|
||||
if (retryAfter) headers['Retry-After'] = String(retryAfter);
|
||||
|
||||
if (!(request.headers.get('accept') ?? '').includes('text/html')) {
|
||||
return NextResponse.json({ error: getDictionary('en').download[reason] }, { status, headers });
|
||||
}
|
||||
|
||||
const t = getServerDictionary().download;
|
||||
headers['Content-Type'] = 'text/html; charset=utf-8';
|
||||
return new NextResponse(
|
||||
`<!doctype html><html lang="${getLocale()}"><head><meta charset="utf-8">` +
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">' +
|
||||
`<title>${escapeHtml(t.unavailableTitle)}</title></head>` +
|
||||
'<body style="font-family:system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1.5rem;line-height:1.6">' +
|
||||
`<h1 style="font-size:1.25rem">${escapeHtml(t.unavailableTitle)}</h1>` +
|
||||
`<p>${escapeHtml(t[reason])}</p>` +
|
||||
`<p><a href="${escapeHtml(backHref(request))}">${escapeHtml(t.back)}</a></p>` +
|
||||
'</body></html>',
|
||||
{ status, headers },
|
||||
);
|
||||
}
|
||||
|
||||
type RefusalReason = 'notAvailable' | 'rateLimited' | 'immichUnavailable';
|
||||
|
||||
/**
|
||||
* Where the refusal page's link leads: the page the download was started from,
|
||||
* so a visitor lands back on the album rather than the home page. Only a
|
||||
* same-origin `Referer` is followed — anything else would turn this page into an
|
||||
* open redirect with our name on it.
|
||||
*/
|
||||
function backHref(request: NextRequest): string {
|
||||
const referer = request.headers.get('referer');
|
||||
if (!referer) return '/';
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
if (url.origin !== request.nextUrl.origin) return '/';
|
||||
return `${url.pathname}${url.search}`;
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A unique, ZIP-safe entry name. Originals can collide ("IMG_0001.jpg" from two
|
||||
* cards), and a ZIP with duplicate entry names is ambiguous to unzip.
|
||||
*/
|
||||
function uniqueEntryName(raw: string | undefined, used: Set<string>): string {
|
||||
const base = safeDownloadName(raw);
|
||||
let name = base;
|
||||
let n = 2;
|
||||
while (used.has(name)) {
|
||||
const dot = base.lastIndexOf('.');
|
||||
name = dot > 0 ? `${base.slice(0, dot)}-${n}${base.slice(dot)}` : `${base}-${n}`;
|
||||
n++;
|
||||
}
|
||||
used.add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one entry, resolving once it has been written (or the archive has been
|
||||
* torn down because the visitor left).
|
||||
*
|
||||
* archiver processes entries strictly one at a time — that is what keeps memory
|
||||
* flat — but `append()` only *queues* them. Firing every `append` up front (and
|
||||
* so opening every upstream request at once) is what made a large album's later
|
||||
* originals sit long enough to time out and truncate the ZIP. Awaiting the
|
||||
* `entry` event holds the loop until the previous file has finished, so only
|
||||
* one original is ever in flight. `close` resolves too, so an aborted download
|
||||
* stops the loop rather than hanging on a file nothing will read.
|
||||
*
|
||||
* On `close` and `error` the source is destroyed as well. archiver does not do
|
||||
* that for a queued or half-read entry, and the upstream body would otherwise
|
||||
* hold its socket out of undici's pool until GC finalises it (#635).
|
||||
*/
|
||||
function appendEntry(archive: archiver.Archiver, source: Readable, name: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
archive.off('entry', onEntry);
|
||||
archive.off('error', onError);
|
||||
archive.off('close', onClose);
|
||||
};
|
||||
const onEntry = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
// `close` fires when the response is consumed or aborted. Resolving on it
|
||||
// lets the loop see `archive.destroyed` and stop, rather than hanging on a
|
||||
// file nothing will read.
|
||||
const onClose = () => {
|
||||
cleanup();
|
||||
source.destroy();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
source.destroy();
|
||||
reject(error);
|
||||
};
|
||||
archive.once('entry', onEntry);
|
||||
archive.once('error', onError);
|
||||
archive.once('close', onClose);
|
||||
archive.append(source, { name });
|
||||
});
|
||||
}
|
||||
|
||||
/** A refusal to short-circuit with, or a decoded album ready to stream. */
|
||||
type ResolvedAlbum = { error: Response } | { albumName: string; assets: ImmichAsset[] };
|
||||
|
||||
/**
|
||||
* The shared authorisation preamble for both verbs: rate limit, site lock, and
|
||||
* every check the single-asset download performs before streaming a byte.
|
||||
*/
|
||||
async function resolveAlbum(
|
||||
request: NextRequest,
|
||||
params: { album: string },
|
||||
): Promise<ResolvedAlbum> {
|
||||
const ip = getClientIp(request);
|
||||
// Its own bucket, not the single-asset `download:` one: the two share a
|
||||
// client but enforce different limits (5 vs 30 rpm), and letting a handful of
|
||||
// single-file downloads spend the whole-album budget would be wrong.
|
||||
const rl = checkRateLimit(`archive:${ip}`, ARCHIVE_RPM);
|
||||
if (!rl.success) {
|
||||
return {
|
||||
error: refusal(request, 429, 'rateLimited', retryAfterSeconds(rl.resetAt)),
|
||||
};
|
||||
}
|
||||
|
||||
const locked = siteLockResponse(request);
|
||||
if (locked) return { error: locked };
|
||||
|
||||
const albumId = decodeAssetId(params.album);
|
||||
if (!albumId) return { error: refusal(request, 404, 'notAvailable') };
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.albums.includes(albumId)) return { error: refusal(request, 404, 'notAvailable') };
|
||||
if (!config.albumDownloads[albumId]) return { error: refusal(request, 404, 'notAvailable') };
|
||||
|
||||
// Every gate on every route to the album — see the single-asset route.
|
||||
const cookieStore = await cookies();
|
||||
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
||||
if (!isAlbumReachable(albumId, getCookie)) {
|
||||
return { error: refusal(request, 404, 'notAvailable') };
|
||||
}
|
||||
|
||||
let album;
|
||||
try {
|
||||
album = await immich.getAlbum(albumId);
|
||||
} catch (error) {
|
||||
if (error instanceof ImmichUnavailableError) {
|
||||
return {
|
||||
error: refusal(request, 503, 'immichUnavailable', 30),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!album) return { error: refusal(request, 404, 'notAvailable') };
|
||||
|
||||
const assets = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO');
|
||||
return { albumName: album.albumName, assets };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `assets` as a ZIP of originals.
|
||||
*
|
||||
* archiver writes data descriptors, so entry sizes are never known up front and
|
||||
* memory stays flat no matter how large the album is. The loop pulls one
|
||||
* original at a time and stops as soon as the response is gone.
|
||||
*/
|
||||
function streamArchive(albumName: string, assets: ImmichAsset[]): NextResponse {
|
||||
const archive = archiver('zip', { store: true });
|
||||
archive.on('error', (err) => {
|
||||
// A visitor cancelling the download is not a failure worth a log line.
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error(`[Download] Archive stream failed:`, err);
|
||||
});
|
||||
|
||||
const body = Readable.toWeb(archive) as unknown as ReadableStream;
|
||||
|
||||
// Fill the archive in the background: the response has to go out first so the
|
||||
// browser starts reading, and each originals fetch is awaited in turn.
|
||||
void (async () => {
|
||||
try {
|
||||
const used = new Set<string>();
|
||||
for (const asset of assets) {
|
||||
// The visitor left (or the archive failed): stop pulling originals.
|
||||
if (archive.destroyed) break;
|
||||
const result = await immich.streamAsset(asset.id, 'original');
|
||||
if (!result) continue;
|
||||
if (archive.destroyed) {
|
||||
// Left while the headers were on their way: release the body unread.
|
||||
await result.stream.cancel();
|
||||
break;
|
||||
}
|
||||
const nodeStream = Readable.fromWeb(
|
||||
result.stream as unknown as import('node:stream/web').ReadableStream,
|
||||
);
|
||||
await appendEntry(archive, nodeStream, uniqueEntryName(asset.originalFileName, used));
|
||||
}
|
||||
if (!archive.destroyed) await archive.finalize();
|
||||
} catch (err) {
|
||||
archive.destroy(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
})();
|
||||
|
||||
const zipName = `${safeDownloadName(albumName, 'album')}.zip`;
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': contentDisposition(zipName),
|
||||
// Private: authorised per visitor, so a shared cache must not hand the
|
||||
// archive to the next one.
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the POST body with a hard cap, then pull the token list out of it.
|
||||
*
|
||||
* Read by hand rather than through `request.json()`: that helper parses the
|
||||
* whole body before anything can object, which is the one place an unbounded
|
||||
* payload could land. `Content-Length` is checked first (cheap), and the read
|
||||
* itself stops the moment the cap is passed (so a chunked body is capped too).
|
||||
*/
|
||||
async function readSelectedTokens(request: NextRequest): Promise<string[] | null> {
|
||||
const declared = Number(request.headers.get('content-length') ?? '');
|
||||
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return null;
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
const raw = await readBodyCapped(request, MAX_BODY_BYTES);
|
||||
if (raw === null) return null;
|
||||
text = raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { assets?: unknown };
|
||||
if (!Array.isArray(parsed?.assets)) return null;
|
||||
return parsed.assets.filter((token): token is string => typeof token === 'string');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The proofing modal's form POST: one `assets` field per token.
|
||||
const form = new URLSearchParams(text);
|
||||
const tokens = form.getAll('assets').filter((token) => token.length > 0);
|
||||
return tokens.length > 0 ? tokens : null;
|
||||
}
|
||||
|
||||
/** Read a request body as text, aborting once it exceeds `limit` bytes. */
|
||||
async function readBodyCapped(request: NextRequest, limit: number): Promise<string | null> {
|
||||
const stream = request.body;
|
||||
if (!stream) return '';
|
||||
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = '';
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > limit) {
|
||||
await reader.cancel();
|
||||
return null;
|
||||
}
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ album: string }> },
|
||||
) {
|
||||
const { album } = await params;
|
||||
const resolved = await resolveAlbum(request, { album });
|
||||
if ('error' in resolved) return resolved.error;
|
||||
return streamArchive(resolved.albumName, resolved.assets);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ album: string }> },
|
||||
) {
|
||||
const { album } = await params;
|
||||
const resolved = await resolveAlbum(request, { album });
|
||||
if ('error' in resolved) return resolved.error;
|
||||
|
||||
const tokens = await readSelectedTokens(request);
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return refusal(request, 404, 'notAvailable');
|
||||
}
|
||||
|
||||
// Every token must decode to an asset that really belongs to the album; a
|
||||
// token is unforgeable (AES-GCM), but membership is the check that keeps one
|
||||
// album's archive from naming another album's assets.
|
||||
//
|
||||
// The `seen` set matters as much as the membership check: a token is only a
|
||||
// lookup, not a consume, so the same asset could otherwise be appended once
|
||||
// per occurrence. With the absolute ceiling that bounds the whole request.
|
||||
const byId = new Map(resolved.assets.map((a) => [a.id, a]));
|
||||
const seen = new Set<string>();
|
||||
const selected: ImmichAsset[] = [];
|
||||
for (const token of tokens) {
|
||||
if (selected.length >= MAX_SELECTION_ASSETS) {
|
||||
return refusal(request, 404, 'notAvailable');
|
||||
}
|
||||
const assetId = decodeAssetId(token);
|
||||
if (!assetId) return refusal(request, 404, 'notAvailable');
|
||||
if (seen.has(assetId)) continue;
|
||||
const asset = byId.get(assetId);
|
||||
if (!asset) return refusal(request, 404, 'notAvailable');
|
||||
seen.add(assetId);
|
||||
selected.push(asset);
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
return refusal(request, 404, 'notAvailable');
|
||||
}
|
||||
|
||||
return streamArchive(resolved.albumName, selected);
|
||||
}
|
||||
Reference in New Issue
Block a user