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