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).
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>blob-sanitize.mjs tests</title>
|
||||
<style>
|
||||
body { font: 14px/1.5 monospace; background: #0d1117; color: #c9d1d9; padding: 24px; }
|
||||
h1 { font-size: 18px; }
|
||||
.pass { color: #3fb950; }
|
||||
.fail { color: #f85149; }
|
||||
.summary { font-size: 16px; font-weight: bold; margin-top: 16px; padding: 12px; border: 1px solid #30363d; border-radius: 6px; }
|
||||
pre { background: #161b22; padding: 8px; border-radius: 4px; overflow-x: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>blob-sanitize.mjs — client-side stripping tests</h1>
|
||||
<p>Tests run in the browser. Fixtures are crafted in-memory (no exiftool needed).</p>
|
||||
<pre id="out"></pre>
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<script type="module">
|
||||
import { stripBlobMetadata, hasImageMetadata } from '../www/js/blob-sanitize.mjs';
|
||||
|
||||
const out = document.getElementById('out');
|
||||
const summaryEl = document.getElementById('summary');
|
||||
let pass = 0, fail = 0;
|
||||
|
||||
function log(msg) {
|
||||
out.textContent += msg + '\n';
|
||||
}
|
||||
function ok(name) { pass++; log(` ✅ PASS: ${name}`); }
|
||||
function bad(name, detail) { fail++; log(` ❌ FAIL: ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// SHA-256 of a File/Blob, returns hex.
|
||||
async function sha256(file) {
|
||||
const buf = await file.arrayBuffer();
|
||||
const h = await crypto.subtle.digest('SHA-256', buf);
|
||||
return Array.from(new Uint8Array(h)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// Read file bytes as Uint8Array.
|
||||
async function bytes(file) {
|
||||
return new Uint8Array(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
// Check if a JPEG contains an Exif\0\0 marker.
|
||||
function jpegHasExif(arr) {
|
||||
if (arr.length < 4 || arr[0] !== 0xff || arr[1] !== 0xd8) return false;
|
||||
let i = 2;
|
||||
while (i + 4 < arr.length) {
|
||||
if (arr[i] !== 0xff) break;
|
||||
const marker = arr[i + 1];
|
||||
if (marker === 0xda) break;
|
||||
if (marker === 0xe1) {
|
||||
if (i + 10 <= arr.length) {
|
||||
const head = arr.slice(i + 4, i + 10);
|
||||
if (head[0] === 0x45 && head[1] === 0x78 && head[2] === 0x69 &&
|
||||
head[3] === 0x66 && head[4] === 0x00 && head[5] === 0x00) return true;
|
||||
}
|
||||
const segLen = (arr[i + 2] << 8) | arr[i + 3];
|
||||
i += 2 + segLen;
|
||||
} else if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
i += 2;
|
||||
} else {
|
||||
const segLen = (arr[i + 2] << 8) | arr[i + 3];
|
||||
i += 2 + segLen;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if a PNG has an eXIf chunk.
|
||||
function pngHasExif(arr) {
|
||||
if (arr.length < 8) return false;
|
||||
let i = 8;
|
||||
while (i + 8 < arr.length) {
|
||||
const len = (arr[i] << 24) | (arr[i + 1] << 16) | (arr[i + 2] << 8) | arr[i + 3];
|
||||
const t = new TextDecoder().decode(arr.slice(i + 4, i + 8));
|
||||
if (t === 'eXIf') return true;
|
||||
if (t === 'IEND') break;
|
||||
i += 8 + len + 4;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create a small test JPEG with EXIF using canvas + manual APP1 injection.
|
||||
async function makeExifJpeg() {
|
||||
// First make a clean 2x2 JPEG via canvas.
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2; canvas.height = 2;
|
||||
canvas.getContext('2d').fillRect(0, 0, 2, 2);
|
||||
const cleanBlob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.9));
|
||||
const cleanBytes = new Uint8Array(await cleanBlob.arrayBuffer());
|
||||
|
||||
// Build an APP1 Exif segment: FF E1 <len> Exif\0\0 <minimal TIFF header>
|
||||
const exifPayload = new Uint8Array([
|
||||
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // Exif\0\0
|
||||
0x49, 0x49, 0x2a, 0x00, // little-endian TIFF header
|
||||
0x08, 0x00, 0x00, 0x00, // offset to IFD0
|
||||
0x00, 0x00, // 0 entries
|
||||
0x00, 0x00, 0x00, 0x00 // next IFD = 0
|
||||
]);
|
||||
const segLen = exifPayload.length + 2;
|
||||
const app1 = new Uint8Array([0xff, 0xe1, (segLen >> 8) & 0xff, segLen & 0xff, ...exifPayload]);
|
||||
|
||||
// Insert APP1 right after FF D8 (before the existing markers).
|
||||
const out = new Uint8Array(cleanBytes.length + app1.length);
|
||||
out.set(cleanBytes.slice(0, 2), 0); // FF D8
|
||||
out.set(app1, 2); // APP1 Exif
|
||||
out.set(cleanBytes.slice(2), 2 + app1.length); // rest
|
||||
return new File([out], 'exif.jpg', { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
// Create a clean JPEG via canvas.
|
||||
async function makeCleanJpeg() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2; canvas.height = 2;
|
||||
canvas.getContext('2d').fillRect(0, 0, 2, 2);
|
||||
const blob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.9));
|
||||
return new File([blob], 'clean.jpg', { type: 'image/jpeg' });
|
||||
}
|
||||
|
||||
// Create a PNG with an eXIf chunk.
|
||||
async function makeExifPng() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2; canvas.height = 2;
|
||||
canvas.getContext('2d').fillRect(0, 0, 2, 2);
|
||||
const blob = await new Promise((r) => canvas.toBlob(r, 'image/png'));
|
||||
const cleanBytes = new Uint8Array(await blob.arrayBuffer());
|
||||
|
||||
// Build an eXIf chunk: <len:4> eXIf <data> <crc:4>
|
||||
const exifData = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
|
||||
const chunk = new Uint8Array(4 + 4 + exifData.length + 4);
|
||||
const dv = new DataView(chunk.buffer);
|
||||
dv.setUint32(0, exifData.length); // length
|
||||
chunk[4] = 0x65; chunk[5] = 0x58; chunk[6] = 0x49; chunk[7] = 0x66; // "eXIf"
|
||||
chunk.set(exifData, 8);
|
||||
// CRC left as 0 — browsers don't validate on encode, and we only check the output.
|
||||
|
||||
// Insert before IEND. Find IEND (49 45 4E 44) in cleanBytes.
|
||||
let iendOff = -1;
|
||||
for (let i = cleanBytes.length - 12; i >= 8; i--) {
|
||||
if (cleanBytes[i] === 0x49 && cleanBytes[i + 1] === 0x45 &&
|
||||
cleanBytes[i + 2] === 0x4e && cleanBytes[i + 3] === 0x44) {
|
||||
// IEND chunk starts 4 bytes before the type (at the length field)
|
||||
iendOff = i - 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (iendOff < 0) return new File([cleanBytes], 'exif.png', { type: 'image/png' });
|
||||
const out = new Uint8Array(cleanBytes.length + chunk.length);
|
||||
out.set(cleanBytes.slice(0, iendOff), 0);
|
||||
out.set(chunk, iendOff);
|
||||
out.set(cleanBytes.slice(iendOff), iendOff + chunk.length);
|
||||
return new File([out], 'exif.png', { type: 'image/png' });
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function run() {
|
||||
log('=== blob-sanitize.mjs tests ===\n');
|
||||
|
||||
// Test 1: EXIF JPEG → stripped, no Exif marker, hash differs.
|
||||
log('[JPEG EXIF stripping]');
|
||||
{
|
||||
const exifFile = await makeExifJpeg();
|
||||
const stripped = await stripBlobMetadata(exifFile);
|
||||
const outBytes = await bytes(stripped);
|
||||
if (!jpegHasExif(outBytes)) ok('EXIF JPEG: no Exif marker after stripping');
|
||||
else bad('EXIF JPEG: no Exif marker after stripping');
|
||||
|
||||
const origHash = await sha256(exifFile);
|
||||
const newHash = await sha256(stripped);
|
||||
if (origHash !== newHash) ok('EXIF JPEG: hash differs (re-encode happened)');
|
||||
else bad('EXIF JPEG: hash differs (re-encode happened)');
|
||||
|
||||
if (stripped.type === 'image/jpeg') ok('EXIF JPEG: output type is image/jpeg');
|
||||
else bad('EXIF JPEG: output type is image/jpeg', `got ${stripped.type}`);
|
||||
}
|
||||
|
||||
// Test 2: clean JPEG → still re-encoded (canvas always re-encodes).
|
||||
log('[clean JPEG]');
|
||||
{
|
||||
const clean = await makeCleanJpeg();
|
||||
const stripped = await stripBlobMetadata(clean);
|
||||
const outBytes = await bytes(stripped);
|
||||
if (!jpegHasExif(outBytes)) ok('clean JPEG: no Exif marker');
|
||||
else bad('clean JPEG: no Exif marker');
|
||||
}
|
||||
|
||||
// Test 3: PNG with eXIf → stripped, no eXIf chunk.
|
||||
log('[PNG eXIf stripping]');
|
||||
{
|
||||
const exifPng = await makeExifPng();
|
||||
const stripped = await stripBlobMetadata(exifPng);
|
||||
const outBytes = await bytes(stripped);
|
||||
if (!pngHasExif(outBytes)) ok('PNG eXIf: no eXIf chunk after stripping');
|
||||
else bad('PNG eXIf: no eXIf chunk after stripping');
|
||||
|
||||
const origHash = await sha256(exifPng);
|
||||
const newHash = await sha256(stripped);
|
||||
if (origHash !== newHash) ok('PNG eXIf: hash differs');
|
||||
else bad('PNG eXIf: hash differs');
|
||||
}
|
||||
|
||||
// Test 4: hasImageMetadata detects EXIF.
|
||||
log('[hasImageMetadata]');
|
||||
{
|
||||
const exifFile = await makeExifJpeg();
|
||||
const detected = await hasImageMetadata(exifFile);
|
||||
if (detected === true) ok('hasImageMetadata: detects EXIF JPEG');
|
||||
else bad('hasImageMetadata: detects EXIF JPEG', `got ${detected}`);
|
||||
|
||||
const clean = await makeCleanJpeg();
|
||||
const cleanDetected = await hasImageMetadata(clean);
|
||||
if (cleanDetected === false) ok('hasImageMetadata: clean JPEG → false');
|
||||
else bad('hasImageMetadata: clean JPEG → false', `got ${cleanDetected}`);
|
||||
}
|
||||
|
||||
// Test 5: pass-through for audio.
|
||||
log('[pass-through audio]');
|
||||
{
|
||||
const audioBytes = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]);
|
||||
const audioFile = new File([audioBytes], 'test.mp3', { type: 'audio/mpeg' });
|
||||
const result = await stripBlobMetadata(audioFile);
|
||||
if (result === audioFile) ok('audio: pass-through (same File object)');
|
||||
else bad('audio: pass-through (same File object)');
|
||||
}
|
||||
|
||||
// Test 6: PDF with /Author → stripped.
|
||||
log('[PDF /Author stripping]');
|
||||
{
|
||||
const pdfContent = '%PDF-1.4\n/Author (Secret Person)\n/Producer (Test)\n%%EOF';
|
||||
const pdfFile = new File([pdfContent], 'test.pdf', { type: 'application/pdf' });
|
||||
const stripped = await stripBlobMetadata(pdfFile);
|
||||
const outText = new TextDecoder().decode(await bytes(stripped));
|
||||
if (!outText.includes('Secret Person')) ok('PDF: /Author value removed');
|
||||
else bad('PDF: /Author value removed');
|
||||
if (outText.includes('/Author()')) ok('PDF: /Author key blanked to ()');
|
||||
else bad('PDF: /Author key blanked to ()', `got: ${outText}`);
|
||||
}
|
||||
|
||||
// Test 7: null/undefined input.
|
||||
log('[edge cases]');
|
||||
{
|
||||
const r1 = await stripBlobMetadata(null);
|
||||
if (r1 === null) ok('null input → null');
|
||||
else bad('null input → null');
|
||||
}
|
||||
|
||||
// Summary.
|
||||
log('');
|
||||
const color = fail === 0 ? '#3fb950' : '#f85149';
|
||||
summaryEl.innerHTML = `<span style="color:${color}">${pass} passed, ${fail} failed</span>`;
|
||||
summaryEl.style.borderColor = fail === 0 ? '#3fb950' : '#f85149';
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
log(`\nFATAL: ${err?.message || err}\n${err?.stack || ''}`);
|
||||
fail++;
|
||||
summaryEl.innerHTML = `<span style="color:#f85149">${pass} passed, ${fail} failed</span>`;
|
||||
summaryEl.style.borderColor = '#f85149';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<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;
|
||||
}
|
||||
+29
-6
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user