release: v0.20.0
This commit is contained in:
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user