From e81e65ff40b00f6d3e3c8c4344dab5eb02994391 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:32:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(blossom):=20Phase=202=20management=20?= =?UTF-8?q?=E2=80=94=20blob=20delete-on-orphan=20+=20kind-10063=20server?= =?UTF-8?q?=20list=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deleteByUrl maps a Blossom URL back to its content-addressed blob (blobSha256FromUrl) and deletes it via BUD-12, defaulting to the URL's own origin. Wired into the edit-modal orphan cleanup so a superseded Blossom video/thumbnail is reclaimed (best-effort, never blocks the edit). - BUD-03 kind-10063 server-list discovery: read (serversFromServerListEvent) + build (buildBlossomServerListEvent) + an extracted, testable import/publish helper (blossomServerList.js), plus 'Import from my server list' / 'Publish my server list' controls in the Storage pane's Blossom section. Co-Authored-By: Claude Opus 4.8 --- components/profile-modal.html | 10 ++ js/nostrEventSchemas.js | 38 ++++++ js/services/blossomService.js | 63 ++++++++++ js/ui/editModalController.js | 24 ++++ .../profileModal/ProfileStorageController.js | 66 +++++++++++ js/ui/profileModal/blossomServerList.js | 79 +++++++++++++ tests/blossom-server-list.test.mjs | 111 ++++++++++++++++++ tests/blossom-service.test.mjs | 105 +++++++++++++++++ tests/torrent-metadata-schema.test.mjs | 19 +++ 9 files changed, 515 insertions(+) create mode 100644 js/ui/profileModal/blossomServerList.js create mode 100644 tests/blossom-server-list.test.mjs diff --git a/components/profile-modal.html b/components/profile-modal.html index f9beec3e..d66bbd31 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1027,6 +1027,16 @@ + +
+ + +
+

diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 68013ffe..19e45b38 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -2575,6 +2575,44 @@ export function buildTorrentMetadataEvent(params) { }; } +// --- Blossom server list (BUD-03, kind 10063) -------------------------------- +// A user's preferred Blossom media servers, in order. Lets other clients discover +// where a user hosts blobs, and lets bitvid pre-fill its storage pane from it. +export const BLOSSOM_SERVER_LIST_KIND = 10063; + +/** + * Builds a Blossom server-list event (BUD-03, kind 10063): one `['server', ]` + * tag per server, in preference order, de-duped. + * + * @param {Object} params + * @param {string} params.pubkey + * @param {number} params.created_at + * @param {string[]} params.servers - Ordered server URLs. + * @returns {Object} Unsigned event. + */ +export function buildBlossomServerListEvent(params) { + const { pubkey, created_at, servers } = params || {}; + const seen = new Set(); + const tags = []; + if (Array.isArray(servers)) { + for (const entry of servers) { + const url = typeof entry === "string" ? entry.trim() : ""; + if (url && !seen.has(url)) { + seen.add(url); + tags.push(["server", url]); + } + } + } + tags.push(["client", "bitvid"]); + return { + kind: BLOSSOM_SERVER_LIST_KIND, + pubkey, + created_at, + tags, + content: "", + }; +} + /** * Builds a Zap Request event (Kind 9734). * Used to request an invoice from a Lightning Service Provider (LSP). diff --git a/js/services/blossomService.js b/js/services/blossomService.js index aef5dcb9..e47d35db 100644 --- a/js/services/blossomService.js +++ b/js/services/blossomService.js @@ -129,6 +129,32 @@ export function ensureTypedFile(file) { } } +// Extract the blob id (sha256, 64 hex) from a Blossom URL — the last path segment +// minus any extension (`https:///.mp4` → ``). Returns "" when +// the URL doesn't point at a Blossom-style content-addressed blob. +export function blobSha256FromUrl(url) { + const raw = typeof url === "string" ? url.trim() : ""; + if (!raw) return ""; + try { + const path = new URL(raw).pathname; + const last = path.split("/").pop() || ""; + const hex = last.split(".")[0].toLowerCase(); + return /^[0-9a-f]{64}$/.test(hex) ? hex : ""; + } catch { + return ""; + } +} + +// Read the server URLs from a user's kind-10063 Blossom server-list event (BUD-03): +// one `["server", ]` tag per server, in preference order. De-duped + trimmed. +export function serversFromServerListEvent(event) { + const tags = Array.isArray(event?.tags) ? event.tags : []; + const urls = tags + .filter((t) => Array.isArray(t) && t[0] === "server" && typeof t[1] === "string") + .map((t) => t[1]); + return resolveBlossomServers({ servers: urls }); +} + export class BlossomService { constructor(deps = {}) { this.deps = deps; @@ -406,6 +432,43 @@ export class BlossomService { }); } + /** + * Delete the blob behind a Blossom URL from every configured server + * (best-effort). Used by orphan cleanup when a Blossom-hosted video/thumbnail is + * superseded or deleted. A non-Blossom URL (no 64-hex blob id) is a no-op. + * + * @returns {Promise<{sha256:string, deleted:string[], failed:{server:string,error:string}[]}>} + */ + async deleteByUrl({ url, servers, signer } = {}) { + const sha256 = blobSha256FromUrl(url); + if (!sha256) { + return { sha256: "", deleted: [], failed: [] }; + } + let serverList = resolveBlossomServers({ servers }); + if (serverList.length === 0) { + // No explicit servers — delete from the blob URL's own origin (the server + // that served it). Callers with the full mirror set should pass `servers`. + try { + serverList = [new URL(url).origin]; + } catch { + serverList = []; + } + } + const deleted = []; + const failed = []; + await Promise.allSettled( + serverList.map(async (server) => { + try { + await this.deleteFile({ server, sha256, signer }); + deleted.push(server); + } catch (error) { + failed.push({ server, error: error?.message || String(error) }); + } + }), + ); + return { sha256, deleted, failed }; + } + /** * List a user's blobs on a Blossom server (BUD-12 `GET /list/`). Some * servers require a signed list auth; provide the signer to cover those. diff --git a/js/ui/editModalController.js b/js/ui/editModalController.js index 2a74c06c..9ec5011e 100644 --- a/js/ui/editModalController.js +++ b/js/ui/editModalController.js @@ -1,5 +1,9 @@ import { devLogger } from "../utils/logger.js"; import r2Service from "../services/r2Service.js"; +import blossomService, { + blobSha256FromUrl, +} from "../services/blossomService.js"; +import { getActiveSigner } from "../nostrClientRegistry.js"; import { readRelayPublishSummary } from "../nostrPublish.js"; export default class EditModalController { @@ -125,6 +129,26 @@ export default class EditModalController { `[edit] Removed ${cleanup.deleted.length} superseded storage object(s).` ); } + + // Blossom orphans are content-addressed blobs, not R2/S3 keys — delete + // them via BUD-12 (best-effort, from each blob URL's own origin). + const blossomUrls = orphans + .map((o) => o.url || o.thumbnail || "") + .filter((u) => blobSha256FromUrl(u)); + if (blossomUrls.length) { + const activeSigner = getActiveSigner(); + if (activeSigner && typeof activeSigner.signEvent === "function") { + const signer = (draft) => activeSigner.signEvent(draft); + for (const url of blossomUrls) { + const res = await blossomService.deleteByUrl({ url, signer }); + if (res.deleted?.length) { + devLogger.log( + `[edit] Deleted superseded Blossom blob ${res.sha256}.` + ); + } + } + } + } } } catch (cleanupErr) { devLogger.warn( diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js index 07cf38d3..bec962ab 100644 --- a/js/ui/profileModal/ProfileStorageController.js +++ b/js/ui/profileModal/ProfileStorageController.js @@ -9,6 +9,10 @@ import { import { getActiveSigner } from "../../nostr/client.js"; import { FEATURE_BLOSSOM_STORAGE } from "../../constants.js"; import { BLOSSOM_PROVIDER } from "../../services/blossomService.js"; +import { + importBlossomServerList, + publishBlossomServerList, +} from "./blossomServerList.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js"; import { storageSyncService } from "../../services/storageSyncService.js"; import { StorageCorsHelp } from "./storageCorsHelp.js"; @@ -106,6 +110,12 @@ export class ProfileStorageController { document.getElementById("storageBlossomSection") || null; this.storageBlossomServersInput = document.getElementById("storageBlossomServers") || null; + this.storageBlossomImportBtn = + document.getElementById("storageBlossomImportBtn") || null; + this.storageBlossomPublishBtn = + document.getElementById("storageBlossomPublishBtn") || null; + this.storageBlossomServerListStatus = + document.getElementById("storageBlossomServerListStatus") || null; this.storageProviderBlossomOption = document.getElementById("storageProviderBlossomOption") || null; // Reveal the Blossom provider option only when the deployment enables it. @@ -161,6 +171,18 @@ export class ProfileStorageController { }); } + if (this.storageBlossomImportBtn instanceof HTMLElement) { + this.storageBlossomImportBtn.addEventListener("click", () => { + void this.handleImportBlossomServerList(); + }); + } + + if (this.storageBlossomPublishBtn instanceof HTMLElement) { + this.storageBlossomPublishBtn.addEventListener("click", () => { + void this.handlePublishBlossomServerList(); + }); + } + if (this.storageProviderInput instanceof HTMLElement) { this.storageProviderInput.addEventListener("change", () => { void this.handleProviderChange(); @@ -560,6 +582,50 @@ export class ProfileStorageController { this.storageUnlockFailure = null; } + setBlossomServerListStatus(message, kind = "info") { + if (!(this.storageBlossomServerListStatus instanceof HTMLElement)) return; + this.storageBlossomServerListStatus.textContent = message || ""; + const tone = + kind === "error" + ? "text-status-danger" + : kind === "success" + ? "text-status-success" + : "text-muted"; + this.storageBlossomServerListStatus.className = `text-xs ${tone}`; + } + + blossomServerListArgs() { + return { + client: this.mainController.services?.nostrClient, + pubkey: this.mainController.normalizeHexPubkey( + this.mainController.getActivePubkey(), + ), + }; + } + + async handleImportBlossomServerList() { + this.setBlossomServerListStatus("Fetching your server list…"); + const res = await importBlossomServerList(this.blossomServerListArgs()); + if ( + res.servers?.length && + this.storageBlossomServersInput instanceof HTMLElement + ) { + this.storageBlossomServersInput.value = res.servers.join("\n"); + } + if (res.error) devLogger.warn("[ProfileModal] Blossom import:", res.error); + this.setBlossomServerListStatus(res.message, res.kind); + } + + async handlePublishBlossomServerList() { + this.setBlossomServerListStatus("Publishing your server list…"); + const res = await publishBlossomServerList({ + ...this.blossomServerListArgs(), + servers: this.parseBlossomServers(), + }); + if (res.error) devLogger.warn("[ProfileModal] Blossom publish:", res.error); + this.setBlossomServerListStatus(res.message, res.kind); + } + // After a page reload, a persisted nsec session restores the logged-in pubkey + UI // but NOT the in-memory signer (the key is passphrase-encrypted). Storage unlock then // has no usable signer, and the generic "No active signer" error is misleading. diff --git a/js/ui/profileModal/blossomServerList.js b/js/ui/profileModal/blossomServerList.js new file mode 100644 index 00000000..dc2e1a8f --- /dev/null +++ b/js/ui/profileModal/blossomServerList.js @@ -0,0 +1,79 @@ +// BUD-03 (kind 10063) Blossom server-list import/publish — the relay read/write +// logic, extracted from ProfileStorageController so it stays thin and this is +// unit-testable. Each function returns a { kind, message, ... } result the UI can +// render directly (never throws). See docs/blossom-plan.md (Decision 3). +import { serversFromServerListEvent } from "../../services/blossomService.js"; +import { + buildBlossomServerListEvent, + BLOSSOM_SERVER_LIST_KIND, +} from "../../nostrEventSchemas.js"; + +function readRelaysOf(client) { + if (Array.isArray(client?.readRelays) && client.readRelays.length) { + return client.readRelays; + } + return Array.isArray(client?.relays) ? client.relays : []; +} + +/** + * Fetch the newest kind-10063 server list published by `pubkey` and return its + * servers. Does NOT persist — the caller drops them into the form for Save. + * + * @returns {Promise<{kind:"success"|"error", message:string, servers?:string[], error?:unknown}>} + */ +export async function importBlossomServerList({ client, pubkey } = {}) { + if (!client || typeof client.getSubscriptionManager !== "function" || !pubkey) { + return { kind: "error", message: "Log in to import your server list." }; + } + try { + const events = await client.getSubscriptionManager().list({ + filters: [{ kinds: [BLOSSOM_SERVER_LIST_KIND], authors: [pubkey] }], + relays: readRelaysOf(client), + }); + const newest = (Array.isArray(events) ? events : []) + .filter((e) => e && Array.isArray(e.tags)) + .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]; + const servers = newest ? serversFromServerListEvent(newest) : []; + if (!servers.length) { + return { + kind: "error", + message: "No published server list found for your account.", + }; + } + return { + kind: "success", + message: `Imported ${servers.length} server(s). Click Save to use them.`, + servers, + }; + } catch (error) { + return { kind: "error", message: "Import failed. Please try again.", error }; + } +} + +/** + * Build + sign + publish the given servers as the user's kind-10063 list. + * + * @returns {Promise<{kind:"success"|"error", message:string, error?:unknown}>} + */ +export async function publishBlossomServerList({ client, pubkey, servers } = {}) { + if (!client || typeof client.signAndPublishEvent !== "function" || !pubkey) { + return { kind: "error", message: "Log in to publish your server list." }; + } + if (!Array.isArray(servers) || servers.length === 0) { + return { kind: "error", message: "Add at least one server first." }; + } + try { + const event = buildBlossomServerListEvent({ + pubkey, + created_at: Math.floor(Date.now() / 1000), + servers, + }); + await client.signAndPublishEvent(event); + return { + kind: "success", + message: `Published ${servers.length} server(s) to your Nostr relays.`, + }; + } catch (error) { + return { kind: "error", message: "Publish failed. Please try again.", error }; + } +} diff --git a/tests/blossom-server-list.test.mjs b/tests/blossom-server-list.test.mjs new file mode 100644 index 00000000..fed76b6a --- /dev/null +++ b/tests/blossom-server-list.test.mjs @@ -0,0 +1,111 @@ +// BUD-03 kind-10063 server-list import/publish helpers (extracted from the storage +// pane). Every path returns a { kind, message, ... } result — never throws — so the +// UI can render it directly. See docs/blossom-plan.md (Decision 3). +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + importBlossomServerList, + publishBlossomServerList, +} from "../js/ui/profileModal/blossomServerList.js"; + +const PK = "a".repeat(64); +const listEvent = (servers, created_at = 1000) => ({ + kind: 10063, + created_at, + tags: servers.map((s) => ["server", s]), +}); + +test("importBlossomServerList returns the NEWEST list's servers, author-scoped", async () => { + const client = { + readRelays: ["wss://r"], + getSubscriptionManager: () => ({ + list: async ({ filters, relays }) => { + assert.deepEqual(filters[0].kinds, [10063]); + assert.deepEqual(filters[0].authors, [PK]); + assert.deepEqual(relays, ["wss://r"]); + return [listEvent(["https://old"], 100), listEvent(["https://a", "https://b"], 999)]; + }, + }), + }; + const res = await importBlossomServerList({ client, pubkey: PK }); + assert.equal(res.kind, "success"); + assert.deepEqual(res.servers, ["https://a", "https://b"]); +}); + +test("importBlossomServerList: nothing published → error result (no throw)", async () => { + const client = { getSubscriptionManager: () => ({ list: async () => [] }) }; + const res = await importBlossomServerList({ client, pubkey: PK }); + assert.equal(res.kind, "error"); + assert.match(res.message, /No published server list/); +}); + +test("importBlossomServerList: not logged in → error", async () => { + const res = await importBlossomServerList({ client: null, pubkey: "" }); + assert.equal(res.kind, "error"); + assert.match(res.message, /Log in/); +}); + +test("importBlossomServerList: relay error is caught, returned as an error result", async () => { + const client = { + getSubscriptionManager: () => ({ + list: async () => { + throw new Error("relay down"); + }, + }), + }; + const res = await importBlossomServerList({ client, pubkey: PK }); + assert.equal(res.kind, "error"); + assert.match(res.message, /Import failed/); + assert.equal(res.error.message, "relay down"); +}); + +test("publishBlossomServerList signs + publishes a kind-10063 event with server tags", async () => { + let published = null; + const client = { + signAndPublishEvent: async (ev) => { + published = ev; + return { id: "x" }; + }, + }; + const res = await publishBlossomServerList({ + client, + pubkey: PK, + servers: ["https://a", "https://b"], + }); + assert.equal(res.kind, "success"); + assert.equal(published.kind, 10063); + assert.equal(published.pubkey, PK); + assert.deepEqual( + published.tags.filter((t) => t[0] === "server"), + [["server", "https://a"], ["server", "https://b"]], + ); +}); + +test("publishBlossomServerList: no servers → error (nothing published)", async () => { + let called = false; + const client = { + signAndPublishEvent: async () => { + called = true; + }, + }; + const res = await publishBlossomServerList({ client, pubkey: PK, servers: [] }); + assert.equal(res.kind, "error"); + assert.match(res.message, /at least one server/); + assert.equal(called, false); +}); + +test("publishBlossomServerList: publish failure → error result (no throw)", async () => { + const client = { + signAndPublishEvent: async () => { + throw new Error("nope"); + }, + }; + const res = await publishBlossomServerList({ + client, + pubkey: PK, + servers: ["https://a"], + }); + assert.equal(res.kind, "error"); + assert.match(res.message, /Publish failed/); +}); diff --git a/tests/blossom-service.test.mjs b/tests/blossom-service.test.mjs index dc83ebf6..aa9ac359 100644 --- a/tests/blossom-service.test.mjs +++ b/tests/blossom-service.test.mjs @@ -10,6 +10,8 @@ import blossomService, { BLOSSOM_PROVIDER, isBlossomProvider, resolveBlossomServers, + blobSha256FromUrl, + serversFromServerListEvent, } from "../js/services/blossomService.js"; import { FEATURE_BLOSSOM_STORAGE } from "../js/constants.js"; @@ -375,6 +377,109 @@ test("deleteFile guards + calls deleteBlob with a delete auth", async () => { assert.deepEqual(calls[0], { server: "https://a", hash: SHA }); }); +test("blobSha256FromUrl extracts the 64-hex blob id (or '' for non-blobs)", () => { + const sha = "a".repeat(64); + assert.equal(blobSha256FromUrl(`https://npub1x.blossom.band/${sha}.mp4`), sha); + assert.equal(blobSha256FromUrl(`https://blossom.band/${sha}`), sha); + assert.equal(blobSha256FromUrl(`https://cdn.example/u/2026/clip.mp4`), "", "not a blob path"); + assert.equal(blobSha256FromUrl("not a url"), ""); + assert.equal(blobSha256FromUrl(""), ""); +}); + +test("deleteByUrl maps a Blossom URL to its blob and deletes it on every server", async () => { + const svc = new BlossomService(); + const calls = []; + svc.loadSdk = async () => ({ + createDeleteAuth: async () => ({ kind: 24242, t: "delete" }), + deleteBlob: async (server, hash) => { + calls.push({ server, hash }); + return true; + }, + }); + const sha = "b".repeat(64); + const out = await svc.deleteByUrl({ + url: `https://blossom.band/${sha}.mp4`, + servers: ["https://a", "https://b"], + signer, + }); + assert.equal(out.sha256, sha); + assert.deepEqual(out.deleted.sort(), ["https://a", "https://b"]); + assert.deepEqual(calls.map((c) => c.hash), [sha, sha]); +}); + +test("deleteByUrl falls back to the URL's own origin when no servers are given", async () => { + const svc = new BlossomService(); + const calls = []; + svc.loadSdk = async () => ({ + createDeleteAuth: async () => ({}), + deleteBlob: async (server, hash) => { + calls.push({ server, hash }); + return true; + }, + }); + const sha = "d".repeat(64); + const out = await svc.deleteByUrl({ + url: `https://npub1x.blossom.band/${sha}.mp4`, + signer, + }); + assert.equal(out.sha256, sha); + assert.deepEqual(out.deleted, ["https://npub1x.blossom.band"], "deleted from the URL origin"); + assert.equal(calls[0].server, "https://npub1x.blossom.band"); +}); + +test("deleteByUrl is a no-op for a non-Blossom URL", async () => { + const svc = new BlossomService(); + let called = false; + svc.loadSdk = async () => ({ + createDeleteAuth: async () => ({}), + deleteBlob: async () => { + called = true; + }, + }); + const out = await svc.deleteByUrl({ + url: "https://cdn.example/video.mp4", + servers: ["https://a"], + signer, + }); + assert.equal(out.sha256, ""); + assert.equal(called, false, "no delete attempted for a non-blob URL"); +}); + +test("deleteByUrl reports per-server failures without throwing", async () => { + const svc = new BlossomService(); + svc.loadSdk = async () => ({ + createDeleteAuth: async () => ({}), + deleteBlob: async (server) => { + if (server === "https://a") throw new Error("403 forbidden"); + return true; + }, + }); + const sha = "c".repeat(64); + const out = await svc.deleteByUrl({ + url: `https://x/${sha}`, + servers: ["https://a", "https://b"], + signer, + }); + assert.deepEqual(out.deleted, ["https://b"]); + assert.equal(out.failed.length, 1); + assert.match(out.failed[0].error, /403/); +}); + +test("serversFromServerListEvent reads + de-dupes the BUD-03 server tags", () => { + const event = { + kind: 10063, + tags: [ + ["server", " https://a "], + ["server", "https://a"], + ["server", "https://b"], + ["client", "x"], + ["server", ""], + ], + }; + assert.deepEqual(serversFromServerListEvent(event), ["https://a", "https://b"]); + assert.deepEqual(serversFromServerListEvent(null), []); +}); + test("listFiles returns the server's blob descriptors for a pubkey", async () => { const svc = new BlossomService(); svc.loadSdk = async () => ({ diff --git a/tests/torrent-metadata-schema.test.mjs b/tests/torrent-metadata-schema.test.mjs index c22c83c6..307fd519 100644 --- a/tests/torrent-metadata-schema.test.mjs +++ b/tests/torrent-metadata-schema.test.mjs @@ -10,6 +10,8 @@ import { normalizeInfoHash, torrentMetadataDTag, TORRENT_METADATA_KIND, + buildBlossomServerListEvent, + BLOSSOM_SERVER_LIST_KIND, } from "../js/nostrEventSchemas.js"; const INFOHASH = "0123456789abcdef0123456789abcdef01234567"; // 40 hex @@ -77,3 +79,20 @@ test("buildTorrentMetadataEvent rejects an invalid infohash or empty payload", ( /non-empty base64 torrent payload/, ); }); + +test("buildBlossomServerListEvent builds a de-duped kind-10063 server list (BUD-03)", () => { + const ev = buildBlossomServerListEvent({ + pubkey: PK, + created_at: 1700000000, + servers: [" https://a ", "https://a", "https://b", ""], + }); + assert.equal(ev.kind, BLOSSOM_SERVER_LIST_KIND); + assert.equal(ev.kind, 10063); + assert.deepEqual( + ev.tags.filter((t) => t[0] === "server"), + [["server", "https://a"], ["server", "https://b"]], + "one server tag per unique url, in order", + ); + assert.deepEqual(ev.tags.find((t) => t[0] === "client"), ["client", "bitvid"]); + assert.equal(ev.content, ""); +});