mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(blossom): Phase 2 management — blob delete-on-orphan + kind-10063 server list (#30)
- 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 <[email protected]>
This commit is contained in:
1 parent
51072e4871
commit
e81e65ff40
9 files changed
+515
No files matched your search
@@ -1027,6 +1027,16 @@
|
||||
<textarea id="storageBlossomServers" rows="3" class="input" placeholder="https://blossom.example.com
|
||||
https://another.blossom.example"></textarea>
|
||||
</label>
|
||||
<!-- BUD-03 kind-10063 discovery: import the list you've published, or publish the list above. -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button id="storageBlossomImportBtn" type="button" class="btn-ghost focus-ring text-xs">
|
||||
Import from my server list
|
||||
</button>
|
||||
<button id="storageBlossomPublishBtn" type="button" class="btn-ghost focus-ring text-xs">
|
||||
Publish my server list
|
||||
</button>
|
||||
</div>
|
||||
<p id="storageBlossomServerListStatus" class="text-xs text-muted" role="status"></p>
|
||||
</div>
|
||||
|
||||
<!-- S3-family fields (R2 / B2 / Custom S3). Hidden for Blossom. -->
|
||||
|
||||
@@ -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', <url>]`
|
||||
* 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).
|
||||
|
||||
@@ -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://<host>/<sha256>.mp4` → `<sha256>`). 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", <url>]` 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/<pubkey>`). Some
|
||||
* servers require a signed list auth; provide the signer to cover those.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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, "");
|
||||
});
|
||||
Reference in new issue
Block a user