feat(privacy): strip EXIF/XMP/IPTC from image uploads by default (#109)
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
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
This commit is contained in:
@@ -382,6 +382,14 @@ export function UploadCardRenderer({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
|
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
|
||||||
|
{metadata.metadataStripped && (
|
||||||
|
<Box alignItems="Center" gap="100" style={{ marginTop: config.space.S100 }}>
|
||||||
|
<Icon size="50" src={Icons.Shield} style={{ color: color.Success.Main }} />
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Photo metadata removed (location, camera, time)
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||||
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ import { sendStateEvent } from '../../../utils/room';
|
|||||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||||
|
import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
|
||||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useAlive } from '../../../hooks/useAlive';
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
@@ -138,7 +141,19 @@ export function RoomProfileEdit({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [imageFile]);
|
}, [imageFile]);
|
||||||
|
|
||||||
const pickFile = useFilePicker(setImageFile, false);
|
// [Gitea #109] Avatars go through the same metadata strip as messages.
|
||||||
|
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||||
|
const pickFile = useFilePicker(
|
||||||
|
useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
|
||||||
|
setImageFile(r.file),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[stripImageMetadata],
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
const handleRemoveUpload = useCallback(() => {
|
const handleRemoveUpload = useCallback(() => {
|
||||||
setImageFile(undefined);
|
setImageFile(undefined);
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||||
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
|
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
|
||||||
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||||
|
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||||
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
||||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||||
@@ -377,10 +378,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setUploadBoard(true);
|
setUploadBoard(true);
|
||||||
setSelectedFiles({
|
setSelectedFiles({
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
item: await filesToUploadItems(room, files),
|
item: await filesToUploadItems(room, files, stripImageMetadata),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setSelectedFiles, room],
|
[setSelectedFiles, room, stripImageMetadata],
|
||||||
);
|
);
|
||||||
const pickFile = useFilePicker(handleFiles, true);
|
const pickFile = useFilePicker(handleFiles, true);
|
||||||
const handleFilePaste = useFilePasteHandler(handleFiles);
|
const handleFilePaste = useFilePasteHandler(handleFiles);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { UserAvatar } from '../../../components/user-avatar';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { nameInitials } from '../../../utils/common';
|
import { nameInitials } from '../../../utils/common';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
|
||||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
@@ -89,7 +90,19 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [imageFile]);
|
}, [imageFile]);
|
||||||
|
|
||||||
const pickFile = useFilePicker(setImageFile, false);
|
// [Gitea #109] Avatars go through the same metadata strip as messages.
|
||||||
|
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||||
|
const pickFile = useFilePicker(
|
||||||
|
useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
|
||||||
|
setImageFile(r.file),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[stripImageMetadata],
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
const handleRemoveUpload = useCallback(() => {
|
const handleRemoveUpload = useCallback(() => {
|
||||||
setImageFile(undefined);
|
setImageFile(undefined);
|
||||||
|
|||||||
@@ -1442,6 +1442,7 @@ function Privacy() {
|
|||||||
'warnOnUnverifiedDevices',
|
'warnOnUnverifiedDevices',
|
||||||
);
|
);
|
||||||
const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||||
|
const [stripImageMeta, setStripImageMeta] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -1453,6 +1454,13 @@ function Privacy() {
|
|||||||
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
|
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Remove Photo Metadata Before Sending"
|
||||||
|
description="Strip location, camera model and timestamp (EXIF/XMP) from JPEG, PNG and WebP images you send or set as an avatar. Pixels are untouched; the rotation is kept. Videos and GIFs are not covered."
|
||||||
|
after={<Switch variant="Primary" value={stripImageMeta} onChange={setStripImageMeta} />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Hide Typing & Read Receipts"
|
title="Hide Typing & Read Receipts"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useAtomValue, useStore } from 'jotai';
|
import { useAtomValue, useStore } from 'jotai';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
@@ -53,6 +55,7 @@ export function Share() {
|
|||||||
const [shared, setShared] = useState<Shared | null | undefined>(undefined);
|
const [shared, setShared] = useState<Shared | null | undefined>(undefined);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
readSharedPayload()
|
readSharedPayload()
|
||||||
@@ -80,7 +83,7 @@ export function Share() {
|
|||||||
if (shared.files.length) {
|
if (shared.files.length) {
|
||||||
store.set(roomIdToUploadItemsAtomFamily(room.roomId), {
|
store.set(roomIdToUploadItemsAtomFamily(room.roomId), {
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
item: await filesToUploadItems(room, shared.files),
|
item: await filesToUploadItems(room, shared.files, stripImageMetadata),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const text = shareDraftText(shared.payload);
|
const text = shareDraftText(shared.payload);
|
||||||
@@ -95,7 +98,7 @@ export function Share() {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[shared, busy, navigateRoom, store],
|
[shared, busy, navigateRoom, store, stripImageMetadata],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type TUploadMetadata = {
|
|||||||
compressImage?: boolean;
|
compressImage?: boolean;
|
||||||
/** Cached compression result (populated in the background when compressImage is set to true) */
|
/** Cached compression result (populated in the background when compressImage is set to true) */
|
||||||
compressionResult?: CompressionResult | null;
|
compressionResult?: CompressionResult | null;
|
||||||
|
/** [Gitea #109] EXIF/XMP/IPTC was removed from this image before upload. */
|
||||||
|
metadataStripped?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TUploadItem = {
|
export type TUploadItem = {
|
||||||
|
|||||||
@@ -268,6 +268,9 @@ export interface Settings {
|
|||||||
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
|
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
|
||||||
// send, and from links rendered in the timeline. Local only.
|
// send, and from links rendered in the timeline. Local only.
|
||||||
stripTrackingParams: boolean;
|
stripTrackingParams: boolean;
|
||||||
|
// [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
|
||||||
|
// uploads without re-encoding. Default on.
|
||||||
|
stripImageMetadata: boolean;
|
||||||
|
|
||||||
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
|
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
|
||||||
// so other devices pick them up. Device-local itself (utils/settingsSync).
|
// so other devices pick them up. Device-local itself (utils/settingsSync).
|
||||||
@@ -390,6 +393,7 @@ const defaultSettings: Settings = {
|
|||||||
warnOnUnverifiedDevices: false,
|
warnOnUnverifiedDevices: false,
|
||||||
|
|
||||||
stripTrackingParams: true,
|
stripTrackingParams: true,
|
||||||
|
stripImageMetadata: true,
|
||||||
|
|
||||||
settingsSync: true,
|
settingsSync: true,
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 633 B |
Binary file not shown.
|
After Width: | Height: | Size: 841 B |
Binary file not shown.
|
After Width: | Height: | Size: 368 B |
Binary file not shown.
|
After Width: | Height: | Size: 268 B |
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
@@ -0,0 +1,125 @@
|
|||||||
|
/* 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/* 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 };
|
||||||
|
}
|
||||||
@@ -3,20 +3,44 @@ import { TUploadItem } from '../state/room/roomInputDrafts';
|
|||||||
import { encryptFile } from './matrix';
|
import { encryptFile } from './matrix';
|
||||||
import { safeFile } from './mimeTypes';
|
import { safeFile } from './mimeTypes';
|
||||||
import { fulfilledPromiseSettledResult } from './common';
|
import { fulfilledPromiseSettledResult } from './common';
|
||||||
|
import { stripImageMetadata } from './stripImageMetadata';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Files → composer upload items for `room`, encrypting first when the room
|
* Files → composer upload items for `room`, encrypting first when the room
|
||||||
* is E2EE. Shared by the composer's pick/drop/paste paths and the PWA share
|
* is E2EE. Shared by the composer's pick/drop/paste paths and the PWA share
|
||||||
* target (#155).
|
* target (#155).
|
||||||
*/
|
*/
|
||||||
export async function filesToUploadItems(room: Room, files: File[]): Promise<TUploadItem[]> {
|
export async function filesToUploadItems(
|
||||||
const safeFiles = files.map(safeFile);
|
room: Room,
|
||||||
const metadata = { markedAsSpoiler: false };
|
files: File[],
|
||||||
|
stripMetadata = true,
|
||||||
|
): Promise<TUploadItem[]> {
|
||||||
|
const prepared = await Promise.all(
|
||||||
|
files.map(async (raw) => {
|
||||||
|
const f = safeFile(raw);
|
||||||
|
// [Gitea #109] Drop EXIF/XMP/IPTC before anything else sees the bytes
|
||||||
|
// (including encryption), so what's uploaded never carried them.
|
||||||
|
const { file, stripped } = stripMetadata
|
||||||
|
? await stripImageMetadata(f)
|
||||||
|
: { file: f, stripped: false };
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
metadata: { markedAsSpoiler: false, metadataStripped: stripped || undefined },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (room.hasEncryptionStateEvent()) {
|
if (room.hasEncryptionStateEvent()) {
|
||||||
const encrypted = fulfilledPromiseSettledResult(
|
const encrypted = fulfilledPromiseSettledResult(
|
||||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f))),
|
await Promise.allSettled(
|
||||||
|
prepared.map(async ({ file, metadata }) => ({ ...(await encryptFile(file)), metadata })),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return encrypted.map((ef) => ({ ...ef, metadata }));
|
return encrypted;
|
||||||
}
|
}
|
||||||
return safeFiles.map((f) => ({ file: f, originalFile: f, encInfo: undefined, metadata }));
|
return prepared.map(({ file, metadata }) => ({
|
||||||
|
file,
|
||||||
|
originalFile: file,
|
||||||
|
encInfo: undefined,
|
||||||
|
metadata,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user