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 ``; 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 `
+
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");
});
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
+ // "" 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");
+ });
+});