release: v0.15.0 (#585)
Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
This commit is contained in:
251
lib/metadataStrip.ts
Normal file
251
lib/metadataStrip.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Removes camera metadata (EXIF, XMP, IPTC) from image bytes on the way out.
|
||||
*
|
||||
* Why this exists: `/api/image/:token?size=original` streams the untouched file
|
||||
* from Immich, and the asset token authorises *the asset, not the size* — so a
|
||||
* thumbnail token lifted from the public HTML plus `?size=original` returns the
|
||||
* full original, GPS coordinates and all. `app/api/exif/[id]/route.ts`
|
||||
* deliberately hands the browser only city and country; the original download
|
||||
* walked straight past that.
|
||||
*
|
||||
* Why zero the bytes instead of cutting them out: the pixel data must stay
|
||||
* bit-identical — re-encoding would cost quality, which is the whole reason we
|
||||
* serve originals. Overwriting in place keeps every other byte and the total
|
||||
* length untouched, so Content-Length stays valid and, crucially for AVIF, the
|
||||
* absolute offsets in the `iloc` box keep pointing where they did. Removing an
|
||||
* item would mean rewriting every offset in the container.
|
||||
*
|
||||
* A zeroed APPn segment is simply an application segment a decoder does not
|
||||
* recognise, and decoders skip those. A zeroed ISOBMFF item payload is still
|
||||
* addressed by `iloc`, but no longer parses as EXIF.
|
||||
*
|
||||
* NOT stripped: JPEG APP0 (JFIF) and APP2 (ICC colour profile). Dropping the
|
||||
* ICC profile would visibly shift colours — that is a rendering change, not a
|
||||
* privacy one.
|
||||
*/
|
||||
|
||||
/** Half-open byte range [start, end). */
|
||||
export type ByteRange = { start: number; end: number };
|
||||
|
||||
/** Formats whose metadata layout we can locate with confidence. */
|
||||
export const STRIPPABLE_CONTENT_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/avif',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
] as const;
|
||||
|
||||
export function isStrippable(contentType: string): boolean {
|
||||
const t = contentType.toLowerCase().split(';')[0].trim();
|
||||
return (STRIPPABLE_CONTENT_TYPES as readonly string[]).includes(t);
|
||||
}
|
||||
|
||||
const u16 = (b: Uint8Array, i: number) => (b[i] << 8) | b[i + 1];
|
||||
const u32 = (b: Uint8Array, i: number) =>
|
||||
((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
||||
const fourcc = (b: Uint8Array, i: number) =>
|
||||
String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]);
|
||||
|
||||
/** Read a big-endian unsigned integer of `n` bytes (n <= 8). */
|
||||
function uint(b: Uint8Array, i: number, n: number): number {
|
||||
let v = 0;
|
||||
for (let k = 0; k < n; k++) v = v * 256 + b[i + k];
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── JPEG ────────────────────────────────────────────────────────────────────
|
||||
// Segment layout: 0xFF <marker> <2-byte length incl. itself> <payload>.
|
||||
// Metadata always precedes SOS (0xDA); everything after it is entropy-coded
|
||||
// image data that must not be touched.
|
||||
|
||||
const JPEG_STRIP_MARKERS = new Set([
|
||||
0xe1, // APP1 — EXIF and XMP
|
||||
0xed, // APP13 — Photoshop/IPTC
|
||||
0xfe, // COM — free-text comment
|
||||
]);
|
||||
|
||||
export function findJpegMetadataRanges(b: Uint8Array): ByteRange[] {
|
||||
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) {
|
||||
throw new Error('not a JPEG (missing SOI)');
|
||||
}
|
||||
const ranges: ByteRange[] = [];
|
||||
let pos = 2;
|
||||
|
||||
while (pos + 3 < b.length) {
|
||||
if (b[pos] !== 0xff) throw new Error(`lost segment alignment at ${pos}`);
|
||||
const marker = b[pos + 1];
|
||||
|
||||
// Standalone markers carry no length field.
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
if (marker === 0xd9) break; // EOI
|
||||
if (marker === 0xda) break; // SOS — image data follows
|
||||
|
||||
const len = u16(b, pos + 2);
|
||||
if (len < 2) throw new Error(`bogus segment length ${len} at ${pos}`);
|
||||
const payloadStart = pos + 4;
|
||||
const segmentEnd = pos + 2 + len;
|
||||
if (segmentEnd > b.length) break; // truncated head — stop where we are
|
||||
|
||||
if (JPEG_STRIP_MARKERS.has(marker) && segmentEnd > payloadStart) {
|
||||
ranges.push({ start: payloadStart, end: segmentEnd });
|
||||
}
|
||||
pos = segmentEnd;
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// ── ISOBMFF (AVIF / HEIC / HEIF) ────────────────────────────────────────────
|
||||
// Boxes are <4-byte size><4-byte type>[<8-byte largesize>]<payload>. Metadata
|
||||
// items live in `meta`: `iinf`/`infe` name them, `iloc` says where their bytes
|
||||
// are. We zero exactly those byte ranges.
|
||||
|
||||
type Box = { type: string; start: number; contentStart: number; end: number };
|
||||
|
||||
function readBoxes(b: Uint8Array, from: number, to: number): Box[] {
|
||||
const boxes: Box[] = [];
|
||||
let pos = from;
|
||||
while (pos + 8 <= to) {
|
||||
let size = u32(b, pos);
|
||||
let contentStart = pos + 8;
|
||||
if (size === 1) {
|
||||
if (pos + 16 > to) break;
|
||||
size = uint(b, pos + 8, 8);
|
||||
contentStart = pos + 16;
|
||||
} else if (size === 0) {
|
||||
size = to - pos; // extends to the end
|
||||
}
|
||||
if (size < 8) break;
|
||||
const end = Math.min(pos + size, to);
|
||||
boxes.push({ type: fourcc(b, pos + 4), start: pos, contentStart, end });
|
||||
if (end <= pos) break;
|
||||
pos = end;
|
||||
}
|
||||
return boxes;
|
||||
}
|
||||
|
||||
/** item_ID -> item_type, from the `iinf` box. */
|
||||
function readItemTypes(b: Uint8Array, iinf: Box): Map<number, string> {
|
||||
const types = new Map<number, string>();
|
||||
const version = b[iinf.contentStart];
|
||||
let pos = iinf.contentStart + 4; // version + flags
|
||||
if (version === 0) pos += 2;
|
||||
else pos += 4; // entry_count
|
||||
|
||||
for (const infe of readBoxes(b, pos, iinf.end)) {
|
||||
if (infe.type !== 'infe') continue;
|
||||
const v = b[infe.contentStart];
|
||||
let p = infe.contentStart + 4;
|
||||
if (v < 2) continue; // versions 0/1 predate item_type
|
||||
const itemId = v === 2 ? u16(b, p) : u32(b, p);
|
||||
p += v === 2 ? 2 : 4;
|
||||
p += 2; // item_protection_index
|
||||
types.set(itemId, fourcc(b, p));
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
/** item_ID -> byte ranges, from the `iloc` box. */
|
||||
function readItemLocations(b: Uint8Array, iloc: Box): Map<number, ByteRange[]> {
|
||||
const out = new Map<number, ByteRange[]>();
|
||||
const version = b[iloc.contentStart];
|
||||
let p = iloc.contentStart + 4; // version + flags
|
||||
|
||||
const offsetSize = b[p] >> 4;
|
||||
const lengthSize = b[p] & 0x0f;
|
||||
const baseOffsetSize = b[p + 1] >> 4;
|
||||
const indexSize = version === 1 || version === 2 ? b[p + 1] & 0x0f : 0;
|
||||
p += 2;
|
||||
|
||||
const itemCount = version < 2 ? u16(b, p) : u32(b, p);
|
||||
p += version < 2 ? 2 : 4;
|
||||
|
||||
for (let i = 0; i < itemCount && p < iloc.end; i++) {
|
||||
const itemId = version < 2 ? u16(b, p) : u32(b, p);
|
||||
p += version < 2 ? 2 : 4;
|
||||
if (version === 1 || version === 2) p += 2; // construction_method
|
||||
p += 2; // data_reference_index
|
||||
const baseOffset = baseOffsetSize ? uint(b, p, baseOffsetSize) : 0;
|
||||
p += baseOffsetSize;
|
||||
const extentCount = u16(b, p);
|
||||
p += 2;
|
||||
|
||||
const ranges: ByteRange[] = [];
|
||||
for (let e = 0; e < extentCount; e++) {
|
||||
if ((version === 1 || version === 2) && indexSize) p += indexSize;
|
||||
const extentOffset = offsetSize ? uint(b, p, offsetSize) : 0;
|
||||
p += offsetSize;
|
||||
const extentLength = lengthSize ? uint(b, p, lengthSize) : 0;
|
||||
p += lengthSize;
|
||||
if (extentLength > 0) {
|
||||
const start = baseOffset + extentOffset;
|
||||
ranges.push({ start, end: start + extentLength });
|
||||
}
|
||||
}
|
||||
if (ranges.length) out.set(itemId, ranges);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function findIsobmffMetadataRanges(b: Uint8Array): ByteRange[] {
|
||||
const top = readBoxes(b, 0, b.length);
|
||||
if (!top.some((x) => x.type === 'ftyp')) throw new Error('not ISOBMFF (no ftyp)');
|
||||
const meta = top.find((x) => x.type === 'meta');
|
||||
if (!meta) return []; // no metadata box at all — nothing to strip
|
||||
|
||||
// `meta` is a FullBox: skip version + flags before its children.
|
||||
const children = readBoxes(b, meta.contentStart + 4, meta.end);
|
||||
const iinf = children.find((x) => x.type === 'iinf');
|
||||
const iloc = children.find((x) => x.type === 'iloc');
|
||||
if (!iinf || !iloc) return [];
|
||||
|
||||
const types = readItemTypes(b, iinf);
|
||||
const locations = readItemLocations(b, iloc);
|
||||
|
||||
const ranges: ByteRange[] = [];
|
||||
for (const [itemId, itemType] of types) {
|
||||
// 'Exif' is EXIF; 'mime' items are XMP in every AVIF/HEIC writer we see.
|
||||
if (itemType !== 'Exif' && itemType !== 'mime') continue;
|
||||
for (const r of locations.get(itemId) ?? []) ranges.push(r);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Byte ranges to blank out, or null when the format is not one we understand.
|
||||
* Callers must treat null as "do not serve this original" — silently passing it
|
||||
* through is what caused the leak in the first place.
|
||||
*/
|
||||
export function findMetadataRanges(b: Uint8Array, contentType: string): ByteRange[] | null {
|
||||
const t = contentType.toLowerCase().split(';')[0].trim();
|
||||
try {
|
||||
if (t === 'image/jpeg') return findJpegMetadataRanges(b);
|
||||
if (t === 'image/avif' || t === 'image/heic' || t === 'image/heif') {
|
||||
return findIsobmffMetadataRanges(b);
|
||||
}
|
||||
} catch {
|
||||
return null; // malformed or unexpected layout — refuse rather than leak
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Overwrite `ranges` with zeroes. Mutates and returns `b`. */
|
||||
export function blankRanges(b: Uint8Array, ranges: ByteRange[]): Uint8Array {
|
||||
for (const { start, end } of ranges) {
|
||||
const from = Math.max(0, Math.min(start, b.length));
|
||||
const to = Math.max(0, Math.min(end, b.length));
|
||||
if (to > from) b.fill(0, from, to);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Convenience for buffered callers and tests. */
|
||||
export function stripMetadata(b: Uint8Array, contentType: string): Uint8Array | null {
|
||||
const ranges = findMetadataRanges(b, contentType);
|
||||
if (ranges === null) return null;
|
||||
return blankRanges(b, ranges);
|
||||
}
|
||||
Reference in New Issue
Block a user