mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Wires the encrypted-sync core (0abb82b9) to the live client + active signer and
adds the storage-credential sync service. No UI yet — the Storage pane toggle is
the next increment.
- js/nostr/encryptedSyncFacade.js: default encryptedSync instance bound to
nostrClient (write set via getDeletePublishRelays, read relays, pool) and the
active signer's signEvent. Keeps the core pure/testable.
- js/services/storageService.js: exportAccountRecord / importAccountRecord — get
the on-disk account record and restore it on another device. Import REPLACES
the local record (same-pubkey envelope unlocks with the same signer); cross-
device merge is a follow-up.
- js/services/storageSyncService.js: enable/disable/push/pull under d-tag
"bitvid:storage-connections", with a per-pubkey opt-in flag (off by default).
enable() pushes immediately; disable() wipes the published note.
tests/storage-sync-service.test.mjs: enable-pushes-exported-record, pull-restores
on a fresh device, disable-clears, empty-record-doesn't-publish. Mutation-verified
(skip-import and skip-clear both fail).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
778 lines
23 KiB
JavaScript
778 lines
23 KiB
JavaScript
import { userLogger } from "../utils/logger.js";
|
|
import { clearLegacyR2Settings, loadLegacyR2Settings } from "../r2.js";
|
|
import { bytesToHex } from "../../vendor/crypto-helpers.bundle.min.js";
|
|
import { testS3Connection } from "../storage/r2-s3.js";
|
|
|
|
const DB_NAME = "bitvid-storage";
|
|
const DB_VERSION = 1;
|
|
const STORE_ACCOUNTS = "accounts";
|
|
|
|
/**
|
|
* STORE_ACCOUNTS Schema:
|
|
* {
|
|
* pubkey: string, // Primary Key (hex)
|
|
* encryptedMasterKey: { // Encrypted version of the AES-GCM master key
|
|
* method: "nip44" | "nip04",
|
|
* ciphertext: string
|
|
* },
|
|
* connections: { // Map of connection configurations
|
|
* [connectionId]: {
|
|
* id: string,
|
|
* provider: string, // e.g., "cloudflare_r2"
|
|
* meta: { ... }, // Plaintext metadata (label, default status, etc.)
|
|
* encrypted: { // AES-GCM encrypted payload (access keys, etc.)
|
|
* cipher: string,
|
|
* iv: string
|
|
* }
|
|
* }
|
|
* }
|
|
* }
|
|
*/
|
|
|
|
export const PROVIDERS = Object.freeze({
|
|
R2: "cloudflare_r2",
|
|
S3: "aws_s3",
|
|
GENERIC: "generic_s3",
|
|
});
|
|
|
|
function resolveConnectionMeta(config) {
|
|
return config?.meta || {};
|
|
}
|
|
|
|
function resolveBucket(config) {
|
|
return config.bucket || resolveConnectionMeta(config).bucket;
|
|
}
|
|
|
|
function resolveRegion(config) {
|
|
return config.region || resolveConnectionMeta(config).region;
|
|
}
|
|
|
|
function resolveEndpoint(config) {
|
|
return config.endpoint || resolveConnectionMeta(config).endpoint;
|
|
}
|
|
|
|
function resolvePublicBaseUrl(config) {
|
|
const meta = resolveConnectionMeta(config);
|
|
return (
|
|
config.publicBaseUrl ||
|
|
config.baseDomain ||
|
|
config.publicUrl ||
|
|
meta.publicBaseUrl ||
|
|
meta.baseDomain ||
|
|
meta.publicUrl ||
|
|
meta.publicAccessUrl ||
|
|
meta.publicBucketUrl ||
|
|
""
|
|
);
|
|
}
|
|
|
|
function normalizeConnectionMeta(meta = {}, config = {}) {
|
|
const normalized = { ...meta };
|
|
const publicBaseUrl = resolvePublicBaseUrl({ ...config, meta });
|
|
if (publicBaseUrl) {
|
|
normalized.publicBaseUrl = publicBaseUrl;
|
|
normalized.baseDomain = publicBaseUrl;
|
|
normalized.publicUrl = publicBaseUrl;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function resolveR2Endpoint(config) {
|
|
const accountId =
|
|
config.accountId || resolveConnectionMeta(config).accountId || "";
|
|
if (!accountId) {
|
|
return resolveEndpoint(config);
|
|
}
|
|
return `https://${accountId}.r2.cloudflarestorage.com`;
|
|
}
|
|
|
|
function resolveForcePathStyle(config, fallback = false) {
|
|
const meta = resolveConnectionMeta(config);
|
|
if (typeof config.forcePathStyle === "boolean") {
|
|
return config.forcePathStyle;
|
|
}
|
|
if (typeof meta.forcePathStyle === "boolean") {
|
|
return meta.forcePathStyle;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function buildS3TestConfig(config, overrides = {}) {
|
|
return {
|
|
accessKeyId: config.accessKeyId,
|
|
secretAccessKey: config.secretAccessKey,
|
|
bucket: resolveBucket(config),
|
|
region: resolveRegion(config),
|
|
endpoint: resolveEndpoint(config),
|
|
forcePathStyle: resolveForcePathStyle(config),
|
|
publicBaseUrl: resolvePublicBaseUrl(config),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function encodeNpub(pubkey) {
|
|
if (!pubkey) return null;
|
|
try {
|
|
if (typeof window !== "undefined" && window.NostrTools?.nip19?.npubEncode) {
|
|
return window.NostrTools.nip19.npubEncode(pubkey);
|
|
}
|
|
} catch (err) {
|
|
userLogger.warn("[StorageService] Failed to encode npub for migration:", err);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const PROVIDER_TESTS = {
|
|
[PROVIDERS.R2]: (config) =>
|
|
testS3Connection(
|
|
buildS3TestConfig(config, {
|
|
endpoint: resolveR2Endpoint(config),
|
|
forcePathStyle: true,
|
|
})
|
|
),
|
|
[PROVIDERS.S3]: (config) =>
|
|
testS3Connection(
|
|
buildS3TestConfig(config, {
|
|
forcePathStyle: resolveForcePathStyle(config, false),
|
|
})
|
|
),
|
|
[PROVIDERS.GENERIC]: (config) =>
|
|
testS3Connection(
|
|
buildS3TestConfig(config, {
|
|
forcePathStyle: resolveForcePathStyle(config, true),
|
|
})
|
|
),
|
|
};
|
|
|
|
function hexToBytes(hex) {
|
|
if (typeof hex !== "string") return new Uint8Array(0);
|
|
const bytes = new Uint8Array(hex.length / 2);
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function createStorageUnlockError(code, message, cause) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
if (cause) {
|
|
error.cause = cause;
|
|
}
|
|
return error;
|
|
}
|
|
|
|
/**
|
|
* Service to manage encrypted connection storage using IndexedDB.
|
|
* Connections are encrypted with a random Master Key (AES-GCM),
|
|
* which is itself encrypted using the user's Nostr signer (NIP-04).
|
|
*/
|
|
export class StorageService {
|
|
constructor() {
|
|
this.db = null;
|
|
this.dbPromise = null;
|
|
// Cache decrypted master keys in memory: Map<pubkey, CryptoKey>
|
|
this.masterKeys = new Map();
|
|
}
|
|
|
|
/**
|
|
* Initializes the IndexedDB connection.
|
|
* Uses a promise caching pattern to ensure we only open the DB once,
|
|
* even if multiple calls to init() happen simultaneously.
|
|
*
|
|
* @returns {Promise<IDBDatabase>}
|
|
*/
|
|
async init() {
|
|
if (this.db) return this.db;
|
|
if (this.dbPromise) return this.dbPromise;
|
|
|
|
this.dbPromise = new Promise((resolve, reject) => {
|
|
if (typeof indexedDB === "undefined") {
|
|
reject(new Error("IndexedDB is not supported in this environment."));
|
|
return;
|
|
}
|
|
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
if (!db.objectStoreNames.contains(STORE_ACCOUNTS)) {
|
|
// keyPath is pubkey (hex string)
|
|
db.createObjectStore(STORE_ACCOUNTS, { keyPath: "pubkey" });
|
|
}
|
|
};
|
|
|
|
request.onsuccess = (event) => {
|
|
this.db = event.target.result;
|
|
resolve(this.db);
|
|
};
|
|
|
|
request.onerror = (event) => {
|
|
userLogger.error("[StorageService] Failed to open DB:", event.target.error);
|
|
reject(event.target.error);
|
|
};
|
|
});
|
|
|
|
return this.dbPromise;
|
|
}
|
|
|
|
/**
|
|
* Helper to execute a transaction.
|
|
* @private
|
|
*/
|
|
async _transaction(mode, callback) {
|
|
const db = await this.init();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE_ACCOUNTS, mode);
|
|
const store = tx.objectStore(STORE_ACCOUNTS);
|
|
const request = callback(store);
|
|
|
|
tx.oncomplete = () => resolve(request.result);
|
|
tx.onerror = () => reject(tx.error);
|
|
tx.onabort = () => reject(new Error("Transaction aborted"));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetches the account record for a pubkey.
|
|
* @private
|
|
*/
|
|
async _getAccount(pubkey) {
|
|
return this._transaction("readonly", (store) => store.get(pubkey));
|
|
}
|
|
|
|
/**
|
|
* Saves the account record.
|
|
* @private
|
|
*/
|
|
async _saveAccount(data) {
|
|
return this._transaction("readwrite", (store) => store.put(data));
|
|
}
|
|
|
|
/**
|
|
* Generates a new random AES-GCM key for the session/account.
|
|
* @private
|
|
* @returns {Promise<CryptoKey>}
|
|
*/
|
|
async _generateMasterKey() {
|
|
return crypto.subtle.generateKey(
|
|
{
|
|
name: "AES-GCM",
|
|
length: 256,
|
|
},
|
|
true, // extractable
|
|
["encrypt", "decrypt"]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Encrypts the Master Key using the user's signer (NIP-04/44).
|
|
*
|
|
* Strategy:
|
|
* 1. Export the random AES-GCM Master Key to hex.
|
|
* 2. Encrypt that hex string using the user's Nostr signer.
|
|
* - Prefer NIP-44 (modern, better security).
|
|
* - Fallback to NIP-04 (legacy, widely supported).
|
|
* 3. Store the result along with the method used.
|
|
*
|
|
* @private
|
|
*/
|
|
async _encryptMasterKey(masterKey, signer, pubkey) {
|
|
// 1. Export key to raw bytes
|
|
const rawBuffer = await crypto.subtle.exportKey("raw", masterKey);
|
|
const rawBytes = new Uint8Array(rawBuffer);
|
|
const hexKey = bytesToHex(rawBytes);
|
|
|
|
// 2. Encrypt hex key with signer (encrypt to self)
|
|
// We prefer NIP-44 if available, else NIP-04
|
|
const caps = signer.capabilities || { nip04: true, nip44: true };
|
|
|
|
if (caps.nip44 && typeof signer.nip44Encrypt === "function") {
|
|
try {
|
|
const ciphertext = await signer.nip44Encrypt(pubkey, hexKey);
|
|
return { method: "nip44", ciphertext };
|
|
} catch (err) {
|
|
userLogger.warn("[StorageService] NIP-44 encrypt failed, falling back to NIP-04", err);
|
|
}
|
|
}
|
|
|
|
if (caps.nip04 && typeof signer.nip04Encrypt === "function") {
|
|
const ciphertext = await signer.nip04Encrypt(pubkey, hexKey);
|
|
return { method: "nip04", ciphertext };
|
|
} else if (caps.nip04 && typeof signer.encrypt === "function") {
|
|
// Legacy NIP-04 direct method on some signer objects
|
|
const ciphertext = await signer.encrypt(pubkey, hexKey);
|
|
return { method: "nip04", ciphertext };
|
|
}
|
|
|
|
throw new Error("Signer does not support encryption (NIP-04 or NIP-44).");
|
|
}
|
|
|
|
/**
|
|
* Decrypts the Master Key using the user's signer.
|
|
* Handles both NIP-44 and NIP-04 based on the `method` field stored
|
|
* with the key.
|
|
*
|
|
* @private
|
|
*/
|
|
async _decryptMasterKey(encryptedData, signer, pubkey) {
|
|
const { method, ciphertext } = encryptedData;
|
|
const caps = signer.capabilities || { nip04: true, nip44: true };
|
|
let hexKey = null;
|
|
|
|
if (method === "nip44" && caps.nip44 && typeof signer.nip44Decrypt === "function") {
|
|
hexKey = await signer.nip44Decrypt(pubkey, ciphertext);
|
|
} else if (method === "nip04" || !method) {
|
|
// Default to NIP-04 if method not specified (legacy)
|
|
if (caps.nip04 && typeof signer.nip04Decrypt === "function") {
|
|
hexKey = await signer.nip04Decrypt(pubkey, ciphertext);
|
|
} else if (caps.nip04 && typeof signer.decrypt === "function") {
|
|
hexKey = await signer.decrypt(pubkey, ciphertext);
|
|
}
|
|
}
|
|
|
|
if (!hexKey) {
|
|
throw new Error(`Failed to decrypt master key using ${method || "nip04"}`);
|
|
}
|
|
|
|
const rawBytes = hexToBytes(hexKey);
|
|
return crypto.subtle.importKey(
|
|
"raw",
|
|
rawBytes,
|
|
"AES-GCM",
|
|
true,
|
|
["encrypt", "decrypt"]
|
|
);
|
|
}
|
|
|
|
_normalizeUnlockDecryptError(error) {
|
|
if (error?.code === "storage-unlock-permission-denied") {
|
|
return error;
|
|
}
|
|
if (error?.code === "storage-unlock-no-decryptor") {
|
|
return error;
|
|
}
|
|
if (error?.code === "storage-unlock-decrypt-failed") {
|
|
return error;
|
|
}
|
|
|
|
const rawCode =
|
|
typeof error?.code === "string" ? error.code.trim().toLowerCase() : "";
|
|
const rawMessage =
|
|
typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
|
|
|
if (
|
|
rawCode === "extension-permission-denied" ||
|
|
rawCode === "extension-encryption-permission-denied" ||
|
|
rawCode.includes("permission-denied") ||
|
|
rawMessage.includes("permission denied") ||
|
|
rawMessage.includes("user rejected")
|
|
) {
|
|
return createStorageUnlockError(
|
|
"storage-unlock-permission-denied",
|
|
"Signer permission denied while decrypting storage credentials.",
|
|
error,
|
|
);
|
|
}
|
|
|
|
if (
|
|
rawCode === "nip04-missing" ||
|
|
rawCode === "nip44-missing" ||
|
|
rawCode.includes("no-decrypt") ||
|
|
rawMessage.includes("does not support") ||
|
|
rawMessage.includes("failed to decrypt master key")
|
|
) {
|
|
return createStorageUnlockError(
|
|
"storage-unlock-no-decryptor",
|
|
"Signer does not expose a compatible decrypt method (NIP-04/NIP-44).",
|
|
error,
|
|
);
|
|
}
|
|
|
|
return createStorageUnlockError(
|
|
"storage-unlock-decrypt-failed",
|
|
"Unable to decrypt the encrypted storage key.",
|
|
error,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Encrypts a connection payload (secrets) using the session's Master Key.
|
|
* Uses AES-GCM with a random 12-byte IV.
|
|
*
|
|
* @private
|
|
*/
|
|
async _encryptPayload(payload, masterKey) {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const encoded = new TextEncoder().encode(JSON.stringify(payload));
|
|
|
|
const ciphertextBuffer = await crypto.subtle.encrypt(
|
|
{
|
|
name: "AES-GCM",
|
|
iv: iv,
|
|
},
|
|
masterKey,
|
|
encoded
|
|
);
|
|
|
|
return {
|
|
cipher: bytesToHex(new Uint8Array(ciphertextBuffer)),
|
|
iv: bytesToHex(iv),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decrypts a connection payload using the session's Master Key.
|
|
*
|
|
* @private
|
|
*/
|
|
async _decryptPayload(encrypted, masterKey) {
|
|
const { cipher, iv } = encrypted;
|
|
const ciphertextBytes = hexToBytes(cipher);
|
|
const ivBytes = hexToBytes(iv);
|
|
|
|
const decryptedBuffer = await crypto.subtle.decrypt(
|
|
{
|
|
name: "AES-GCM",
|
|
iv: ivBytes,
|
|
},
|
|
masterKey,
|
|
ciphertextBytes
|
|
);
|
|
|
|
const decoded = new TextDecoder().decode(decryptedBuffer);
|
|
return JSON.parse(decoded);
|
|
}
|
|
|
|
/**
|
|
* Unlocks the storage for a given pubkey.
|
|
* Retrieves or creates the Master Key.
|
|
*
|
|
* @param {string} pubkey - The user's pubkey (hex).
|
|
* @param {object} options - { signer: Object }
|
|
*/
|
|
async unlock(pubkey, { signer } = {}) {
|
|
if (!pubkey) throw new Error("Pubkey required to unlock storage.");
|
|
if (!signer) throw new Error("Signer required to unlock storage.");
|
|
|
|
if (this.masterKeys.has(pubkey)) {
|
|
return; // Already unlocked
|
|
}
|
|
|
|
const account = await this._getAccount(pubkey);
|
|
let masterKey;
|
|
|
|
if (account && account.encryptedMasterKey) {
|
|
// Decrypt existing key
|
|
try {
|
|
masterKey = await this._decryptMasterKey(account.encryptedMasterKey, signer, pubkey);
|
|
} catch (error) {
|
|
throw this._normalizeUnlockDecryptError(error);
|
|
}
|
|
} else {
|
|
// Create new key
|
|
masterKey = await this._generateMasterKey();
|
|
const encryptedMasterKey = await this._encryptMasterKey(masterKey, signer, pubkey);
|
|
|
|
// Save new account record
|
|
await this._saveAccount({
|
|
pubkey,
|
|
encryptedMasterKey,
|
|
connections: account?.connections || {}, // Preserve if existed but key was missing (edge case)
|
|
});
|
|
}
|
|
|
|
this.masterKeys.set(pubkey, masterKey);
|
|
userLogger.log(`[StorageService] Unlocked storage for ${pubkey.slice(0, 8)}...`);
|
|
|
|
await this._migrateLegacyR2Settings(pubkey);
|
|
}
|
|
|
|
/**
|
|
* Locks the storage (clears cached master key).
|
|
*/
|
|
lock(pubkey) {
|
|
if (this.masterKeys.has(pubkey)) {
|
|
this.masterKeys.delete(pubkey);
|
|
userLogger.log(`[StorageService] Locked storage for ${pubkey.slice(0, 8)}...`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the storage is currently unlocked for the user.
|
|
*/
|
|
isUnlocked(pubkey) {
|
|
return this.masterKeys.has(pubkey);
|
|
}
|
|
|
|
/**
|
|
* Saves a connection configuration.
|
|
* Requires unlock() to be called first.
|
|
*
|
|
* @param {string} pubkey
|
|
* @param {string} connectionId
|
|
* @param {object} payload - Secrets and config (e.g. accessKey, secretKey)
|
|
* @param {object} meta - Plaintext metadata (label, provider, etc.)
|
|
*/
|
|
async saveConnection(pubkey, connectionId, payload, meta = {}) {
|
|
if (!this.masterKeys.has(pubkey)) {
|
|
throw new Error("Storage is locked. Call unlock() first.");
|
|
}
|
|
|
|
const masterKey = this.masterKeys.get(pubkey);
|
|
const encrypted = await this._encryptPayload(payload, masterKey);
|
|
const account = (await this._getAccount(pubkey)) || { pubkey, connections: {} };
|
|
const normalizedMeta = normalizeConnectionMeta(meta, payload);
|
|
|
|
if (!account.connections) {
|
|
account.connections = {};
|
|
}
|
|
|
|
// If this connection is being set as default, unset others
|
|
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: payload.provider || meta.provider,
|
|
meta: {
|
|
...normalizedMeta,
|
|
lastSaved: Date.now(),
|
|
provider: payload.provider || meta.provider,
|
|
},
|
|
encrypted,
|
|
};
|
|
|
|
await this._saveAccount(account);
|
|
userLogger.log(`[StorageService] Saved connection ${connectionId}`);
|
|
}
|
|
|
|
/**
|
|
* Retrieves and decrypts a connection.
|
|
* Requires unlock() to be called first.
|
|
*/
|
|
async getConnection(pubkey, connectionId) {
|
|
if (!this.masterKeys.has(pubkey)) {
|
|
throw new Error("Storage is locked. Call unlock() first.");
|
|
}
|
|
|
|
const account = await this._getAccount(pubkey);
|
|
const conn = account?.connections?.[connectionId];
|
|
|
|
if (!conn) return null;
|
|
|
|
const masterKey = this.masterKeys.get(pubkey);
|
|
const payload = await this._decryptPayload(conn.encrypted, masterKey);
|
|
const normalizedMeta = normalizeConnectionMeta(conn.meta, payload);
|
|
const publicBaseUrl = resolvePublicBaseUrl({
|
|
...payload,
|
|
meta: normalizedMeta,
|
|
});
|
|
const baseDomain = publicBaseUrl || payload.baseDomain || normalizedMeta.baseDomain || "";
|
|
|
|
return {
|
|
...payload,
|
|
publicBaseUrl,
|
|
baseDomain,
|
|
meta: normalizedMeta,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Lists connections (metadata only).
|
|
* Does NOT require unlock().
|
|
*/
|
|
async listConnections(pubkey) {
|
|
const account = await this._getAccount(pubkey);
|
|
if (!account || !account.connections) return [];
|
|
|
|
return Object.values(account.connections).map((conn) => ({
|
|
id: conn.id,
|
|
provider: conn.provider,
|
|
meta: conn.meta,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Deletes a connection.
|
|
*/
|
|
async deleteConnection(pubkey, connectionId) {
|
|
const account = await this._getAccount(pubkey);
|
|
if (account && account.connections && account.connections[connectionId]) {
|
|
delete account.connections[connectionId];
|
|
await this._saveAccount(account);
|
|
userLogger.log(`[StorageService] Deleted connection ${connectionId}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exports the raw on-disk account record for a pubkey, for opt-in encrypted
|
|
* cross-login sync (todo #15). The record is already self-protected
|
|
* (encryptedMasterKey envelope + per-connection AES-GCM payloads) but its
|
|
* `meta` is plaintext (bucket/endpoint names), so callers MUST re-encrypt the
|
|
* whole record to self before publishing. Returns null when nothing is stored.
|
|
*/
|
|
async exportAccountRecord(pubkey) {
|
|
if (!pubkey) {
|
|
return null;
|
|
}
|
|
const account = await this._getAccount(pubkey);
|
|
return account || null;
|
|
}
|
|
|
|
/**
|
|
* Imports an account record previously exported via {@link exportAccountRecord}
|
|
* (e.g. pulled from the user's encrypted Nostr note on a second device). The
|
|
* record is encrypted-to-self by the SAME pubkey, so its envelope unlocks with
|
|
* the same signer. This REPLACES the local record for the pubkey; the next
|
|
* unlock decrypts the imported master key. (Merge across devices is a follow-up.)
|
|
*/
|
|
async importAccountRecord(pubkey, record) {
|
|
if (!pubkey) {
|
|
throw new Error("Pubkey required to import storage account record.");
|
|
}
|
|
if (!record || typeof record !== "object" || !record.encryptedMasterKey) {
|
|
throw new Error("Invalid storage account record.");
|
|
}
|
|
const toSave = { ...record, pubkey };
|
|
await this._saveAccount(toSave);
|
|
// Drop any in-memory master key so the next unlock uses the imported envelope.
|
|
this.lock(pubkey);
|
|
return toSave;
|
|
}
|
|
|
|
/**
|
|
* Sets a connection as default for uploads (updates metadata).
|
|
*/
|
|
async setDefaultConnection(pubkey, connectionId) {
|
|
const account = await this._getAccount(pubkey);
|
|
if (!account || !account.connections) return;
|
|
|
|
// Reset others
|
|
for (const key in account.connections) {
|
|
const conn = account.connections[key];
|
|
if (conn.meta) {
|
|
conn.meta.defaultForUploads = (key === connectionId);
|
|
}
|
|
}
|
|
|
|
await this._saveAccount(account);
|
|
}
|
|
|
|
/**
|
|
* Tests a connection access by delegating to the provider's test handler.
|
|
* Implements a Strategy pattern where `PROVIDER_TESTS` maps provider IDs
|
|
* to their specific verification logic (e.g., S3 `HeadBucket`).
|
|
*
|
|
* @param {string} provider - The provider ID (e.g. "cloudflare_r2")
|
|
* @param {object} config - The decrypted configuration object
|
|
* @returns {Promise<{success: boolean, message?: string, error?: string}>}
|
|
*/
|
|
async testAccess(provider, config) {
|
|
const handler = PROVIDER_TESTS[provider];
|
|
if (!handler) {
|
|
return { success: false, error: `No test handler available for provider: ${provider}` };
|
|
}
|
|
|
|
try {
|
|
const result = await handler(config);
|
|
return { success: true, message: result.message || "Connection verified." };
|
|
} catch (err) {
|
|
const cleanError = err.message || "Unknown connection error";
|
|
userLogger.warn(`[StorageService] Test failed for ${provider}:`, err);
|
|
return { success: false, error: cleanError };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tests a saved connection by ID.
|
|
* Requires unlock() to be called first.
|
|
*
|
|
* @param {string} pubkey
|
|
* @param {string} connectionId
|
|
*/
|
|
async testConnection(pubkey, connectionId) {
|
|
const connection = await this.getConnection(pubkey, connectionId);
|
|
if (!connection) {
|
|
throw new Error("Connection not found.");
|
|
}
|
|
|
|
return this.testAccess(connection.provider, connection);
|
|
}
|
|
|
|
async _migrateLegacyR2Settings(pubkey) {
|
|
let legacySettings = null;
|
|
try {
|
|
legacySettings = await loadLegacyR2Settings();
|
|
} catch (err) {
|
|
userLogger.warn("[StorageService] Legacy R2 settings load failed:", err);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!legacySettings ||
|
|
!legacySettings.accountId ||
|
|
!legacySettings.accessKeyId ||
|
|
!legacySettings.secretAccessKey ||
|
|
!legacySettings.baseDomain
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const existingConnections = await this.listConnections(pubkey);
|
|
const hasR2 = existingConnections.some(
|
|
(connection) => connection.provider === PROVIDERS.R2
|
|
);
|
|
if (hasR2) {
|
|
return;
|
|
}
|
|
|
|
const npub = encodeNpub(pubkey);
|
|
const legacyEntry =
|
|
npub && legacySettings.buckets ? legacySettings.buckets[npub] : null;
|
|
const publicBaseUrl =
|
|
legacyEntry?.publicBaseUrl || legacySettings.baseDomain || "";
|
|
|
|
const payload = {
|
|
provider: PROVIDERS.R2,
|
|
accountId: legacySettings.accountId,
|
|
accessKeyId: legacySettings.accessKeyId,
|
|
secretAccessKey: legacySettings.secretAccessKey,
|
|
bucket: legacyEntry?.bucket || "",
|
|
};
|
|
const meta = {
|
|
provider: PROVIDERS.R2,
|
|
label: "Cloudflare R2 (Migrated)",
|
|
publicBaseUrl,
|
|
baseDomain: publicBaseUrl,
|
|
defaultForUploads: existingConnections.length === 0,
|
|
migratedFromLegacy: true,
|
|
};
|
|
|
|
const baseId = "cloudflare-r2-migrated";
|
|
let connectionId = baseId;
|
|
let suffix = 1;
|
|
while (existingConnections.some((conn) => conn.id === connectionId)) {
|
|
connectionId = `${baseId}-${suffix}`;
|
|
suffix += 1;
|
|
}
|
|
|
|
try {
|
|
await this.saveConnection(pubkey, connectionId, payload, meta);
|
|
await clearLegacyR2Settings();
|
|
userLogger.log("[StorageService] Migrated legacy R2 settings.");
|
|
} catch (err) {
|
|
userLogger.warn("[StorageService] Legacy R2 migration failed:", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
const storageService = new StorageService();
|
|
export default storageService;
|