Add blob-sanitize.mjs with stripBlobMetadata() (canvas re-encode for JPEG/PNG/WebP, byte-level PDF /Author stripper, pass-through for audio/video) and hasImageMetadata() (lightweight EXIF/XMP detection). Wire into uploadToServer() before calculateSHA256 so the client-signed hash commits to cleaned bytes (preserves BUD-01 content addressing). Add skipSanitize opt-out for mirror/verbatim uploads. On 415 + X-Reason: exif_detected, retry once with forced stricter re-encode. Update post-composer status text and surface metadata-rejection errors. See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 1).
237 lines
9.8 KiB
JavaScript
237 lines
9.8 KiB
JavaScript
/**
|
||
* 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<File> — cleaned or pass-through
|
||
* hasImageMetadata(file) → Promise<boolean> — 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 <value>"
|
||
// 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), <hex string>, <dict>,
|
||
// 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<File>} — 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<boolean>} — 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;
|
||
}
|