From 9e5c5126dd185d54968a01c44093baddd888bbc2 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:43:20 +0200 Subject: [PATCH] feat: add expiring backups and live account checks --- PROJECT_MEMORY.md | 18 ++-- lib/config-store.js | 1 + lib/online-backup-keyring.js | 97 +++++++++++------ lib/online-backup-manager.js | 16 +-- lib/online-backup.js | 40 ++++++- lib/settings-backup.js | 12 ++- main.js | 40 +++++-- preload.js | 7 +- renderer/app.js | 130 ++++++++++++++++++----- renderer/i18n.js | 10 ++ renderer/styles.css | 67 +++++++++++- services/backup-api/src/server.mjs | 84 +++++++++++++-- services/backup-api/test/server.test.mjs | 81 +++++++++++++- tests/online-backup-keyring.test.js | 29 ++++- tests/online-backup-manager.test.js | 32 ++++-- tests/online-backup.test.js | 23 +++- tests/package-build-files.test.js | 5 +- tests/settings-backup.test.js | 48 +++++++-- tests/ui-smoke.js | 98 +++++++++++++++-- 19 files changed, 710 insertions(+), 128 deletions(-) diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index bc07ff4..f3def4a 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -7,7 +7,7 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große ## Aktueller Zustand - Aktive Arbeitslinie: `master` aus `Sucukdeluxe/Multi-Hoster-Upload`. -- Zuletzt geprüfter Funktionsstand: Version `2.1.41`, Fix-Commit `40ce83d`. +- Zuletzt veröffentlichter Funktionsstand: Version `2.1.41`; aktueller unveröffentlichter Arbeitsstand basiert auf `96037f2`. - Einstiegspunkt des Electron-Hauptprozesses: `main.js`. - Oberfläche: `renderer/`; gekapselte Fachlogik: `lib/`; Online-Backup-Dienst: `services/backup-api/`. - Die Abhängigkeiten sind lokal mit Node.js 24 installiert. @@ -19,6 +19,11 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große - Schlägt dieser Nachweis fehl, bleibt die Queue erhalten und der Fehler wird als lokale Persistenzstörung behandelt, damit kein stiller Doppel-Upload entsteht. - DoodStream-OTP-Prüfungen verwenden dieselbe Cookie-Sitzung weiter, fassen identische oder parallele Checks zusammen und fordern einen neuen Code nur nach einer ausdrücklichen Aktion mit mindestens 60 Sekunden Abstand an. - DoodStream-Accounts mit API-Key werden auch beim Health-Check über die API geprüft und lösen keinen Web-OTP aus. +- Sammelchecks melden jedes Account-Ergebnis einzeln an den Renderer, sodass fertige Karten sofort grün, rot oder als OTP-pflichtig erscheinen, während die übrigen Accounts weiter geprüft werden. +- Neue Online-Backups unterstützen `24 Stunden`, `3 Tage`, `7 Tage` (Standard), `31 Tage` und `Unbegrenzt`. Endliche Schlüssel werden lokal aus dem verschlüsselten Schlüsselbund entfernt und serverseitig ab Ablauf nicht mehr wiederhergestellt; der Dienst räumt abgelaufene Datensätze bei Zugriff oder der nächsten Speicherung auf. +- Vorhandene Online-Backups und alte Upload-Payloads ohne Ablaufangabe bleiben zur Abwärtskompatibilität unbegrenzt gültig. +- Backup-Importe wenden Sprache und automatischen Account-Check sofort an. Alle Konfigurationsfelder werden übertragen; Warteschlange, Upload-Wiederherstellungsstatus, letzter Dateiauswahlordner, Automatik-Telemetrie und Pausenzustand bleiben bewusst geräte- beziehungsweise laufzeitgebunden. +- Ein nicht vorhandener Ordnerüberwachungspfad bleibt nach dem Import sichtbar gespeichert, die Überwachung wird aber deaktiviert und der Nutzer erhält eine Warnung. Ein nicht vorhandener Log-Ordner wird ebenfalls gemeldet, ohne den konfigurierten Pfad still zu löschen. - Version `2.1.41` ist als GitHub- und Forgejo-Release veröffentlicht; produktive Server wurden dadurch nicht neu gestartet. - Der eingebaute Updater liest Releases und Binärdateien von Forgejo; GitHub liefert ergänzend die öffentlichen Release Notes. Ein Release ist deshalb erst vollständig, wenn die vier Assets auch im Forgejo-Release vorhanden sind. - Forgejo bewahrt Leerzeichen in Asset-Namen, GitHub normalisiert sie zu Punkten. Das Forgejo-`latest.yml` und der Release-Plan verwenden Namen wie `Multi-Hoster-Upload Setup 2.1.41.exe`; das GitHub-Manifest muss auf den dort tatsächlich veröffentlichten Punktnamen zeigen. @@ -50,17 +55,18 @@ npm audit --omit=dev ## Offene nächste Schritte -- Die nächste fachliche Änderung mit Sascha festlegen und auf Basis des verifizierten Stands umsetzen. +- Der Ablaufzeit-Code ist noch nicht veröffentlicht oder produktiv ausgerollt. Bei einer späteren ausdrücklichen Release-Freigabe zuerst den kompatiblen Backup-API-Dienst ausrollen und prüfen, danach den Desktop-Client veröffentlichen; Rollback sind der vorherige Dienststand und Version `2.1.41`. - Bei Bedarf einen Arbeitsweg ohne `&` im absoluten Pfad verwenden oder die npm-Aufrufe weiterhin direkt ausführen. ## Zuletzt verifiziert -Stand: 31.08.2026 +Stand: 01.09.2026 - Lint: erfolgreich, 0 Warnungen und 0 Fehler. -- Haupttests: 803 erfolgreich, 0 fehlgeschlagen. -- Backup-API-Tests: 15 erfolgreich, 0 fehlgeschlagen. +- Haupttests: 805 erfolgreich, 0 fehlgeschlagen. +- Backup-API-Tests: 17 erfolgreich, 0 fehlgeschlagen. +- Der vollständige opt-in UI-Smoke bestätigte alle neu ergänzten Prüfungen für fortlaufende Account-Statusmeldungen, Ablauf-Auswahl, Schlüsselbereinigung, Metadatenanzeige sowie sofortige Sprach-/Auto-Check-Übernahme; die 16 bekannten themenfremden Abweichungen blieben unverändert. - Produktionsabhängigkeiten: `npm audit --omit=dev` meldet 0 Schwachstellen. -- Der Fix-Commit `40ce83d` wurde auf GitHub `origin/master` und Forgejo `sync/github-master` verifiziert. +- Vor Beginn dieser Änderung zeigten GitHub `origin/master` und Forgejo `sync/github-master` beide auf `96037f2`; der neue Stand muss nach dem Sitzungs-Commit auf beiden Remotes verifiziert werden. - GitHub- und Forgejo-Release `v2.1.41` wurden jeweils mit Installer, Portable-Build, Blockmap und einem zum Anbieter passenden Update-Manifest veröffentlicht. - Der von Version `2.1.40` verwendete Forgejo-Endpoint liefert `2.1.41` als neuesten stabilen Release. diff --git a/lib/config-store.js b/lib/config-store.js index b23b82d..26de883 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -58,6 +58,7 @@ const DEFAULTS = { }, globalSettings: { language: 'en', + autoHealthCheckEnabled: true, alwaysOnTop: false, shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart logFilePath: '', diff --git a/lib/online-backup-keyring.js b/lib/online-backup-keyring.js index ff2b541..0cb7a18 100644 --- a/lib/online-backup-keyring.js +++ b/lib/online-backup-keyring.js @@ -4,9 +4,10 @@ const crypto = require('node:crypto'); const secretStore = require('./secret-store'); const { parseOnlineBackupKey } = require('./online-backup'); -const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id']; +const STORED_LEGACY_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id']; +const STORED_EXPIRING_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'expiresAt', 'id']; const STORED_V1_DOCUMENT_KEYS = ['keys', 'version']; -const STORED_V2_DOCUMENT_KEYS = ['generation', 'keys', 'version']; +const STORED_GENERATED_DOCUMENT_KEYS = ['generation', 'keys', 'version']; const KEYRING_ERROR_CODES = Object.freeze({ structure: 'KEYRING_STRUCTURE_INVALID', unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE', @@ -78,7 +79,8 @@ function createOnlineBackupKeyring({ decryptField = secretStore.decryptField, isEncrypted = secretStore.isEncrypted, parseKey = parseOnlineBackupKey, - fsImpl = fs.promises + fsImpl = fs.promises, + now = () => Date.now() }) { const directory = path.dirname(filePath); const basename = path.basename(filePath); @@ -128,7 +130,7 @@ function createOnlineBackupKeyring({ return { version: 1, generation: 0, keys: document.keys }; } if ( - hasExactKeys(document, STORED_V2_DOCUMENT_KEYS) + hasExactKeys(document, STORED_GENERATED_DOCUMENT_KEYS) && document.version === 2 && Number.isSafeInteger(document.generation) && document.generation > 0 @@ -178,8 +180,10 @@ function createOnlineBackupKeyring({ } function validateEntry(entry) { + const hasLegacyShape = hasExactKeys(entry, STORED_LEGACY_ENTRY_KEYS); + const hasExpiringShape = hasExactKeys(entry, STORED_EXPIRING_ENTRY_KEYS); if ( - !hasExactKeys(entry, STORED_ENTRY_KEYS) + (!hasLegacyShape && !hasExpiringShape) || !isCanonicalId(entry.id) || typeof entry.encryptedKey !== 'string' || !isEncrypted(entry.encryptedKey) @@ -194,6 +198,16 @@ function createOnlineBackupKeyring({ return { issue: KEYRING_ERROR_CODES.structure, id: entry.id }; } if (createdAt !== entry.createdAt) return { issue: KEYRING_ERROR_CODES.structure, id: entry.id }; + let expiresAt = null; + if (hasExpiringShape && entry.expiresAt !== null) { + if (typeof entry.expiresAt !== 'string') return { issue: KEYRING_ERROR_CODES.structure, id: entry.id }; + try { + expiresAt = normalizeTimestamp(entry.expiresAt); + } catch { + return { issue: KEYRING_ERROR_CODES.structure, id: entry.id }; + } + if (expiresAt !== entry.expiresAt || expiresAt <= createdAt) return { issue: KEYRING_ERROR_CODES.structure, id: entry.id }; + } let key; try { key = decryptField(entry.encryptedKey); @@ -213,6 +227,7 @@ function createOnlineBackupKeyring({ id: entry.id, encryptedKey: entry.encryptedKey, createdAt, + expiresAt, key } }; @@ -300,22 +315,22 @@ function createOnlineBackupKeyring({ recovered: false }); } - const v2States = states.filter(state => state.version === 2); - if (v2States.length > 0) { - const highestObservedGeneration = Math.max(...v2States.map(state => state.generation)); - selectGeneration(v2States, highestObservedGeneration); - const validV2States = v2States.filter(state => !firstBlockingIssue(state)); - if (validV2States.length > 0) { - const highestValidGeneration = Math.max(...validV2States.map(state => state.generation)); - return selectGeneration(validV2States, highestValidGeneration); + const generatedStates = states.filter(state => state.version === 2); + if (generatedStates.length > 0) { + const highestObservedGeneration = Math.max(...generatedStates.map(state => state.generation)); + selectGeneration(generatedStates, highestObservedGeneration); + const validGeneratedStates = generatedStates.filter(state => !firstBlockingIssue(state)); + if (validGeneratedStates.length > 0) { + const highestValidGeneration = Math.max(...validGeneratedStates.map(state => state.generation)); + return selectGeneration(validGeneratedStates, highestValidGeneration); } } const legacyStates = states.filter(state => state.version === 1); const validLegacyStates = legacyStates.filter(state => !firstBlockingIssue(state)); if (validLegacyStates.length > 0) return selectLegacyState(validLegacyStates); - if (v2States.length > 0) { - const highestObservedGeneration = Math.max(...v2States.map(state => state.generation)); - return selectGeneration(v2States, highestObservedGeneration); + if (generatedStates.length > 0) { + const highestObservedGeneration = Math.max(...generatedStates.map(state => state.generation)); + return selectGeneration(generatedStates, highestObservedGeneration); } return selectLegacyState(legacyStates); } @@ -398,7 +413,7 @@ function createOnlineBackupKeyring({ const contents = JSON.stringify({ version: 2, generation, - keys: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt })) + keys: entries.map(({ id, encryptedKey, createdAt, expiresAt }) => ({ id, encryptedKey, createdAt, expiresAt: expiresAt ?? null })) }); const payload = canonicalPayload(parseDocument(contents)); const stagingPath = temporaryPath('staging'); @@ -427,22 +442,33 @@ function createOnlineBackupKeyring({ } catch {} } - async function list() { - const state = await readState(); - const entries = state.entries - .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - .map(({ id, key, createdAt }) => Object.freeze({ - id, - displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt - })); - return Object.freeze({ - entries: Object.freeze(entries), - issues: Object.freeze([...state.issues]) + function isExpired(entry) { + return entry.expiresAt !== null && new Date(entry.expiresAt).getTime() <= Number(now()); + } + + function list() { + return serialize(async () => { + const state = await readState(); + const activeEntries = state.entries.filter(entry => !isExpired(entry)); + if (activeEntries.length !== state.entries.length && !firstBlockingIssue(state)) { + await writeEntries(activeEntries, nextGeneration(state.generation)); + } + const entries = activeEntries + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .map(({ id, key, createdAt, expiresAt }) => Object.freeze({ + id, + displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, + createdAt, + expiresAt + })); + return Object.freeze({ + entries: Object.freeze(entries), + issues: Object.freeze([...state.issues]) + }); }); } - function prepare(key, createdAt) { + function prepare(key, createdAt, expiresAt = null) { let parsed; try { parsed = parseKey(key); @@ -458,10 +484,16 @@ function createOnlineBackupKeyring({ if (typeof encryptedKey !== 'string' || encryptedKey === key || !isEncrypted(encryptedKey)) { throw issueError(KEYRING_ERROR_CODES.encrypt); } + const normalizedCreatedAt = normalizeTimestamp(createdAt); + const normalizedExpiresAt = expiresAt === null ? null : normalizeTimestamp(expiresAt); + if (normalizedExpiresAt !== null && normalizedExpiresAt <= normalizedCreatedAt) { + throw issueError(KEYRING_ERROR_CODES.structure); + } return Object.freeze({ id: parsed.id, encryptedKey, - createdAt: normalizeTimestamp(createdAt) + createdAt: normalizedCreatedAt, + expiresAt: normalizedExpiresAt }); } @@ -482,7 +514,8 @@ function createOnlineBackupKeyring({ const state = await readState(); if (state.duplicateIds.has(id)) throw issueError(KEYRING_ERROR_CODES.duplicate); const entry = state.entries.find(current => current.id === id); - if (entry) return entry.key; + if (entry && !isExpired(entry)) return entry.key; + if (entry) return null; const matchingProblem = state.problems.find(problem => problem.id === id); if (matchingProblem) throw issueError(matchingProblem.code); const blockingIssue = firstBlockingIssue(state); diff --git a/lib/online-backup-manager.js b/lib/online-backup-manager.js index fe555c3..e7bd863 100644 --- a/lib/online-backup-manager.js +++ b/lib/online-backup-manager.js @@ -27,7 +27,8 @@ function sanitizeEntry(entry) { return { id: entry.id, displayKey: entry.displayKey, - createdAt: entry.createdAt + createdAt: entry.createdAt, + expiresAt: entry.expiresAt ?? null }; } @@ -35,7 +36,8 @@ function sanitizeCreatedEntry(entry, key) { return { id: entry.id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt: entry.createdAt + createdAt: entry.createdAt, + expiresAt: entry.expiresAt ?? null }; } @@ -71,11 +73,11 @@ function createOnlineBackupManager({ }; } - async function createTransaction() { + async function createTransaction(retention) { const createdAt = new Date().toISOString(); const settings = await loadSettings(); - const created = createBackup(settings, appVersion(), createdAt); - const prepared = keyring.prepare(created.key, createdAt); + const created = createBackup(settings, appVersion(), createdAt, retention); + const prepared = keyring.prepare(created.key, createdAt, created.expiresAt ?? null); await uploadBackup(created.record); try { await keyring.commit(prepared); @@ -117,9 +119,9 @@ function createOnlineBackupManager({ } } - async function createManaged() { + async function createManaged(retention = '7d') { try { - return await serialize(createTransaction); + return await serialize(() => createTransaction(retention)); } catch (error) { return keyringFailure(error, ERRORS.create); } diff --git a/lib/online-backup.js b/lib/online-backup.js index 10026d7..e6274b3 100644 --- a/lib/online-backup.js +++ b/lib/online-backup.js @@ -16,6 +16,14 @@ const MAX_PLAINTEXT_BYTES = 512 * 1024; const REQUEST_TIMEOUT_MS = 12_000; const KEY_CONTEXT = Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8'); const AAD_CONTEXT = Buffer.from('MHU-ONLINE-BACKUP-V1', 'utf8'); +const ONLINE_BACKUP_RETENTION_SECONDS = Object.freeze({ + '1d': 24 * 60 * 60, + '3d': 3 * 24 * 60 * 60, + '7d': 7 * 24 * 60 * 60, + '31d': 31 * 24 * 60 * 60, + forever: null +}); +const DEFAULT_ONLINE_BACKUP_RETENTION = '7d'; function checksum(idBytes, masterKey) { return crypto.createHash('sha256').update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH); @@ -131,7 +139,27 @@ function parseOnlineBackupKey(key) { }; } -function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString()) { +function normalizeOnlineBackupRetention(value = DEFAULT_ONLINE_BACKUP_RETENTION) { + const normalized = String(value || '').trim(); + if (!Object.prototype.hasOwnProperty.call(ONLINE_BACKUP_RETENTION_SECONDS, normalized)) { + throw new Error('Gültigkeitsdauer der Online-Sicherung ist ungültig'); + } + return normalized; +} + +function onlineBackupExpiration(exportedAt, retention) { + const normalized = normalizeOnlineBackupRetention(retention); + const seconds = ONLINE_BACKUP_RETENTION_SECONDS[normalized]; + if (seconds === null) return null; + const created = new Date(exportedAt); + if (!Number.isFinite(created.getTime())) throw new Error('Erstellungszeit der Online-Sicherung ist ungültig'); + return new Date(created.getTime() + seconds * 1000).toISOString(); +} + +function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString(), retention = DEFAULT_ONLINE_BACKUP_RETENTION) { + const normalizedRetention = normalizeOnlineBackupRetention(retention); + const expiresInSeconds = ONLINE_BACKUP_RETENTION_SECONDS[normalizedRetention]; + const expiresAt = onlineBackupExpiration(exportedAt, normalizedRetention); const idBytes = crypto.randomBytes(RECORD_ID_LENGTH); const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH); const key = encodeKey(idBytes, masterKey); @@ -163,8 +191,10 @@ function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOS record: { id: parsed.id, blob: blobBytes.toString('base64url'), - deleteVerifier - } + deleteVerifier, + expiresInSeconds + }, + expiresAt }; } @@ -241,10 +271,14 @@ async function deleteOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options) } module.exports = { + DEFAULT_ONLINE_BACKUP_RETENTION, ONLINE_BACKUP_API_URL, + ONLINE_BACKUP_RETENTION_SECONDS, createOnlineBackup, deleteOnlineBackup, downloadOnlineBackup, + normalizeOnlineBackupRetention, + onlineBackupExpiration, parseOnlineBackupKey, restoreOnlineBackup, uploadOnlineBackup diff --git a/lib/settings-backup.js b/lib/settings-backup.js index 5a2165b..1044d80 100644 --- a/lib/settings-backup.js +++ b/lib/settings-backup.js @@ -33,6 +33,13 @@ function createPortableSettingsSnapshot(config) { history: [] }; snapshot.globalSettings.pendingQueue = null; + snapshot.globalSettings.uploadRecovery = null; + snapshot.globalSettings.lastBrowseDirectory = ''; + if (snapshot.globalSettings.folderMonitor && typeof snapshot.globalSettings.folderMonitor === 'object') { + snapshot.globalSettings.folderMonitor.paused = false; + snapshot.globalSettings.folderMonitor.pausedAt = null; + delete snapshot.globalSettings.folderMonitor.telemetry; + } return snapshot; } @@ -41,14 +48,15 @@ function prepareImportedSettings(value, options = {}) { const imported = createPortableSettingsSnapshot(value); const pathExists = options.pathExists || fs.existsSync; const pathDirname = options.pathDirname || path.dirname; + const warnings = Array.isArray(options.warnings) ? options.warnings : null; const globalSettings = imported.globalSettings; if (globalSettings.logFilePath && !pathExists(pathDirname(globalSettings.logFilePath))) { - globalSettings.logFilePath = ''; + warnings?.push('Log-Dateipfad (Ordner nicht gefunden)'); } if (globalSettings.folderMonitor && typeof globalSettings.folderMonitor === 'object') { if (globalSettings.folderMonitor.folderPath && !pathExists(globalSettings.folderMonitor.folderPath)) { - globalSettings.folderMonitor.folderPath = ''; globalSettings.folderMonitor.enabled = false; + warnings?.push('Ordnerüberwachung (Ordner nicht gefunden und deaktiviert)'); } } return imported; diff --git a/main.js b/main.js index 3cff553..c4ad655 100644 --- a/main.js +++ b/main.js @@ -19,7 +19,7 @@ const { createAccountCooldownController, createAccountPicker } = require('./lib/ const ClouddropUploader = require('./lib/clouddrop-upload'); const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater'); const backupCrypto = require('./lib/backup-crypto'); -const { downloadOnlineBackup } = require('./lib/online-backup'); +const { downloadOnlineBackup, normalizeOnlineBackupRetention } = require('./lib/online-backup'); const { createOnlineBackupKeyring } = require('./lib/online-backup-keyring'); const { createOnlineBackupManager } = require('./lib/online-backup-manager'); const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup'); @@ -1461,7 +1461,7 @@ async function checkClouddropHealth(hosterConfig) { // requestedChecks can be: // - array of strings (hoster names) for legacy/all-accounts check // - array of { hoster, accountId } for specific account checks -async function runHosterHealthCheck(config, requestedChecks) { +async function runHosterHealthCheck(config, requestedChecks, onResult = null) { const allowed = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx', 'clouddrop.cc']; // Normalize input to [{ hoster, accountId? }] @@ -1533,7 +1533,11 @@ async function runHosterHealthCheck(config, requestedChecks) { const groupResults = await Promise.all(Array.from(groups.values()).map(async (group) => { const out = []; for (const c of group) { - out.push(await runOne(c)); + const result = await runOne(c); + out.push(result); + if (typeof onResult === 'function') { + try { onResult(result); } catch {} + } } return out; })); @@ -1970,10 +1974,21 @@ ipcMain.handle('export-history', async (_event, format) => { }; }); -ipcMain.handle('run-health-check', async (_event, payload) => { +ipcMain.handle('run-health-check', async (event, payload) => { const config = configStore.load(); const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : []; - return runHosterHealthCheck(config, hosters); + const requestId = typeof payload?.requestId === 'string' && /^[A-Za-z0-9_-]{1,80}$/u.test(payload.requestId) + ? payload.requestId + : null; + return runHosterHealthCheck(config, hosters, requestId ? (result) => { + if (!event.sender.isDestroyed()) { + event.sender.send('health-check:result', { + requestId, + checkedAt: new Date().toISOString(), + result + }); + } + } : null); }); // Validate ephemeral credentials WITHOUT persisting them to config.hosters. @@ -2974,7 +2989,8 @@ async function applyImportedSettings(imported) { settingsImportGate.begin(); try { await waitForConfigStoreWrites(); - const prepared = prepareImportedSettings(imported); + const preparationWarnings = []; + const prepared = prepareImportedSettings(imported, { warnings: preparationWarnings }); const ts = new Date().toISOString().replace(/[:.]/g, '-'); const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`); try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {} @@ -2985,7 +3001,7 @@ async function applyImportedSettings(imported) { const config = configStore.load(); _invalidateLogSettings(config.globalSettings); const warnings = await syncImportedRuntime(config); - return { config, warnings }; + return { config, warnings: [...new Set([...preparationWarnings, ...warnings])] }; } finally { settingsImportGate.end(); } @@ -3079,8 +3095,14 @@ ipcMain.handle('online-backup:list-managed', (event) => ( invokeTrustedOnlineBackupIpc(event, () => onlineBackupManager.listManaged()) )); -ipcMain.handle('online-backup:create-managed', (event) => ( - invokeTrustedOnlineBackupIpc(event, () => onlineBackupManager.createManaged()) +ipcMain.handle('online-backup:create-managed', (event, retention) => ( + invokeTrustedOnlineBackupIpc(event, () => { + try { + return onlineBackupManager.createManaged(normalizeOnlineBackupRetention(retention)); + } catch (error) { + return { ok: false, error: error.message || String(error) }; + } + }) )); ipcMain.handle('online-backup:copy-managed', (event, id) => ( diff --git a/preload.js b/preload.js index d811731..5016126 100644 --- a/preload.js +++ b/preload.js @@ -70,6 +70,11 @@ contextBridge.exposeInMainWorld('api', { completeUploadFinalization: (payload) => ipcRenderer.invoke('complete-upload-finalization', payload), finishAfterActive: () => ipcRenderer.invoke('finish-after-active'), runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload), + onHealthCheckResult: (callback) => { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on('health-check:result', listener); + return () => ipcRenderer.removeListener('health-check:result', listener); + }, validateCredentials: (payload) => ipcRenderer.invoke('validate-credentials', payload), // Log import @@ -97,7 +102,7 @@ contextBridge.exposeInMainWorld('api', { exportBackup: (options) => ipcRenderer.invoke('export-backup', options), importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword), listManagedOnlineBackups: () => ipcRenderer.invoke('online-backup:list-managed'), - createManagedOnlineBackup: () => ipcRenderer.invoke('online-backup:create-managed'), + createManagedOnlineBackup: (retention) => ipcRenderer.invoke('online-backup:create-managed', retention), copyManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:copy-managed', id), deleteManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:delete-managed', id), restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key), diff --git a/renderer/app.js b/renderer/app.js index 416bfda..27b20a6 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -65,6 +65,7 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; +let healthCheckRequestSequence = 0; let automationRuntimeStatus = Object.freeze({}); let automationRuntimeStatusAvailable = false; let automationPauseResumeBusy = false; @@ -87,6 +88,7 @@ let managedOnlineBackupAuthoritativeLoadGeneration = 0; let managedOnlineBackupActiveMutations = 0; let onlineBackupStatusContextGeneration = 0; let managedOnlineBackupRefreshIssue = null; +let managedOnlineBackupExpiryTimer = null; const managedOnlineBackupOperationQueues = new Map(); let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0; @@ -1345,6 +1347,10 @@ async function init() { setUiLanguage(config.globalSettings?.language); hosterSettings = config.hosterSettings || {}; autoHealthCheckEnabled = loadAutoCheckPreference(); + if (config.globalSettings?.autoHealthCheckEnabled !== autoHealthCheckEnabled) { + config.globalSettings = { ...(config.globalSettings || {}), autoHealthCheckEnabled }; + saveGlobalSettingsTracked(config.globalSettings).catch(() => {}); + } ensureAccountStatusEntries(); syncSelectedUploadHosters(); restoreQueueStateFromConfig(); @@ -3599,7 +3605,13 @@ function applyImportedConfig(importedConfig, message) { accountStatuses = {}; ensureAccountStatusEntries(); syncSelectedUploadHosters(); + autoHealthCheckEnabled = config.globalSettings?.autoHealthCheckEnabled !== false; + try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {} alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); + const importedLanguage = setUiLanguage(config.globalSettings?.language); + const importedUrl = new URL(window.location.href); + importedUrl.searchParams.set('language', importedLanguage); + window.history.replaceState(null, '', importedUrl.href); renderSettings(); renderAccounts(); renderHosterSummary(); @@ -3678,12 +3690,20 @@ function normalizeManagedOnlineBackups(entries) { const candidates = []; for (const entry of Array.isArray(entries) ? entries : []) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; - if (Object.keys(entry).sort().join(',') !== 'createdAt,displayKey,id') continue; + const shape = Object.keys(entry).sort().join(','); + if (shape !== 'createdAt,displayKey,id' && shape !== 'createdAt,displayKey,expiresAt,id') continue; if (!isCanonicalManagedOnlineBackupId(entry.id)) continue; if (typeof entry.displayKey !== 'string' || !/^MHU2-[A-Za-z0-9_-]{4}…[A-Za-z0-9_-]{4}$/.test(entry.displayKey)) continue; const createdAt = new Date(entry.createdAt); if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== entry.createdAt) continue; - candidates.push({ id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt }); + let expiresAt = null; + if (shape.includes('expiresAt') && entry.expiresAt !== null) { + const expiration = new Date(entry.expiresAt); + if (typeof entry.expiresAt !== 'string' || Number.isNaN(expiration.getTime()) || expiration.toISOString() !== entry.expiresAt || entry.expiresAt <= entry.createdAt) continue; + expiresAt = entry.expiresAt; + } + if (expiresAt !== null && new Date(expiresAt).getTime() <= Date.now()) continue; + candidates.push({ id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt, expiresAt }); } const counts = new Map(); for (const entry of candidates) counts.set(entry.id, (counts.get(entry.id) || 0) + 1); @@ -3739,6 +3759,8 @@ function removeManagedOnlineBackup(id, focusTarget = null) { function renderManagedOnlineBackups(focusTarget = undefined) { const list = document.getElementById('managedOnlineBackupList'); if (!list) return; + clearTimeout(managedOnlineBackupExpiryTimer); + managedOnlineBackupExpiryTimer = null; const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget; const content = document.createDocumentFragment(); if (!managedOnlineBackupsAuthoritative) { @@ -3761,7 +3783,13 @@ function renderManagedOnlineBackups(focusTarget = undefined) { key.textContent = entry.displayKey; const created = document.createElement('span'); created.className = 'online-backup-managed-created'; - created.textContent = formatDateTime(entry.createdAt).text; + const createdLabel = document.createElement('span'); + createdLabel.textContent = `${localizeUiText('Erstellt')}: ${formatDateTime(entry.createdAt).text}`; + const expirationLabel = document.createElement('span'); + expirationLabel.textContent = entry.expiresAt + ? `${localizeUiText('Gültig bis')}: ${formatDateTime(entry.expiresAt).text}` + : localizeUiText('Unbegrenzt gültig'); + created.append(createdLabel, expirationLabel); const actions = document.createElement('div'); actions.className = 'online-backup-managed-actions'; const copyButton = document.createElement('button'); @@ -3790,6 +3818,17 @@ function renderManagedOnlineBackups(focusTarget = undefined) { } list.replaceChildren(content); restoreManagedOnlineBackupFocus(target); + const nextExpiry = managedOnlineBackups + .map(entry => entry.expiresAt ? new Date(entry.expiresAt).getTime() : 0) + .filter(timestamp => timestamp > Date.now()) + .sort((left, right) => left - right)[0]; + if (nextExpiry) { + const delay = Math.min(2_147_000_000, Math.max(0, nextExpiry - Date.now() + 50)); + managedOnlineBackupExpiryTimer = setTimeout(() => { + managedOnlineBackupExpiryTimer = null; + loadManagedOnlineBackups(); + }, delay); + } } function renderManagedOnlineBackupRefreshIssue() { @@ -3948,10 +3987,13 @@ async function doOnlineBackupCreate() { } const authority = beginManagedOnlineBackupMutation(); const createButton = document.getElementById('createOnlineBackupBtn'); + const retentionSelect = document.getElementById('onlineBackupRetentionSelect'); + const retention = retentionSelect?.value || '7d'; if (createButton) createButton.disabled = true; + if (retentionSelect) retentionSelect.disabled = true; setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', authority.statusContext); try { - const result = await window.api.createManagedOnlineBackup(); + const result = await window.api.createManagedOnlineBackup(retention); if (!result?.ok) { setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext); return; @@ -3964,6 +4006,7 @@ async function doOnlineBackupCreate() { } finally { endManagedOnlineBackupMutation(); if (createButton?.isConnected) createButton.disabled = false; + if (retentionSelect?.isConnected) retentionSelect.disabled = false; doOnlineBackupCreate.busy = false; } } @@ -6013,33 +6056,46 @@ function showAppChoice({ message, title, confirmText, alternateText, cancelText async function executeHealthCheck(hosters, _mode, generations) { renderHealthCheckResults([]); - const result = await window.api.runHealthCheck({ hosters }); - const rows = result && Array.isArray(result.results) ? result.results : []; - const checkedAt = result?.checkedAt || new Date().toISOString(); - const currentRows = rows.filter((row) => { + const requestId = `hc-${Date.now()}-${++healthCheckRequestSequence}`; + const rowsByKey = new Map(); + const applyResult = (row, checkedAt) => { if (!row) return false; const key = row.accountId || row.hoster; const generation = generations?.get(key); - return generation === undefined || _isCurrentAccountStatusGeneration(key, generation); - }); - const completedKeys = new Set(); - currentRows.forEach((row) => { - const key = row.accountId || row.hoster; - if (key) { - completedKeys.add(key); - accountStatuses[key] = { - status: row.status || 'unchecked', - message: row.message || '', - checkedAt: row.checkedAt || checkedAt - }; - } - }); - for (const [key, generation] of generations || []) { - if (completedKeys.has(key) || !_isCurrentAccountStatusGeneration(key, generation)) continue; - accountStatuses[key] = { status: 'error', message: 'Keine Antwort vom Hoster erhalten', checkedAt }; + if (!key || (generation !== undefined && !_isCurrentAccountStatusGeneration(key, generation))) return false; + rowsByKey.set(key, row); + accountStatuses[key] = { + status: row.status || 'unchecked', + message: row.message || '', + checkedAt: row.checkedAt || checkedAt + }; + if (row.accountId) updateAccountCard(row.accountId); + else renderAccounts(); + renderHosterModal(); + renderHealthCheckResults([...rowsByKey.values()]); + return true; + }; + const stopListening = typeof window.api.onHealthCheckResult === 'function' + ? window.api.onHealthCheckResult((payload) => { + if (payload?.requestId === requestId) applyResult(payload.result, payload.checkedAt || new Date().toISOString()); + }) + : null; + let result; + try { + result = await window.api.runHealthCheck({ hosters, requestId }); + } finally { + if (typeof stopListening === 'function') stopListening(); } + const rows = result && Array.isArray(result.results) ? result.results : []; + const checkedAt = result?.checkedAt || new Date().toISOString(); + rows.forEach(row => applyResult(row, checkedAt)); + for (const [key, generation] of generations || []) { + if (rowsByKey.has(key) || !_isCurrentAccountStatusGeneration(key, generation)) continue; + accountStatuses[key] = { status: 'error', message: 'Keine Antwort vom Hoster erhalten', checkedAt }; + updateAccountCard(key); + } + const currentRows = [...rowsByKey.values()]; renderHealthCheckResults(currentRows); - renderAccounts(); renderHosterModal(); return currentRows; } @@ -6553,6 +6609,18 @@ function renderSettings() {
@@ -9283,6 +9351,8 @@ function setupListeners() { autoToggle.addEventListener('change', (e) => { autoHealthCheckEnabled = !!e.target.checked; try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {} + config.globalSettings = { ...(config.globalSettings || {}), autoHealthCheckEnabled }; + saveGlobalSettingsTracked(config.globalSettings).catch(() => {}); }); } @@ -9967,8 +10037,12 @@ function formatDateTime(value) { } function loadAutoCheckPreference() { - try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; } - catch { return true; } + try { + const stored = localStorage.getItem(AUTO_CHECK_PREF_KEY); + if (stored === '0' || stored === '1') return stored === '1'; + } + catch {} + return config.globalSettings?.autoHealthCheckEnabled !== false; } // --- Queue table column resizing (JDownloader-style) --- diff --git a/renderer/i18n.js b/renderer/i18n.js index e33602f..9a0fc3a 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -378,6 +378,14 @@ ['Verschlüsseltes Online-Backup', 'Encrypted online backup'], ['Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.', 'Encryption takes place only on this device. The server stores encrypted data only.'], ['Neuen Schlüssel erzeugen', 'Generate new key'], + ['Gültigkeitsdauer', 'Validity period'], + ['24 Stunden', '24 hours'], + ['3 Tage', '3 days'], + ['7 Tage (Standard)', '7 days (default)'], + ['31 Tage', '31 days'], + ['Erstellt', 'Created'], + ['Gültig bis', 'Valid until'], + ['Unbegrenzt gültig', 'Valid indefinitely'], ['Auf diesem Gerät erstellt', 'Created on this device'], ['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'], ['Schlüssel kopieren', 'Copy key'], @@ -541,6 +549,8 @@ ['Nicht alle Einstellungen konnten gespeichert werden', 'Not all settings could be saved'], ['Online-Schlüssel kopiert', 'Online key copied'], ['Online-Sicherung konnte nicht erstellt werden', 'Online backup could not be created'], + ['Gültigkeitsdauer der Online-Sicherung ist ungültig', 'Online backup validity period is invalid'], + ['Erstellungszeit der Online-Sicherung ist ungültig', 'Online backup creation time is invalid'], ['Online-Sicherungen konnten nicht geladen werden', 'Online backups could not be loaded'], ['Online-Sicherung konnte nicht kopiert werden', 'Online backup could not be copied'], ['Online-Sicherung konnte nicht importiert werden', 'Online backup could not be imported'], diff --git a/renderer/styles.css b/renderer/styles.css index ef8ac4a..2dc9d8d 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -2173,7 +2173,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } .online-backup-managed-row { display: grid; - grid-template-columns: 168px 188px minmax(0, 1fr); + grid-template-columns: 168px 238px minmax(0, 1fr); gap: 10px; align-items: center; min-width: 0; @@ -2192,6 +2192,8 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } } .online-backup-managed-created { + display: grid; + gap: 3px; min-width: 0; color: var(--text-dim); font-size: 13px; @@ -2240,10 +2242,64 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } .online-backup-footer { display: flex; + align-items: flex-end; + gap: 12px; justify-content: flex-end; min-width: 0; } +.online-backup-retention-field { + display: grid; + gap: 6px; + min-width: 190px; +} + +.online-backup-retention-field label { + color: var(--text); + font-size: 12px; + font-weight: 600; +} + +.online-backup-retention-select { + position: relative; +} + +.online-backup-retention-select::after { + position: absolute; + top: 50%; + right: 14px; + width: 7px; + height: 7px; + border-right: 2px solid var(--text-dim); + border-bottom: 2px solid var(--text-dim); + content: ''; + pointer-events: none; + transform: translateY(-65%) rotate(45deg); +} + +.online-backup-retention-select select { + width: 100%; + min-height: 34px; + padding: 7px 38px 7px 11px; + border: 1px solid var(--border); + border-radius: 6px; + appearance: none; + color: var(--text); + background: var(--bg-card); + font: inherit; + font-size: 13px; +} + +.online-backup-retention-select select:focus { + border-color: var(--accent); + outline: 2px solid color-mix(in srgb, var(--accent) 32%, transparent); + outline-offset: 1px; +} + +.online-backup-retention-select:has(select:disabled)::after { + opacity: 0.5; +} + .online-backup-status { min-height: 18px; color: var(--text-dim); @@ -2273,6 +2329,15 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } .online-backup-key-row { grid-template-columns: 1fr; } + + .online-backup-footer { + align-items: stretch; + flex-direction: column; + } + + .online-backup-retention-field { + width: 100%; + } } .icon-sprite { diff --git a/services/backup-api/src/server.mjs b/services/backup-api/src/server.mjs index f72729b..efaa59b 100644 --- a/services/backup-api/src/server.mjs +++ b/services/backup-api/src/server.mjs @@ -11,6 +11,7 @@ const idPattern = /^[A-Za-z0-9_-]{22}$/ const verifierPattern = /^[A-Za-z0-9_-]{43}$/ const blobPattern = /^[A-Za-z0-9_-]+$/ const notFoundBody = '{"error":"not_found"}' +const allowedRetentionSeconds = new Set([86_400, 259_200, 604_800, 2_678_400]) function isCanonicalBase64Url(value, byteLength, pattern) { if (typeof value !== 'string' || !pattern.test(value)) return false @@ -21,7 +22,9 @@ function isCanonicalBase64Url(value, byteLength, pattern) { function isValidBackup(payload) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false const keys = Object.keys(payload).sort() - if (keys.join(',') !== 'blob,deleteVerifier,id') return false + const shape = keys.join(',') + if (shape !== 'blob,deleteVerifier,id' && shape !== 'blob,deleteVerifier,expiresInSeconds,id') return false + if (shape.includes('expiresInSeconds') && payload.expiresInSeconds !== null && !allowedRetentionSeconds.has(payload.expiresInSeconds)) return false if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false @@ -164,6 +167,36 @@ async function directoryUsage(rootDir) { return { bytes, records } } +function isCanonicalTimestamp(value) { + if (typeof value !== 'string') return false + const timestamp = new Date(value) + return Number.isFinite(timestamp.getTime()) && timestamp.toISOString() === value +} + +function recordExpired(record, nowMs) { + return record.expiresAt !== null && new Date(record.expiresAt).getTime() <= nowMs +} + +async function cleanupExpiredRecords(rootDir, nowMs) { + const entries = await readdir(rootDir, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isFile() || !/^[A-Za-z0-9_-]{22}\.json$/.test(entry.name)) continue + const id = entry.name.slice(0, -5) + let record + try { + record = await readRecord(rootDir, id) + } catch { + continue + } + if (!record || !recordExpired(record, nowMs)) continue + try { + await unlink(recordPath(rootDir, id)) + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + } +} + async function syncDirectory(rootDir) { let handle try { @@ -219,15 +252,21 @@ async function recordExists(rootDir, id) { } } -async function createRecord(rootDir, payload, maxStorageBytes, maxRecords) { +async function createRecord(rootDir, payload, maxStorageBytes, maxRecords, nowMs) { await mkdir(rootDir, { recursive: true }) await cleanupTemporaryFiles(rootDir) + await cleanupExpiredRecords(rootDir, nowMs) if (await recordExists(rootDir, payload.id)) return 'duplicate' + const expiresInSeconds = Object.prototype.hasOwnProperty.call(payload, 'expiresInSeconds') + ? payload.expiresInSeconds + : null + const expiresAt = expiresInSeconds === null ? null : new Date(nowMs + expiresInSeconds * 1000).toISOString() const contents = Buffer.from(JSON.stringify({ - version: 1, + version: 2, blob: payload.blob, deleteVerifier: payload.deleteVerifier, - createdAt: new Date().toISOString() + createdAt: new Date(nowMs).toISOString(), + expiresAt }), 'utf8') const usage = await directoryUsage(rootDir) if (usage.bytes + contents.length > maxStorageBytes || usage.records >= maxRecords) return 'full' @@ -282,10 +321,26 @@ async function readRecord(rootDir, id) { try { const raw = await readFile(recordPath(rootDir, id), 'utf8') const record = JSON.parse(raw) - if (record?.version !== 1 || typeof record.blob !== 'string' || !isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)) { + const keys = record && typeof record === 'object' && !Array.isArray(record) ? Object.keys(record).sort().join(',') : '' + const validBlob = typeof record?.blob === 'string' + && blobPattern.test(record.blob) + && Buffer.from(record.blob, 'base64url').length <= maxBlobBytes + && Buffer.from(record.blob, 'base64url').toString('base64url') === record.blob + const legacy = keys === 'blob,createdAt,deleteVerifier,version' + && record.version === 1 + && validBlob + && isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern) + && isCanonicalTimestamp(record.createdAt) + const expiring = keys === 'blob,createdAt,deleteVerifier,expiresAt,version' + && record.version === 2 + && validBlob + && isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern) + && isCanonicalTimestamp(record.createdAt) + && (record.expiresAt === null || (isCanonicalTimestamp(record.expiresAt) && record.expiresAt > record.createdAt)) + if (!legacy && !expiring) { throw new Error('Invalid stored record') } - return record + return legacy ? { ...record, expiresAt: null } : record } catch (error) { if (error.code === 'ENOENT') return null throw error @@ -383,6 +438,7 @@ export function createBackupServer(options) { const maxConcurrentPerClient = options.maxConcurrentPerClient ?? 8 const maxConcurrentTotal = options.maxConcurrentTotal ?? 64 const trustedProxyAddresses = new Set(options.trustedProxyAddresses ?? []) + const now = options.now ?? (() => Date.now()) if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) { throw new Error('Invalid rate limit') } @@ -398,6 +454,7 @@ export function createBackupServer(options) { if (!Number.isSafeInteger(healthCacheMs) || healthCacheMs < 1) throw new Error('Invalid health cache') if (!Number.isSafeInteger(maxConcurrentPerClient) || maxConcurrentPerClient < 1) throw new Error('Invalid per-client concurrency') if (!Number.isSafeInteger(maxConcurrentTotal) || maxConcurrentTotal < maxConcurrentPerClient) throw new Error('Invalid total concurrency') + if (typeof now !== 'function' || !Number.isFinite(Number(now()))) throw new Error('Invalid clock') const consumeRateLimit = createRateLimiter(rateLimit) const consumeUploadRateLimit = createRateLimiter(uploadRateLimit) const consumeRequestRateLimit = createRateLimiter(requestRateLimit) @@ -471,7 +528,18 @@ export function createBackupServer(options) { return } const record = await readRecord(options.rootDir, parsed.value.id) - if (!record) { + if (!record || recordExpired(record, Number(now()))) { + if (record) { + await runStorageMutation(() => withStorageLock(options.rootDir, async () => { + const current = await readRecord(options.rootDir, parsed.value.id) + if (!current || !recordExpired(current, Number(now()))) return + try { + await unlink(recordPath(options.rootDir, parsed.value.id)) + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + })) + } sendNotFound(response) return } @@ -522,7 +590,7 @@ export function createBackupServer(options) { } const result = await runStorageMutation(() => withStorageLock( options.rootDir, - () => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords) + () => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords, Number(now())) )) if (result === 'duplicate') { sendJson(response, 409, { error: 'already_exists' }) diff --git a/services/backup-api/test/server.test.mjs b/services/backup-api/test/server.test.mjs index 475668d..def81f6 100644 --- a/services/backup-api/test/server.test.mjs +++ b/services/backup-api/test/server.test.mjs @@ -110,7 +110,10 @@ test('validates payload shape, content type and decoded blob size', async (t) => { ...valid.payload, blob: 'not+base64url' }, { ...valid.payload, deleteVerifier: 'short' }, { id: valid.payload.id, blob: valid.payload.blob }, - { ...valid.payload, extra: true } + { ...valid.payload, extra: true }, + { ...valid.payload, expiresInSeconds: 0 }, + { ...valid.payload, expiresInSeconds: 30 * 24 * 60 * 60 }, + { ...valid.payload, expiresInSeconds: '604800' } ] for (const body of invalid) { @@ -139,6 +142,82 @@ test('validates payload shape, content type and decoded blob size', async (t) => assert.equal(tooLarge.status, 413) }) +test('expires finite backups at the exact deadline and removes their ciphertext', async (t) => { + let nowMs = Date.parse('2026-09-01T10:00:00.000Z') + const api = await startApi({ now: () => nowMs }) + t.after(() => api.close()) + const backup = fixture() + backup.payload.expiresInSeconds = 86_400 + + const created = await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(backup.payload) + }) + assert.equal(created.status, 201) + const storedPath = join(api.rootDir, `${backup.payload.id}.json`) + const stored = JSON.parse(await readFile(storedPath, 'utf8')) + assert.equal(stored.version, 2) + assert.equal(stored.expiresAt, '2026-09-02T10:00:00.000Z') + + const beforeDeadline = await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: backup.payload.id }) + }) + assert.equal(beforeDeadline.status, 200) + + nowMs += 86_400_000 + const expired = await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: backup.payload.id }) + }) + assert.equal(expired.status, 404) + await assert.rejects(stat(storedPath), error => error.code === 'ENOENT') +}) + +test('keeps legacy records unlimited and reclaims expired records on the next upload', async (t) => { + let nowMs = Date.parse('2026-09-01T10:00:00.000Z') + const api = await startApi({ now: () => nowMs }) + t.after(() => api.close()) + const legacy = fixture() + await writeFile(join(api.rootDir, `${legacy.payload.id}.json`), JSON.stringify({ + version: 1, + blob: legacy.payload.blob, + deleteVerifier: legacy.payload.deleteVerifier, + createdAt: '2026-01-01T00:00:00.000Z' + })) + const finite = fixture() + finite.payload.expiresInSeconds = 86_400 + await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(finite.payload) + }) + + nowMs += 2 * 86_400_000 + const replacement = fixture() + replacement.payload.expiresInSeconds = null + assert.equal((await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(replacement.payload) + })).status, 201) + + await assert.rejects(stat(join(api.rootDir, `${finite.payload.id}.json`)), error => error.code === 'ENOENT') + assert.equal((await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: legacy.payload.id }) + })).status, 200) + assert.equal((await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: replacement.payload.id }) + })).status, 200) +}) + test('deletes only with the matching client secret and returns constant not-found responses', async (t) => { const api = await startApi() t.after(() => api.close()) diff --git a/tests/online-backup-keyring.test.js b/tests/online-backup-keyring.test.js index fbcf8c5..6d14bc1 100644 --- a/tests/online-backup-keyring.test.js +++ b/tests/online-backup-keyring.test.js @@ -44,7 +44,8 @@ function fixture(options = {}) { encryptField: options.encryptField || encrypt, decryptField: options.decryptField || decrypt, isEncrypted: options.isEncrypted || isCanonicalEnvelope, - fsImpl: options.fsImpl + fsImpl: options.fsImpl, + now: options.now }) }; } @@ -82,12 +83,14 @@ describe('encrypted online backup keyring', () => { assert.equal(document.version, 2); assert.equal(document.generation, 1); assert.equal(document.keys.length, 1); + assert.equal(document.keys[0].expiresAt, null); assert.equal(fs.readFileSync(filePath, 'utf8').includes(key), false); assert.deepEqual(snapshot.issues, []); assert.deepEqual(snapshot.entries, [{ id: parseOnlineBackupKey(key).id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt: timestamp + createdAt: timestamp, + expiresAt: null }]); assert.equal(Object.isFrozen(snapshot), true); assert.equal(Object.isFrozen(snapshot.entries), true); @@ -95,6 +98,25 @@ describe('encrypted online backup keyring', () => { assert.equal(await keyring.getKey(prepared.id), key); }); + it('removes expired local keys from the managed list and encrypted keyring', async () => { + let currentTime = new Date(timestamp).getTime(); + const { filePath, keyring } = fixture({ now: () => currentTime }); + const finite = validKey(); + const unlimited = validKey(); + const expiresAt = '2026-08-23T10:00:00.000Z'; + await keyring.commit(keyring.prepare(finite, timestamp, expiresAt)); + await keyring.commit(keyring.prepare(unlimited, timestamp, null)); + + assert.deepEqual((await keyring.list()).entries.map(entry => entry.expiresAt), [expiresAt, null]); + currentTime = new Date(expiresAt).getTime(); + const snapshot = await keyring.list(); + + assert.deepEqual(snapshot.entries.map(entry => entry.id), [parseOnlineBackupKey(unlimited).id]); + assert.equal(await keyring.getKey(parseOnlineBackupKey(finite).id), null); + const document = JSON.parse(fs.readFileSync(filePath, 'utf8')); + assert.equal(document.keys.some(entry => entry.id === parseOnlineBackupKey(finite).id), false); + }); + it('keeps a valid v1 primary authoritative over an older v1 backup and migrates to v2', async () => { const { filePath, backupPath, keyring } = fixture(); const backupKey = validKey(); @@ -342,7 +364,8 @@ describe('encrypted online backup keyring', () => { assert.deepEqual((await keyring.list()).entries, [{ id: parseOnlineBackupKey(key).id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt: timestamp + createdAt: timestamp, + expiresAt: null }]); }); diff --git a/tests/online-backup-manager.test.js b/tests/online-backup-manager.test.js index 117ebac..e1257ae 100644 --- a/tests/online-backup-manager.test.js +++ b/tests/online-backup-manager.test.js @@ -28,10 +28,12 @@ function createFixture(overrides = {}) { entries.set(id, { id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt: '2026-08-22T10:00:00.000Z' + createdAt: '2026-08-22T10:00:00.000Z', + expiresAt: null }); } let createdAt; + let expiresAt; let createArguments; const removalPlans = new WeakMap(); const keyring = { @@ -43,11 +45,12 @@ function createFixture(overrides = {}) { issues: overrides.listIssues || [] }; }, - prepare: (value, timestamp) => { + prepare: (value, timestamp, expiration) => { events.push('prepare'); if (overrides.prepareError) throw overrides.prepareError; createdAt = timestamp; - return { id, encryptedKey: 'encrypted-key', createdAt: timestamp }; + expiresAt = expiration ?? null; + return { id, encryptedKey: 'encrypted-key', createdAt: timestamp, expiresAt }; }, commit: async (entry) => { events.push('commit'); @@ -56,7 +59,8 @@ function createFixture(overrides = {}) { entries.set(entry.id, { id: entry.id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt: entry.createdAt + createdAt: entry.createdAt, + expiresAt: entry.expiresAt ?? null }); }, getKey: async (entryId) => { @@ -90,7 +94,8 @@ function createFixture(overrides = {}) { appVersion: () => '2.1.31', createBackup: (...args) => { createArguments = args; - return { key, record }; + const expiration = args[3] === 'forever' ? null : new Date(new Date(args[2]).getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(); + return { key, record, expiresAt: expiration }; }, uploadBackup: async (uploadedRecord) => { events.push('upload'); @@ -112,6 +117,9 @@ function createFixture(overrides = {}) { get createdAt() { return createdAt; }, + get expiresAt() { + return expiresAt; + }, get createArguments() { return createArguments; } @@ -174,14 +182,15 @@ describe('transactional online backup manager', () => { const result = await fixture.manager.createManaged(); assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit']); - assert.deepEqual(fixture.createArguments, [settings, '2.1.31', fixture.createdAt]); + assert.deepEqual(fixture.createArguments, [settings, '2.1.31', fixture.createdAt, '7d']); assert.match(fixture.createdAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); assert.deepEqual(result, { ok: true, entry: { id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt: fixture.createdAt + createdAt: fixture.createdAt, + expiresAt: fixture.expiresAt } }); assert.equal(JSON.stringify(result).includes(key), false); @@ -202,7 +211,8 @@ describe('transactional online backup manager', () => { entry: { id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt: fixture.createdAt + createdAt: fixture.createdAt, + expiresAt: fixture.expiresAt } }); }); @@ -445,7 +455,8 @@ describe('transactional online backup manager', () => { entries: [{ id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt: '2026-08-22T10:00:00.000Z' + createdAt: '2026-08-22T10:00:00.000Z', + expiresAt: null }] }); fixture.keyring.list = async () => { @@ -464,7 +475,8 @@ describe('transactional online backup manager', () => { entries: [{ id, displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`, - createdAt: '2026-08-22T10:00:00.000Z' + createdAt: '2026-08-22T10:00:00.000Z', + expiresAt: null }], warningCode: 'KEYRING_DECRYPT_FAILED', warning: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden' diff --git a/tests/online-backup.test.js b/tests/online-backup.test.js index 8a7ccbd..7b974a5 100644 --- a/tests/online-backup.test.js +++ b/tests/online-backup.test.js @@ -49,7 +49,28 @@ describe('online backup key', () => { assert.equal(serialized.includes('secret-api-key'), false); assert.equal(serialized.includes('private-webhook'), false); assert.equal(serialized.includes(parsed.masterKey.toString('base64url')), false); - assert.deepEqual(Object.keys(created.record).sort(), ['blob', 'deleteVerifier', 'id']); + assert.deepEqual(Object.keys(created.record).sort(), ['blob', 'deleteVerifier', 'expiresInSeconds', 'id']); + assert.equal(created.record.expiresInSeconds, 604_800); + }); + + it('supports the allowed validity periods with seven days as the default', () => { + const { createOnlineBackup, normalizeOnlineBackupRetention } = require('../lib/online-backup'); + const createdAt = '2026-08-09T00:00:00.000Z'; + const expected = new Map([ + ['1d', [86_400, '2026-08-10T00:00:00.000Z']], + ['3d', [259_200, '2026-08-12T00:00:00.000Z']], + ['7d', [604_800, '2026-08-16T00:00:00.000Z']], + ['31d', [2_678_400, '2026-09-09T00:00:00.000Z']], + ['forever', [null, null]] + ]); + + assert.equal(createOnlineBackup(settings(), '2.1.41', createdAt).record.expiresInSeconds, 604_800); + for (const [retention, [seconds, expiresAt]] of expected) { + const created = createOnlineBackup(settings(), '2.1.41', createdAt, retention); + assert.equal(created.record.expiresInSeconds, seconds); + assert.equal(created.expiresAt, expiresAt); + } + assert.throws(() => normalizeOnlineBackupRetention('30d'), /Gültigkeitsdauer/i); }); it('rejects corrupted keys, ciphertext and oversized settings', () => { diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index 0e18427..1422a0a 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -329,7 +329,7 @@ test('exposes managed online backup operations through narrow IPC boundaries', ( const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); assert.match(preloadSource, /listManagedOnlineBackups:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:list-managed'\)/u); - assert.match(preloadSource, /createManagedOnlineBackup:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:create-managed'\)/u); + assert.match(preloadSource, /createManagedOnlineBackup:\s*\(retention\)\s*=>\s*ipcRenderer\.invoke\('online-backup:create-managed',\s*retention\)/u); assert.match(preloadSource, /copyManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:copy-managed',\s*id\)/u); assert.match(preloadSource, /deleteManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:delete-managed',\s*id\)/u); assert.match(preloadSource, /restoreOnlineBackup:\s*\(key\)\s*=>\s*ipcRenderer\.invoke\('online-backup:restore',\s*key\)/u); @@ -344,7 +344,7 @@ test('exposes managed online backup operations through narrow IPC boundaries', ( assert.match(mainSource, /decoded\.toString\('base64url'\)\s*!==\s*id/u); assert.doesNotMatch(mainSource, /ipcMain\.handle\('online-backup:create'/u); assert.match(mainSource, /ipcMain\.handle\('online-backup:list-managed',[\s\S]*?onlineBackupManager\.listManaged\(\)/u); - assert.match(mainSource, /ipcMain\.handle\('online-backup:create-managed',[\s\S]*?onlineBackupManager\.createManaged\(\)/u); + assert.match(mainSource, /ipcMain\.handle\('online-backup:create-managed',[\s\S]*?onlineBackupManager\.createManaged\(normalizeOnlineBackupRetention\(retention\)\)/u); assert.match(mainSource, /ipcMain\.handle\('online-backup:copy-managed',[\s\S]*?onlineBackupManager\.copyManaged\(/u); assert.match(mainSource, /ipcMain\.handle\('online-backup:delete-managed',[\s\S]*?onlineBackupManager\.deleteManaged\(/u); }); @@ -393,6 +393,7 @@ test('managed online backup handlers reject every sender outside the local main ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, mainWindow, onlineBackupManager, + normalizeOnlineBackupRetention: value => value || '7d', path, pathToFileURL }; diff --git a/tests/settings-backup.test.js b/tests/settings-backup.test.js index d1ddf9b..fefe0ec 100644 --- a/tests/settings-backup.test.js +++ b/tests/settings-backup.test.js @@ -7,7 +7,22 @@ describe('settings backup snapshot', () => { const input = { hosters: { 'voe.sx': [{ id: 'v1', username: 'user', password: 'secret', enabled: true }] }, hosterSettings: { 'voe.sx': { retries: 7 } }, - globalSettings: { alwaysOnTop: true, pendingQueue: [{ file: 'private.mkv' }] }, + globalSettings: { + language: 'de', + autoHealthCheckEnabled: false, + alwaysOnTop: true, + pendingQueue: [{ file: 'private.mkv' }], + uploadRecovery: { jobs: ['private.mkv'] }, + lastBrowseDirectory: 'Z:\\private', + folderMonitor: { + enabled: true, + folderPath: 'D:\\watch', + recursive: true, + paused: true, + pausedAt: 123, + telemetry: { detected: 8 } + } + }, history: [{ file: 'done.mkv' }], rotationCursors: { 'voe.sx': 4 } }; @@ -17,13 +32,27 @@ describe('settings backup snapshot', () => { assert.deepEqual(snapshot, { hosters: input.hosters, hosterSettings: input.hosterSettings, - globalSettings: { alwaysOnTop: true, pendingQueue: null }, + globalSettings: { + language: 'de', + autoHealthCheckEnabled: false, + alwaysOnTop: true, + pendingQueue: null, + uploadRecovery: null, + lastBrowseDirectory: '', + folderMonitor: { + enabled: true, + folderPath: 'D:\\watch', + recursive: true, + paused: false, + pausedAt: null + } + }, history: [] }); assert.notEqual(snapshot.hosters, input.hosters); }); - it('validates imports and clears only source-machine paths that do not exist locally', () => { + it('preserves configured paths, disables a missing monitored folder and reports both missing paths', () => { const { prepareImportedSettings } = require('../lib/settings-backup'); const snapshot = { hosters: { 'byse.sx': [{ id: 'b1', apiKey: 'secret', enabled: true }] }, @@ -36,12 +65,19 @@ describe('settings backup snapshot', () => { } }; - const imported = prepareImportedSettings(snapshot, { pathExists: () => false, pathDirname: (value) => value }); + const warnings = []; + const imported = prepareImportedSettings(snapshot, { pathExists: () => false, pathDirname: (value) => value, warnings }); - assert.equal(imported.globalSettings.logFilePath, ''); - assert.deepEqual(imported.globalSettings.folderMonitor, { enabled: false, folderPath: '' }); + assert.equal(imported.globalSettings.logFilePath, 'Z:\\missing\\upload.log'); + assert.deepEqual(imported.globalSettings.folderMonitor, { enabled: false, folderPath: 'Z:\\missing\\watch', paused: false, pausedAt: null }); assert.equal(imported.globalSettings.pendingQueue, null); + assert.equal(imported.globalSettings.uploadRecovery, null); + assert.equal(imported.globalSettings.lastBrowseDirectory, ''); assert.deepEqual(imported.history, []); + assert.deepEqual(warnings, [ + 'Log-Dateipfad (Ordner nicht gefunden)', + 'Ordnerüberwachung (Ordner nicht gefunden und deaktiviert)' + ]); assert.throws(() => prepareImportedSettings({ hosters: {} }), /ungültige Struktur/i); }); }); diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 1600f51..961b99a 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -63,18 +63,21 @@ let initialConfigReadDelayed = false; let startupLanguagePendingSnapshot = null; let managedOnlineBackupEntries = [ { id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }, - { id: 'AQEBAQEBAQEBAQEBAQEBAQ', displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z' } + { id: 'AQEBAQEBAQEBAQEBAQEBAQ', displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z', expiresAt: '2026-09-22T10:00:00.000Z' }, + { id: 'AwMDAwMDAwMDAwMDAwMDAw', displayKey: 'MHU2-OLDK…0000', createdAt: '2026-08-01T10:00:00.000Z', expiresAt: '2026-08-02T10:00:00.000Z' } ]; const managedOnlineBackupCopyIds = []; const managedOnlineBackupDeleteIds = []; let managedOnlineBackupCreateCalls = 0; +const managedOnlineBackupCreateRetentions = []; let managedOnlineBackupDeleteMode = 'failure'; let releaseManagedOnlineBackupDelete = null; const managedOnlineBackupHandlers = { 'online-backup:list-managed': async () => ({ ok: true, entries: managedOnlineBackupEntries.map(entry => ({ ...entry })) }), - 'online-backup:create-managed': async () => { + 'online-backup:create-managed': async (_event, retention) => { managedOnlineBackupCreateCalls++; - const entry = { id: 'AgICAgICAgICAgICAgICAg', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' }; + managedOnlineBackupCreateRetentions.push(retention); + const entry = { id: 'AgICAgICAgICAgICAgICAg', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z', expiresAt: '2026-09-23T12:00:00.000Z' }; managedOnlineBackupEntries = [...managedOnlineBackupEntries, entry]; return { ok: true, entry: { ...entry } }; }, @@ -1191,6 +1194,48 @@ setTimeout(async () => { check('Completed account checks expose their timestamp and release generation tokens', completedAccountCheckState.status === 'ok' && completedAccountCheckState.checkedAt === '2026-08-20T12:34:00.000Z' && completedAccountCheckState.subtitle.includes('geprüft') && completedAccountCheckState.generations === 0); restoreInitialIpcHandler('run-health-check'); + ipcMain.removeHandler('run-health-check'); + ipcMain.handle('run-health-check', (event, payload) => new Promise(resolve => { + const [first, second] = payload.hosters; + setTimeout(() => { + event.sender.send('health-check:result', { + requestId: payload.requestId, + checkedAt: '2026-08-20T12:35:00.000Z', + result: { accountId: first.accountId, hoster: first.hoster, status: 'ok', message: 'First ready' } + }); + }, 20); + setTimeout(() => { + const secondResult = { accountId: second.accountId, hoster: second.hoster, status: 'error', message: 'Second failed' }; + event.sender.send('health-check:result', { + requestId: payload.requestId, + checkedAt: '2026-08-20T12:35:01.000Z', + result: secondResult + }); + resolve({ + checkedAt: '2026-08-20T12:35:01.000Z', + results: [ + { accountId: first.accountId, hoster: first.hoster, status: 'ok', message: 'First ready' }, + secondResult + ] + }); + }, 180); + })); + const progressiveCheck = wc.executeJavaScript(\`(() => { + HOSTERS.forEach(name => { config.hosters[name] = []; }); + config.hosters['byse.sx'] = [ + { id: 'ui-progress-first', enabled: true, authType: 'api', apiKey: 'first-key' }, + { id: 'ui-progress-second', enabled: true, authType: 'api', apiKey: 'second-key' } + ]; + accountStatuses = {}; + healthCheckRunning = false; + renderAccounts(); + return runHealthCheck('manual'); + })()\`); + const progressiveIntermediate = await waitUntil(() => wc.executeJavaScript(\`accountStatuses['ui-progress-first']?.status === 'ok' && accountStatuses['ui-progress-second']?.status === 'checking'\`)); + const progressiveFinal = await progressiveCheck.then(() => wc.executeJavaScript(\`({ first: accountStatuses['ui-progress-first']?.status, second: accountStatuses['ui-progress-second']?.status, running: healthCheckRunning, generations: accountStatusGenerations.size })\`)); + check('Batch account checks update each card as soon as that account finishes', progressiveIntermediate === true && progressiveFinal.first === 'ok' && progressiveFinal.second === 'error' && progressiveFinal.running === false && progressiveFinal.generations === 0); + restoreInitialIpcHandler('run-health-check'); + let resolveStaleAccountCheck = null; ipcMain.removeHandler('run-health-check'); ipcMain.handle('run-health-check', () => new Promise(resolve => { resolveStaleAccountCheck = resolve; })); @@ -1251,6 +1296,27 @@ setTimeout(async () => { const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")'); check('Settings tab active', settingsActive); + const importedLanguageState = await wc.executeJavaScript(\`(() => { + const original = structuredClone(config); + const target = document.documentElement.lang === 'de' ? 'en' : 'de'; + const targetAutoCheck = config.globalSettings.autoHealthCheckEnabled === false; + const imported = structuredClone(config); + imported.globalSettings.language = target; + imported.globalSettings.autoHealthCheckEnabled = targetAutoCheck; + applyImportedConfig(imported, 'Importiert'); + const state = { + target, + documentLanguage: document.documentElement.lang, + urlLanguage: new URL(window.location.href).searchParams.get('language'), + inputLanguage: document.getElementById('languageInput')?.value, + targetAutoCheck, + importedAutoCheck: autoHealthCheckEnabled + }; + applyImportedConfig(original, 'Zurückgesetzt'); + return state; + })()\`); + check('Imported backup language and automatic account check apply immediately', importedLanguageState.documentLanguage === importedLanguageState.target && importedLanguageState.urlLanguage === importedLanguageState.target && importedLanguageState.inputLanguage === importedLanguageState.target && importedLanguageState.importedAutoCheck === importedLanguageState.targetAutoCheck); + let updateCheckCallCount = 0; const updateCheckResolvers = []; ipcMain.removeHandler('app:check-updates'); @@ -1405,9 +1471,12 @@ setTimeout(async () => { check('Global parallel uploads default 0', parallel === '0'); await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'backup\\']")?.click()'); - const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "managedOnlineBackupHeading", "managedOnlineBackupList", "managedOnlineBackupRefreshStatus", "reloadManagedOnlineBackupsBtn", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id))) && !document.getElementById("onlineBackupKeyOutput") && !document.getElementById("copyOnlineBackupKeyBtn")'); + const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "onlineBackupRetentionSelect", "managedOnlineBackupHeading", "managedOnlineBackupList", "managedOnlineBackupRefreshStatus", "reloadManagedOnlineBackupsBtn", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id))) && !document.getElementById("onlineBackupKeyOutput") && !document.getElementById("copyOnlineBackupKeyBtn")'); check('Online backup controls exist', onlineBackupControls); + const onlineBackupRetentionState = await wc.executeJavaScript('(() => { const select = document.getElementById("onlineBackupRetentionSelect"); const wrapper = select.closest(".online-backup-retention-select"); return { value: select.value, options: [...select.options].map(option => option.value + ":" + option.textContent), arrowRight: getComputedStyle(wrapper, "::after").right, fontSize: getComputedStyle(select).fontSize }; })()'); + check('Online backup validity defaults to seven days and offers every requested duration', onlineBackupRetentionState.value === '7d' && onlineBackupRetentionState.options.join('|') === '1d:24 Stunden|3d:3 Tage|7d:7 Tage (Standard)|31d:31 Tage|forever:Unbegrenzt' && onlineBackupRetentionState.arrowRight === '14px' && parseFloat(onlineBackupRetentionState.fontSize) >= 10); + const onlineBackupKeyContract = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput")?.maxLength + "|" + document.getElementById("onlineBackupKeyInput")?.getAttribute("pattern")'); check('Online backup input enforces the 75-character MHU key format', onlineBackupKeyContract === '75|MHU2-[A-Za-z0-9_-]{70}'); @@ -1415,8 +1484,21 @@ setTimeout(async () => { check('Online backup uses a narrow managed preload bridge', onlineBackupBridge === 'function|function|function|function|function|undefined'); const managedOnlineBackupLoaded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 2')); - const managedOnlineBackupInitialState = await wc.executeJavaScript('(() => ({ keys: [...document.querySelectorAll(".online-backup-managed-key")].map(element => element.textContent), described: [...document.querySelectorAll(".online-backup-managed-row")].every(row => [...row.querySelectorAll("button")].every(button => button.getAttribute("aria-describedby") === row.querySelector(".online-backup-managed-key").id)), secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()'); - check('Managed online backups render canonical masked IDs with accessible actions', managedOnlineBackupLoaded === true && managedOnlineBackupInitialState.keys.join('|') === 'MHU2-ZYXW…9876|MHU2-ABCD…1234' && managedOnlineBackupInitialState.described === true && managedOnlineBackupInitialState.secretInBody === false); + const managedOnlineBackupInitialState = await wc.executeJavaScript('(() => ({ keys: [...document.querySelectorAll(".online-backup-managed-key")].map(element => element.textContent), metadata: [...document.querySelectorAll(".online-backup-managed-created")].map(element => element.textContent), described: [...document.querySelectorAll(".online-backup-managed-row")].every(row => [...row.querySelectorAll("button")].every(button => button.getAttribute("aria-describedby") === row.querySelector(".online-backup-managed-key").id)), secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()'); + check('Managed online backups render validity, hide expired entries and keep accessible actions', managedOnlineBackupLoaded === true && managedOnlineBackupInitialState.keys.join('|') === 'MHU2-ZYXW…9876|MHU2-ABCD…1234' && managedOnlineBackupInitialState.metadata[0].includes('Gültig bis') && managedOnlineBackupInitialState.metadata[1].includes('Unbegrenzt gültig') && managedOnlineBackupInitialState.described === true && managedOnlineBackupInitialState.secretInBody === false); + + const expiringUiEntry = { + id: 'BAQEBAQEBAQEBAQEBAQEBA', + displayKey: 'MHU2-TEMP…5555', + createdAt: new Date(Date.now() - 1_000).toISOString(), + expiresAt: new Date(Date.now() + 400).toISOString() + }; + managedOnlineBackupEntries = [...managedOnlineBackupEntries, expiringUiEntry]; + await wc.executeJavaScript('loadManagedOnlineBackups()'); + const expiringUiEntryAppeared = await waitUntil(() => wc.executeJavaScript('[...document.querySelectorAll(".online-backup-managed-row")].some(element => element.dataset.managedOnlineBackupId === "BAQEBAQEBAQEBAQEBAQEBA")')); + const expiringUiEntryDisappeared = await waitUntil(() => wc.executeJavaScript('![...document.querySelectorAll(".online-backup-managed-row")].some(element => element.dataset.managedOnlineBackupId === "BAQEBAQEBAQEBAQEBAQEBA")'), 2000); + managedOnlineBackupEntries = managedOnlineBackupEntries.filter(entry => entry.id !== expiringUiEntry.id); + check('A managed online key disappears automatically when its validity expires', expiringUiEntryAppeared === true && expiringUiEntryDisappeared === true); await wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-copy-btn").click()'); await waitUntil(() => managedOnlineBackupCopyIds.length === 1); @@ -1443,10 +1525,10 @@ setTimeout(async () => { const managedDeleteSuccessState = await wc.executeJavaScript('(() => ({ key: document.querySelector(".online-backup-managed-key")?.textContent, status: document.getElementById("onlineBackupStatus")?.textContent, focusAction: document.activeElement?.dataset.managedOnlineBackupAction, focusId: document.activeElement?.dataset.managedOnlineBackupId, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()'); check('Managed online backup row disappears only after successful deletion and focuses the neighboring action', managedDeletePendingState.count === 2 && managedDeletePendingState.controlsDisabled === true && managedDeleteSucceeded === true && managedDeleteSuccessState.key === 'MHU2-ABCD…1234' && managedDeleteSuccessState.status === 'Schlüssel gelöscht' && managedDeleteSuccessState.focusAction === 'delete' && managedDeleteSuccessState.focusId === 'AAAAAAAAAAAAAAAAAAAAAA' && managedDeleteSuccessState.secretInBody === false); - await wc.executeJavaScript('document.getElementById("createOnlineBackupBtn").click()'); + await wc.executeJavaScript('document.getElementById("onlineBackupRetentionSelect").value = "31d"; document.getElementById("createOnlineBackupBtn").click()'); const managedCreateSucceeded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 2')); const managedCreateState = await wc.executeJavaScript('(() => ({ first: document.querySelector(".online-backup-managed-key")?.textContent, status: document.getElementById("onlineBackupStatus")?.textContent, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()'); - check('Creating a managed online backup inserts the returned entry with the exact success text', managedOnlineBackupCreateCalls === 1 && managedCreateSucceeded === true && managedCreateState.first === 'MHU2-QWER…4321' && managedCreateState.status === 'Neuer Schlüssel erstellt.' && managedCreateState.secretInBody === false); + check('Creating a managed online backup passes the chosen validity and inserts the returned entry', managedOnlineBackupCreateCalls === 1 && managedOnlineBackupCreateRetentions.join('|') === '31d' && managedCreateSucceeded === true && managedCreateState.first === 'MHU2-QWER…4321' && managedCreateState.status === 'Neuer Schlüssel erstellt.' && managedCreateState.secretInBody === false); const invalidOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-short"; document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled + "|" + document.getElementById("onlineBackupStatus").textContent'); check('Invalid online backup keys stay blocked with visible guidance', invalidOnlineBackup === 'true|Der Schlüssel muss exakt 75 Zeichen lang sein.');