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>
This commit is contained in:
@@ -32,12 +32,14 @@ import { GET, POST } from '../route';
|
||||
import { getConfig, getConfigOrNull } from '@/lib/config';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { getClientIp } from '@/lib/rate-limit';
|
||||
|
||||
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 mockClientIp = getClientIp as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const ALBUM = {
|
||||
id: 'album-uuid',
|
||||
@@ -269,6 +271,75 @@ describe('refusals', () => {
|
||||
expect(html).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
/**
|
||||
* Next rebuilds `nextUrl` from its own bind address, so behind
|
||||
* `next start -H 127.0.0.1` or a reverse proxy it reads `localhost` while
|
||||
* the visitor's Referer names the host they typed. Comparing against
|
||||
* `nextUrl.origin` sent every visitor home.
|
||||
*/
|
||||
it('follows the Referer when Next knows itself as localhost but was asked as another host', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const req = new NextRequest('http://localhost:3598/api/download/album-token/archive', {
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
Host: '127.0.0.1:3598',
|
||||
Referer: 'http://127.0.0.1:3598/polen/krakau',
|
||||
},
|
||||
});
|
||||
const html = await (await GET(req, params)).text();
|
||||
expect(html).toContain('href="/polen/krakau"');
|
||||
});
|
||||
|
||||
it('does not follow a Referer whose path would read as a host', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const html = await (await GET(browsesHtml('http://localhost//evil.example/x'), params)).text();
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('ignores X-Forwarded-Host unless a proxy is trusted', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const req = new NextRequest('http://localhost/api/download/album-token/archive', {
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
'X-Forwarded-Host': 'evil.example',
|
||||
Referer: 'https://evil.example/phish',
|
||||
},
|
||||
});
|
||||
const html = await (await GET(req, params)).text();
|
||||
expect(html).toContain('href="/"');
|
||||
});
|
||||
|
||||
/**
|
||||
* The page used to be browser-default: white, blue link, system font — in
|
||||
* the middle of a dark gallery. It now carries the site's colour mode and
|
||||
* accent in an inline stylesheet, and a policy that allows nothing else.
|
||||
*/
|
||||
it("wears the site's colour mode and accent, under a policy that allows only its style", async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
mockConfigOrNull.mockReturnValue({
|
||||
lang: 'de',
|
||||
colorMode: 'dark',
|
||||
theme: { preset: 'studio-modern', accent: '#e60012' },
|
||||
});
|
||||
const res = await GET(browsesHtml(), params);
|
||||
const html = await res.text();
|
||||
expect(html).toContain('<style>');
|
||||
expect(html).toContain('color-scheme:dark;--bg:#121212');
|
||||
expect(html).toContain('--accent:#e60012');
|
||||
expect(html).not.toContain('style="');
|
||||
expect(res.headers.get('content-security-policy')).toBe(
|
||||
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'",
|
||||
);
|
||||
});
|
||||
|
||||
it("follows the visitor's OS when the site's mode is auto", async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
mockConfigOrNull.mockReturnValue({ colorMode: 'auto' });
|
||||
const html = await (await GET(browsesHtml(), params)).text();
|
||||
expect(html).toContain('@media (prefers-color-scheme: light){:root{color-scheme:light;');
|
||||
});
|
||||
|
||||
it('keeps JSON for an API caller', async () => {
|
||||
mockConfig.mockReturnValue({ albums: ['album-uuid'], albumDownloads: {} });
|
||||
const res = await GET(getReq(), params);
|
||||
@@ -311,3 +382,51 @@ describe('abort', () => {
|
||||
expect(mockStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The album archive and the proofing archive draw on one pool of slots per
|
||||
* client, so holding two album ZIPs open closes the proofing route as well.
|
||||
*/
|
||||
describe('concurrent archives', () => {
|
||||
it('shares one per-client cap with the proofing archive route', async () => {
|
||||
mockClientIp.mockReturnValue('198.51.100.7');
|
||||
// Originals that never finish, so both archives stay open.
|
||||
mockStream.mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('first-chunk'));
|
||||
},
|
||||
}),
|
||||
contentType: 'image/jpeg',
|
||||
contentLength: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const first = await GET(getReq(), params);
|
||||
const second = await POST(postReq(['asset-token-1']), params);
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
|
||||
const third = await GET(getReq(), params);
|
||||
expect(third.status).toBe(429);
|
||||
expect(third.headers.get('retry-after')).toBeTruthy();
|
||||
|
||||
const { GET: proofArchive } = await import('@/app/api/proof/[token]/archive/route');
|
||||
const proof = await proofArchive(
|
||||
new NextRequest('http://localhost/api/proof/tok/archive?scope=album'),
|
||||
{ params: Promise.resolve({ token: 'tok' }) },
|
||||
);
|
||||
expect(proof.status).toBe(429);
|
||||
|
||||
// Closing one download frees its slot for the next.
|
||||
await first.body!.cancel();
|
||||
await vi.waitFor(async () => {
|
||||
const again = await GET(getReq(), params);
|
||||
expect(again.status).toBe(200);
|
||||
await again.body!.cancel();
|
||||
});
|
||||
await second.body!.cancel();
|
||||
mockClientIp.mockReturnValue('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,18 +16,15 @@
|
||||
* in a blob.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Readable } from 'node:stream';
|
||||
import archiver from 'archiver';
|
||||
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 { contentDisposition, safeDownloadName } from '@/lib/downloadName';
|
||||
import { getDictionary } from '@/lib/i18n';
|
||||
import { getLocale, getServerDictionary } from '@/lib/i18n/server';
|
||||
import { refusal, withArchiveSlot, type ArchiveStreamer } from '@/lib/zipArchive';
|
||||
import { readBodyCapped } from '@/lib/requestBody';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -52,146 +49,6 @@ const MAX_SELECTION_ASSETS = 1000;
|
||||
*/
|
||||
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[] };
|
||||
|
||||
@@ -248,62 +105,6 @@ async function resolveAlbum(
|
||||
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.
|
||||
*
|
||||
@@ -342,41 +143,16 @@ async function readSelectedTokens(request: NextRequest): Promise<string[] | null
|
||||
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);
|
||||
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(
|
||||
@@ -384,6 +160,15 @@ export async function POST(
|
||||
{ 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;
|
||||
|
||||
@@ -418,5 +203,5 @@ export async function POST(
|
||||
return refusal(request, 404, 'notAvailable');
|
||||
}
|
||||
|
||||
return streamArchive(resolved.albumName, selected);
|
||||
return stream(resolved.albumName, selected);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user