diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index 002edc84c..281188d6f 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -382,6 +382,14 @@ export function UploadCardRenderer({ /> )} + {metadata.metadataStripped && ( + + + + Photo metadata removed (location, camera, time) + + + )} {upload.status === UploadStatus.Idle && !fileSizeExceeded && ( )} diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index b9e7c8b84..a48a65307 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -43,7 +43,10 @@ import { sendStateEvent } from '../../../utils/room'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { useObjectURL } from '../../../hooks/useObjectURL'; import { createUploadAtom, UploadSuccess } from '../../../state/upload'; +import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata'; import { useFilePicker } from '../../../hooks/useFilePicker'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useAlive } from '../../../hooks/useAlive'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; @@ -138,7 +141,19 @@ export function RoomProfileEdit({ return undefined; }, [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(() => { setImageFile(undefined); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index ad1462129..36ec86659 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -279,6 +279,7 @@ export const RoomInput = forwardRef( const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled'); // [Gitea #103] Privacy: drop tracking params from links on paste and on send. const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams'); + const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata'); const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled; const showLocation = composerToolbarButtons?.showLocation ?? true; const showPoll = composerToolbarButtons?.showPoll ?? true; @@ -377,10 +378,10 @@ export const RoomInput = forwardRef( setUploadBoard(true); setSelectedFiles({ type: 'PUT', - item: await filesToUploadItems(room, files), + item: await filesToUploadItems(room, files, stripImageMetadata), }); }, - [setSelectedFiles, room], + [setSelectedFiles, room, stripImageMetadata], ); const pickFile = useFilePicker(handleFiles, true); const handleFilePaste = useFilePasteHandler(handleFiles); diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 9fc2e463d..b134f4510 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -47,6 +47,7 @@ import { UserAvatar } from '../../../components/user-avatar'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { nameInitials } from '../../../utils/common'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata'; import { useFilePicker } from '../../../hooks/useFilePicker'; import { useObjectURL } from '../../../hooks/useObjectURL'; import { stopPropagation } from '../../../utils/keyboard'; @@ -89,7 +90,19 @@ function ProfileAvatar({ profile, userId }: ProfileProps) { return undefined; }, [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(() => { setImageFile(undefined); diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 30208416f..a2594f802 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1442,6 +1442,7 @@ function Privacy() { 'warnOnUnverifiedDevices', ); const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams'); + const [stripImageMeta, setStripImageMeta] = useSetting(settingsAtom, 'stripImageMetadata'); return ( @@ -1453,6 +1454,13 @@ function Privacy() { after={} /> + + } + /> + (undefined); const [query, setQuery] = useState(''); const [busy, setBusy] = useState(false); + const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata'); useEffect(() => { readSharedPayload() @@ -80,7 +83,7 @@ export function Share() { if (shared.files.length) { store.set(roomIdToUploadItemsAtomFamily(room.roomId), { type: 'PUT', - item: await filesToUploadItems(room, shared.files), + item: await filesToUploadItems(room, shared.files, stripImageMetadata), }); } const text = shareDraftText(shared.payload); @@ -95,7 +98,7 @@ export function Share() { setBusy(false); } }, - [shared, busy, navigateRoom, store], + [shared, busy, navigateRoom, store, stripImageMetadata], ); return ( diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index e82c6a0a4..da33bb1cc 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -15,6 +15,8 @@ export type TUploadMetadata = { compressImage?: boolean; /** Cached compression result (populated in the background when compressImage is set to true) */ compressionResult?: CompressionResult | null; + /** [Gitea #109] EXIF/XMP/IPTC was removed from this image before upload. */ + metadataStripped?: boolean; }; export type TUploadItem = { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 360712bc9..a747f56af 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -268,6 +268,9 @@ export interface Settings { // [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or // send, and from links rendered in the timeline. Local only. 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 // so other devices pick them up. Device-local itself (utils/settingsSync). @@ -390,6 +393,7 @@ const defaultSettings: Settings = { warnOnUnverifiedDevices: false, stripTrackingParams: true, + stripImageMetadata: true, settingsSync: true, diff --git a/src/app/utils/__fixtures__/clean.jpg b/src/app/utils/__fixtures__/clean.jpg new file mode 100644 index 000000000..48501159c Binary files /dev/null and b/src/app/utils/__fixtures__/clean.jpg differ diff --git a/src/app/utils/__fixtures__/gps.jpg b/src/app/utils/__fixtures__/gps.jpg new file mode 100644 index 000000000..20143e43d Binary files /dev/null and b/src/app/utils/__fixtures__/gps.jpg differ diff --git a/src/app/utils/__fixtures__/meta.png b/src/app/utils/__fixtures__/meta.png new file mode 100644 index 000000000..70e1e079c Binary files /dev/null and b/src/app/utils/__fixtures__/meta.png differ diff --git a/src/app/utils/__fixtures__/meta.webp b/src/app/utils/__fixtures__/meta.webp new file mode 100644 index 000000000..35ac042db Binary files /dev/null and b/src/app/utils/__fixtures__/meta.webp differ diff --git a/src/app/utils/__fixtures__/plain.jpg b/src/app/utils/__fixtures__/plain.jpg new file mode 100644 index 000000000..9b1b0040d Binary files /dev/null and b/src/app/utils/__fixtures__/plain.jpg differ diff --git a/src/app/utils/stripImageMetadata.test.ts b/src/app/utils/stripImageMetadata.test.ts new file mode 100644 index 000000000..a19bfc468 --- /dev/null +++ b/src/app/utils/stripImageMetadata.test.ts @@ -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 }); + }); +}); diff --git a/src/app/utils/stripImageMetadata.ts b/src/app/utils/stripImageMetadata.ts new file mode 100644 index 000000000..5ed74afca --- /dev/null +++ b/src/app/utils/stripImageMetadata.ts @@ -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 { + 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 }; +} diff --git a/src/app/utils/uploadItems.ts b/src/app/utils/uploadItems.ts index 026bb65e3..e85b55f82 100644 --- a/src/app/utils/uploadItems.ts +++ b/src/app/utils/uploadItems.ts @@ -3,20 +3,44 @@ import { TUploadItem } from '../state/room/roomInputDrafts'; import { encryptFile } from './matrix'; import { safeFile } from './mimeTypes'; import { fulfilledPromiseSettledResult } from './common'; +import { stripImageMetadata } from './stripImageMetadata'; /** * 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 * target (#155). */ -export async function filesToUploadItems(room: Room, files: File[]): Promise { - const safeFiles = files.map(safeFile); - const metadata = { markedAsSpoiler: false }; +export async function filesToUploadItems( + room: Room, + files: File[], + stripMetadata = true, +): Promise { + 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()) { 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, + })); }