/* eslint-disable no-bitwise -- byte-level container parsing */ /** * [Gitea #109] Remove EXIF / XMP / IPTC metadata from an image WITHOUT * re-encoding its pixels, so phone photos stop carrying GPS coordinates, * device model and timestamps into the room. JPEG, PNG and WebP are handled * at the container level; anything else is returned untouched. * * JPEG orientation: a bare strip would un-rotate photos whose pixels are * stored sideways, so when the EXIF Orientation tag is not 1 a minimal EXIF * block carrying only that tag is written back. */ export type StripResult = { blob: Blob; /** Whether any metadata segment was actually removed. */ stripped: boolean; /** EXIF orientation that was preserved (JPEG only), if any. */ orientation?: number; }; const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0" /** Read the EXIF Orientation tag (1–8) from an APP1 payload, or undefined. */ export function readJpegOrientation(app1: Uint8Array): number | undefined { if (app1.length < 14) return undefined; for (let i = 0; i < 6; i += 1) if (app1[i] !== EXIF_HEADER[i]) return undefined; const tiff = 6; const le = app1[tiff] === 0x49 && app1[tiff + 1] === 0x49; const view = new DataView(app1.buffer, app1.byteOffset, app1.byteLength); const u16 = (o: number) => view.getUint16(o, le); const u32 = (o: number) => view.getUint32(o, le); if (u16(tiff + 2) !== 0x2a) return undefined; const ifd0 = tiff + u32(tiff + 4); if (ifd0 + 2 > app1.length) return undefined; const count = u16(ifd0); for (let i = 0; i < count; i += 1) { const entry = ifd0 + 2 + i * 12; if (entry + 12 > app1.length) return undefined; if (u16(entry) === 0x0112) { const value = u16(entry + 8); return value >= 1 && value <= 8 ? value : undefined; } } return undefined; } /** A minimal APP1 segment: EXIF header + TIFF + one IFD0 entry (Orientation). */ export function buildOrientationApp1(orientation: number): Uint8Array { // TIFF (big-endian): "MM", 0x002A, IFD offset 8; IFD0: count 1, entry, next 0. const tiff = new Uint8Array([ 0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, orientation & 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]); const payload = new Uint8Array(EXIF_HEADER.length + tiff.length); payload.set(EXIF_HEADER, 0); payload.set(tiff, EXIF_HEADER.length); const seg = new Uint8Array(4 + payload.length); seg[0] = 0xff; seg[1] = 0xe1; const len = payload.length + 2; seg[2] = (len >> 8) & 0xff; seg[3] = len & 0xff; seg.set(payload, 4); return seg; } /** JPEG: drop APP1 (EXIF/XMP), APP13 (IPTC), COM; keep JFIF/ICC and the image. */ export function stripJpeg(bytes: Uint8Array): { out: Uint8Array; stripped: boolean; orientation?: number; } { if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return { out: bytes, stripped: false }; const parts: Uint8Array[] = [bytes.subarray(0, 2)]; let pos = 2; let stripped = false; let orientation: number | undefined; let insertedOrientation = false; while (pos + 4 <= bytes.length) { if (bytes[pos] !== 0xff) break; const marker = bytes[pos + 1]; // Start of scan: everything from here to the end is entropy-coded data. if (marker === 0xda) break; const len = (bytes[pos + 2] << 8) | bytes[pos + 3]; const segEnd = pos + 2 + len; if (len < 2 || segEnd > bytes.length) break; const drop = marker === 0xe1 || marker === 0xed || marker === 0xfe; if (drop) { stripped = true; if (marker === 0xe1 && orientation === undefined) { orientation = readJpegOrientation(bytes.subarray(pos + 4, segEnd)); } } else { parts.push(bytes.subarray(pos, segEnd)); } pos = segEnd; // Put the orientation-only EXIF right after SOI/JFIF, before anything else. if (!insertedOrientation && orientation !== undefined && orientation !== 1 && marker !== 0xe0) { insertedOrientation = true; parts.push(buildOrientationApp1(orientation)); } } if (!stripped) return { out: bytes, stripped: false }; if (orientation !== undefined && orientation !== 1 && !insertedOrientation) { parts.push(buildOrientationApp1(orientation)); } parts.push(bytes.subarray(pos)); const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); let o = 0; parts.forEach((p) => { out.set(p, o); o += p.length; }); return { out, stripped, orientation: orientation !== 1 ? orientation : undefined }; } const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; const PNG_DROP = new Set(['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME']); /** PNG: drop eXIf and text chunks (XMP lives in iTXt); keep colour chunks. */ export function stripPng(bytes: Uint8Array): { out: Uint8Array; stripped: boolean } { if (bytes.length < 8) return { out: bytes, stripped: false }; for (let i = 0; i < 8; i += 1) if (bytes[i] !== PNG_SIG[i]) return { out: bytes, stripped: false }; const parts: Uint8Array[] = [bytes.subarray(0, 8)]; let pos = 8; let stripped = false; while (pos + 8 <= bytes.length) { const len = ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0; const type = String.fromCharCode( bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7], ); const end = pos + 12 + len; if (end > bytes.length) break; if (PNG_DROP.has(type)) stripped = true; else parts.push(bytes.subarray(pos, end)); pos = end; } if (!stripped) return { out: bytes, stripped: false }; parts.push(bytes.subarray(pos)); const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); let o = 0; parts.forEach((p) => { out.set(p, o); o += p.length; }); return { out, stripped }; } const fourcc = (b: Uint8Array, o: number) => String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]); /** WebP: drop EXIF/XMP chunks and clear their VP8X flags; fix the RIFF size. */ export function stripWebp(bytes: Uint8Array): { out: Uint8Array; stripped: boolean } { if (bytes.length < 12 || fourcc(bytes, 0) !== 'RIFF' || fourcc(bytes, 8) !== 'WEBP') { return { out: bytes, stripped: false }; } const chunks: Uint8Array[] = []; let pos = 12; let stripped = false; let vp8x: Uint8Array | undefined; while (pos + 8 <= bytes.length) { const id = fourcc(bytes, pos); const len = (bytes[pos + 4] | (bytes[pos + 5] << 8) | (bytes[pos + 6] << 16) | (bytes[pos + 7] << 24)) >>> 0; const end = pos + 8 + len + (len % 2); if (pos + 8 + len > bytes.length) break; if (id === 'EXIF' || id === 'XMP ') stripped = true; else { const chunk = bytes.slice(pos, Math.min(end, bytes.length)); if (id === 'VP8X') vp8x = chunk; chunks.push(chunk); } pos = end; } if (!stripped) return { out: bytes, stripped: false }; // VP8X flags byte: bit 3 = EXIF, bit 2 = XMP (0x08 / 0x04). if (vp8x && vp8x.length >= 9) vp8x[8] &= ~0x0c; const body = chunks.reduce((n, c) => n + c.length, 0); const out = new Uint8Array(12 + body); out.set(bytes.subarray(0, 12), 0); const riffSize = 4 + body; out[4] = riffSize & 0xff; out[5] = (riffSize >> 8) & 0xff; out[6] = (riffSize >> 16) & 0xff; out[7] = (riffSize >> 24) & 0xff; let o = 12; chunks.forEach((c) => { out.set(c, o); o += c.length; }); return { out, stripped }; } export const STRIPPABLE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; export function stripImageMetadataBytes( bytes: Uint8Array, mimeType: string, ): { out: Uint8Array; stripped: boolean; orientation?: number } { if (mimeType === 'image/jpeg') return stripJpeg(bytes); if (mimeType === 'image/png') return stripPng(bytes); if (mimeType === 'image/webp') return stripWebp(bytes); return { out: bytes, stripped: false }; } /** Strip a File/Blob; returns the same object when nothing changed or the type is unsupported. */ export async function stripImageMetadata(file: File): Promise { if (!STRIPPABLE_TYPES.includes(file.type)) return { blob: file, file, stripped: false }; const bytes = new Uint8Array(await file.arrayBuffer()); const { out, stripped, orientation } = stripImageMetadataBytes(bytes, file.type); if (!stripped) return { blob: file, file, stripped: false }; const next = new File([out as BlobPart], file.name, { type: file.type, lastModified: file.lastModified, }); return { blob: next, file: next, stripped: true, orientation }; }