CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Metadata was only dropped as a side effect of opt-in compression, so a phone photo carried its GPS fix, camera model and timestamp into the room and the media store. utils/stripImageMetadata.ts now removes it at the container level, without touching pixels: JPEG drops APP1/APP13/COM (writing back a minimal EXIF holding only Orientation when it isn't 1, so sideways-stored photos still display upright), PNG drops eXIf and the text chunks XMP lives in, WebP drops EXIF/XMP and clears the VP8X flags. Other types pass through. Applied before encryption on every composer path (attach, paste, drop, share target) and to user/room avatar picks; GIF upload is excluded. Setting → General → Privacy "Remove Photo Metadata Before Sending", default on. The upload card says "Photo metadata removed". Unit tests on generated fixtures with a GPS IFD (JPEG orientation 6, JPEG + comment, PNG with eXIf + XMP, WebP with EXIF); verified end to end: the bytes stored by Synapse decode fine and carry only Orientation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
251 lines
8.5 KiB
TypeScript
251 lines
8.5 KiB
TypeScript
/* 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<StripResult & { file: File }> {
|
||
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 };
|
||
}
|