mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(storage): Blossom Phase 1a — upload engine [LOCAL] (#30)
The Blossom upload orchestration, isolated + unit-tested. Only routes when a connection's provider is "blossom", so the S3/R2 path is completely unaffected (that branch can't trigger until the Phase-1b config UI can create a Blossom connection). - blossomService now owns the orchestration (WebTorrent-free, so it's Node- testable): uploadFile (multiServerUpload + BUD-11 auth) and uploadVideo (blob → injected torrent generator → upload .torrent as a 2nd blob → magnet with ws=the Blossom video url + xs=the Blossom .torrent url → blossom storagePointer). Same result shape as the S3 path, so publish is untouched. Exports isBlossomProvider + resolveBlossomServers. - mediaUploader: thin Blossom branch at the top of uploadThumbnail/uploadVideo (delegates to blossomService, injecting generateTorrentMetadata so the service never imports WebTorrent); new getSigner dep. UploadModal + EditModal pass getSigner: () => getActiveSigner(). Tests: tests/blossom-service.test.mjs (7 — guards, mirror, torrent/magnet orchestration, storagePointer). Lint + build clean. Flag still off. NEXT (Phase 1b): the keyless multi-server Storage-pane config UI + connection model (servers in plaintext connection meta — no secret) + resolveActiveConnection Blossom branch, so a server can be configured and uploads run end-to-end. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
02e60f1842
commit
0eafd38ec4
5 files changed
+287
-77
No files matched your search
+103
-40
@@ -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<object>} 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` (`<server>/<sha256>`) 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<serverUrl, BlobDescriptor{ url, sha256, size, type, … }>
|
||||
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<object>} 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; // <server>/<sha256>
|
||||
|
||||
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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user