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");
}