release: v0.20.0

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

View File

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

View File

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