diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..41583e3 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/web/bun.lock b/web/bun.lock index e9a95f5..9e377f6 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,6 +5,7 @@ "": { "name": "drop-web", "dependencies": { + "@mary/exif-rm": "npm:@jsr/mary__exif-rm", "solid-js": "^1.9.13", }, "devDependencies": { @@ -69,6 +70,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mary/exif-rm": ["@jsr/mary__exif-rm@0.2.2", "https://npm.jsr.io/~/11/@jsr/mary__exif-rm/0.2.2.tgz", {}, "sha512-+ZpLaC+1CyqWhH608Sqd6/yTG0pOlokn2tCXha7s1SMQ+GLKo4Nn/PskTeeP9Pt+6gNYSu6ednoSlRvXb2ZGxg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], diff --git a/web/package.json b/web/package.json index 75de27f..6fe79e0 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "build": "vite build" }, "dependencies": { + "@mary/exif-rm": "npm:@jsr/mary__exif-rm", "solid-js": "^1.9.13" }, "devDependencies": { diff --git a/web/src/lib/exif.test.ts b/web/src/lib/exif.test.ts new file mode 100644 index 0000000..5d0ce78 --- /dev/null +++ b/web/src/lib/exif.test.ts @@ -0,0 +1,55 @@ +/// + +import { describe, expect, test } from "bun:test"; + +import { stripExifFromBuffer } from "./exif"; + +const encoder = new TextEncoder(); + +function buffer(bytes: number[]) { + return new Uint8Array(bytes).buffer; +} + +function textBuffer(text: string) { + return encoder.encode(text).buffer; +} + +describe("EXIF stripping helpers", () => { + test("removes GPS data carried inside JPEG EXIF metadata", () => { + const jpegWithGpsExif = buffer([ + 0xff, 0xd8, 0xff, 0xe1, 0x00, 0x16, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00, 0x47, 0x50, 0x53, + 0x4c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x52, 0x65, 0x66, 0xff, 0xda, 0x00, 0x08, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0xff, 0xd9, + ]); + + const stripped = stripExifFromBuffer(jpegWithGpsExif); + const strippedBytes = new Uint8Array(stripped); + + expect(new TextDecoder().decode(strippedBytes)).not.toContain("GPS"); + expect(Array.from(strippedBytes)).toEqual([ + 0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0xff, 0xd9, + ]); + }); + + test("removes JPEG EXIF segments before encryption", () => { + const jpegWithExif = buffer([ + 0xff, 0xd8, 0xff, 0xe1, 0x00, 0x0a, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00, 0x01, 0x02, 0xff, + 0xda, 0x00, 0x08, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0xff, 0xd9, + ]); + + const stripped = stripExifFromBuffer(jpegWithExif); + const strippedBytes = Array.from(new Uint8Array(stripped)); + + expect(stripped).not.toBe(jpegWithExif); + expect(strippedBytes).toEqual([ + 0xff, 0xd8, 0xff, 0xda, 0x00, 0x08, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0xff, 0xd9, + ]); + }); + + test("leaves unsupported files untouched", () => { + const plain = textBuffer("not an image"); + const stripped = stripExifFromBuffer(plain); + + expect(stripped).toBe(plain); + }); +}); diff --git a/web/src/lib/exif.ts b/web/src/lib/exif.ts new file mode 100644 index 0000000..79ea417 --- /dev/null +++ b/web/src/lib/exif.ts @@ -0,0 +1,28 @@ +import { remove as removeExif } from "@mary/exif-rm"; + +export type UploadFileBuffer = { + fileName: string; + fileBuffer: ArrayBuffer; +}; + +function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const { buffer, byteOffset, byteLength } = bytes; + if (buffer instanceof ArrayBuffer && byteOffset === 0 && byteLength === buffer.byteLength) { + return buffer; + } + return buffer.slice(byteOffset, byteOffset + byteLength) as ArrayBuffer; +} + +export function stripExifFromBuffer(buffer: ArrayBuffer) { + const stripped = removeExif(new Uint8Array(buffer)); + return stripped === null ? buffer : exactArrayBuffer(stripped); +} + +export async function prepareFileForUpload(file: File, index: number): Promise { + const buffer = await file.arrayBuffer(); + + return { + fileName: file.name || `file-${index + 1}`, + fileBuffer: stripExifFromBuffer(buffer), + }; +} diff --git a/web/src/pages/Upload.tsx b/web/src/pages/Upload.tsx index 2044aa6..3e32ae9 100644 --- a/web/src/pages/Upload.tsx +++ b/web/src/pages/Upload.tsx @@ -1,6 +1,7 @@ import { createSignal, Show, For, onMount, onCleanup, createMemo, createEffect } from "solid-js"; import { generateKey } from "../lib/crypto"; +import { prepareFileForUpload } from "../lib/exif"; import { btnClass, btnStyle, fadeIn } from "../lib/ui"; import { formatBytes } from "../lib/utils"; @@ -396,10 +397,7 @@ export default function Upload() { const fileBuffers: { fileName: string; fileBuffer: ArrayBuffer }[] = []; for (const [index, f] of selectedFiles.entries()) { - fileBuffers.push({ - fileName: f.name || `file-${index + 1}`, - fileBuffer: await f.arrayBuffer(), - }); + fileBuffers.push(await prepareFileForUpload(f, index)); if (uploadCancelled) throw new Error("Upload cancelled"); }