Files
immich-folio/app/api/download/[album]/archive/route.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

208 lines
7.6 KiB
TypeScript

/**
* 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 } from 'next/server';
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 { refusal, withArchiveSlot, type ArchiveStreamer } from '@/lib/zipArchive';
import { readBodyCapped } from '@/lib/requestBody';
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;
/** 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 };
}
/**
* 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;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ album: string }> },
) {
const { album } = await params;
return withArchiveSlot(request, async (stream) => {
const resolved = await resolveAlbum(request, { album });
if ('error' in resolved) return resolved.error;
return stream(resolved.albumName, resolved.assets);
});
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ album: string }> },
) {
const { album } = await params;
return withArchiveSlot(request, (stream) => streamSelection(request, album, stream));
}
/** The POST handler's body, run inside the client's archive slot. */
async function streamSelection(
request: NextRequest,
album: string,
stream: ArchiveStreamer,
): Promise<Response> {
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 stream(resolved.albumName, selected);
}