diff --git a/js/services/blossomService.js b/js/services/blossomService.js index 5c23b7bf..ba1edef6 100644 --- a/js/services/blossomService.js +++ b/js/services/blossomService.js @@ -1,33 +1,42 @@ -// Blossom (nostr-native blob storage) upload service — Phase 0 skeleton. +// Blossom (nostr-native blob storage) upload service. // -// Implements the same `uploadFile({…}) → { url, key, … }` contract as -// s3UploadService / r2Service (see js/ui/components/mediaUploader.js), so a later -// `isBlossomProvider` branch can route to it with no change downstream. Auth is a -// signed kind-24242 nostr event (BUD-11) built from bitvid's existing signer — no +// Owns the Blossom upload orchestration so mediaUploader stays thin and this +// logic is unit-testable without pulling WebTorrent. Implements the same result +// shape as the S3/R2 path (see js/ui/components/mediaUploader.js) so a +// `provider === "blossom"` connection flows through publish unchanged. Auth is a +// signed kind-24242 nostr event (BUD-11) from bitvid's existing signer — no // access keys. Uploads mirror to multiple servers for resilience (BUD-04). -// -// Phase 0: the vendored SDK loader, availability gate, and the core upload path -// exist but nothing is wired into mediaUploader yet, and the flag is off by -// default. Phase 1 completes torrent parity (magnet/infoHash), the result-shape -// mapping, and the mediaUploader routing + Storage-pane config. See -// docs/blossom-plan.md. +// See docs/blossom-plan.md / TODO #30. import { FEATURE_BLOSSOM_STORAGE } from "../constants.js"; -import { userLogger } from "../utils/logger.js"; +import { buildStoragePointerValue } from "../utils/storagePointer.js"; // Vendored, pinned blossom-client-sdk bundle (scripts/build-blossom-sdk.mjs). // Lazy-imported so its (small) weight never touches the main bundle. const BLOSSOM_SDK_BUNDLE_URL = "../../vendor/blossom-client-sdk.bundle.min.js"; -function normalizeServerList(servers) { +export const BLOSSOM_PROVIDER = "blossom"; + +export function isBlossomProvider(provider) { + return provider === BLOSSOM_PROVIDER; +} + +// Extract a clean, de-duped server-URL list from a connection's credentials +// (servers live in the plaintext connection meta — they are public, not secret). +export function resolveBlossomServers(credentials) { + const source = credentials || {}; + const raw = Array.isArray(source.servers) + ? source.servers + : Array.isArray(source.meta?.servers) + ? source.meta.servers + : []; const out = []; const seen = new Set(); - for (const entry of Array.isArray(servers) ? servers : []) { + for (const entry of raw) { const url = typeof entry === "string" ? entry.trim() : ""; - if (!url || seen.has(url)) { - continue; + if (url && !seen.has(url)) { + seen.add(url); + out.push(url); } - seen.add(url); - out.push(url); } return out; } @@ -38,13 +47,11 @@ export class BlossomService { this._sdkPromise = null; } - // On/off gate — off ⇒ the SDK is never imported and callers should not route - // uploads here. + // On/off gate — off ⇒ the SDK is never imported. isAvailable() { return FEATURE_BLOSSOM_STORAGE === true; } - // Lazy-load the vendored SDK once. async loadSdk() { if (this._sdkPromise) { return this._sdkPromise; @@ -57,25 +64,14 @@ export class BlossomService { } /** - * Upload a file to one or more Blossom servers, mirroring for resilience. - * - * @param {object} params - * @param {File|Blob} params.file - the blob to upload. - * @param {string[]} params.servers - Blossom server base URLs (first = primary). - * @param {(draft: object) => Promise} params.signer - bitvid's active - * signer (BUD-11 kind-24242 auth). Same shape the app already uses. - * @param {string} [params.type] - the auth `t` verb (default "upload"). - * @param {(pct:number)=>void} [params.onProgress] - * @returns {Promise<{ url: string, key: string, servers: string[], descriptors: object[] }>} - * - * NOTE (Phase 1): torrent parity (magnet/infoHash), storagePointer, and the full - * mediaUploader result shape are added when this is wired into the upload flow. + * Upload a single blob to one or more Blossom servers (mirrored). Returns the + * primary blob descriptor's `url` (`/`) and `key` (sha256). */ async uploadFile({ file, servers, signer, type = "upload", onProgress } = {}) { if (!file) { throw new Error("Blossom upload requires a file."); } - const serverList = normalizeServerList(servers); + const serverList = resolveBlossomServers({ servers }); if (serverList.length === 0) { throw new Error("Blossom upload requires at least one server URL."); } @@ -95,13 +91,10 @@ export class BlossomService { // One auth per action, scoped to the blob's sha256; the SDK reuses it across // the mirror set so a multi-server upload isn't N signer prompts. const results = await multiServerUpload(serverList, file, { - onAuth: (server, sha256) => - createUploadAuth(signer, sha256, { type }), - onProgress: - typeof onProgress === "function" ? onProgress : undefined, + onAuth: (server, sha256) => createUploadAuth(signer, sha256, { type }), + onProgress: typeof onProgress === "function" ? onProgress : undefined, }); - // results: Map const descriptors = Array.from(results?.values?.() || []); const primary = descriptors.find((d) => typeof d?.url === "string" && d.url) || null; @@ -116,6 +109,76 @@ export class BlossomService { descriptors, }; } + + /** + * Full video upload: the blob, then (if `generateTorrent` yields one) the + * `.torrent` as a second blob, wired into a magnet (ws = the Blossom video URL, + * xs = the Blossom .torrent URL). Returns the same shape mediaUploader's S3 path + * returns so publish is untouched. `generateTorrent` is injected by the caller + * so this module never imports WebTorrent (keeps it unit-testable). + * + * @param {object} params + * @param {File|Blob} params.file + * @param {string[]} params.servers + * @param {(draft:object)=>Promise} params.signer + * @param {(args:{file:File,videoPublicUrl:string})=>Promise<{hasValidInfoHash:boolean,infoHash?:string,torrentFile?:File}>} [params.generateTorrent] + * @param {(fraction:number)=>void} [params.onProgress] + */ + async uploadVideo({ file, servers, signer, generateTorrent, onProgress } = {}) { + const serverList = resolveBlossomServers({ servers }); + if (serverList.length === 0) { + throw new Error("No Blossom servers configured for this connection."); + } + + const uploaded = await this.uploadFile({ + file, + servers: serverList, + signer, + type: "upload", + onProgress, + }); + const videoPublicUrl = uploaded.url; // / + + const result = { + url: videoPublicUrl, + key: uploaded.key, + storagePointer: buildStoragePointerValue({ + provider: BLOSSOM_PROVIDER, + prefix: serverList[0], // where the blob lives, for later delete/list (BUD-12) + }), + infoHash: "", + magnet: "", + torrentUrl: "", + torrentFile: null, + hasValidInfoHash: false, + }; + + let torrent = null; + if (typeof generateTorrent === "function") { + torrent = await generateTorrent({ file, videoPublicUrl }); + } + + if (torrent?.hasValidInfoHash && torrent.torrentFile) { + const torrentUploaded = await this.uploadFile({ + file: torrent.torrentFile, + servers: serverList, + signer, + type: "upload", + }); + const name = typeof file?.name === "string" ? file.name : "video"; + result.infoHash = torrent.infoHash; + result.magnet = `magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent( + name, + )}&ws=${encodeURIComponent(videoPublicUrl)}&xs=${encodeURIComponent( + torrentUploaded.url, + )}`; + result.torrentUrl = torrentUploaded.url; + result.torrentFile = torrent.torrentFile; + result.hasValidInfoHash = true; + } + + return result; + } } const blossomService = new BlossomService(); diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js index c441a00a..b2222b27 100644 --- a/js/ui/components/EditModal.js +++ b/js/ui/components/EditModal.js @@ -9,6 +9,7 @@ import { createModalAccessibility } from "./modalAccessibility.js"; import { Nip71FormManager } from "./nip71FormManager.js"; import { MediaUploader } from "./mediaUploader.js"; import { initEditModalUpload } from "./editModalUpload.js"; +import { getActiveSigner } from "../../nostrClientRegistry.js"; import { devLogger } from "../../utils/logger.js"; export class EditModal { @@ -51,6 +52,7 @@ export class EditModal { this.getCurrentPubkey ? this.getCurrentPubkey() : null, safeEncodeNpub: typeof safeEncodeNpub === "function" ? safeEncodeNpub : (p) => p, + getSigner: () => getActiveSigner(), }); this.getMode = typeof getMode === "function" diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 9a1714ed..971b0632 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -96,6 +96,7 @@ export class UploadModal { getCurrentPubkey: () => this.getCurrentPubkey ? this.getCurrentPubkey() : null, safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), + getSigner: () => getActiveSigner(), }); this.eventTarget = eventTarget instanceof EventTarget ? eventTarget : new EventTarget(); diff --git a/js/ui/components/mediaUploader.js b/js/ui/components/mediaUploader.js index 8f701cbf..29a7d737 100644 --- a/js/ui/components/mediaUploader.js +++ b/js/ui/components/mediaUploader.js @@ -24,6 +24,10 @@ import { buildStoragePointerValue, buildStoragePrefixFromKey, } from "../../utils/storagePointer.js"; +import blossomServiceDefault, { + isBlossomProvider, + resolveBlossomServers, +} from "../../services/blossomService.js"; const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; @@ -46,6 +50,8 @@ export class MediaUploader { storageService, getCurrentPubkey, safeEncodeNpub, + blossomService, + getSigner, } = {}) { this.r2Service = r2Service || null; this.s3Service = s3Service || null; @@ -54,6 +60,10 @@ export class MediaUploader { typeof getCurrentPubkey === "function" ? getCurrentPubkey : null; this.safeEncodeNpub = typeof safeEncodeNpub === "function" ? safeEncodeNpub : () => ""; + // Blossom (nostr-native) upload path. Only exercised when a connection's + // provider is "blossom", so S3/R2 uploads are completely unaffected. + this.blossomService = blossomService || blossomServiceDefault; + this.getSigner = typeof getSigner === "function" ? getSigner : null; } serviceFor(provider) { @@ -120,6 +130,9 @@ export class MediaUploader { // routing when the modal's provider state is stale. const effectiveProvider = credentials?.provider || credentials?.meta?.provider || provider; + if (isBlossomProvider(effectiveProvider)) { + return this.uploadThumbnailToBlossom(file, { credentials, onProgress }); + } const service = this.serviceFor(effectiveProvider); const { settings, bucketEntry } = await service.prepareUpload(npub, { @@ -214,6 +227,9 @@ export class MediaUploader { // R2 connection (accountId, no endpoint) through the S3 path — or vice-versa. const effectiveProvider = credentials?.provider || credentials?.meta?.provider || provider; + if (isBlossomProvider(effectiveProvider)) { + return this.uploadVideoToBlossom(file, { credentials, onProgress }); + } const service = this.serviceFor(effectiveProvider); const { settings, bucketEntry } = await service.prepareUpload(npub, { @@ -313,6 +329,67 @@ export class MediaUploader { return result; } + // --- Blossom (nostr-native blob storage) path -------------------------------- + // Thin wiring only: resolve servers + signer, then delegate the orchestration + // to blossomService (which owns upload + torrent + magnet + storagePointer and + // stays WebTorrent-free / unit-testable). The torrent generator is injected so + // blossomService never imports WebTorrent. See docs/blossom-plan.md. + + // Adapt bitvid's active signer (object with signEvent) to the SDK's signer + // shape: an async (eventTemplate) → signed event. + async resolveBlossomSigner() { + const signer = this.getSigner ? await this.getSigner() : null; + if (!signer || typeof signer.signEvent !== "function") { + throw new Error("A Nostr signer is required to upload to Blossom."); + } + return (draft) => signer.signEvent(draft); + } + + async uploadThumbnailToBlossom(file, { credentials, onProgress } = {}) { + const servers = resolveBlossomServers(credentials); + if (servers.length === 0) { + throw new Error("No Blossom servers configured for this connection."); + } + const signer = await this.resolveBlossomSigner(); + const uploaded = await this.blossomService.uploadFile({ + file, + servers, + signer, + type: "upload", + onProgress: (fraction) => { + if (typeof onProgress === "function") onProgress(fraction); + }, + }); + return { url: uploaded.url, key: uploaded.key }; + } + + async uploadVideoToBlossom(file, { credentials, onProgress } = {}) { + const emit = (fraction, label) => { + if (typeof onProgress === "function") onProgress({ fraction, label }); + }; + const servers = resolveBlossomServers(credentials); + if (servers.length === 0) { + throw new Error("No Blossom servers configured for this connection."); + } + const signer = await this.resolveBlossomSigner(); + + emit(0, "Uploading video to Blossom..."); + const result = await this.blossomService.uploadVideo({ + file, + servers, + signer, + generateTorrent: (args) => this.generateTorrentMetadata(args), + onProgress: (fraction) => emit(fraction, null), + }); + emit( + 1, + result.hasValidInfoHash + ? "Ready to publish!" + : "Upload complete (No torrent fallback).", + ); + return result; + } + /** * Derive a WebTorrent webseed for an EXTERNALLY-hosted video URL so an external * link keeps the P2P benefit. Best-effort: fetches the remote file (CORS- @@ -400,4 +477,4 @@ export class MediaUploader { } } -export { isR2Provider, normalizeInfoHash, isValidInfoHash }; +export { isR2Provider, isBlossomProvider, normalizeInfoHash, isValidInfoHash }; diff --git a/tests/blossom-service.test.mjs b/tests/blossom-service.test.mjs index 2877b9a1..f90c4f5f 100644 --- a/tests/blossom-service.test.mjs +++ b/tests/blossom-service.test.mjs @@ -1,55 +1,122 @@ -// Phase 0 skeleton tests for blossomService: the availability gate and the -// input guards run without loading the vendored SDK (they throw first). Upload -// round-trips against a real Blossom server are exercised in Phase 1. See -// docs/blossom-plan.md / TODO #30. +// blossomService tests (Node-safe — no WebTorrent). Covers the availability gate, +// server-list resolution, input guards, and the uploadVideo orchestration (blob + +// injected torrent → magnet + storagePointer). The vendored SDK is mocked via +// loadSdk; real-server round-trips are exercised in-browser. See docs/blossom-plan.md. import test from "node:test"; import assert from "node:assert/strict"; -import blossomService, { BlossomService } from "../js/services/blossomService.js"; +import blossomService, { + BlossomService, + BLOSSOM_PROVIDER, + isBlossomProvider, + resolveBlossomServers, +} from "../js/services/blossomService.js"; -test("isAvailable reflects the FEATURE_BLOSSOM_STORAGE flag (off by default)", () => { +const SHA = "a".repeat(64); +const signer = async (draft) => ({ ...draft, id: "id", sig: "sig", pubkey: "pk" }); + +// A BlossomService whose SDK is mocked: multiServerUpload returns a descriptor +// map keyed by server, echoing the file so we can assert which blob was sent. +function mockService() { + const uploads = []; + const svc = new BlossomService(); + svc.loadSdk = async () => ({ + createUploadAuth: async (s, sha256, opts) => ({ kind: 24242, sha256, opts }), + multiServerUpload: async (servers, file, opts) => { + uploads.push({ servers: [...servers], file }); + // Give each blob a distinct sha256 so video vs .torrent URLs differ. + const sha = file?.name?.includes(".torrent") ? "b".repeat(64) : SHA; + if (typeof opts?.onAuth === "function") await opts.onAuth(servers[0], sha); + const map = new Map(); + for (const server of servers) { + map.set(server, { url: `${server}/${sha}`, sha256: sha, size: 1, type: "x" }); + } + return map; + }, + }); + return { svc, uploads }; +} + +test("isBlossomProvider / BLOSSOM_PROVIDER only match 'blossom'", () => { + assert.equal(BLOSSOM_PROVIDER, "blossom"); + assert.equal(isBlossomProvider("blossom"), true); + assert.equal(isBlossomProvider("cloudflare_r2"), false); +}); + +test("isAvailable reflects the flag (off by default)", () => { assert.equal(blossomService.isAvailable(), false); }); -test("uploadFile rejects a missing file before touching the SDK", async () => { - const svc = new BlossomService(); - await assert.rejects( - () => svc.uploadFile({ servers: ["https://blossom.example"], signer: async () => ({}) }), - /requires a file/, +test("resolveBlossomServers de-dupes, trims, and reads meta.servers", () => { + assert.deepEqual( + resolveBlossomServers({ servers: [" https://a ", "https://a", "https://b", "", " "] }), + ["https://a", "https://b"], ); + assert.deepEqual(resolveBlossomServers({ meta: { servers: ["https://c"] } }), ["https://c"]); + assert.deepEqual(resolveBlossomServers({}), []); }); -test("uploadFile rejects an empty server list", async () => { - const svc = new BlossomService(); +test("uploadFile guards: file, servers, signer required", async () => { + const { svc } = mockService(); + await assert.rejects(() => svc.uploadFile({ servers: ["https://s"], signer }), /requires a file/); await assert.rejects( - () => svc.uploadFile({ file: new Blob(["x"]), servers: [], signer: async () => ({}) }), + () => svc.uploadFile({ file: new Blob(["x"]), servers: [], signer }), /at least one server/, ); -}); - -test("uploadFile rejects a non-function signer", async () => { - const svc = new BlossomService(); await assert.rejects( - () => - svc.uploadFile({ - file: new Blob(["x"]), - servers: ["https://blossom.example"], - signer: null, - }), + () => svc.uploadFile({ file: new Blob(["x"]), servers: ["https://s"], signer: null }), /requires a signer/, ); }); -test("uploadFile de-dupes and trims the server list before requiring one", async () => { - const svc = new BlossomService(); - // All-blank/dup servers collapse to empty → the "at least one server" guard. - await assert.rejects( - () => - svc.uploadFile({ - file: new Blob(["x"]), - servers: [" ", "", " "], - signer: async () => ({}), - }), - /at least one server/, - ); +test("uploadFile returns the primary descriptor url + sha256 key, mirrored to all servers", async () => { + const { svc, uploads } = mockService(); + const out = await svc.uploadFile({ + file: new File([new Uint8Array([1])], "v.mp4"), + servers: ["https://a", "https://b"], + signer, + }); + assert.equal(out.url, `https://a/${SHA}`); + assert.equal(out.key, SHA); + assert.deepEqual(uploads[0].servers, ["https://a", "https://b"], "mirrored to both"); +}); + +test("uploadVideo without a torrent returns a blossom storagePointer and no magnet", async () => { + const { svc } = mockService(); + const result = await svc.uploadVideo({ + file: new File([new Uint8Array([1, 2])], "clip.mp4"), + servers: ["https://a"], + signer, + generateTorrent: async () => ({ hasValidInfoHash: false, torrentFile: null }), + }); + assert.equal(result.url, `https://a/${SHA}`); + assert.equal(result.key, SHA); + assert.match(result.storagePointer, /blossom/); + assert.equal(result.magnet, ""); + assert.equal(result.hasValidInfoHash, false); +}); + +test("uploadVideo with a torrent uploads the .torrent as a blob and builds a magnet (ws + xs)", async () => { + const { svc, uploads } = mockService(); + const infoHash = "0".repeat(40); + const result = await svc.uploadVideo({ + file: new File([new Uint8Array([1, 2])], "clip.mp4"), + servers: ["https://a"], + signer, + generateTorrent: async ({ videoPublicUrl }) => { + assert.equal(videoPublicUrl, `https://a/${SHA}`, "ws is the blossom video url"); + return { + hasValidInfoHash: true, + infoHash, + torrentFile: new File([new Uint8Array([9])], "clip.torrent"), + }; + }, + }); + assert.equal(uploads.length, 2, "video blob + .torrent blob"); + assert.equal(result.hasValidInfoHash, true); + assert.equal(result.infoHash, infoHash); + assert.match(result.magnet, new RegExp(`btih:${infoHash}`)); + assert.match(result.magnet, /ws=https%3A%2F%2Fa%2F/, "ws = blossom video url"); + assert.match(result.magnet, /xs=https%3A%2F%2Fa%2F/, "xs = blossom .torrent url"); + assert.equal(result.torrentUrl, `https://a/${"b".repeat(64)}`); });