feat: add encrypted online settings backup keys

Add immutable client-encrypted settings snapshots with independent MDD2 capability keys so fresh installs can restore configuration without transferring backup files. Keep credentials encrypted end to end, preserve queues and history during import, and avoid exposing identifiers in request URLs or errors. Include the persistent API with quota, rate limits, crash-safe storage locking, durability checks, and end-to-end race and recovery coverage.
This commit is contained in:
Sucukdeluxe
2026-08-07 18:29:06 +02:00
parent 5f19293ed2
commit 6e44e63167
22 changed files with 1832 additions and 44 deletions
+41 -16
View File
@@ -25,7 +25,7 @@ import {
} from "../shared/types";
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
import { importDlcContainers } from "./container";
import { APP_VERSION } from "./constants";
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
@@ -57,7 +57,8 @@ import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog
import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@@ -415,7 +416,7 @@ export class AppController {
// object about to be applied, so they are never rolled back to a stale snapshot.
// All-time totals take the max; daily/total usage and account statuses are taken
// live; per-key Debrid-Link usage is filtered to keys that still exist.
private overlayLiveUsageCounters(target: AppSettings): void {
private overlayLiveUsageCounters(target: AppSettings): void {
const liveSettings = this.manager.getSettings();
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
@@ -429,8 +430,19 @@ export class AppController {
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
}
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
}
private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void {
const restoredSettings = normalizeSettings(importedSettings);
this.overlayLiveUsageCounters(restoredSettings);
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { settingsOnlyImport: true });
if (restoreRemoteDiagnostics) {
this.restoreRemoteDiagnosticsFromBackup(remoteDiagnostics, true);
}
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial);
@@ -747,7 +759,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.audit("INFO", "Download-Statistik zurückgesetzt");
}
public exportBackup(): Buffer {
public exportBackup(): Buffer {
let remoteDiagnostics: BackupRemoteDiagnostics | undefined;
if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) {
const status = getDebugServerRuntimeStatus();
@@ -771,8 +783,25 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
});
return encryptBackup(JSON.stringify(payloadObj));
}
return encryptBackup(JSON.stringify(payloadObj));
}
public async exportOnlineBackup(): Promise<{ key: string }> {
const created = createOnlineBackup({ ...this.settings }, APP_VERSION);
await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL);
this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only" });
return { key: created.key };
}
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL);
this.applySettingsOnlyBackup(payload.settings);
this.audit("INFO", "Online-Sicherung importiert", {
kind: "settings-only",
accountSummary: buildAccountSummary(this.settings)
});
return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" };
}
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
this.audit("INFO", "Support-Bundle exportiert");
@@ -824,20 +853,16 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
importedSettingsRecord[key] = currentSettingsRecord[key];
}
}
const restoredSettings = normalizeSettings(importedSettings);
const restoredSettings = normalizeSettings(importedSettings);
// Settings-only backup: keep the running queue AND the live counters untouched.
// Overlay the live usage/status counters so they don't roll back to the backup's
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
// policy still governs FUTURE completions through the normal path. Do NOT stop the
// manager, wipe the session, block persistence or relaunch.
if (!hasSession) {
this.overlayLiveUsageCounters(restoredSettings);
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, true);
// manager, wipe the session, block persistence or relaunch.
if (!hasSession) {
this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true);
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings)
});