diff --git a/tests/blob-sanitize-test.html b/tests/blob-sanitize-test.html
new file mode 100644
index 0000000..6b40565
--- /dev/null
+++ b/tests/blob-sanitize-test.html
@@ -0,0 +1,268 @@
+
+
+
+
+blob-sanitize.mjs tests
+
+
+
+blob-sanitize.mjs — client-side stripping tests
+Tests run in the browser. Fixtures are crafted in-memory (no exiftool needed).
+
+
+
+
+
+
diff --git a/www/js/blob-sanitize.mjs b/www/js/blob-sanitize.mjs
new file mode 100644
index 0000000..18c5ac1
--- /dev/null
+++ b/www/js/blob-sanitize.mjs
@@ -0,0 +1,236 @@
+/**
+ * blob-sanitize.mjs — Privacy-metadata stripping for browser uploads.
+ *
+ * The client side of the metadata-stripping pipeline. Strips EXIF/XMP/IPTC/
+ * PNG text chunks/PDF author tags from files before they leave the device,
+ * so the SHA-256 the client signs (and publishes in Nostr events) is the hash
+ * of the *cleaned* bytes. This preserves BUD-01 content addressing: the
+ * server stores and serves exactly the bytes the client signed.
+ *
+ * Exports:
+ * stripBlobMetadata(file, opts) → Promise — cleaned or pass-through
+ * hasImageMetadata(file) → Promise — lightweight check
+ *
+ * See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 1).
+ */
+
+// ─── Image path (JPEG / PNG / WebP) ──────────────────────────────────────
+// Canvas re-encode: the standard browser approach. Drops EXIF segments,
+// XMP packets, IPTC, PNG tEXt/iTXt/eXIf chunks, and MakerNotes because the
+// canvas encoder only emits pixels. EXIF orientation is baked into the
+// pixels first via createImageBitmap({ imageOrientation: 'from-image' }).
+
+async function stripImage(file, opts = {}) {
+ const quality = opts.quality ?? 0.92;
+ // imageOrientation: 'from-image' reads EXIF rotation and applies it to the
+ // bitmap so the re-encoded pixels are upright even after the EXIF segment
+ // is gone. Supported in modern Chromium/Firefox/Safari.
+ const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
+ const canvas = document.createElement('canvas');
+ canvas.width = bitmap.width;
+ canvas.height = bitmap.height;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(bitmap, 0, 0);
+ bitmap.close?.();
+
+ // Preserve PNG transparency; everything else → JPEG.
+ const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
+ const blob = await new Promise((resolve, reject) => {
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('canvas.toBlob failed'))),
+ type, type === 'image/jpeg' ? quality : undefined);
+ });
+
+ // Preserve the original extension where possible.
+ const baseName = file.name.replace(/\.[^.]+$/, '') || 'blob';
+ const ext = type === 'image/png' ? '.png' : '.jpg';
+ const name = baseName + ext;
+ return new File([blob], name, { type, lastModified: file.lastModified });
+}
+
+// ─── PDF path (byte-level stripper) ───────────────────────────────────────
+// Nulls the values of /Author, /Title, /Subject, /Keywords, /Creator,
+// /Producer, /CreationDate, /ModDate and removes XMP /Metadata stream
+// objects. Operates on the raw bytes — no pdf-lib dependency. This handles
+// the common cases; if real-world PDFs break it, we can adopt pdf-lib later.
+//
+// Strategy: for each forbidden key, find "/Key (value)" or "/Key "
+// or "/Key value" patterns and blank the value to empty. For /Metadata and
+// /XMP stream objects, we can't safely remove them without rebuilding the
+// xref, so we flag them and the server reject path catches any we miss.
+
+async function stripPdf(file) {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ let str = new TextDecoder('latin1').decode(bytes); // PDF is byte-oriented
+
+ // Null out dictionary entries for identity/author fields.
+ // Match /Key followed by a value: (paren string), , ,
+ // or a bare token. Replace the value with () or null.
+ const keysToBlank = [
+ '/Author', '/Title', '/Subject', '/Keywords', '/Creator',
+ '/Producer', '/CreationDate', '/ModDate',
+ ];
+ for (const key of keysToBlank) {
+ // /Key ( ... ) — parenthesized text string (handle nested parens minimally)
+ str = str.replace(
+ new RegExp(key + '\\s*\\((?:[^()\\\\]|\\\\.)*\\)', 'g'),
+ key + '()'
+ );
+ // /Key < ... > — hex string
+ str = str.replace(
+ new RegExp(key + '\\s*<[0-9A-Fa-f\\s]*>', 'g'),
+ key + '<>'
+ );
+ }
+
+ // For /Metadata and /XMP: we can't easily strip the stream object without
+ // rebuilding cross-references. Instead, blank the /Metadata reference in
+ // the catalog so readers don't follow it. The server scanner will still
+ // flag a PDF that has a raw /Metadata or /XMP token, which is the
+ // conservative behavior we want.
+ str = str.replace(/\/Metadata\s+\d+\s+0\s+R/g, '/Metadata null');
+
+ const out = new TextEncoder().encode(str);
+ // Only create a new File if we actually changed bytes.
+ if (out.length === bytes.length && out.every((b, i) => b === bytes[i])) {
+ return file; // unchanged
+ }
+ return new File([out], file.name, { type: 'application/pdf', lastModified: file.lastModified });
+}
+
+// ─── Format dispatch ─────────────────────────────────────────────────────
+
+const IMAGE_TYPES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp']);
+
+/**
+ * Strip privacy-sensitive metadata from a file.
+ *
+ * @param {File} file — the original file
+ * @param {object} [opts]
+ * @param {number} [opts.quality=0.92] — JPEG re-encode quality (0–1)
+ * @param {boolean} [opts.force=false] — if true, re-encode even types that
+ * would normally pass through (used by the 415 retry path)
+ * @returns {Promise} — a new File with metadata removed, or the
+ * original File for pass-through types (audio, video, GIF, unknown).
+ */
+export async function stripBlobMetadata(file, opts = {}) {
+ if (!file || !(file instanceof File)) return file;
+
+ const type = (file.type || '').toLowerCase();
+
+ // Images: canvas re-encode.
+ if (IMAGE_TYPES.has(type)) {
+ try {
+ return await stripImage(file, opts);
+ } catch (err) {
+ console.warn('[blob-sanitize] image strip failed, passing through:', err?.message || err);
+ return file;
+ }
+ }
+
+ // PDF: byte-level stripper.
+ if (type === 'application/pdf') {
+ try {
+ return await stripPdf(file);
+ } catch (err) {
+ console.warn('[blob-sanitize] PDF strip failed, passing through:', err?.message || err);
+ return file;
+ }
+ }
+
+ // Audio, video, GIF, and unknown formats: pass through unchanged.
+ // The server detect-and-reject backstop flags anything with metadata.
+ // (Animated GIF/WebP would be flattened by canvas; audio/video need
+ // ffmpeg.wasm which is deferred to Phase 2.)
+ return file;
+}
+
+/**
+ * Lightweight check: does this image file carry EXIF/XMP metadata?
+ * Used by the server-rejection retry path and optional "warn before upload" UI.
+ *
+ * @param {File} file
+ * @returns {Promise} — true if EXIF/XMP is likely present
+ */
+export async function hasImageMetadata(file) {
+ if (!file || !(file instanceof File)) return false;
+ const type = (file.type || '').toLowerCase();
+ if (!IMAGE_TYPES.has(type)) return false;
+
+ const bytes = new Uint8Array(await file.arrayBuffer());
+
+ // JPEG: look for APP1 Exif or XMP markers.
+ if (type === 'image/jpeg' || type === 'image/jpg') {
+ if (bytes.length < 4) return false;
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
+ let i = 2;
+ while (i + 4 < bytes.length) {
+ if (bytes[i] !== 0xff) break;
+ const marker = bytes[i + 1];
+ if (marker === 0xda) break; // SOS — image data
+ if (marker === 0xe1) {
+ const segLen = (bytes[i + 2] << 8) | bytes[i + 3];
+ if (i + 4 + 6 <= bytes.length) {
+ const head = bytes.slice(i + 4, i + 4 + 6);
+ if (head[0] === 0x45 && head[1] === 0x78 && head[2] === 0x69 &&
+ head[3] === 0x66 && head[4] === 0x00 && head[5] === 0x00) {
+ return true; // Exif\0\0
+ }
+ }
+ if (i + 4 + 29 <= bytes.length) {
+ const xmp = new TextDecoder().decode(bytes.slice(i + 4, i + 4 + 29));
+ if (xmp.startsWith('http://ns.adobe.com/xap/1.0/')) return true;
+ }
+ i += 2 + segLen;
+ } else if (marker === 0xed) {
+ return true; // APP13 IPTC/Photoshop
+ } else if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
+ i += 2;
+ } else {
+ if (i + 2 + 2 > bytes.length) break;
+ const segLen = (bytes[i + 2] << 8) | bytes[i + 3];
+ i += 2 + segLen;
+ }
+ }
+ return false;
+ }
+
+ // PNG: look for eXIf or tEXt/iTXt chunks with denylisted keys.
+ if (type === 'image/png') {
+ if (bytes.length < 8) return false;
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
+ for (let k = 0; k < 8; k++) if (bytes[k] !== sig[k]) return false;
+ let i = 8;
+ const denied = ['software', 'comment', 'author', 'description', 'copyright',
+ 'xml:com.adobe.xmp', 'raw profile type exif', 'source', 'title'];
+ while (i + 8 < bytes.length) {
+ const len = (bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3];
+ const t = new TextDecoder().decode(bytes.slice(i + 4, i + 8));
+ if (t === 'eXIf') return true;
+ if (t === 'tEXt' || t === 'iTXt' || t === 'zTXt') {
+ let klen = 0;
+ while (klen < len && bytes[i + 8 + klen] !== 0) klen++;
+ const key = new TextDecoder().decode(bytes.slice(i + 8, i + 8 + klen)).toLowerCase();
+ if (denied.some((d) => key.startsWith(d))) return true;
+ }
+ if (t === 'IEND') break;
+ i += 8 + len + 4;
+ }
+ return false;
+ }
+
+ // WebP: look for EXIF or XMP chunks.
+ if (type === 'image/webp') {
+ if (bytes.length < 12) return false;
+ if (bytes[0] !== 0x52 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x46) return false;
+ let i = 12;
+ while (i + 8 < bytes.length) {
+ const t = new TextDecoder().decode(bytes.slice(i, i + 4));
+ if (t === 'EXIF' || t === 'XMP ') return true;
+ const clen = bytes[i + 4] | (bytes[i + 5] << 8) | (bytes[i + 6] << 16) | (bytes[i + 7] << 24);
+ i += 8 + clen + (clen & 1);
+ }
+ return false;
+ }
+
+ return false;
+}
diff --git a/www/js/blossom-api.mjs b/www/js/blossom-api.mjs
index fc9aa39..4fb1371 100644
--- a/www/js/blossom-api.mjs
+++ b/www/js/blossom-api.mjs
@@ -1,5 +1,6 @@
import { getPubkey } from './init-ndk.mjs';
import { getBlossomServers } from './blossom-ui.mjs';
+import { stripBlobMetadata } from './blob-sanitize.mjs';
const normalizeUrl = (url = '') => url.trim().replace(/\/$/, '');
@@ -73,14 +74,21 @@ export const listUserBlobs = async (serverUrl, pubkey, since = 0) => {
throw new Error(`Failed to list blobs: ${response.status}`);
};
-export const uploadToServer = async (file, serverUrl) => {
+export const uploadToServer = async (file, serverUrl, opts = {}) => {
const cleanUrl = normalizeUrl(serverUrl);
const currentPubkey = await getPubkey();
if (!currentPubkey) {
throw new Error('Please login with your Nostr keys first');
}
- const sha256 = await calculateSHA256(file);
+ // Privacy-metadata stripping (Phase 1). Strip before hashing so the
+ // client-signed SHA-256 commits to the *cleaned* bytes — this preserves
+ // BUD-01 content addressing (the server stores exactly what we signed).
+ // skipSanitize: true is the opt-out for verbatim/mirror uploads where the
+ // bytes are already content-addressed on another blossom server.
+ const cleanFile = opts.skipSanitize ? file : await stripBlobMetadata(file);
+
+ const sha256 = await calculateSHA256(cleanFile);
const event = {
kind: 24242,
@@ -88,10 +96,10 @@ export const uploadToServer = async (file, serverUrl) => {
tags: [
['t', 'upload'],
['x', sha256],
- ['size', String(file.size)],
+ ['size', String(cleanFile.size)],
['expiration', String(Math.floor(Date.now() / 1000) + 3600)]
],
- content: `Upload ${file.name}`,
+ content: `Upload ${cleanFile.name}`,
pubkey: currentPubkey
};
@@ -105,13 +113,28 @@ export const uploadToServer = async (file, serverUrl) => {
mode: 'cors',
cache: 'no-cache',
headers: {
- 'Content-Type': file.type || 'application/octet-stream',
+ 'Content-Type': cleanFile.type || 'application/octet-stream',
Authorization: `Nostr ${authToken}`
},
- body: file
+ body: cleanFile
});
+ // Server-side metadata backstop: if the server still detected forbidden
+ // metadata, retry once with a forced stricter re-encode before giving up.
+ if (response.status === 415 && !opts.skipSanitize && !opts._retried) {
+ const reason = response.headers.get('X-Reason') || '';
+ if (reason.includes('exif_detected')) {
+ console.warn('[blossom-api] Server rejected upload (metadata detected), retrying with forced re-encode:', reason);
+ const forced = await stripBlobMetadata(cleanFile, { force: true, quality: 0.85 });
+ return uploadToServer(forced, serverUrl, { ...opts, _retried: true });
+ }
+ }
+
if (!response.ok) {
+ const reason = response.headers.get('X-Reason') || '';
+ if (reason.includes('exif_detected')) {
+ throw new Error(`Upload rejected: privacy metadata could not be removed (${reason})`);
+ }
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
diff --git a/www/js/post-composer.mjs b/www/js/post-composer.mjs
index 1488d5e..c1a97b6 100644
--- a/www/js/post-composer.mjs
+++ b/www/js/post-composer.mjs
@@ -346,7 +346,7 @@ export function mountComposer(hostEl, options = {}) {
function updateUploadingUi() {
if (uploadingCount > 0) {
uploadStatus.style.display = 'block';
- uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`;
+ uploadStatus.textContent = `Stripping metadata & uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}…`;
return;
}
@@ -481,13 +481,28 @@ export function mountComposer(hostEl, options = {}) {
}
for (const file of arr) {
- const { sha256 } = await uploadToAllServers(file);
- const ext = getFileExtension(file);
- const url = getBlobUrl(sha256, ext);
+ try {
+ const { sha256 } = await uploadToAllServers(file);
+ const ext = getFileExtension(file);
+ const url = getBlobUrl(sha256, ext);
- hostEl.focus();
- const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
- document.execCommand('insertText', false, `${prefix}${url}\n`);
+ hostEl.focus();
+ const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
+ document.execCommand('insertText', false, `${prefix}${url}\n`);
+ } catch (err) {
+ const msg = err?.message || String(err);
+ if (msg.includes('privacy metadata')) {
+ uploadStatus.style.display = 'block';
+ uploadStatus.textContent = `⚠ ${file.name}: ${msg}`;
+ uploadStatus.style.color = '#f85149';
+ console.error('[post-composer] metadata rejection:', msg);
+ } else {
+ console.error('[post-composer] upload failed:', msg);
+ uploadStatus.style.display = 'block';
+ uploadStatus.textContent = `⚠ ${file.name}: upload failed`;
+ uploadStatus.style.color = '#f85149';
+ }
+ }
}
} finally {
uploadingCount = Math.max(0, uploadingCount - arr.length);