From d07417524b5126dd9bd77c2cf1d8e3a5f1af702c Mon Sep 17 00:00:00 2001 From: sovITxyz Date: Fri, 17 Jul 2026 19:49:39 -0600 Subject: [PATCH 1/3] feat: Blossom image uploads in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds public/js/blossom.js — click the image button, drag, or paste an image and it uploads to public Blossom servers (blossom.band primary + BUD-04 mirror to blossom.nostr.build / nostr.download / cdn.nostrcheck.me) and inserts the markdown. Auth is a NbreadSigner-signed kind-24242 event; the returned URL is validated as a clean https:// destination before it's spliced into the post (rejects markdown-breakout chars). Uploads reuse the in-page signers (extension / NIP-46 / pasted key); the Amber NIP-55 redirect signer falls back to the URL template. All inserts go through the existing execCommand undo seam. --- public/js/blossom.js | 329 ++++++++++++++++++++++++++++++++++++ public/js/editor-toolbar.js | 217 +++++++++++++++++++++++- src/views/main/editor.tsx | 10 +- test/unit/blossom.spec.ts | 203 ++++++++++++++++++++++ 4 files changed, 752 insertions(+), 7 deletions(-) create mode 100644 public/js/blossom.js create mode 100644 test/unit/blossom.spec.ts diff --git a/public/js/blossom.js b/public/js/blossom.js new file mode 100644 index 0000000..93f38ea --- /dev/null +++ b/public/js/blossom.js @@ -0,0 +1,329 @@ +// NbreadBlossom — direct-from-browser image uploads to Blossom media servers +// (Nostr blob storage, BUD-01/02/04). The editor toolbar (editor-toolbar.js) +// calls uploadBlob() when the user drops, pastes, or picks an image; the file +// bytes go straight to the server over an authenticated PUT and never touch +// our Worker. +// +// Flow: hash the blob (SHA-256) -> sign a kind-24242 "Upload Blob" auth event +// with NbreadSigner -> PUT the raw bytes to the PRIMARY server's /upload -> +// validate the returned descriptor (https URL + sha match) -> best-effort +// mirror the URL (BUD-04, no re-upload) to the remaining servers. On a +// network/5xx/429/timeout failure the next server is promoted to primary. +// +// Load order (classic + diff --git a/test/unit/blossom.spec.ts b/test/unit/blossom.spec.ts new file mode 100644 index 0000000..fb0f97a --- /dev/null +++ b/test/unit/blossom.spec.ts @@ -0,0 +1,203 @@ +// Blossom uploader — PURE helper coverage. The two IIFEs are imported for +// their side effect (nostr-crypto assigns globalThis.NbreadCrypto, which +// blossom.js's base64url encoder depends on; blossom assigns +// globalThis.NbreadBlossom). Only the network-free helpers are exercised +// here — uploadBlob() (fetch + crypto.subtle) is covered by the integration +// surface, never unit-tested. +import { describe, expect, it } from "vitest"; +// @ts-ignore — plain browser IIFE, intentionally shipped without types +import "../../public/js/vendor/nostr-crypto.js"; +// @ts-ignore — plain browser IIFE, intentionally shipped without types +import "../../public/js/blossom.js"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +const crypto = (globalThis as any).NbreadCrypto as { + base64Decode: (s: string) => Uint8Array; + utf8Decode: (b: Uint8Array) => string; +}; + +const blossom = (globalThis as any).NbreadBlossom as { + BLOSSOM_SERVERS: string[]; + buildAuthEvent: ( + shaHex: string, + sec: number, + ) => { kind: number; created_at: number; content: string; tags: string[][] }; + encodeAuthHeader: (signed: unknown) => string; + validateFile: ( + file: unknown, + opts?: { maxBytes?: number; types?: string[] }, + ) => { ok: boolean; reason?: string }; + isHttpsUrl: (u: unknown) => boolean; + extForType: (mime: string) => string; + uploadBlob: (file: unknown, opts?: unknown) => Promise; +}; + +/** Reverse of blossom's base64url(no-pad) so we can round-trip the header. */ +function fromBase64Url(s: string): string { + const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); + const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad; + return crypto.utf8Decode(crypto.base64Decode(b64)); +} + +describe("blossom is loaded", () => { + it("assigns the API to globalThis", () => { + expect(blossom).toBeDefined(); + expect(typeof blossom.uploadBlob).toBe("function"); + expect(typeof blossom.buildAuthEvent).toBe("function"); + }); + + it("exposes the four Blossom servers, primary first", () => { + expect(blossom.BLOSSOM_SERVERS).toEqual([ + "https://blossom.band", + "https://blossom.nostr.build", + "https://nostr.download", + "https://cdn.nostrcheck.me", + ]); + }); +}); + +describe("buildAuthEvent", () => { + const sha = "ab".repeat(32); // 64 lowercase hex chars + + it("builds the kind-24242 'Upload Blob' template with the right tags", () => { + const now = 1_700_000_000; + const ev = blossom.buildAuthEvent(sha, now); + expect(ev.kind).toBe(24242); + expect(ev.created_at).toBe(now); + expect(ev.content).toBe("Upload Blob"); + expect(ev.tags).toContainEqual(["t", "upload"]); + expect(ev.tags).toContainEqual(["x", sha]); + expect(ev.tags).toContainEqual(["expiration", String(now + 300)]); + }); + + it("stamps created_at at the given second and never in the future", () => { + const now = Math.floor(Date.now() / 1000); + const ev = blossom.buildAuthEvent(sha, now); + expect(ev.created_at).toBe(now); + expect(ev.created_at).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)); + // expiration is strictly after created_at. + const exp = ev.tags.find((t) => t[0] === "expiration")!; + expect(Number(exp[1])).toBeGreaterThan(ev.created_at); + }); +}); + +describe("encodeAuthHeader", () => { + it("produces 'Nostr ' that decodes back to the event", () => { + const signed = { + id: "f".repeat(64), + pubkey: "0".repeat(64), + kind: 24242, + created_at: 1_700_000_000, + content: "Upload Blob", + tags: [["t", "upload"]], + sig: "1".repeat(128), + }; + const header = blossom.encodeAuthHeader(signed); + expect(header.slice(0, 6)).toBe("Nostr "); + // Exactly one space after "Nostr". + expect(header[6]).not.toBe(" "); + const b64url = header.slice(6); + // base64url alphabet, no padding. + expect(b64url).not.toMatch(/[+/=]/); + expect(JSON.parse(fromBase64Url(b64url))).toEqual(signed); + }); +}); + +describe("validateFile", () => { + const allowed = ["image/png", "image/jpeg", "image/webp", "image/gif"]; + + it("accepts each allowed image type under the cap", () => { + for (const type of allowed) { + expect(blossom.validateFile({ type, size: 1024 }).ok).toBe(true); + } + }); + + it("rejects an oversized file", () => { + const res = blossom.validateFile({ + type: "image/png", + size: 21 * 1024 * 1024, + }); + expect(res.ok).toBe(false); + expect(res.reason).toMatch(/large/i); + }); + + it("honors a custom maxBytes", () => { + expect( + blossom.validateFile({ type: "image/png", size: 100 }, { maxBytes: 50 }) + .ok, + ).toBe(false); + expect( + blossom.validateFile({ type: "image/png", size: 40 }, { maxBytes: 50 }).ok, + ).toBe(true); + }); + + it("rejects a disallowed type (incl. svg, which is an XSS vector)", () => { + expect(blossom.validateFile({ type: "image/svg+xml", size: 10 }).ok).toBe( + false, + ); + expect(blossom.validateFile({ type: "application/pdf", size: 10 }).ok).toBe( + false, + ); + }); + + it("rejects empty and zero-size files", () => { + expect(blossom.validateFile({ type: "image/png", size: 0 }).ok).toBe(false); + }); + + it("rejects non-file inputs", () => { + expect(blossom.validateFile(null).ok).toBe(false); + expect(blossom.validateFile(undefined).ok).toBe(false); + expect(blossom.validateFile("not a file").ok).toBe(false); + expect(blossom.validateFile({}).ok).toBe(false); + expect(blossom.validateFile({ type: "image/png" }).ok).toBe(false); + expect(blossom.validateFile({ size: 100 }).ok).toBe(false); + }); +}); + +describe("isHttpsUrl", () => { + it("accepts absolute https URLs", () => { + expect(blossom.isHttpsUrl("https://blossom.band/abcd.png")).toBe(true); + expect(blossom.isHttpsUrl("https://cdn.nostrcheck.me/x")).toBe(true); + }); + + it("rejects http, javascript:, data:, relative, and empty", () => { + expect(blossom.isHttpsUrl("http://blossom.band/x.png")).toBe(false); + expect(blossom.isHttpsUrl("javascript:alert(1)")).toBe(false); + expect(blossom.isHttpsUrl("data:image/png;base64,AAAA")).toBe(false); + expect(blossom.isHttpsUrl("/relative/path.png")).toBe(false); + expect(blossom.isHttpsUrl("blossom.band/x.png")).toBe(false); + expect(blossom.isHttpsUrl("")).toBe(false); + expect(blossom.isHttpsUrl(null)).toBe(false); + expect(blossom.isHttpsUrl(undefined)).toBe(false); + }); + + it("rejects https URLs that would break out of the markdown destination", () => { + // A hostile/compromised server can return a string that new URL() parses + // as protocol https: but whose ')'/whitespace/newline terminates the + // "![](url)" image and injects live attacker markdown after it. + expect( + blossom.isHttpsUrl("https://evil.com/x.png) [phish](https://evil.com)"), + ).toBe(false); + expect(blossom.isHttpsUrl("https://evil.com/x.png)\n\n# Injected")).toBe( + false, + ); + expect(blossom.isHttpsUrl("https://evil.com/x.png (title)")).toBe(false); + expect(blossom.isHttpsUrl("https://evil.com/a b.png")).toBe(false); + expect(blossom.isHttpsUrl('https://evil.com/x"y.png')).toBe(false); + expect(blossom.isHttpsUrl("https://evil.com/x`y.png")).toBe(false); + }); +}); + +describe("extForType", () => { + it("maps known image MIME types to extensions", () => { + expect(blossom.extForType("image/png")).toBe("png"); + expect(blossom.extForType("image/jpeg")).toBe("jpg"); + expect(blossom.extForType("image/webp")).toBe("webp"); + expect(blossom.extForType("image/gif")).toBe("gif"); + }); + + it("falls back to 'bin' for anything else", () => { + expect(blossom.extForType("application/octet-stream")).toBe("bin"); + expect(blossom.extForType("")).toBe("bin"); + }); +}); From bd0295d418a1067f31a79d2019c97036824c76b1 Mon Sep 17 00:00:00 2001 From: sovITxyz Date: Fri, 17 Jul 2026 19:49:39 -0600 Subject: [PATCH 2/3] feat: allow Blossom media servers in the apex connect-src CSP Opens connect-src to the four Blossom origins so browser uploads reach them; script-src and every other directive are unchanged, and BLOG_CSP (blog subdomains, JS-free) is untouched. img-src * already covered displaying the results. --- src/middleware/headers.ts | 10 +++++++++- test/integration/headers.spec.ts | 8 ++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/middleware/headers.ts b/src/middleware/headers.ts index 39b1674..5ae516f 100644 --- a/src/middleware/headers.ts +++ b/src/middleware/headers.ts @@ -75,7 +75,15 @@ export const BLOG_CSP = export const APEX_CSP = "default-src 'none'; script-src 'self' https://challenges.cloudflare.com; " + "style-src 'self' 'unsafe-inline'; img-src * data:; media-src *; " + - "connect-src 'self' wss:; frame-src https://challenges.cloudflare.com; " + + // connect-src: 'self' (login/preview/mirror fetches) + wss: (client-side + // relay broadcast to user-chosen relays) + the four Blossom media servers + // the editor uploads images to via browser PUT (BUD-02 /upload, BUD-04 + // /mirror). These are XHR/fetch destinations only, NOT script sources + // (script-src is untouched); img-src * already covers displaying the + // resulting image URLs on any host. + "connect-src 'self' wss: https://blossom.band https://blossom.nostr.build " + + "https://nostr.download https://cdn.nostrcheck.me; " + + "frame-src https://challenges.cloudflare.com; " + "form-action 'self'; base-uri 'none'; frame-ancestors 'none'"; /** Referrer policy applied to every response (both host classes). */ diff --git a/test/integration/headers.spec.ts b/test/integration/headers.spec.ts index c4719ee..75d6d35 100644 --- a/test/integration/headers.spec.ts +++ b/test/integration/headers.spec.ts @@ -85,8 +85,12 @@ describe("apex class", () => { // The CSP must actually permit what the page uses: same-origin scripts. expect(await res.text()).toContain('src="/js/login.js"'); expect(APEX_CSP).toContain("script-src 'self'"); - // …and the editor's relay broadcast (wss:) + same-origin fetches. - expect(APEX_CSP).toContain("connect-src 'self' wss:"); + // …and the editor's relay broadcast (wss:) + same-origin fetches + the + // four Blossom media servers the editor uploads images to (browser PUT). + expect(APEX_CSP).toContain( + "connect-src 'self' wss: https://blossom.band https://blossom.nostr.build " + + "https://nostr.download https://cdn.nostrcheck.me;", + ); // …and the Turnstile script + iframe on the dashboard claim form. expect(APEX_CSP).toContain("https://challenges.cloudflare.com"); }); From a9b4f21bf625803c51d2c04c465c8e74a4b14a73 Mon Sep 17 00:00:00 2001 From: sovITxyz Date: Fri, 17 Jul 2026 19:49:39 -0600 Subject: [PATCH 3/3] docs: document editor image upload (Blossom) --- docs/manual-signer-tests.md | 27 +++++++++++++++++++++++++++ public/js/README.md | 7 ++++++- src/views/main/docs.tsx | 7 +++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/manual-signer-tests.md b/docs/manual-signer-tests.md index 67671d0..f28d03b 100644 --- a/docs/manual-signer-tests.md +++ b/docs/manual-signer-tests.md @@ -165,3 +165,30 @@ allowlist. Uses [`nak`](https://github.com/fiatjaf/nak) (any NIP-01 CLI works). on a third-party long-form reader (e.g. habla.news) configured to include `wss://nbread.lol/relay`, and confirm it loads the nbread-hosted 30023 — reads are open (no auth) to anyone. + +## 9. Image upload (Blossom) + +Uploads go direct from the browser to public Blossom servers, authorized +with a `NbreadSigner`-signed kind 24242 event. Run with an in-page signer +(NIP-07 extension, NIP-46 remote, or pasted local key) unless noted. + +- [ ] **Button upload**: in the editor, click the image button and pick a + PNG → it uploads and inserts `![](https://blossom.band/…)`; the Preview + tab renders the image, and it renders on the published post too. +- [ ] **Drag & drop**: drag an image file onto the editor textarea → same + insert + render as above. +- [ ] **Paste**: copy an image to the clipboard and paste into the editor → + same insert + render. +- [ ] **Oversized rejected**: try a file larger than 20 MiB → rejected + client-side with an error, nothing inserted, no upload request sent. +- [ ] **Non-image rejected**: try a `.txt`/`.pdf` → rejected client-side, + nothing inserted. +- [ ] **NIP-55 fallback (Amber on Android)**: with the Amber redirect + signer, the image button does NOT attempt an upload (signing would + navigate away) — it falls back to prompting for / inserting an image + URL instead. +- [ ] **Devtools audit**: open Network, upload an image, and confirm the + `PUT` goes to a Blossom origin (`blossom.band` or a mirror) with an + `Authorization: Nostr …` header, and that the strict CSP + (`script-src 'self'`) does not block the upload (the connect/img + origins are allowed). diff --git a/public/js/README.md b/public/js/README.md index 4325bff..ff064df 100644 --- a/public/js/README.md +++ b/public/js/README.md @@ -27,6 +27,9 @@ time, no runtime dependencies — every file is a plain IIFE served as-is): `nbread:preview-requested` event (dispatched by the Preview tab), caches the last previewed value, and calls `window.NbreadDraft.clear()` after a successful publish/delete. +- `blossom.js` — image upload to public Blossom servers (BUD PUT + `/upload` + BUD-04 `/mirror`) authorized with an `NbreadSigner`-signed + kind-24242 event; loads after `signer.js` and before `editor-toolbar.js`. - `editor-md.js` — DOM-free markdown text-manipulation core (`globalThis.NbreadEditorMd`): every helper maps `(value, selStart, selEnd, ...)` to a @@ -42,7 +45,9 @@ time, no runtime dependencies — every file is a plain IIFE served as-is): Load order matters (classic `