fix(storage): make Blossom actually usable in the upload modal [LOCAL] (#30)

Two root causes for "Blossom set as default but not showing / not usable":

1. SAVE required unlock. saveConnection throws "Storage is locked" without a
   master key — but Blossom has no secret. Added
   storageService.saveKeylessConnection() (writes plaintext meta, no encryption,
   no unlock) and the Blossom save branch now uses it. So a Blossom connection
   saves even if the user never unlocked storage.

2. UploadModal treated Blossom like a locked S3 connection: it only set
   activeCredentials when isStorageUnlocked, showed "Locked 🔒", blocked the
   file-pick behind an unlock prompt, and mislabeled it "S3 Compatible". Now:
   getProviderLabel → "Blossom"; loadFromStorage resolves servers from the
   plaintext meta (no unlock); the summary shows "N servers / Nostr-native"; the
   two upload gates and updateLockUi bypass unlock for Blossom
   (activeProviderIsBlossom). Uploads authorize with the Nostr signer instead.

Bumped UploadModal size baseline (+gating code). Lint + build clean. Local only.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-09 21:40:18 -04:00
1 parent 5935702e75
commit ba62b35ada
4 files changed
+78 -21

No files matched your search

+35
View File
@@ -591,6 +591,41 @@ export class StorageService {
userLogger.log(`[StorageService] Saved connection ${connectionId}`);
}
/**
* Save a KEYLESS connection (e.g. Blossom): all config lives in the plaintext
* meta and there is no secret to encrypt, so this does NOT require unlock().
* Used for providers whose auth is the user's Nostr signer, not a stored key.
*/
async saveKeylessConnection(pubkey, connectionId, meta = {}) {
const account = (await this._getAccount(pubkey)) || {
pubkey,
connections: {},
};
if (!account.connections) {
account.connections = {};
}
if (meta.defaultForUploads) {
for (const key in account.connections) {
const conn = account.connections[key];
if (conn.meta) {
conn.meta.defaultForUploads = false;
}
}
}
account.connections[connectionId] = {
id: connectionId,
provider: meta.provider || connectionId,
meta: {
...meta,
lastSaved: Date.now(),
provider: meta.provider || connectionId,
},
encrypted: null,
};
await this._saveAccount(account);
userLogger.log(`[StorageService] Saved keyless connection ${connectionId}`);
}
/**
* Retrieves and decrypts a connection.
* Requires unlock() to be called first.
+36 -7
View File
@@ -16,6 +16,10 @@ import { sanitizeBucketName } from "../../storage/r2-mgmt.js";
import { buildR2Key, buildPublicUrl } from "../../r2.js";
import { buildS3ObjectUrl } from "../../services/s3Service.js";
import { PROVIDERS } from "../../services/storageService.js";
import {
BLOSSOM_PROVIDER,
isBlossomProvider,
} from "../../services/blossomService.js";
import {
buildStoragePointerValue,
buildStoragePrefixFromKey,
@@ -119,6 +123,9 @@ export class UploadModal {
this.activeProvider = null;
this.isStorageUnlocked = false;
this.storageConfigured = false;
// Blossom connections have no encrypted secret, so they never need a PIN
// unlock — the upload authorizes with the Nostr signer instead.
this.activeProviderIsBlossom = false;
// #44: per-modal upload-destination override (null = account default).
this.selectedConnectionId = null;
@@ -556,9 +563,10 @@ export class UploadModal {
e.target.value = ""; // Clear selection
return;
}
if (!this.isStorageUnlocked) {
if (!this.isStorageUnlocked && !this.activeProviderIsBlossom) {
// #56: unlock inline (silent restore or one passphrase prompt) and
// continue with this same file pick instead of erroring.
// continue with this same file pick instead of erroring. Blossom has no
// secret to unlock — it authorizes with the Nostr signer at upload time.
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
e.target.value = "";
@@ -664,8 +672,9 @@ export class UploadModal {
this.resetThumbnailPicker();
return;
}
if (!this.isStorageUnlocked) {
if (!this.isStorageUnlocked && !this.activeProviderIsBlossom) {
// #56: prompt to unlock inline instead of silently dropping the pick.
// Blossom needs no unlock (signer-authorized at upload time).
const unlocked = await this.ensureStorageUnlockedForUpload();
if (!unlocked) {
this.resetThumbnailPicker();
@@ -934,12 +943,20 @@ export class UploadModal {
if (targetConn) {
this.storageConfigured = true;
this.activeProvider = targetConn.provider;
this.activeProviderIsBlossom = isBlossomProvider(targetConn.provider);
// UI Updates
this.toggleStorageView("summary");
const providerName = this.getProviderLabel(targetConn.provider || targetConn.meta?.provider);
const bucketName = targetConn.meta?.bucket || "Unknown Bucket";
const urlStyle = this.describeUrlStyle(targetConn.provider, targetConn.meta?.forcePathStyle);
const blossomServers = Array.isArray(targetConn.meta?.servers)
? targetConn.meta.servers
: [];
const bucketName = this.activeProviderIsBlossom
? `${blossomServers.length} server${blossomServers.length === 1 ? "" : "s"}`
: targetConn.meta?.bucket || "Unknown Bucket";
const urlStyle = this.activeProviderIsBlossom
? "Nostr-native (content-addressed)"
: this.describeUrlStyle(targetConn.provider, targetConn.meta?.forcePathStyle);
if (this.statusText.summaryProvider) {
this.statusText.summaryProvider.textContent = providerName;
@@ -955,7 +972,14 @@ export class UploadModal {
}
if (this.statusText.summaryCopy) this.statusText.summaryCopy.textContent = this.getSummaryCopy(targetConn.provider);
if (this.isStorageUnlocked) {
if (this.activeProviderIsBlossom) {
// No secret to decrypt — servers live in the plaintext meta.
this.activeCredentials = {
provider: BLOSSOM_PROVIDER,
servers: blossomServers,
meta: targetConn.meta || {},
};
} else if (this.isStorageUnlocked) {
const details = await this.storageService.getConnection(pubkey, targetConn.id);
if (details) {
this.activeCredentials = details;
@@ -970,6 +994,7 @@ export class UploadModal {
this.storageConfigured = false;
this.activeCredentials = null;
this.activeProvider = null;
this.activeProviderIsBlossom = false;
this.toggleStorageView("empty");
}
this.updateLockUi();
@@ -1064,7 +1089,8 @@ export class UploadModal {
}
updateLockUi() {
const locked = !this.isStorageUnlocked;
// Blossom has no secret to unlock — never show it as "locked".
const locked = !this.isStorageUnlocked && !this.activeProviderIsBlossom;
if (this.statusText?.storageLock) {
this.statusText.storageLock.textContent = locked ? "Locked 🔒" : "Unlocked 🔓";
this.statusText.storageLock.className = locked ? "text-xs text-critical" : "text-xs text-success";
@@ -1080,6 +1106,9 @@ export class UploadModal {
}
getProviderLabel(provider) {
if (isBlossomProvider(provider)) {
return "Blossom";
}
if (provider === PROVIDERS.R2 || provider === "cloudflare_r2") {
return "Cloudflare R2";
}
+6 -13
View File
@@ -805,25 +805,18 @@ export class ProfileStorageController {
const isDefaultBlossom = this.storageDefaultInput?.checked || false;
this.setStorageFormStatus("Saving...", "info");
try {
await saveProviderConnection(storageService, pubkey, {
// Keyless: no secret to encrypt, so no storage unlock needed.
await storageService.saveKeylessConnection(pubkey, BLOSSOM_PROVIDER, {
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,
servers,
defaultForUploads: isDefaultBlossom,
label: `Blossom (${servers.length} server${servers.length === 1 ? "" : "s"})`,
});
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",
);
this.setStorageFormStatus("Failed to save Blossom servers.", "error");
}
return;
}