mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(storage): Blossom Phase 1b — keyless multi-server config UI [LOCAL, flag ON] (#30)
Makes Blossom configurable + uploadable end-to-end (local testing).
- Storage pane: a "Blossom (Nostr-native)" provider option (revealed only when the
flag is on) that swaps the S3 field group (#storageS3Fields) for a keyless
#storageBlossomSection — a multi-server textarea (one URL per line). No
access-key/secret/bucket/CORS.
- ProfileStorageController: parseBlossomServers(); save branch persists the server
list in the PLAINTEXT connection meta ({servers}, no encrypted secret) via the
per-provider slot; provider-change loads servers from meta WITHOUT an unlock;
Test does a best-effort reachability ping; visibility branch hides S3 fields +
CORS helper for Blossom.
- mediaUploader.resolveActiveConnection: Blossom connections resolve from plaintext
meta with no unlock (uploads authorize via the Nostr signer, not a stored key),
so credentials = { provider, servers, meta } flow straight into the Phase-1a
engine.
- instance-config: FEATURE_BLOSSOM_STORAGE = true for LOCAL testing (reset to false
before pushing).
Tests: blossom-service 7/7 (isAvailable now flag-relative). Lint + build clean.
Blossom UI present in dist.
VERIFY locally: Storage → Provider → Blossom → add a server → Save → upload a
video → it plays from <server>/<sha256>. Watch the UploadModal's own storage
gating (may need a tweak for the no-secret/no-PIN Blossom case).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
0eafd38ec4
commit
c5e8c7f9f1
5 files changed
+194
-4
No files matched your search
@@ -25,6 +25,7 @@ import {
|
||||
buildStoragePrefixFromKey,
|
||||
} from "../../utils/storagePointer.js";
|
||||
import blossomServiceDefault, {
|
||||
BLOSSOM_PROVIDER,
|
||||
isBlossomProvider,
|
||||
resolveBlossomServers,
|
||||
} from "../../services/blossomService.js";
|
||||
@@ -109,6 +110,21 @@ export class MediaUploader {
|
||||
base.configured = true;
|
||||
base.provider = targetConn.provider;
|
||||
|
||||
// Blossom has no encrypted secret — its server list lives in the plaintext
|
||||
// connection meta, so credentials are available without unlocking storage
|
||||
// (uploads authorize with the Nostr signer, not a stored key).
|
||||
if (isBlossomProvider(targetConn.provider)) {
|
||||
base.unlocked = true;
|
||||
base.credentials = {
|
||||
provider: BLOSSOM_PROVIDER,
|
||||
servers: Array.isArray(targetConn.meta?.servers)
|
||||
? targetConn.meta.servers
|
||||
: [],
|
||||
meta: targetConn.meta || {},
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
if (base.unlocked) {
|
||||
const details = await this.storageService.getConnection(
|
||||
pubkey,
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
derivePublicBaseUrl,
|
||||
} from "../../services/s3Service.js";
|
||||
import { getActiveSigner } from "../../nostr/client.js";
|
||||
import { FEATURE_BLOSSOM_STORAGE } from "../../constants.js";
|
||||
import { BLOSSOM_PROVIDER } from "../../services/blossomService.js";
|
||||
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
|
||||
import { storageSyncService } from "../../services/storageSyncService.js";
|
||||
import { StorageCorsHelp } from "./storageCorsHelp.js";
|
||||
@@ -97,6 +99,41 @@ export class ProfileStorageController {
|
||||
this.storageSyncToggle = document.getElementById("storageSyncToggle") || null;
|
||||
this.storageSyncRestoreBtn = document.getElementById("storageSyncRestoreBtn") || null;
|
||||
this.storageSyncStatus = document.getElementById("storageSyncStatus") || null;
|
||||
|
||||
// Blossom (nostr-native) — keyless multi-server storage.
|
||||
this.storageS3Fields = document.getElementById("storageS3Fields") || null;
|
||||
this.storageBlossomSection =
|
||||
document.getElementById("storageBlossomSection") || null;
|
||||
this.storageBlossomServersInput =
|
||||
document.getElementById("storageBlossomServers") || null;
|
||||
this.storageProviderBlossomOption =
|
||||
document.getElementById("storageProviderBlossomOption") || null;
|
||||
// Reveal the Blossom provider option only when the deployment enables it.
|
||||
if (
|
||||
FEATURE_BLOSSOM_STORAGE === true &&
|
||||
this.storageProviderBlossomOption instanceof HTMLElement
|
||||
) {
|
||||
this.storageProviderBlossomOption.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
parseBlossomServers() {
|
||||
const raw = this.storageBlossomServersInput?.value || "";
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const line of raw.split(/[\n,]/)) {
|
||||
const url = line.trim().replace(/\/+$/, "");
|
||||
if (!url || seen.has(url)) {
|
||||
continue;
|
||||
}
|
||||
// Only accept absolute http(s) server URLs.
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(url);
|
||||
out.push(url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
registerEventListeners() {
|
||||
@@ -336,7 +373,35 @@ export class ProfileStorageController {
|
||||
this.mainController.getActivePubkey(),
|
||||
);
|
||||
const storageService = this.mainController.services?.storageService;
|
||||
if (!pubkey || !storageService || !storageService.isUnlocked?.(pubkey)) {
|
||||
if (!pubkey || !storageService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedProvider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
|
||||
// Blossom servers live in plaintext connection meta — load them without an
|
||||
// unlock (there's no encrypted secret to decrypt).
|
||||
if (selectedProvider === BLOSSOM_PROVIDER) {
|
||||
try {
|
||||
const connections = await storageService.listConnections(pubkey);
|
||||
const match = findProviderConnection(connections, BLOSSOM_PROVIDER);
|
||||
const servers = Array.isArray(match?.meta?.servers)
|
||||
? match.meta.servers
|
||||
: [];
|
||||
if (this.storageBlossomServersInput instanceof HTMLElement) {
|
||||
this.storageBlossomServersInput.value = servers.join("\n");
|
||||
}
|
||||
if (this.storageDefaultInput instanceof HTMLElement) {
|
||||
this.storageDefaultInput.checked =
|
||||
match?.meta?.defaultForUploads === true;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[ProfileModal] Failed to load Blossom servers:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storageService.isUnlocked?.(pubkey)) {
|
||||
return;
|
||||
}
|
||||
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
@@ -364,6 +429,21 @@ export class ProfileStorageController {
|
||||
|
||||
updateStorageFormVisibility() {
|
||||
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
|
||||
// Blossom has no bucket/keys/CORS — swap the whole S3 field group for the
|
||||
// keyless multi-server section.
|
||||
const isBlossom = provider === BLOSSOM_PROVIDER;
|
||||
if (this.storageS3Fields instanceof HTMLElement) {
|
||||
this.storageS3Fields.classList.toggle("hidden", isBlossom);
|
||||
}
|
||||
if (this.storageBlossomSection instanceof HTMLElement) {
|
||||
this.storageBlossomSection.classList.toggle("hidden", !isBlossom);
|
||||
}
|
||||
if (isBlossom) {
|
||||
this.corsHelp.setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isR2 = provider === "cloudflare_r2";
|
||||
// B2 derives its endpoint from the region, so its raw Endpoint field is hidden.
|
||||
const isB2 = provider === PROVIDERS.B2;
|
||||
@@ -710,6 +790,44 @@ export class ProfileStorageController {
|
||||
if (!storageService) return;
|
||||
|
||||
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
|
||||
// Blossom: keyless. Persist the server list in the (plaintext) connection meta;
|
||||
// there is no secret to encrypt and no bucket/endpoint to validate.
|
||||
if (provider === BLOSSOM_PROVIDER) {
|
||||
const servers = this.parseBlossomServers();
|
||||
if (servers.length === 0) {
|
||||
this.setStorageFormStatus(
|
||||
"Add at least one Blossom server URL (https://…).",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const isDefaultBlossom = this.storageDefaultInput?.checked || false;
|
||||
this.setStorageFormStatus("Saving...", "info");
|
||||
try {
|
||||
await saveProviderConnection(storageService, pubkey, {
|
||||
provider: BLOSSOM_PROVIDER,
|
||||
payload: { provider: BLOSSOM_PROVIDER },
|
||||
meta: {
|
||||
provider: BLOSSOM_PROVIDER,
|
||||
servers,
|
||||
defaultForUploads: isDefaultBlossom,
|
||||
label: `Blossom (${servers.length} server${servers.length === 1 ? "" : "s"})`,
|
||||
},
|
||||
isDefault: isDefaultBlossom,
|
||||
});
|
||||
this.setStorageFormStatus("Blossom servers saved.", "success");
|
||||
this.mainController.showSuccess("Blossom storage saved.");
|
||||
} catch (error) {
|
||||
devLogger.error("Failed to save Blossom connection:", error);
|
||||
this.setStorageFormStatus(
|
||||
"Failed to save Blossom servers. Unlock storage first if prompted.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
|
||||
const region = this.storageRegionInput?.value?.trim() || "auto";
|
||||
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
|
||||
@@ -860,6 +978,40 @@ export class ProfileStorageController {
|
||||
}
|
||||
|
||||
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
||||
|
||||
// Blossom: no credentials to test. Best-effort reachability ping of each
|
||||
// server (CORS may block it — a failure here doesn't mean upload will fail,
|
||||
// so it's informational). The real verification is an upload.
|
||||
if (provider === BLOSSOM_PROVIDER) {
|
||||
const servers = this.parseBlossomServers();
|
||||
if (servers.length === 0) {
|
||||
this.setStorageFormStatus(
|
||||
"Add at least one Blossom server URL first.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.setStorageFormStatus("Checking servers…", "info");
|
||||
let reachable = 0;
|
||||
await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
try {
|
||||
await fetch(server, { method: "HEAD", mode: "cors" });
|
||||
reachable += 1;
|
||||
} catch {
|
||||
// CORS/offline — inconclusive, not necessarily a failure.
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.setStorageFormStatus(
|
||||
reachable > 0
|
||||
? `Reached ${reachable}/${servers.length} server(s). Upload to fully verify.`
|
||||
: `Couldn't reach the server(s) from the browser (CORS may block a HEAD). Save and try an upload to verify.`,
|
||||
reachable > 0 ? "success" : "info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
|
||||
const region = this.storageRegionInput?.value?.trim() || "auto";
|
||||
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
|
||||
|
||||
Reference in new issue
Block a user