Migrate legacy R2 settings to storage service

This commit is contained in:
thePR0M3TH3AN committed 2026-01-25 21:33:00 -05:00
1 parent 758841180d
commit 1e2f6b989a
4 files changed
+123 -195

No files matched your search

+2 -2
View File
@@ -6,7 +6,7 @@
> **Security Note:** This integration requires storing encrypted credentials in the browser. See [`README.md`](../README.md) and [`docs/storage-tab-design.md`](./storage-tab-design.md) for security warnings and alternative upload modes for untrusted environments.
While the new **Storage Tab** (powered by `StorageService`) manages the secure storage of credentials, `r2Service` is responsible for:
The **Storage Tab** (powered by `StorageService`) is the single supported configuration path for R2 credentials. `r2Service` is responsible for:
- Uploading the selected video/HLS asset via multipart S3 uploads.
- Verifying public access via the configured Public Bucket URL.
@@ -69,7 +69,7 @@ The app will attempt to upload a small test file to verify the credentials and p
## Configuration keys
R2 credentials are no longer stored in plain `bitvidSettings`. They are encapsulated within the `StorageService` data model.
R2 credentials are no longer stored in plain `bitvidSettings`. They are encapsulated within the `StorageService` data model. Legacy `r2Settings` are migrated into `StorageService` on first unlock and then cleared.
**R2 Connection Profile (Encrypted):**
- `provider`: `cloudflare_r2`
+12 -31
View File
@@ -92,7 +92,12 @@ function openSettingsDb() {
});
}
export async function loadR2Settings() {
/**
* Legacy R2 settings loader (deprecated).
* Used only for one-time migration into StorageService.
* @deprecated
*/
export async function loadLegacyR2Settings() {
try {
const db = await openSettingsDb();
if (db) {
@@ -126,36 +131,12 @@ export async function loadR2Settings() {
return normalizeSettings(null);
}
export async function saveR2Settings(settings) {
const normalized = normalizeSettings(settings);
let db = null;
try {
db = await openSettingsDb();
} catch (err) {
userLogger.warn("Unable to open IndexedDB, continuing with fallback:", err);
}
if (db) {
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const req = store.put(normalized, SETTINGS_KEY);
req.onsuccess = () => resolve();
req.onerror = () =>
reject(req.error || new Error("Failed to save R2 settings"));
});
} else if (typeof localStorage !== "undefined") {
localStorage.setItem(
LOCALSTORAGE_FALLBACK_KEY,
JSON.stringify(normalized)
);
}
return normalized;
}
export async function clearR2Settings() {
/**
* Legacy R2 settings clearer (deprecated).
* Used only after successful migration into StorageService.
* @deprecated
*/
export async function clearLegacyR2Settings() {
let cleared = false;
try {
const db = await openSettingsDb();
+28 -162
View File
@@ -17,13 +17,8 @@
*/
import {
loadR2Settings,
saveR2Settings,
clearR2Settings,
buildR2Key,
buildPublicUrl,
mergeBucketEntry,
sanitizeBaseDomain,
} from "../r2.js";
import {
sanitizeBucketName,
@@ -290,101 +285,24 @@ class R2Service {
}
async loadSettings() {
try {
const settings = await loadR2Settings();
this.setSettings(settings);
this.populateCloudflareSettingsInputs(settings);
return settings;
} catch (err) {
userLogger.error("Failed to load Cloudflare settings:", err);
this.setSettings(createDefaultSettings());
this.populateCloudflareSettingsInputs(this.getSettings());
this.setCloudflareSettingsStatus(
"Failed to load saved settings.",
"error"
);
throw err;
}
const settings = createDefaultSettings();
this.setSettings(settings);
this.populateCloudflareSettingsInputs(settings);
this.setCloudflareSettingsStatus(
"Legacy Cloudflare settings have been retired. Use the Storage tab.",
"warning"
);
return settings;
}
async handleCloudflareSettingsSubmit(formValues = {}, { quiet = false } = {}) {
// Legacy support wrapper: redirects to storageService if possible, or warns.
// For now, we allow saving to legacy for backward compatibility if explicit flow isn't used,
// but we prefer to use storageService.
const accountId = String(formValues.accountId || "").trim();
const accessKeyId = String(formValues.accessKeyId || "").trim();
const secretAccessKey = String(formValues.secretAccessKey || "").trim();
const baseDomain = sanitizeBaseDomain(formValues.baseDomain || ""); // This is the Public Bucket URL
if (baseDomain.includes(".r2.cloudflarestorage.com")) {
if (!quiet) {
this.setCloudflareSettingsStatus(
"It looks like you entered the S3 API URL. Please use your Public Bucket URL (e.g. https://pub-xxx.r2.dev or your custom domain).",
"error"
);
}
return false;
}
if (!accountId || !accessKeyId || !secretAccessKey) {
if (!quiet) {
this.setCloudflareSettingsStatus(
"Account ID, Access Key ID, and Secret are required.",
"error"
);
}
return false;
}
if (!baseDomain) {
if (!quiet) {
this.setCloudflareSettingsStatus(
"Public Bucket URL is required (e.g., https://pub-xxx.r2.dev).",
"error"
);
}
return false;
}
// Try to save to StorageService if available and unlocked
// We can't easily know the active pubkey here unless passed or inferred from context.
// Since this method is legacy, we'll default to legacy behavior but warn.
let buckets = { ...(this.getSettings().buckets || {}) };
const previousAccount = this.getSettings().accountId || "";
const previousBaseDomain = this.getSettings().baseDomain || "";
if (previousAccount !== accountId || previousBaseDomain !== baseDomain) {
buckets = {};
}
const updatedSettings = {
accountId,
accessKeyId,
secretAccessKey,
baseDomain,
buckets,
};
try {
const saved = await saveR2Settings(updatedSettings);
this.setSettings(saved);
this.populateCloudflareSettingsInputs(saved);
if (!quiet) {
this.setCloudflareSettingsStatus("Settings saved locally.", "success");
}
return true;
} catch (err) {
userLogger.error("Failed to save Cloudflare settings:", err);
if (!quiet) {
this.setCloudflareSettingsStatus(
"Failed to save settings. Check console for details.",
"error"
);
}
return false;
if (!quiet) {
this.setCloudflareSettingsStatus(
"Legacy settings are disabled. Configure Cloudflare R2 in the Storage tab.",
"error"
);
}
return false;
}
async saveSettings(formValues = {}, options = {}) {
@@ -462,33 +380,17 @@ class R2Service {
}
}
// 2. Fallback to legacy
const legacy = this.cloudflareSettings;
if (
legacy &&
legacy.accountId &&
legacy.accessKeyId &&
legacy.secretAccessKey
) {
return { ...legacy, isLegacy: true };
}
return null;
}
async handleCloudflareClearSettings() {
try {
await clearR2Settings();
const refreshed = await loadR2Settings();
this.setSettings(refreshed);
this.populateCloudflareSettingsInputs(refreshed);
this.setCloudflareSettingsStatus("Settings cleared.", "success");
return true;
} catch (err) {
userLogger.error("Failed to clear Cloudflare settings:", err);
this.setCloudflareSettingsStatus("Failed to clear settings.", "error");
return false;
}
this.setSettings(createDefaultSettings());
this.populateCloudflareSettingsInputs(this.getSettings());
this.setCloudflareSettingsStatus(
"Legacy settings are no longer supported. Clear settings in the Storage tab.",
"warning"
);
return false;
}
async clearSettings() {
@@ -607,35 +509,6 @@ class R2Service {
lastUpdated: Date.now(),
};
// If using legacy settings, we save the bucket mapping.
// If using StorageService (settings.isLegacy === false), we do NOT save to legacy store
// to avoid partial state or overwriting. We just return the manual entry.
if (settings.isLegacy !== false) {
const currentSettings = this.getSettings();
if (!this.cloudflareSettings) {
this.cloudflareSettings = currentSettings;
}
let entry = currentSettings.buckets?.[npub];
let savedEntry = entry;
if (
!entry ||
entry.bucket !== manualEntry.bucket ||
entry.publicBaseUrl !== manualEntry.publicBaseUrl
) {
const updatedSettings = await saveR2Settings(
mergeBucketEntry(currentSettings, npub, manualEntry)
);
this.setSettings(updatedSettings);
savedEntry = updatedSettings.buckets?.[npub] || manualEntry;
}
return {
entry: savedEntry,
usedManagedFallback: false,
customDomainStatus: "manual",
};
}
return {
entry: manualEntry,
usedManagedFallback: false,
@@ -797,7 +670,7 @@ class R2Service {
bucket: effectiveSettings.bucket || "",
baseDomain: effectiveSettings.baseDomain || "",
publicBaseUrl: effectiveSettings.publicBaseUrl || "",
isLegacy: effectiveSettings.isLegacy !== false,
legacyFallback: Boolean(effectiveSettings.isLegacy),
});
if (!accountId || !accessKeyId || !secretAccessKey) {
@@ -858,19 +731,12 @@ class R2Service {
forcedTorrentKey = "",
forcedTorrentUrl = "",
} = {}) {
// If no explicit credentials, we might save settingsInput to legacy DB.
// If explicitCredentials ARE provided, we skip saving and use them directly.
if (!explicitCredentials && settingsInput) {
const saved = await this.handleCloudflareSettingsSubmit(settingsInput, {
quiet: true,
});
if (!saved) {
this.setCloudflareUploadStatus(
"Fix your R2 settings before uploading.",
"error"
);
return false;
}
if (settingsInput) {
this.setCloudflareUploadStatus(
"Legacy Cloudflare settings are disabled. Configure R2 in the Storage tab.",
"error"
);
return false;
}
if (!npub) {
+81
View File
@@ -1,4 +1,5 @@
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";
@@ -109,6 +110,18 @@ function buildS3TestConfig(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(
@@ -404,6 +417,8 @@ export class StorageService {
this.masterKeys.set(pubkey, masterKey);
userLogger.log(`[StorageService] Unlocked storage for ${pubkey.slice(0, 8)}...`);
await this._migrateLegacyR2Settings(pubkey);
}
/**
@@ -587,6 +602,72 @@ export class StorageService {
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();