Files
client/www/js/blob-sanitize.mjs
T
Laan Tungir d1f537fcae Phase 1: client-side metadata stripping
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).
2026-07-31 07:25:56 -04:00

237 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (01)
* @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;
}