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
126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
/* eslint-disable no-bitwise -- byte-level container parsing */
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
buildOrientationApp1,
|
|
readJpegOrientation,
|
|
stripJpeg,
|
|
stripPng,
|
|
stripWebp,
|
|
stripImageMetadataBytes,
|
|
} from './stripImageMetadata';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const fixture = (name: string) =>
|
|
new Uint8Array(fs.readFileSync(path.join(here, '__fixtures__', name)));
|
|
const has = (bytes: Uint8Array, text: string) =>
|
|
Buffer.from(bytes).includes(Buffer.from(text, 'latin1'));
|
|
|
|
/** Walk JPEG segments up to SOS; returns marker list (sanity: structure intact). */
|
|
const jpegMarkers = (b: Uint8Array): string[] => {
|
|
const out: string[] = [];
|
|
let pos = 2;
|
|
while (pos + 4 <= b.length && b[pos] === 0xff) {
|
|
const m = b[pos + 1];
|
|
out.push(m.toString(16));
|
|
if (m === 0xda) break;
|
|
pos += 2 + ((b[pos + 2] << 8) | b[pos + 3]);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
describe('stripJpeg', () => {
|
|
it('removes GPS, model and timestamp, keeps a valid JPEG', () => {
|
|
const src = fixture('gps.jpg');
|
|
assert.ok(has(src, 'LotusPhone 9'));
|
|
const { out, stripped } = stripJpeg(src);
|
|
assert.equal(stripped, true);
|
|
assert.ok(!has(out, 'LotusPhone 9'));
|
|
assert.ok(!has(out, '2026:09:20'));
|
|
assert.ok(out.length < src.length);
|
|
assert.deepEqual([out[0], out[1]], [0xff, 0xd8]);
|
|
assert.deepEqual([out[out.length - 2], out[out.length - 1]], [0xff, 0xd9]);
|
|
assert.ok(jpegMarkers(out).includes('da'));
|
|
// Pixel data untouched: everything from SOS on is byte-identical.
|
|
const sos = (b: Uint8Array) => Buffer.from(b).indexOf(Buffer.from([0xff, 0xda]));
|
|
assert.deepEqual(Buffer.from(out.subarray(sos(out))), Buffer.from(src.subarray(sos(src))));
|
|
});
|
|
|
|
it('keeps a non-default orientation in a minimal EXIF block', () => {
|
|
const { out, orientation } = stripJpeg(fixture('gps.jpg'));
|
|
assert.equal(orientation, 6);
|
|
const markers = jpegMarkers(out);
|
|
assert.ok(markers.includes('e1'));
|
|
// Find the APP1 we wrote and read the orientation back.
|
|
let pos = 2;
|
|
let found: number | undefined;
|
|
while (pos + 4 <= out.length && out[pos] === 0xff) {
|
|
const len = (out[pos + 2] << 8) | out[pos + 3];
|
|
if (out[pos + 1] === 0xe1) {
|
|
found = readJpegOrientation(out.subarray(pos + 4, pos + 2 + len));
|
|
assert.equal(len, buildOrientationApp1(6).length - 2);
|
|
}
|
|
if (out[pos + 1] === 0xda) break;
|
|
pos += 2 + len;
|
|
}
|
|
assert.equal(found, 6);
|
|
assert.ok(!has(out, 'LotusPhone 9'));
|
|
});
|
|
|
|
it('drops EXIF and the comment segment when orientation is 1 (no EXIF written back)', () => {
|
|
const src = fixture('plain.jpg');
|
|
assert.ok(has(src, 'made with love'));
|
|
const { out, stripped, orientation } = stripJpeg(src);
|
|
assert.equal(stripped, true);
|
|
assert.equal(orientation, undefined);
|
|
assert.ok(!has(out, 'made with love'));
|
|
assert.ok(!has(out, 'Cam'));
|
|
assert.ok(!jpegMarkers(out).includes('e1'));
|
|
});
|
|
|
|
it('returns the input untouched when there is nothing to strip', () => {
|
|
const src = fixture('clean.jpg');
|
|
const { out, stripped } = stripJpeg(src);
|
|
assert.equal(stripped, false);
|
|
assert.equal(out, src);
|
|
});
|
|
});
|
|
|
|
describe('stripPng', () => {
|
|
it('drops eXIf and text chunks, keeps IHDR/IDAT/IEND', () => {
|
|
const src = fixture('meta.png');
|
|
assert.ok(has(src, 'xmpmeta'));
|
|
const { out, stripped } = stripPng(src);
|
|
assert.equal(stripped, true);
|
|
assert.ok(!has(out, 'xmpmeta'));
|
|
assert.ok(!has(out, 'LotusPhone'));
|
|
assert.ok(!has(out, 'eXIf'));
|
|
assert.ok(has(out, 'IHDR') && has(out, 'IDAT') && has(out, 'IEND'));
|
|
});
|
|
});
|
|
|
|
describe('stripWebp', () => {
|
|
it('drops the EXIF chunk, clears the VP8X flag and fixes the RIFF size', () => {
|
|
const src = fixture('meta.webp');
|
|
assert.ok(has(src, 'LotusPhone'));
|
|
const { out, stripped } = stripWebp(src);
|
|
assert.equal(stripped, true);
|
|
assert.ok(!has(out, 'LotusPhone'));
|
|
assert.ok(!has(out, 'EXIF'));
|
|
const riff = out[4] | (out[5] << 8) | (out[6] << 16) | (out[7] << 24);
|
|
assert.equal(riff, out.length - 8);
|
|
const vp8x = Buffer.from(out).indexOf(Buffer.from('VP8X'));
|
|
if (vp8x !== -1) assert.equal(out[vp8x + 8] & 0x0c, 0);
|
|
});
|
|
});
|
|
|
|
describe('stripImageMetadataBytes', () => {
|
|
it('passes unknown types through', () => {
|
|
const b = new Uint8Array([1, 2, 3]);
|
|
assert.deepEqual(stripImageMetadataBytes(b, 'image/gif'), { out: b, stripped: false });
|
|
});
|
|
});
|