mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Saves push silently as before, but if the relay copy is NEWER than the one this device last pushed (another device changed it), the user is now asked to confirm before overwriting — preventing a silent multi-device clobber. No nag on normal saves. - js/services/settingsSyncFlags.js: track per-pubkey+kind last-pushed created_at (getSyncPushedAt/setSyncPushedAt). - js/services/syncConflict.js: pushWithConflictCheck — peek remote created_at (list-only, no decrypt), compare to last push, call confirmOverwrite only on a real conflict, then push strictly-newer than remote so the replace wins. - storage/walletSyncService push()/enable() route through it and accept a confirmOverwrite callback. - ProfileStorage/WalletController supply confirmOverwrite (window.confirm) and surface a "kept the newer copy" status when the user declines. tests/sync-conflict.test.mjs: no-remote→silent, newer→prompt(+decline blocks push), confirm→strictly-newer push, older→no prompt. Mutation-verified (ignoring the decline fails). Service-test twins gained exists(). Co-Authored-By: Claude Opus 4.8 <[email protected]>
867 lines
30 KiB
JavaScript
867 lines
30 KiB
JavaScript
import { devLogger } from "../../utils/logger.js";
|
|
import { PROVIDERS } from "../../services/storageService.js";
|
|
import {
|
|
prepareS3Connection,
|
|
getCorsOrigins,
|
|
} from "../../services/s3Service.js";
|
|
import { getActiveSigner } from "../../nostr/client.js";
|
|
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
|
|
import { storageSyncService } from "../../services/storageSyncService.js";
|
|
|
|
export class ProfileStorageController {
|
|
constructor(mainController) {
|
|
this.mainController = mainController;
|
|
|
|
this.storageUnlockBtn = null;
|
|
this.storageSaveBtn = null;
|
|
this.storageTestBtn = null;
|
|
this.storageClearBtn = null;
|
|
this.storageUnlockSection = null;
|
|
this.storageFormSection = null;
|
|
this.storageStatusText = null;
|
|
this.storageFormStatus = null;
|
|
|
|
this.storageProviderInput = null;
|
|
this.storageEndpointInput = null;
|
|
this.storageRegionInput = null;
|
|
this.storageAccessKeyInput = null;
|
|
this.storageSecretKeyInput = null;
|
|
this.storageBucketInput = null;
|
|
this.storagePrefixInput = null;
|
|
this.storagePrefixWarning = null;
|
|
this.storageDefaultInput = null;
|
|
this.storageR2Helper = null;
|
|
this.storageS3Helper = null;
|
|
this.storageForcePathStyleInput = null;
|
|
this.storageForcePathStyleLabel = null;
|
|
|
|
this.storageSyncSection = null;
|
|
this.storageSyncToggle = null;
|
|
this.storageSyncRestoreBtn = null;
|
|
this.storageSyncStatus = null;
|
|
|
|
this.storageUnlockFailure = null;
|
|
}
|
|
|
|
cacheDomReferences() {
|
|
this.storageUnlockBtn = document.getElementById("profileStorageUnlockBtn") || null;
|
|
this.storageSaveBtn = document.getElementById("storageSaveBtn") || null;
|
|
this.storageTestBtn = document.getElementById("storageTestBtn") || null;
|
|
this.storageClearBtn = document.getElementById("storageClearBtn") || null;
|
|
this.storageUnlockSection = document.getElementById("profileStorageUnlock") || null;
|
|
this.storageFormSection = document.getElementById("profileStorageForm") || null;
|
|
this.storageStatusText = document.getElementById("profileStorageStatus") || null;
|
|
this.storageFormStatus = document.getElementById("storageFormStatus") || null;
|
|
|
|
this.storageProviderInput = document.getElementById("storageProvider") || null;
|
|
this.storageEndpointInput = document.getElementById("storageEndpoint") || null;
|
|
this.storageRegionInput = document.getElementById("storageRegion") || null;
|
|
this.storageAccessKeyInput = document.getElementById("storageAccessKey") || null;
|
|
this.storageSecretKeyInput = document.getElementById("storageSecretKey") || null;
|
|
this.storageBucketInput = document.getElementById("storageBucket") || null;
|
|
this.storagePrefixInput = document.getElementById("storagePrefix") || null;
|
|
this.storagePrefixWarning = document.getElementById("storagePrefixWarning") || null;
|
|
this.storageDefaultInput = document.getElementById("storageDefault") || null;
|
|
this.storageR2Helper = document.getElementById("storageR2Helper") || null;
|
|
this.storageS3Helper = document.getElementById("storageS3Helper") || null;
|
|
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
|
|
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
|
|
this.storageSyncSection = document.getElementById("profileStorageSync") || null;
|
|
this.storageSyncToggle = document.getElementById("storageSyncToggle") || null;
|
|
this.storageSyncRestoreBtn = document.getElementById("storageSyncRestoreBtn") || null;
|
|
this.storageSyncStatus = document.getElementById("storageSyncStatus") || null;
|
|
}
|
|
|
|
registerEventListeners() {
|
|
if (this.storageUnlockBtn instanceof HTMLElement) {
|
|
this.storageUnlockBtn.addEventListener("click", () => {
|
|
void this.handleUnlockStorage();
|
|
});
|
|
}
|
|
|
|
if (this.storageSaveBtn instanceof HTMLElement) {
|
|
this.storageSaveBtn.addEventListener("click", () => {
|
|
void this.handleSaveStorage();
|
|
});
|
|
}
|
|
|
|
if (this.storageTestBtn instanceof HTMLElement) {
|
|
this.storageTestBtn.addEventListener("click", () => {
|
|
void this.handleTestStorage();
|
|
});
|
|
}
|
|
|
|
if (this.storageClearBtn instanceof HTMLElement) {
|
|
this.storageClearBtn.addEventListener("click", () => {
|
|
void this.handleClearStorage();
|
|
});
|
|
}
|
|
|
|
if (this.storageProviderInput instanceof HTMLElement) {
|
|
this.storageProviderInput.addEventListener("change", () => {
|
|
this.updateStorageFormVisibility();
|
|
});
|
|
}
|
|
|
|
if (this.storagePrefixInput instanceof HTMLElement) {
|
|
this.storagePrefixInput.addEventListener("input", () => {
|
|
this.handlePublicUrlInput();
|
|
});
|
|
}
|
|
|
|
if (this.storageSyncToggle instanceof HTMLElement) {
|
|
this.storageSyncToggle.addEventListener("change", () => {
|
|
void this.handleToggleSync();
|
|
});
|
|
}
|
|
|
|
if (this.storageSyncRestoreBtn instanceof HTMLElement) {
|
|
this.storageSyncRestoreBtn.addEventListener("click", () => {
|
|
void this.handleRestoreSync();
|
|
});
|
|
}
|
|
}
|
|
|
|
async populateStoragePane() {
|
|
const storageService = this.mainController.services.storageService;
|
|
const pubkey = this.mainController.normalizeHexPubkey(
|
|
this.mainController.getActivePubkey()
|
|
);
|
|
|
|
this.handleClearStorage();
|
|
|
|
if (!pubkey) {
|
|
// Not logged in
|
|
if (this.storageUnlockSection) this.storageUnlockSection.classList.add("hidden");
|
|
if (this.storageFormSection) this.storageFormSection.classList.add("hidden");
|
|
if (this.storageStatusText) {
|
|
this.storageStatusText.textContent = "Please login to manage storage.";
|
|
this.storageStatusText.className = "text-xs text-status-danger";
|
|
}
|
|
return;
|
|
}
|
|
|
|
let isUnlocked = storageService && storageService.masterKeys.has(pubkey);
|
|
|
|
// Auto-unlock
|
|
if (!isUnlocked && storageService && !this.storageUnlockFailure) {
|
|
const signer = getActiveSigner();
|
|
if (
|
|
signer &&
|
|
(typeof signer.nip44Decrypt === "function" ||
|
|
typeof signer.nip04Decrypt === "function")
|
|
) {
|
|
try {
|
|
await storageService.unlock(pubkey, { signer });
|
|
isUnlocked = storageService.masterKeys.has(pubkey);
|
|
} catch (autoUnlockError) {
|
|
devLogger.log(
|
|
"[ProfileModal] Auto-unlock storage skipped:",
|
|
autoUnlockError?.message || autoUnlockError
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.storageStatusText) {
|
|
if (isUnlocked) {
|
|
this.storageStatusText.textContent = "Unlocked";
|
|
this.storageStatusText.className = "text-xs text-status-success";
|
|
} else if (this.storageUnlockFailure?.message) {
|
|
this.storageStatusText.textContent = `Locked (${this.storageUnlockFailure.message})`;
|
|
this.storageStatusText.className = "text-xs text-status-danger";
|
|
} else {
|
|
this.storageStatusText.textContent = "Locked";
|
|
this.storageStatusText.className = "text-xs text-status-warning";
|
|
}
|
|
}
|
|
|
|
if (isUnlocked) {
|
|
if (this.storageUnlockSection)
|
|
this.storageUnlockSection.classList.add("hidden");
|
|
if (this.storageFormSection)
|
|
this.storageFormSection.classList.remove("hidden");
|
|
|
|
try {
|
|
const connections = await storageService.listConnections(pubkey);
|
|
const defaultConn = connections.find((c) => c.meta?.defaultForUploads);
|
|
const targetConn = defaultConn || connections[0];
|
|
|
|
if (targetConn) {
|
|
const conn = await storageService.getConnection(
|
|
pubkey,
|
|
targetConn.id
|
|
);
|
|
if (conn) {
|
|
this.fillStorageForm(conn);
|
|
if (this.storageStatusText) {
|
|
const label = conn.meta?.label || conn.provider || "S3";
|
|
this.storageStatusText.textContent = `Unlocked (${label})`;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
devLogger.error("Failed to load storage connections:", error);
|
|
}
|
|
} else {
|
|
if (this.storageUnlockSection)
|
|
this.storageUnlockSection.classList.remove("hidden");
|
|
if (this.storageFormSection)
|
|
this.storageFormSection.classList.add("hidden");
|
|
}
|
|
|
|
this.renderSyncSection(isUnlocked ? pubkey : "");
|
|
this.updateStorageFormVisibility();
|
|
}
|
|
|
|
confirmSyncOverwrite() {
|
|
if (typeof window === "undefined" || typeof window.confirm !== "function") {
|
|
return true;
|
|
}
|
|
return window.confirm(
|
|
"A newer copy of your storage settings is on your account (changed on " +
|
|
"another device). Overwrite it with this one?"
|
|
);
|
|
}
|
|
|
|
renderSyncSection(pubkey) {
|
|
if (!(this.storageSyncSection instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
// Only meaningful once unlocked: there is something to sync and the signer
|
|
// that encrypts is present.
|
|
if (!pubkey || !storageSyncService.isAvailable()) {
|
|
this.storageSyncSection.classList.add("hidden");
|
|
return;
|
|
}
|
|
this.storageSyncSection.classList.remove("hidden");
|
|
if (this.storageSyncToggle instanceof HTMLInputElement) {
|
|
this.storageSyncToggle.checked = storageSyncService.isEnabled(pubkey);
|
|
}
|
|
this.setSyncStatus("");
|
|
}
|
|
|
|
setSyncStatus(message, tone = "info") {
|
|
if (!(this.storageSyncStatus instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
this.storageSyncStatus.textContent = message || "";
|
|
const toneClass =
|
|
tone === "success"
|
|
? "text-status-success"
|
|
: tone === "error"
|
|
? "text-status-danger"
|
|
: "text-muted";
|
|
this.storageSyncStatus.className = `text-xs ${toneClass}`;
|
|
}
|
|
|
|
async handleToggleSync() {
|
|
const pubkey = this.mainController.normalizeHexPubkey(
|
|
this.mainController.getActivePubkey()
|
|
);
|
|
if (!pubkey) {
|
|
return;
|
|
}
|
|
const enabled =
|
|
this.storageSyncToggle instanceof HTMLInputElement
|
|
? this.storageSyncToggle.checked
|
|
: false;
|
|
try {
|
|
if (enabled) {
|
|
this.setSyncStatus("Encrypting and publishing…");
|
|
const result = await storageSyncService.enable(pubkey, {
|
|
confirmOverwrite: () => this.confirmSyncOverwrite(),
|
|
});
|
|
if (result?.ok) {
|
|
this.setSyncStatus(
|
|
`Synced to ${result.accepted}/${result.total} relays.`,
|
|
"success"
|
|
);
|
|
this.mainController.showSuccess("Storage settings synced (encrypted).");
|
|
} else if (result?.conflict) {
|
|
// User declined to overwrite a newer copy from another device.
|
|
this.setSyncStatus(
|
|
"Kept the newer copy on your account. Use Restore to pull it, or save again to overwrite.",
|
|
);
|
|
} else {
|
|
// Roll the toggle back so it reflects reality.
|
|
if (this.storageSyncToggle instanceof HTMLInputElement) {
|
|
this.storageSyncToggle.checked = false;
|
|
}
|
|
await storageSyncService.disable(pubkey).catch(() => {});
|
|
this.setSyncStatus(
|
|
result?.error === "nothing-to-sync"
|
|
? "Nothing to sync yet — save a connection first."
|
|
: "Could not publish the encrypted copy. Try again.",
|
|
"error"
|
|
);
|
|
}
|
|
} else {
|
|
this.setSyncStatus("Removing the synced copy…");
|
|
await storageSyncService.disable(pubkey);
|
|
this.setSyncStatus("Sync turned off; the synced copy was cleared.");
|
|
this.mainController.showSuccess("Storage sync turned off.");
|
|
}
|
|
} catch (error) {
|
|
devLogger.error("[ProfileModal] Storage sync toggle failed:", error);
|
|
this.setSyncStatus("Sync failed. Please try again.", "error");
|
|
}
|
|
}
|
|
|
|
async handleRestoreSync() {
|
|
const pubkey = this.mainController.normalizeHexPubkey(
|
|
this.mainController.getActivePubkey()
|
|
);
|
|
if (!pubkey) {
|
|
return;
|
|
}
|
|
try {
|
|
this.setSyncStatus("Fetching and decrypting…");
|
|
const result = await storageSyncService.pull(pubkey);
|
|
if (result?.found && result.imported) {
|
|
this.setSyncStatus("Restored from your Nostr account.", "success");
|
|
this.mainController.showSuccess("Storage settings restored.");
|
|
// Re-render the pane so the restored connection shows.
|
|
await this.populateStoragePane();
|
|
} else if (result?.found && !result.imported) {
|
|
this.setSyncStatus("Found a copy but could not import it.", "error");
|
|
} else if (result?.cleared) {
|
|
this.setSyncStatus("No synced settings found (it was cleared).");
|
|
} else {
|
|
this.setSyncStatus("No synced settings found on your account.");
|
|
}
|
|
} catch (error) {
|
|
devLogger.error("[ProfileModal] Storage sync restore failed:", error);
|
|
this.setSyncStatus("Restore failed. Please try again.", "error");
|
|
}
|
|
}
|
|
|
|
fillStorageForm(conn) {
|
|
if (!conn) return;
|
|
const {
|
|
provider,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
accountId: payloadAccountId,
|
|
endpoint: payloadEndpoint,
|
|
forcePathStyle: payloadForcePathStyle,
|
|
} = conn;
|
|
const {
|
|
endpoint,
|
|
region,
|
|
bucket,
|
|
prefix,
|
|
defaultForUploads,
|
|
accountId,
|
|
forcePathStyle: metaForcePathStyle,
|
|
} = conn.meta || {};
|
|
|
|
if (this.storageProviderInput)
|
|
this.storageProviderInput.value = provider || "cloudflare_r2";
|
|
|
|
const resolvedEndpoint =
|
|
endpoint || accountId || payloadAccountId || payloadEndpoint || "";
|
|
|
|
if (this.storageEndpointInput)
|
|
this.storageEndpointInput.value = resolvedEndpoint;
|
|
if (this.storageRegionInput)
|
|
this.storageRegionInput.value = region || "auto";
|
|
if (this.storageAccessKeyInput)
|
|
this.storageAccessKeyInput.value = accessKeyId || "";
|
|
if (this.storageSecretKeyInput)
|
|
this.storageSecretKeyInput.value = secretAccessKey || "";
|
|
if (this.storageBucketInput) this.storageBucketInput.value = bucket || "";
|
|
if (this.storagePrefixInput) this.storagePrefixInput.value = prefix || "";
|
|
if (this.storageDefaultInput)
|
|
this.storageDefaultInput.checked = !!defaultForUploads;
|
|
|
|
if (this.storageForcePathStyleInput) {
|
|
if (typeof payloadForcePathStyle === "boolean") {
|
|
this.storageForcePathStyleInput.checked = payloadForcePathStyle;
|
|
} else if (typeof metaForcePathStyle === "boolean") {
|
|
this.storageForcePathStyleInput.checked = metaForcePathStyle;
|
|
} else {
|
|
this.storageForcePathStyleInput.checked = true;
|
|
}
|
|
}
|
|
|
|
this.updateStorageFormVisibility();
|
|
this.handlePublicUrlInput();
|
|
}
|
|
|
|
handlePublicUrlInput() {
|
|
if (!this.storagePrefixInput || !this.storagePrefixWarning) return;
|
|
this.storagePrefixWarning.textContent = "";
|
|
this.storagePrefixWarning.classList.add("hidden");
|
|
}
|
|
|
|
updateStorageFormVisibility() {
|
|
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
|
const isR2 = provider === "cloudflare_r2";
|
|
|
|
if (this.storageEndpointInput) {
|
|
const label =
|
|
this.storageEndpointInput.parentElement.querySelector("span");
|
|
if (isR2) {
|
|
if (label) label.textContent = "Cloudflare Account ID";
|
|
this.storageEndpointInput.placeholder =
|
|
"Account ID from Cloudflare dashboard";
|
|
this.storageEndpointInput.parentElement.classList.remove("hidden");
|
|
this.storageEndpointInput.type = "text";
|
|
if (this.storagePrefixInput) {
|
|
this.storagePrefixInput.placeholder = "https://pub-xxx.r2.dev";
|
|
}
|
|
} else {
|
|
if (label) label.textContent = "Endpoint URL";
|
|
this.storageEndpointInput.placeholder = "https://s3.example.com";
|
|
this.storageEndpointInput.parentElement.classList.remove("hidden");
|
|
this.storageEndpointInput.type = "url";
|
|
if (this.storagePrefixInput) {
|
|
this.storagePrefixInput.placeholder = "https://cdn.example.com";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.storageR2Helper) {
|
|
if (isR2) this.storageR2Helper.classList.remove("hidden");
|
|
else this.storageR2Helper.classList.add("hidden");
|
|
}
|
|
|
|
if (this.storageS3Helper) {
|
|
if (!isR2) this.storageS3Helper.classList.remove("hidden");
|
|
else this.storageS3Helper.classList.add("hidden");
|
|
}
|
|
|
|
if (this.storageForcePathStyleLabel) {
|
|
if (!isR2)
|
|
this.storageForcePathStyleLabel.classList.remove("hidden", "flex");
|
|
else this.storageForcePathStyleLabel.classList.add("hidden");
|
|
|
|
if (!isR2) {
|
|
this.storageForcePathStyleLabel.classList.add("flex");
|
|
}
|
|
}
|
|
}
|
|
|
|
getStorageUnlockFailureMessage(error) {
|
|
const code = typeof error?.code === "string" ? error.code : "";
|
|
|
|
switch (code) {
|
|
case "storage-unlock-permission-denied":
|
|
return "Storage unlock requires extension encryption permission. Approve the prompt, then retry unlock.";
|
|
case "storage-unlock-no-decryptor":
|
|
return "Your signer cannot decrypt storage keys. Use a signer with NIP-44 or NIP-04 decrypt support.";
|
|
case "storage-unlock-decrypt-failed":
|
|
return "Unable to decrypt your saved storage key. Retry unlock and confirm the active account matches.";
|
|
default:
|
|
return typeof error?.message === "string" && error.message.trim()
|
|
? error.message
|
|
: "Failed to unlock storage. Ensure your signer supports NIP-04/44.";
|
|
}
|
|
}
|
|
|
|
setStorageUnlockFailureState(error) {
|
|
const code =
|
|
typeof error?.code === "string"
|
|
? error.code
|
|
: "storage-unlock-decrypt-failed";
|
|
const message = this.getStorageUnlockFailureMessage(error);
|
|
|
|
this.storageUnlockFailure = { code, message };
|
|
|
|
if (this.storageStatusText) {
|
|
this.storageStatusText.textContent = `Locked (${message})`;
|
|
this.storageStatusText.className = "text-xs text-status-danger";
|
|
}
|
|
|
|
this.setStorageFormStatus(message, "error");
|
|
}
|
|
|
|
clearStorageUnlockFailureState() {
|
|
this.storageUnlockFailure = null;
|
|
}
|
|
|
|
async requestStorageUnlockPermissions() {
|
|
const client = this.mainController.services?.nostrClient;
|
|
if (!client || typeof client.ensureExtensionPermissions !== "function") {
|
|
return { ok: true };
|
|
}
|
|
|
|
return client.ensureExtensionPermissions(DEFAULT_NIP07_ENCRYPTION_METHODS, {
|
|
context: "storage-unlock",
|
|
statusMessage:
|
|
"Approve the extension prompt to allow storage encryption/decryption.",
|
|
showSpinner: true,
|
|
});
|
|
}
|
|
|
|
async handleUnlockStorage() {
|
|
const pubkey = this.mainController.normalizeHexPubkey(
|
|
this.mainController.getActivePubkey()
|
|
);
|
|
if (!pubkey) return;
|
|
|
|
let signer = getActiveSigner();
|
|
if (
|
|
!signer &&
|
|
this.mainController.services.nostrClient &&
|
|
typeof this.mainController.services.nostrClient
|
|
.ensureActiveSignerForPubkey === "function"
|
|
) {
|
|
try {
|
|
signer = await this.mainController.services.nostrClient.ensureActiveSignerForPubkey(
|
|
pubkey
|
|
);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[ProfileModal] Failed to resolve signer for storage unlock:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!signer) {
|
|
this.mainController.showError("No active signer found. Please login.");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
signer.pubkey &&
|
|
this.mainController.normalizeHexPubkey(signer.pubkey) !== pubkey
|
|
) {
|
|
this.mainController.showError(
|
|
`Signer account (${signer.pubkey.slice(
|
|
0,
|
|
8
|
|
)}...) does not match profile (${pubkey.slice(
|
|
0,
|
|
8
|
|
)}...). Please switch accounts in your extension.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const storageService = this.mainController.services.storageService;
|
|
if (!storageService) {
|
|
this.mainController.showError("Storage service unavailable.");
|
|
return;
|
|
}
|
|
|
|
const signerType =
|
|
typeof signer?.type === "string"
|
|
? signer.type.trim().toLowerCase()
|
|
: "";
|
|
const isExtensionSigner =
|
|
signerType === "extension" || signerType === "nip07";
|
|
|
|
const shouldForcePermissionRetry =
|
|
this.storageUnlockBtn?.dataset?.retryAction === "request-permissions";
|
|
|
|
if (shouldForcePermissionRetry) {
|
|
const extensionPermissionCache = this.mainController.services?.nostrClient
|
|
?.extensionPermissionCache;
|
|
if (extensionPermissionCache instanceof Set) {
|
|
for (const method of DEFAULT_NIP07_ENCRYPTION_METHODS) {
|
|
extensionPermissionCache.delete(method);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isExtensionSigner) {
|
|
const permissionResult = await this.requestStorageUnlockPermissions();
|
|
if (!permissionResult?.ok) {
|
|
const permissionError =
|
|
permissionResult?.error ||
|
|
new Error("Extension permissions denied.");
|
|
permissionError.code = "storage-unlock-permission-denied";
|
|
this.setStorageUnlockFailureState(permissionError);
|
|
this.mainController.showError(
|
|
this.getStorageUnlockFailureMessage(permissionError)
|
|
);
|
|
if (this.storageUnlockBtn) {
|
|
this.storageUnlockBtn.dataset.retryAction = "request-permissions";
|
|
this.storageUnlockBtn.textContent = "Retry Permissions + Unlock";
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const hasNip44Decrypt = typeof signer?.nip44Decrypt === "function";
|
|
const hasNip04Decrypt =
|
|
typeof signer?.nip04Decrypt === "function" ||
|
|
typeof signer?.decrypt === "function";
|
|
|
|
if (!hasNip44Decrypt && !hasNip04Decrypt) {
|
|
const missingDecryptError = new Error(
|
|
"This signer cannot decrypt storage keys (NIP-44/NIP-04 missing)."
|
|
);
|
|
missingDecryptError.code = "storage-unlock-no-decryptor";
|
|
this.setStorageUnlockFailureState(missingDecryptError);
|
|
this.mainController.showError(
|
|
this.getStorageUnlockFailureMessage(missingDecryptError)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (this.storageUnlockBtn) {
|
|
this.storageUnlockBtn.disabled = true;
|
|
this.storageUnlockBtn.textContent = "Unlocking...";
|
|
}
|
|
|
|
try {
|
|
await storageService.unlock(pubkey, { signer });
|
|
this.clearStorageUnlockFailureState();
|
|
this.mainController.showSuccess("Storage unlocked.");
|
|
this.populateStoragePane();
|
|
} catch (error) {
|
|
devLogger.error("Failed to unlock storage:", error);
|
|
this.setStorageUnlockFailureState(error);
|
|
this.mainController.showError(this.getStorageUnlockFailureMessage(error));
|
|
if (this.storageUnlockBtn) {
|
|
this.storageUnlockBtn.dataset.retryAction = "request-permissions";
|
|
}
|
|
} finally {
|
|
if (this.storageUnlockBtn) {
|
|
this.storageUnlockBtn.disabled = false;
|
|
const shouldShowRetry =
|
|
this.storageUnlockFailure?.code === "storage-unlock-permission-denied";
|
|
if (shouldShowRetry) {
|
|
this.storageUnlockBtn.textContent = "Retry Permissions + Unlock";
|
|
this.storageUnlockBtn.dataset.retryAction = "request-permissions";
|
|
} else {
|
|
this.storageUnlockBtn.textContent = "Unlock Storage";
|
|
delete this.storageUnlockBtn.dataset.retryAction;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleSaveStorage() {
|
|
const pubkey = this.mainController.normalizeHexPubkey(
|
|
this.mainController.getActivePubkey()
|
|
);
|
|
if (!pubkey) return;
|
|
|
|
const storageService = this.mainController.services.storageService;
|
|
if (!storageService) return;
|
|
|
|
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
|
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
|
|
const region = this.storageRegionInput?.value?.trim() || "auto";
|
|
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
|
|
const secretAccessKey = this.storageSecretKeyInput?.value?.trim() || "";
|
|
const bucket = this.storageBucketInput?.value?.trim() || "";
|
|
const prefix = this.storagePrefixInput?.value?.trim() || "";
|
|
const isDefault = this.storageDefaultInput?.checked || false;
|
|
|
|
if (!accessKeyId || !secretAccessKey || !bucket || !endpointOrAccount) {
|
|
this.setStorageFormStatus("Please fill in all required fields.", "error");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
provider === "cloudflare_r2" &&
|
|
(prefix.includes(".r2.cloudflarestorage.com") ||
|
|
prefix.includes(".s3.") ||
|
|
prefix.includes(".amazonaws.com"))
|
|
) {
|
|
this.setStorageFormStatus(
|
|
"Invalid Public URL. Please use your R2.dev or custom domain.",
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let publicBaseUrl = "";
|
|
let forcePathStyle = false;
|
|
if (provider === PROVIDERS.GENERIC) {
|
|
forcePathStyle = this.storageForcePathStyleInput?.checked ?? true;
|
|
}
|
|
|
|
const payload = {
|
|
provider,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
};
|
|
|
|
if (provider === "cloudflare_r2") {
|
|
payload.accountId = endpointOrAccount;
|
|
} else {
|
|
try {
|
|
const normalized = await prepareS3Connection({
|
|
endpoint: endpointOrAccount,
|
|
region,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
bucket,
|
|
forcePathStyle,
|
|
origins: getCorsOrigins(),
|
|
});
|
|
endpointOrAccount = normalized.endpoint;
|
|
publicBaseUrl = normalized.publicBaseUrl;
|
|
forcePathStyle = normalized.forcePathStyle;
|
|
} catch (error) {
|
|
devLogger.error("Failed to validate S3 connection:", error);
|
|
this.setStorageFormStatus(
|
|
error?.message || "Invalid S3 configuration.",
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
payload.endpoint = endpointOrAccount;
|
|
payload.forcePathStyle = forcePathStyle;
|
|
}
|
|
|
|
const meta = {
|
|
provider,
|
|
region,
|
|
bucket,
|
|
prefix,
|
|
defaultForUploads: isDefault,
|
|
label: `${provider} - ${bucket}`,
|
|
endpoint: provider === "cloudflare_r2" ? undefined : endpointOrAccount,
|
|
};
|
|
|
|
if (provider === "cloudflare_r2") {
|
|
meta.accountId = endpointOrAccount;
|
|
meta.publicBaseUrl = prefix;
|
|
meta.baseDomain = prefix;
|
|
} else {
|
|
meta.publicBaseUrl = publicBaseUrl;
|
|
meta.baseDomain = publicBaseUrl;
|
|
meta.forcePathStyle = forcePathStyle;
|
|
}
|
|
|
|
this.setStorageFormStatus("Saving...", "info");
|
|
|
|
try {
|
|
await storageService.saveConnection(pubkey, "default", payload, meta);
|
|
this.setStorageFormStatus("Connection saved.", "success");
|
|
this.mainController.showSuccess("Storage connection saved.");
|
|
|
|
// Keep the encrypted synced copy current if the user opted in. Warn before
|
|
// overwriting a newer copy changed on another device.
|
|
if (storageSyncService.isEnabled(pubkey)) {
|
|
try {
|
|
const syncResult = await storageSyncService.push(pubkey, {
|
|
confirmOverwrite: () => this.confirmSyncOverwrite(),
|
|
});
|
|
if (syncResult?.ok) {
|
|
this.setSyncStatus("Synced copy updated.", "success");
|
|
} else if (syncResult?.conflict) {
|
|
this.setSyncStatus(
|
|
"Synced copy on your account is newer — not overwritten.",
|
|
);
|
|
}
|
|
} catch (syncError) {
|
|
devLogger.warn("[ProfileModal] Storage re-sync after save failed:", syncError);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
devLogger.error("Failed to save connection:", error);
|
|
this.setStorageFormStatus("Failed to save connection.", "error");
|
|
}
|
|
}
|
|
|
|
async handleTestStorage() {
|
|
const pubkey = this.mainController.normalizeHexPubkey(
|
|
this.mainController.getActivePubkey()
|
|
);
|
|
if (!pubkey) return;
|
|
|
|
const storageService = this.mainController.services.storageService;
|
|
if (!storageService) {
|
|
this.setStorageFormStatus("Storage service unavailable.", "error");
|
|
return;
|
|
}
|
|
|
|
const provider = this.storageProviderInput?.value || "cloudflare_r2";
|
|
const endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
|
|
const region = this.storageRegionInput?.value?.trim() || "auto";
|
|
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
|
|
const secretAccessKey = this.storageSecretKeyInput?.value?.trim() || "";
|
|
const bucket = this.storageBucketInput?.value?.trim() || "";
|
|
|
|
const forcePathStyle =
|
|
provider === PROVIDERS.GENERIC
|
|
? this.storageForcePathStyleInput?.checked ?? true
|
|
: false;
|
|
|
|
const publicBaseUrl = this.storagePrefixInput?.value?.trim() || "";
|
|
|
|
if (!accessKeyId || !secretAccessKey || !endpointOrAccount) {
|
|
this.setStorageFormStatus("Missing credentials for test.", "error");
|
|
return;
|
|
}
|
|
|
|
if (
|
|
provider === "cloudflare_r2" &&
|
|
(publicBaseUrl.includes(".r2.cloudflarestorage.com") ||
|
|
publicBaseUrl.includes(".s3.") ||
|
|
publicBaseUrl.includes(".amazonaws.com"))
|
|
) {
|
|
this.setStorageFormStatus(
|
|
"Invalid Public URL. Please use your R2.dev or custom domain.",
|
|
"error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.setStorageFormStatus("Testing connection...", "info");
|
|
|
|
const config = {
|
|
provider,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
region,
|
|
bucket,
|
|
};
|
|
|
|
if (provider === "cloudflare_r2") {
|
|
config.accountId = endpointOrAccount;
|
|
config.publicBaseUrl = publicBaseUrl;
|
|
config.baseDomain = publicBaseUrl;
|
|
} else {
|
|
config.endpoint = endpointOrAccount;
|
|
config.forcePathStyle = forcePathStyle;
|
|
}
|
|
|
|
try {
|
|
const result = await storageService.testAccess(provider, config);
|
|
if (result.success) {
|
|
this.setStorageFormStatus(
|
|
result.message || "Connection Verified!",
|
|
"success"
|
|
);
|
|
} else {
|
|
this.setStorageFormStatus(`Test Failed: ${result.error}`, "error");
|
|
}
|
|
} catch (error) {
|
|
this.setStorageFormStatus(`Test Error: ${error.message}`, "error");
|
|
}
|
|
}
|
|
|
|
handleClearStorage() {
|
|
if (this.storageEndpointInput) this.storageEndpointInput.value = "";
|
|
if (this.storageRegionInput) this.storageRegionInput.value = "auto";
|
|
if (this.storageAccessKeyInput) this.storageAccessKeyInput.value = "";
|
|
if (this.storageSecretKeyInput) this.storageSecretKeyInput.value = "";
|
|
if (this.storageBucketInput) this.storageBucketInput.value = "";
|
|
if (this.storagePrefixInput) this.storagePrefixInput.value = "";
|
|
if (this.storageDefaultInput) this.storageDefaultInput.checked = false;
|
|
if (this.storageProviderInput) this.storageProviderInput.value = "cloudflare_r2";
|
|
if (this.storageForcePathStyleInput) {
|
|
this.storageForcePathStyleInput.checked = true;
|
|
}
|
|
this.updateStorageFormVisibility();
|
|
this.setStorageFormStatus("", "info");
|
|
}
|
|
|
|
setStorageFormStatus(message, variant = "info") {
|
|
if (!this.storageFormStatus) return;
|
|
this.storageFormStatus.textContent = message;
|
|
this.storageFormStatus.className = `text-sm text-status-${variant}`;
|
|
}
|
|
}
|