Compare commits

...
5 Commits
Author SHA1 Message Date
Laan Tungir 8979e47489 Move app-stacks sub-project to zapstore_app_stacks repo, keep app-stacks.html in client for deployment 2026-08-04 16:24:55 -04:00
Laan Tungir d4dfd31263 Rewrite kind 3063 fetch to use #i tag filtering via direct WebSocket, bypassing NDK grouping/cache issues 2026-08-04 11:37:02 -04:00
Laan Tungir b0233f8bc3 Fix relay reconnect for write-only relays: trailing-slash-tolerant pool lookup + add write-only relays to pool on demand 2026-08-04 09:32:52 -04:00
Laan Tungir d66d4f58c9 Publish all 20 app stacks and 182 app definitions via n_signer qrexec 2026-08-03 18:46:27 -04:00
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
12 changed files with 107390 additions and 50 deletions
+90
View File
@@ -0,0 +1,90 @@
# App Stacks Integration Plan
## Constraint
**No changes to HTML structure. No new CSS. No removal of anything.** All additions are JavaScript-only inside the existing `<script type="module">` block in [`www/app-stacks.html`](www/app-stacks.html).
## Goal
Add JavaScript to:
1. Subscribe to kind 30078 events with `#t: app-definition` tag
2. Display received app definitions inside the existing empty `#divBody`
3. Publish new app-definition events (kind 30078) via `publishEvent()` from `init-ndk.mjs`
## What Changes
### File: [`www/app-stacks.html`](www/app-stacks.html)
Only the `<script type="module">` block (lines 149-806) gets additions. Specifically:
#### 1. New global variables (after line 193)
```javascript
// App definitions state
let apps = [];
let appDefSub = null;
let appDefsLoaded = false;
```
#### 2. New functions (inserted after the EVENT LISTENERS section at line 547)
| Function | Purpose |
|----------|---------|
| `parseAppDefinition(evt)` | Parse a kind 30078 event with `#t: app-definition` into `{ identifier, name, pubkey, repository, description, category, eventId }` |
| `getTagValue(tags, name)` | Helper to extract a tag value by name |
| `renderApps()` | Render collected apps as simple HTML into `#divBody` |
| `escapeHtml(str)` | XSS-safe HTML escaping |
| `publishAppDefinition(name, identifier, repository, category)` | Create and publish a kind 30078 app-definition event via `publishEvent()` |
| `showPublishForm()` | Prompt for name/identifier/repository/category, then call `publishAppDefinition()` |
| `subscribeAppDefinitions()` | Call `subscribe()` with `{ kinds: [30078], '#t': ['app-definition'], limit: 500 }` |
| `initAppDefinitionListener()` | Add `ndkEvent` and `ndkEose` window event listeners |
#### 3. Modified: `main()` function (line 616)
Add these calls after `await initializeAuthenticatedPageFeatures()`:
```javascript
// Set up app-definition event listener
initAppDefinitionListener();
// Subscribe to app-definition events
subscribeAppDefinitions();
```
#### 4. Modified: `authMode` default (line 218)
Change from `'required'` to `'optional'` so the page loads without forcing login.
## What Does NOT Change
- `<title>` — stays "TEMPLATE"
- Header text — stays empty
- Body — stays empty (UI is built dynamically by JS)
- Footer — unchanged
- Sidenav — unchanged
- Hamburger menu — unchanged
- Any CSS — no additions
- Any HTML elements — no additions or removals
## Data Flow
```mermaid
flowchart LR
A[Page Load] --> B[main]
B --> C[initAppDefinitionListener]
B --> D[subscribeAppDefinitions]
D --> E[NDK worker subscribes<br>kind 30078 #t: app-definition]
E --> F[ndkEvent window event]
F --> G[parseAppDefinition]
G --> H[renderApps into #divBody]
I[User clicks Publish button] --> J[showPublishForm prompts]
J --> K[publishAppDefinition]
K --> L[publishEvent from init-ndk.mjs]
L --> M[Event arrives via subscription]
M --> G
```
## Files to Modify
| File | Change |
|------|--------|
| [`www/app-stacks.html`](www/app-stacks.html) | Add ~130 lines of JS inside existing `<script type="module">` block. Change `authMode` default. |
+68
View File
@@ -0,0 +1,68 @@
# nostr-login-lite Path Audit
## Finding
**Every HTML page** in the project loads `nostr-lite.js` using the same absolute path:
```html
<script src="/nostr-login-lite/nostr-lite.js"></script>
```
This means the file must be served from the **web root** at `/var/www/html/nostr-login-lite/nostr-lite.js`, not from inside the `client` subdirectory.
## Pages Using This Path
| Page | Line |
|------|------|
| www/index.html | 366 |
| www/template.html | 147 |
| www/template copy.html | 147 |
| www/feed.html | 140 |
| www/post.html | 205 |
| www/notifications.html | 385 |
| www/relays.html | 334 |
| www/cal.html | 469 |
| www/strudel.html | 258 |
| www/vj.html | 1909 |
| www/music.html | 1381 |
| www/cashu.html | 722 |
| www/ai.html | 746 |
| www/blobs.html | 687 |
| www/document.html | 918 |
| www/event-management.html | 436 |
| www/msg.html | 460 |
| www/people.html | 256 |
| www/profile.html | 271 |
| www/todo.html | 308 |
| www/tools.html | 527 |
| www/conway.html | 213 |
| www/didactyl.html | 516 |
| www/keep-alive.html | 244 |
| www/npub.html | 164 |
| www/post-feed.html | 243 |
| www/projects.html | 265 |
| www/skills-edit.html | 642 |
| www/slide-show.html | 334 |
| www/ai-tv.html | 802 |
| www/html-tv.html | 879 |
| www/llm-steganography.html | 870 |
| www/music-greyscale.html | 1336 |
| www/vj-playlist.html | 310 |
| www/block.html | 304 |
| www/c-relay-pg.html | 405 |
| www/relay-admin.html | 517 |
| www/feed-old.html | 142 |
| www/note.html | 439 |
| www/db.html | 381 |
| www/old/event.html | 304 |
| www/old/skills-demo.html | 1018 |
| www/old/stream.html | 326 |
| www/old/relay-test.html | 160 |
| www/old/bunker.html | 148 |
| www/old/links.html | 172 |
| www/old/db_relay.html | 381 |
| www/old/stream-ctrl.html | 328 |
## Conclusion
The dangling symlink at `/var/www/html/client/nostr-login-lite` is safe to remove — none of the pages reference it. They all use the absolute `/nostr-login-lite/` path from the web root.
+268
View File
@@ -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>
-3
View File
@@ -50,9 +50,6 @@ else
exit 1
fi
# Clean up temp directory
ssh $SERVER "rm -rf $TEMP_PATH"
echo ""
echo "✅ All files synced successfully!"
echo ""
+1131
View File
File diff suppressed because it is too large Load Diff
+236
View File
@@ -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 (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;
}
+29 -6
View File
@@ -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}`);
}
+22 -7
View File
@@ -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);
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.95",
"VERSION_NUMBER": "0.7.95",
"BUILD_DATE": "2026-07-31T10:32:03.777Z"
"VERSION": "v0.7.99",
"VERSION_NUMBER": "0.7.99",
"BUILD_DATE": "2026-08-04T20:24:55.694Z"
}
+89543 -31
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff