This commit is contained in:
+12
-6
@@ -7,7 +7,7 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große
|
|||||||
## Aktueller Zustand
|
## Aktueller Zustand
|
||||||
|
|
||||||
- Aktive Arbeitslinie: `master` aus `Sucukdeluxe/Multi-Hoster-Upload`.
|
- 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`.
|
- Einstiegspunkt des Electron-Hauptprozesses: `main.js`.
|
||||||
- Oberfläche: `renderer/`; gekapselte Fachlogik: `lib/`; Online-Backup-Dienst: `services/backup-api/`.
|
- Oberfläche: `renderer/`; gekapselte Fachlogik: `lib/`; Online-Backup-Dienst: `services/backup-api/`.
|
||||||
- Die Abhängigkeiten sind lokal mit Node.js 24 installiert.
|
- 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.
|
- 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-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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- Bei Bedarf einen Arbeitsweg ohne `&` im absoluten Pfad verwenden oder die npm-Aufrufe weiterhin direkt ausführen.
|
||||||
|
|
||||||
## Zuletzt verifiziert
|
## Zuletzt verifiziert
|
||||||
|
|
||||||
Stand: 31.08.2026
|
Stand: 01.09.2026
|
||||||
|
|
||||||
- Lint: erfolgreich, 0 Warnungen und 0 Fehler.
|
- Lint: erfolgreich, 0 Warnungen und 0 Fehler.
|
||||||
- Haupttests: 803 erfolgreich, 0 fehlgeschlagen.
|
- Haupttests: 805 erfolgreich, 0 fehlgeschlagen.
|
||||||
- Backup-API-Tests: 15 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.
|
- 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.
|
- 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.
|
- Der von Version `2.1.40` verwendete Forgejo-Endpoint liefert `2.1.41` als neuesten stabilen Release.
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const DEFAULTS = {
|
|||||||
},
|
},
|
||||||
globalSettings: {
|
globalSettings: {
|
||||||
language: 'en',
|
language: 'en',
|
||||||
|
autoHealthCheckEnabled: true,
|
||||||
alwaysOnTop: false,
|
alwaysOnTop: false,
|
||||||
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
|
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
|
||||||
logFilePath: '',
|
logFilePath: '',
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ const crypto = require('node:crypto');
|
|||||||
const secretStore = require('./secret-store');
|
const secretStore = require('./secret-store');
|
||||||
const { parseOnlineBackupKey } = require('./online-backup');
|
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_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({
|
const KEYRING_ERROR_CODES = Object.freeze({
|
||||||
structure: 'KEYRING_STRUCTURE_INVALID',
|
structure: 'KEYRING_STRUCTURE_INVALID',
|
||||||
unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
|
unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
|
||||||
@@ -78,7 +79,8 @@ function createOnlineBackupKeyring({
|
|||||||
decryptField = secretStore.decryptField,
|
decryptField = secretStore.decryptField,
|
||||||
isEncrypted = secretStore.isEncrypted,
|
isEncrypted = secretStore.isEncrypted,
|
||||||
parseKey = parseOnlineBackupKey,
|
parseKey = parseOnlineBackupKey,
|
||||||
fsImpl = fs.promises
|
fsImpl = fs.promises,
|
||||||
|
now = () => Date.now()
|
||||||
}) {
|
}) {
|
||||||
const directory = path.dirname(filePath);
|
const directory = path.dirname(filePath);
|
||||||
const basename = path.basename(filePath);
|
const basename = path.basename(filePath);
|
||||||
@@ -128,7 +130,7 @@ function createOnlineBackupKeyring({
|
|||||||
return { version: 1, generation: 0, keys: document.keys };
|
return { version: 1, generation: 0, keys: document.keys };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
hasExactKeys(document, STORED_V2_DOCUMENT_KEYS)
|
hasExactKeys(document, STORED_GENERATED_DOCUMENT_KEYS)
|
||||||
&& document.version === 2
|
&& document.version === 2
|
||||||
&& Number.isSafeInteger(document.generation)
|
&& Number.isSafeInteger(document.generation)
|
||||||
&& document.generation > 0
|
&& document.generation > 0
|
||||||
@@ -178,8 +180,10 @@ function createOnlineBackupKeyring({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateEntry(entry) {
|
function validateEntry(entry) {
|
||||||
|
const hasLegacyShape = hasExactKeys(entry, STORED_LEGACY_ENTRY_KEYS);
|
||||||
|
const hasExpiringShape = hasExactKeys(entry, STORED_EXPIRING_ENTRY_KEYS);
|
||||||
if (
|
if (
|
||||||
!hasExactKeys(entry, STORED_ENTRY_KEYS)
|
(!hasLegacyShape && !hasExpiringShape)
|
||||||
|| !isCanonicalId(entry.id)
|
|| !isCanonicalId(entry.id)
|
||||||
|| typeof entry.encryptedKey !== 'string'
|
|| typeof entry.encryptedKey !== 'string'
|
||||||
|| !isEncrypted(entry.encryptedKey)
|
|| !isEncrypted(entry.encryptedKey)
|
||||||
@@ -194,6 +198,16 @@ function createOnlineBackupKeyring({
|
|||||||
return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
|
return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
|
||||||
}
|
}
|
||||||
if (createdAt !== entry.createdAt) 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;
|
let key;
|
||||||
try {
|
try {
|
||||||
key = decryptField(entry.encryptedKey);
|
key = decryptField(entry.encryptedKey);
|
||||||
@@ -213,6 +227,7 @@ function createOnlineBackupKeyring({
|
|||||||
id: entry.id,
|
id: entry.id,
|
||||||
encryptedKey: entry.encryptedKey,
|
encryptedKey: entry.encryptedKey,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
expiresAt,
|
||||||
key
|
key
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -300,22 +315,22 @@ function createOnlineBackupKeyring({
|
|||||||
recovered: false
|
recovered: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const v2States = states.filter(state => state.version === 2);
|
const generatedStates = states.filter(state => state.version === 2);
|
||||||
if (v2States.length > 0) {
|
if (generatedStates.length > 0) {
|
||||||
const highestObservedGeneration = Math.max(...v2States.map(state => state.generation));
|
const highestObservedGeneration = Math.max(...generatedStates.map(state => state.generation));
|
||||||
selectGeneration(v2States, highestObservedGeneration);
|
selectGeneration(generatedStates, highestObservedGeneration);
|
||||||
const validV2States = v2States.filter(state => !firstBlockingIssue(state));
|
const validGeneratedStates = generatedStates.filter(state => !firstBlockingIssue(state));
|
||||||
if (validV2States.length > 0) {
|
if (validGeneratedStates.length > 0) {
|
||||||
const highestValidGeneration = Math.max(...validV2States.map(state => state.generation));
|
const highestValidGeneration = Math.max(...validGeneratedStates.map(state => state.generation));
|
||||||
return selectGeneration(validV2States, highestValidGeneration);
|
return selectGeneration(validGeneratedStates, highestValidGeneration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const legacyStates = states.filter(state => state.version === 1);
|
const legacyStates = states.filter(state => state.version === 1);
|
||||||
const validLegacyStates = legacyStates.filter(state => !firstBlockingIssue(state));
|
const validLegacyStates = legacyStates.filter(state => !firstBlockingIssue(state));
|
||||||
if (validLegacyStates.length > 0) return selectLegacyState(validLegacyStates);
|
if (validLegacyStates.length > 0) return selectLegacyState(validLegacyStates);
|
||||||
if (v2States.length > 0) {
|
if (generatedStates.length > 0) {
|
||||||
const highestObservedGeneration = Math.max(...v2States.map(state => state.generation));
|
const highestObservedGeneration = Math.max(...generatedStates.map(state => state.generation));
|
||||||
return selectGeneration(v2States, highestObservedGeneration);
|
return selectGeneration(generatedStates, highestObservedGeneration);
|
||||||
}
|
}
|
||||||
return selectLegacyState(legacyStates);
|
return selectLegacyState(legacyStates);
|
||||||
}
|
}
|
||||||
@@ -398,7 +413,7 @@ function createOnlineBackupKeyring({
|
|||||||
const contents = JSON.stringify({
|
const contents = JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
generation,
|
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 payload = canonicalPayload(parseDocument(contents));
|
||||||
const stagingPath = temporaryPath('staging');
|
const stagingPath = temporaryPath('staging');
|
||||||
@@ -427,22 +442,33 @@ function createOnlineBackupKeyring({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function list() {
|
function isExpired(entry) {
|
||||||
|
return entry.expiresAt !== null && new Date(entry.expiresAt).getTime() <= Number(now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
return serialize(async () => {
|
||||||
const state = await readState();
|
const state = await readState();
|
||||||
const entries = state.entries
|
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))
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||||
.map(({ id, key, createdAt }) => Object.freeze({
|
.map(({ id, key, createdAt, expiresAt }) => Object.freeze({
|
||||||
id,
|
id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
||||||
createdAt
|
createdAt,
|
||||||
|
expiresAt
|
||||||
}));
|
}));
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
entries: Object.freeze(entries),
|
entries: Object.freeze(entries),
|
||||||
issues: Object.freeze([...state.issues])
|
issues: Object.freeze([...state.issues])
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepare(key, createdAt) {
|
function prepare(key, createdAt, expiresAt = null) {
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = parseKey(key);
|
parsed = parseKey(key);
|
||||||
@@ -458,10 +484,16 @@ function createOnlineBackupKeyring({
|
|||||||
if (typeof encryptedKey !== 'string' || encryptedKey === key || !isEncrypted(encryptedKey)) {
|
if (typeof encryptedKey !== 'string' || encryptedKey === key || !isEncrypted(encryptedKey)) {
|
||||||
throw issueError(KEYRING_ERROR_CODES.encrypt);
|
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({
|
return Object.freeze({
|
||||||
id: parsed.id,
|
id: parsed.id,
|
||||||
encryptedKey,
|
encryptedKey,
|
||||||
createdAt: normalizeTimestamp(createdAt)
|
createdAt: normalizedCreatedAt,
|
||||||
|
expiresAt: normalizedExpiresAt
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +514,8 @@ function createOnlineBackupKeyring({
|
|||||||
const state = await readState();
|
const state = await readState();
|
||||||
if (state.duplicateIds.has(id)) throw issueError(KEYRING_ERROR_CODES.duplicate);
|
if (state.duplicateIds.has(id)) throw issueError(KEYRING_ERROR_CODES.duplicate);
|
||||||
const entry = state.entries.find(current => current.id === id);
|
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);
|
const matchingProblem = state.problems.find(problem => problem.id === id);
|
||||||
if (matchingProblem) throw issueError(matchingProblem.code);
|
if (matchingProblem) throw issueError(matchingProblem.code);
|
||||||
const blockingIssue = firstBlockingIssue(state);
|
const blockingIssue = firstBlockingIssue(state);
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ function sanitizeEntry(entry) {
|
|||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
displayKey: entry.displayKey,
|
displayKey: entry.displayKey,
|
||||||
createdAt: entry.createdAt
|
createdAt: entry.createdAt,
|
||||||
|
expiresAt: entry.expiresAt ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +36,8 @@ function sanitizeCreatedEntry(entry, key) {
|
|||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
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 createdAt = new Date().toISOString();
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
const created = createBackup(settings, appVersion(), createdAt);
|
const created = createBackup(settings, appVersion(), createdAt, retention);
|
||||||
const prepared = keyring.prepare(created.key, createdAt);
|
const prepared = keyring.prepare(created.key, createdAt, created.expiresAt ?? null);
|
||||||
await uploadBackup(created.record);
|
await uploadBackup(created.record);
|
||||||
try {
|
try {
|
||||||
await keyring.commit(prepared);
|
await keyring.commit(prepared);
|
||||||
@@ -117,9 +119,9 @@ function createOnlineBackupManager({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createManaged() {
|
async function createManaged(retention = '7d') {
|
||||||
try {
|
try {
|
||||||
return await serialize(createTransaction);
|
return await serialize(() => createTransaction(retention));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return keyringFailure(error, ERRORS.create);
|
return keyringFailure(error, ERRORS.create);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-3
@@ -16,6 +16,14 @@ const MAX_PLAINTEXT_BYTES = 512 * 1024;
|
|||||||
const REQUEST_TIMEOUT_MS = 12_000;
|
const REQUEST_TIMEOUT_MS = 12_000;
|
||||||
const KEY_CONTEXT = Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8');
|
const KEY_CONTEXT = Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8');
|
||||||
const AAD_CONTEXT = Buffer.from('MHU-ONLINE-BACKUP-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) {
|
function checksum(idBytes, masterKey) {
|
||||||
return crypto.createHash('sha256').update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH);
|
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 idBytes = crypto.randomBytes(RECORD_ID_LENGTH);
|
||||||
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
|
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
|
||||||
const key = encodeKey(idBytes, masterKey);
|
const key = encodeKey(idBytes, masterKey);
|
||||||
@@ -163,8 +191,10 @@ function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOS
|
|||||||
record: {
|
record: {
|
||||||
id: parsed.id,
|
id: parsed.id,
|
||||||
blob: blobBytes.toString('base64url'),
|
blob: blobBytes.toString('base64url'),
|
||||||
deleteVerifier
|
deleteVerifier,
|
||||||
}
|
expiresInSeconds
|
||||||
|
},
|
||||||
|
expiresAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,10 +271,14 @@ async function deleteOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options)
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
DEFAULT_ONLINE_BACKUP_RETENTION,
|
||||||
ONLINE_BACKUP_API_URL,
|
ONLINE_BACKUP_API_URL,
|
||||||
|
ONLINE_BACKUP_RETENTION_SECONDS,
|
||||||
createOnlineBackup,
|
createOnlineBackup,
|
||||||
deleteOnlineBackup,
|
deleteOnlineBackup,
|
||||||
downloadOnlineBackup,
|
downloadOnlineBackup,
|
||||||
|
normalizeOnlineBackupRetention,
|
||||||
|
onlineBackupExpiration,
|
||||||
parseOnlineBackupKey,
|
parseOnlineBackupKey,
|
||||||
restoreOnlineBackup,
|
restoreOnlineBackup,
|
||||||
uploadOnlineBackup
|
uploadOnlineBackup
|
||||||
|
|||||||
+10
-2
@@ -33,6 +33,13 @@ function createPortableSettingsSnapshot(config) {
|
|||||||
history: []
|
history: []
|
||||||
};
|
};
|
||||||
snapshot.globalSettings.pendingQueue = null;
|
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;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,14 +48,15 @@ function prepareImportedSettings(value, options = {}) {
|
|||||||
const imported = createPortableSettingsSnapshot(value);
|
const imported = createPortableSettingsSnapshot(value);
|
||||||
const pathExists = options.pathExists || fs.existsSync;
|
const pathExists = options.pathExists || fs.existsSync;
|
||||||
const pathDirname = options.pathDirname || path.dirname;
|
const pathDirname = options.pathDirname || path.dirname;
|
||||||
|
const warnings = Array.isArray(options.warnings) ? options.warnings : null;
|
||||||
const globalSettings = imported.globalSettings;
|
const globalSettings = imported.globalSettings;
|
||||||
if (globalSettings.logFilePath && !pathExists(pathDirname(globalSettings.logFilePath))) {
|
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 && typeof globalSettings.folderMonitor === 'object') {
|
||||||
if (globalSettings.folderMonitor.folderPath && !pathExists(globalSettings.folderMonitor.folderPath)) {
|
if (globalSettings.folderMonitor.folderPath && !pathExists(globalSettings.folderMonitor.folderPath)) {
|
||||||
globalSettings.folderMonitor.folderPath = '';
|
|
||||||
globalSettings.folderMonitor.enabled = false;
|
globalSettings.folderMonitor.enabled = false;
|
||||||
|
warnings?.push('Ordnerüberwachung (Ordner nicht gefunden und deaktiviert)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return imported;
|
return imported;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const { createAccountCooldownController, createAccountPicker } = require('./lib/
|
|||||||
const ClouddropUploader = require('./lib/clouddrop-upload');
|
const ClouddropUploader = require('./lib/clouddrop-upload');
|
||||||
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater');
|
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater');
|
||||||
const backupCrypto = require('./lib/backup-crypto');
|
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 { createOnlineBackupKeyring } = require('./lib/online-backup-keyring');
|
||||||
const { createOnlineBackupManager } = require('./lib/online-backup-manager');
|
const { createOnlineBackupManager } = require('./lib/online-backup-manager');
|
||||||
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
||||||
@@ -1461,7 +1461,7 @@ async function checkClouddropHealth(hosterConfig) {
|
|||||||
// requestedChecks can be:
|
// requestedChecks can be:
|
||||||
// - array of strings (hoster names) for legacy/all-accounts check
|
// - array of strings (hoster names) for legacy/all-accounts check
|
||||||
// - array of { hoster, accountId } for specific account checks
|
// - 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'];
|
const allowed = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx', 'clouddrop.cc'];
|
||||||
|
|
||||||
// Normalize input to [{ hoster, accountId? }]
|
// 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 groupResults = await Promise.all(Array.from(groups.values()).map(async (group) => {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const c of group) {
|
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;
|
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 config = configStore.load();
|
||||||
const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : [];
|
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.
|
// Validate ephemeral credentials WITHOUT persisting them to config.hosters.
|
||||||
@@ -2974,7 +2989,8 @@ async function applyImportedSettings(imported) {
|
|||||||
settingsImportGate.begin();
|
settingsImportGate.begin();
|
||||||
try {
|
try {
|
||||||
await waitForConfigStoreWrites();
|
await waitForConfigStoreWrites();
|
||||||
const prepared = prepareImportedSettings(imported);
|
const preparationWarnings = [];
|
||||||
|
const prepared = prepareImportedSettings(imported, { warnings: preparationWarnings });
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`);
|
const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`);
|
||||||
try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {}
|
try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {}
|
||||||
@@ -2985,7 +3001,7 @@ async function applyImportedSettings(imported) {
|
|||||||
const config = configStore.load();
|
const config = configStore.load();
|
||||||
_invalidateLogSettings(config.globalSettings);
|
_invalidateLogSettings(config.globalSettings);
|
||||||
const warnings = await syncImportedRuntime(config);
|
const warnings = await syncImportedRuntime(config);
|
||||||
return { config, warnings };
|
return { config, warnings: [...new Set([...preparationWarnings, ...warnings])] };
|
||||||
} finally {
|
} finally {
|
||||||
settingsImportGate.end();
|
settingsImportGate.end();
|
||||||
}
|
}
|
||||||
@@ -3079,8 +3095,14 @@ ipcMain.handle('online-backup:list-managed', (event) => (
|
|||||||
invokeTrustedOnlineBackupIpc(event, () => onlineBackupManager.listManaged())
|
invokeTrustedOnlineBackupIpc(event, () => onlineBackupManager.listManaged())
|
||||||
));
|
));
|
||||||
|
|
||||||
ipcMain.handle('online-backup:create-managed', (event) => (
|
ipcMain.handle('online-backup:create-managed', (event, retention) => (
|
||||||
invokeTrustedOnlineBackupIpc(event, () => onlineBackupManager.createManaged())
|
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) => (
|
ipcMain.handle('online-backup:copy-managed', (event, id) => (
|
||||||
|
|||||||
+6
-1
@@ -70,6 +70,11 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
completeUploadFinalization: (payload) => ipcRenderer.invoke('complete-upload-finalization', payload),
|
completeUploadFinalization: (payload) => ipcRenderer.invoke('complete-upload-finalization', payload),
|
||||||
finishAfterActive: () => ipcRenderer.invoke('finish-after-active'),
|
finishAfterActive: () => ipcRenderer.invoke('finish-after-active'),
|
||||||
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
|
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),
|
validateCredentials: (payload) => ipcRenderer.invoke('validate-credentials', payload),
|
||||||
|
|
||||||
// Log import
|
// Log import
|
||||||
@@ -97,7 +102,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
exportBackup: (options) => ipcRenderer.invoke('export-backup', options),
|
exportBackup: (options) => ipcRenderer.invoke('export-backup', options),
|
||||||
importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword),
|
importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword),
|
||||||
listManagedOnlineBackups: () => ipcRenderer.invoke('online-backup:list-managed'),
|
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),
|
copyManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:copy-managed', id),
|
||||||
deleteManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:delete-managed', id),
|
deleteManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:delete-managed', id),
|
||||||
restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key),
|
restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key),
|
||||||
|
|||||||
+94
-20
@@ -65,6 +65,7 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} };
|
|||||||
let hosterSettings = {};
|
let hosterSettings = {};
|
||||||
let uploading = false;
|
let uploading = false;
|
||||||
let healthCheckRunning = false;
|
let healthCheckRunning = false;
|
||||||
|
let healthCheckRequestSequence = 0;
|
||||||
let automationRuntimeStatus = Object.freeze({});
|
let automationRuntimeStatus = Object.freeze({});
|
||||||
let automationRuntimeStatusAvailable = false;
|
let automationRuntimeStatusAvailable = false;
|
||||||
let automationPauseResumeBusy = false;
|
let automationPauseResumeBusy = false;
|
||||||
@@ -87,6 +88,7 @@ let managedOnlineBackupAuthoritativeLoadGeneration = 0;
|
|||||||
let managedOnlineBackupActiveMutations = 0;
|
let managedOnlineBackupActiveMutations = 0;
|
||||||
let onlineBackupStatusContextGeneration = 0;
|
let onlineBackupStatusContextGeneration = 0;
|
||||||
let managedOnlineBackupRefreshIssue = null;
|
let managedOnlineBackupRefreshIssue = null;
|
||||||
|
let managedOnlineBackupExpiryTimer = null;
|
||||||
const managedOnlineBackupOperationQueues = new Map();
|
const managedOnlineBackupOperationQueues = new Map();
|
||||||
|
|
||||||
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
|
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);
|
setUiLanguage(config.globalSettings?.language);
|
||||||
hosterSettings = config.hosterSettings || {};
|
hosterSettings = config.hosterSettings || {};
|
||||||
autoHealthCheckEnabled = loadAutoCheckPreference();
|
autoHealthCheckEnabled = loadAutoCheckPreference();
|
||||||
|
if (config.globalSettings?.autoHealthCheckEnabled !== autoHealthCheckEnabled) {
|
||||||
|
config.globalSettings = { ...(config.globalSettings || {}), autoHealthCheckEnabled };
|
||||||
|
saveGlobalSettingsTracked(config.globalSettings).catch(() => {});
|
||||||
|
}
|
||||||
ensureAccountStatusEntries();
|
ensureAccountStatusEntries();
|
||||||
syncSelectedUploadHosters();
|
syncSelectedUploadHosters();
|
||||||
restoreQueueStateFromConfig();
|
restoreQueueStateFromConfig();
|
||||||
@@ -3599,7 +3605,13 @@ function applyImportedConfig(importedConfig, message) {
|
|||||||
accountStatuses = {};
|
accountStatuses = {};
|
||||||
ensureAccountStatusEntries();
|
ensureAccountStatusEntries();
|
||||||
syncSelectedUploadHosters();
|
syncSelectedUploadHosters();
|
||||||
|
autoHealthCheckEnabled = config.globalSettings?.autoHealthCheckEnabled !== false;
|
||||||
|
try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {}
|
||||||
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
|
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();
|
renderSettings();
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
renderHosterSummary();
|
renderHosterSummary();
|
||||||
@@ -3678,12 +3690,20 @@ function normalizeManagedOnlineBackups(entries) {
|
|||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (const entry of Array.isArray(entries) ? entries : []) {
|
for (const entry of Array.isArray(entries) ? entries : []) {
|
||||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
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 (!isCanonicalManagedOnlineBackupId(entry.id)) continue;
|
||||||
if (typeof entry.displayKey !== 'string' || !/^MHU2-[A-Za-z0-9_-]{4}…[A-Za-z0-9_-]{4}$/.test(entry.displayKey)) 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);
|
const createdAt = new Date(entry.createdAt);
|
||||||
if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== entry.createdAt) continue;
|
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();
|
const counts = new Map();
|
||||||
for (const entry of candidates) counts.set(entry.id, (counts.get(entry.id) || 0) + 1);
|
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) {
|
function renderManagedOnlineBackups(focusTarget = undefined) {
|
||||||
const list = document.getElementById('managedOnlineBackupList');
|
const list = document.getElementById('managedOnlineBackupList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
clearTimeout(managedOnlineBackupExpiryTimer);
|
||||||
|
managedOnlineBackupExpiryTimer = null;
|
||||||
const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget;
|
const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget;
|
||||||
const content = document.createDocumentFragment();
|
const content = document.createDocumentFragment();
|
||||||
if (!managedOnlineBackupsAuthoritative) {
|
if (!managedOnlineBackupsAuthoritative) {
|
||||||
@@ -3761,7 +3783,13 @@ function renderManagedOnlineBackups(focusTarget = undefined) {
|
|||||||
key.textContent = entry.displayKey;
|
key.textContent = entry.displayKey;
|
||||||
const created = document.createElement('span');
|
const created = document.createElement('span');
|
||||||
created.className = 'online-backup-managed-created';
|
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');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'online-backup-managed-actions';
|
actions.className = 'online-backup-managed-actions';
|
||||||
const copyButton = document.createElement('button');
|
const copyButton = document.createElement('button');
|
||||||
@@ -3790,6 +3818,17 @@ function renderManagedOnlineBackups(focusTarget = undefined) {
|
|||||||
}
|
}
|
||||||
list.replaceChildren(content);
|
list.replaceChildren(content);
|
||||||
restoreManagedOnlineBackupFocus(target);
|
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() {
|
function renderManagedOnlineBackupRefreshIssue() {
|
||||||
@@ -3948,10 +3987,13 @@ async function doOnlineBackupCreate() {
|
|||||||
}
|
}
|
||||||
const authority = beginManagedOnlineBackupMutation();
|
const authority = beginManagedOnlineBackupMutation();
|
||||||
const createButton = document.getElementById('createOnlineBackupBtn');
|
const createButton = document.getElementById('createOnlineBackupBtn');
|
||||||
|
const retentionSelect = document.getElementById('onlineBackupRetentionSelect');
|
||||||
|
const retention = retentionSelect?.value || '7d';
|
||||||
if (createButton) createButton.disabled = true;
|
if (createButton) createButton.disabled = true;
|
||||||
|
if (retentionSelect) retentionSelect.disabled = true;
|
||||||
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', authority.statusContext);
|
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', authority.statusContext);
|
||||||
try {
|
try {
|
||||||
const result = await window.api.createManagedOnlineBackup();
|
const result = await window.api.createManagedOnlineBackup(retention);
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext);
|
setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext);
|
||||||
return;
|
return;
|
||||||
@@ -3964,6 +4006,7 @@ async function doOnlineBackupCreate() {
|
|||||||
} finally {
|
} finally {
|
||||||
endManagedOnlineBackupMutation();
|
endManagedOnlineBackupMutation();
|
||||||
if (createButton?.isConnected) createButton.disabled = false;
|
if (createButton?.isConnected) createButton.disabled = false;
|
||||||
|
if (retentionSelect?.isConnected) retentionSelect.disabled = false;
|
||||||
doOnlineBackupCreate.busy = false;
|
doOnlineBackupCreate.busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6013,33 +6056,46 @@ function showAppChoice({ message, title, confirmText, alternateText, cancelText
|
|||||||
|
|
||||||
async function executeHealthCheck(hosters, _mode, generations) {
|
async function executeHealthCheck(hosters, _mode, generations) {
|
||||||
renderHealthCheckResults([]);
|
renderHealthCheckResults([]);
|
||||||
const result = await window.api.runHealthCheck({ hosters });
|
const requestId = `hc-${Date.now()}-${++healthCheckRequestSequence}`;
|
||||||
const rows = result && Array.isArray(result.results) ? result.results : [];
|
const rowsByKey = new Map();
|
||||||
const checkedAt = result?.checkedAt || new Date().toISOString();
|
const applyResult = (row, checkedAt) => {
|
||||||
const currentRows = rows.filter((row) => {
|
|
||||||
if (!row) return false;
|
if (!row) return false;
|
||||||
const key = row.accountId || row.hoster;
|
const key = row.accountId || row.hoster;
|
||||||
const generation = generations?.get(key);
|
const generation = generations?.get(key);
|
||||||
return generation === undefined || _isCurrentAccountStatusGeneration(key, generation);
|
if (!key || (generation !== undefined && !_isCurrentAccountStatusGeneration(key, generation))) return false;
|
||||||
});
|
rowsByKey.set(key, row);
|
||||||
const completedKeys = new Set();
|
|
||||||
currentRows.forEach((row) => {
|
|
||||||
const key = row.accountId || row.hoster;
|
|
||||||
if (key) {
|
|
||||||
completedKeys.add(key);
|
|
||||||
accountStatuses[key] = {
|
accountStatuses[key] = {
|
||||||
status: row.status || 'unchecked',
|
status: row.status || 'unchecked',
|
||||||
message: row.message || '',
|
message: row.message || '',
|
||||||
checkedAt: row.checkedAt || checkedAt
|
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 || []) {
|
for (const [key, generation] of generations || []) {
|
||||||
if (completedKeys.has(key) || !_isCurrentAccountStatusGeneration(key, generation)) continue;
|
if (rowsByKey.has(key) || !_isCurrentAccountStatusGeneration(key, generation)) continue;
|
||||||
accountStatuses[key] = { status: 'error', message: 'Keine Antwort vom Hoster erhalten', checkedAt };
|
accountStatuses[key] = { status: 'error', message: 'Keine Antwort vom Hoster erhalten', checkedAt };
|
||||||
|
updateAccountCard(key);
|
||||||
}
|
}
|
||||||
|
const currentRows = [...rowsByKey.values()];
|
||||||
renderHealthCheckResults(currentRows);
|
renderHealthCheckResults(currentRows);
|
||||||
renderAccounts();
|
|
||||||
renderHosterModal();
|
renderHosterModal();
|
||||||
return currentRows;
|
return currentRows;
|
||||||
}
|
}
|
||||||
@@ -6553,6 +6609,18 @@ function renderSettings() {
|
|||||||
</section>
|
</section>
|
||||||
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
|
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
|
||||||
<footer class="online-backup-footer" data-settings-search-entry data-settings-search-section="Online-Backup" data-settings-search-label="Neuen Schlüssel erzeugen">
|
<footer class="online-backup-footer" data-settings-search-entry data-settings-search-section="Online-Backup" data-settings-search-label="Neuen Schlüssel erzeugen">
|
||||||
|
<div class="online-backup-retention-field">
|
||||||
|
<label for="onlineBackupRetentionSelect">Gültigkeitsdauer</label>
|
||||||
|
<span class="online-backup-retention-select">
|
||||||
|
<select id="onlineBackupRetentionSelect">
|
||||||
|
<option value="1d">24 Stunden</option>
|
||||||
|
<option value="3d">3 Tage</option>
|
||||||
|
<option value="7d" selected>7 Tage (Standard)</option>
|
||||||
|
<option value="31d">31 Tage</option>
|
||||||
|
<option value="forever">Unbegrenzt</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button class="btn btn-primary" id="createOnlineBackupBtn">Neuen Schlüssel erzeugen</button>
|
<button class="btn btn-primary" id="createOnlineBackupBtn">Neuen Schlüssel erzeugen</button>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
@@ -9283,6 +9351,8 @@ function setupListeners() {
|
|||||||
autoToggle.addEventListener('change', (e) => {
|
autoToggle.addEventListener('change', (e) => {
|
||||||
autoHealthCheckEnabled = !!e.target.checked;
|
autoHealthCheckEnabled = !!e.target.checked;
|
||||||
try { localStorage.setItem(AUTO_CHECK_PREF_KEY, autoHealthCheckEnabled ? '1' : '0'); } catch {}
|
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() {
|
function loadAutoCheckPreference() {
|
||||||
try { const r = localStorage.getItem(AUTO_CHECK_PREF_KEY); return r === null || r === '1'; }
|
try {
|
||||||
catch { return true; }
|
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) ---
|
// --- Queue table column resizing (JDownloader-style) ---
|
||||||
|
|||||||
@@ -378,6 +378,14 @@
|
|||||||
['Verschlüsseltes Online-Backup', 'Encrypted online backup'],
|
['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.'],
|
['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'],
|
['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'],
|
['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.'],
|
['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'],
|
||||||
['Schlüssel kopieren', 'Copy key'],
|
['Schlüssel kopieren', 'Copy key'],
|
||||||
@@ -541,6 +549,8 @@
|
|||||||
['Nicht alle Einstellungen konnten gespeichert werden', 'Not all settings could be saved'],
|
['Nicht alle Einstellungen konnten gespeichert werden', 'Not all settings could be saved'],
|
||||||
['Online-Schlüssel kopiert', 'Online key copied'],
|
['Online-Schlüssel kopiert', 'Online key copied'],
|
||||||
['Online-Sicherung konnte nicht erstellt werden', 'Online backup could not be created'],
|
['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-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 kopiert werden', 'Online backup could not be copied'],
|
||||||
['Online-Sicherung konnte nicht importiert werden', 'Online backup could not be imported'],
|
['Online-Sicherung konnte nicht importiert werden', 'Online backup could not be imported'],
|
||||||
|
|||||||
+66
-1
@@ -2173,7 +2173,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
|
|
||||||
.online-backup-managed-row {
|
.online-backup-managed-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 168px 188px minmax(0, 1fr);
|
grid-template-columns: 168px 238px minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -2192,6 +2192,8 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.online-backup-managed-created {
|
.online-backup-managed-created {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -2240,10 +2242,64 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
|
|
||||||
.online-backup-footer {
|
.online-backup-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
min-width: 0;
|
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 {
|
.online-backup-status {
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
@@ -2273,6 +2329,15 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
.online-backup-key-row {
|
.online-backup-key-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.online-backup-footer {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-retention-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-sprite {
|
.icon-sprite {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const idPattern = /^[A-Za-z0-9_-]{22}$/
|
|||||||
const verifierPattern = /^[A-Za-z0-9_-]{43}$/
|
const verifierPattern = /^[A-Za-z0-9_-]{43}$/
|
||||||
const blobPattern = /^[A-Za-z0-9_-]+$/
|
const blobPattern = /^[A-Za-z0-9_-]+$/
|
||||||
const notFoundBody = '{"error":"not_found"}'
|
const notFoundBody = '{"error":"not_found"}'
|
||||||
|
const allowedRetentionSeconds = new Set([86_400, 259_200, 604_800, 2_678_400])
|
||||||
|
|
||||||
function isCanonicalBase64Url(value, byteLength, pattern) {
|
function isCanonicalBase64Url(value, byteLength, pattern) {
|
||||||
if (typeof value !== 'string' || !pattern.test(value)) return false
|
if (typeof value !== 'string' || !pattern.test(value)) return false
|
||||||
@@ -21,7 +22,9 @@ function isCanonicalBase64Url(value, byteLength, pattern) {
|
|||||||
function isValidBackup(payload) {
|
function isValidBackup(payload) {
|
||||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
||||||
const keys = Object.keys(payload).sort()
|
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.id, 16, idPattern)) return false
|
||||||
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
|
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
|
||||||
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
|
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
|
||||||
@@ -164,6 +167,36 @@ async function directoryUsage(rootDir) {
|
|||||||
return { bytes, records }
|
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) {
|
async function syncDirectory(rootDir) {
|
||||||
let handle
|
let handle
|
||||||
try {
|
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 mkdir(rootDir, { recursive: true })
|
||||||
await cleanupTemporaryFiles(rootDir)
|
await cleanupTemporaryFiles(rootDir)
|
||||||
|
await cleanupExpiredRecords(rootDir, nowMs)
|
||||||
if (await recordExists(rootDir, payload.id)) return 'duplicate'
|
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({
|
const contents = Buffer.from(JSON.stringify({
|
||||||
version: 1,
|
version: 2,
|
||||||
blob: payload.blob,
|
blob: payload.blob,
|
||||||
deleteVerifier: payload.deleteVerifier,
|
deleteVerifier: payload.deleteVerifier,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date(nowMs).toISOString(),
|
||||||
|
expiresAt
|
||||||
}), 'utf8')
|
}), 'utf8')
|
||||||
const usage = await directoryUsage(rootDir)
|
const usage = await directoryUsage(rootDir)
|
||||||
if (usage.bytes + contents.length > maxStorageBytes || usage.records >= maxRecords) return 'full'
|
if (usage.bytes + contents.length > maxStorageBytes || usage.records >= maxRecords) return 'full'
|
||||||
@@ -282,10 +321,26 @@ async function readRecord(rootDir, id) {
|
|||||||
try {
|
try {
|
||||||
const raw = await readFile(recordPath(rootDir, id), 'utf8')
|
const raw = await readFile(recordPath(rootDir, id), 'utf8')
|
||||||
const record = JSON.parse(raw)
|
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')
|
throw new Error('Invalid stored record')
|
||||||
}
|
}
|
||||||
return record
|
return legacy ? { ...record, expiresAt: null } : record
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ENOENT') return null
|
if (error.code === 'ENOENT') return null
|
||||||
throw error
|
throw error
|
||||||
@@ -383,6 +438,7 @@ export function createBackupServer(options) {
|
|||||||
const maxConcurrentPerClient = options.maxConcurrentPerClient ?? 8
|
const maxConcurrentPerClient = options.maxConcurrentPerClient ?? 8
|
||||||
const maxConcurrentTotal = options.maxConcurrentTotal ?? 64
|
const maxConcurrentTotal = options.maxConcurrentTotal ?? 64
|
||||||
const trustedProxyAddresses = new Set(options.trustedProxyAddresses ?? [])
|
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) {
|
if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) {
|
||||||
throw new Error('Invalid rate limit')
|
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(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(maxConcurrentPerClient) || maxConcurrentPerClient < 1) throw new Error('Invalid per-client concurrency')
|
||||||
if (!Number.isSafeInteger(maxConcurrentTotal) || maxConcurrentTotal < maxConcurrentPerClient) throw new Error('Invalid total 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 consumeRateLimit = createRateLimiter(rateLimit)
|
||||||
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
|
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
|
||||||
const consumeRequestRateLimit = createRateLimiter(requestRateLimit)
|
const consumeRequestRateLimit = createRateLimiter(requestRateLimit)
|
||||||
@@ -471,7 +528,18 @@ export function createBackupServer(options) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const record = await readRecord(options.rootDir, parsed.value.id)
|
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)
|
sendNotFound(response)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -522,7 +590,7 @@ export function createBackupServer(options) {
|
|||||||
}
|
}
|
||||||
const result = await runStorageMutation(() => withStorageLock(
|
const result = await runStorageMutation(() => withStorageLock(
|
||||||
options.rootDir,
|
options.rootDir,
|
||||||
() => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords)
|
() => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords, Number(now()))
|
||||||
))
|
))
|
||||||
if (result === 'duplicate') {
|
if (result === 'duplicate') {
|
||||||
sendJson(response, 409, { error: 'already_exists' })
|
sendJson(response, 409, { error: 'already_exists' })
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ test('validates payload shape, content type and decoded blob size', async (t) =>
|
|||||||
{ ...valid.payload, blob: 'not+base64url' },
|
{ ...valid.payload, blob: 'not+base64url' },
|
||||||
{ ...valid.payload, deleteVerifier: 'short' },
|
{ ...valid.payload, deleteVerifier: 'short' },
|
||||||
{ id: valid.payload.id, blob: valid.payload.blob },
|
{ 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) {
|
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)
|
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) => {
|
test('deletes only with the matching client secret and returns constant not-found responses', async (t) => {
|
||||||
const api = await startApi()
|
const api = await startApi()
|
||||||
t.after(() => api.close())
|
t.after(() => api.close())
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ function fixture(options = {}) {
|
|||||||
encryptField: options.encryptField || encrypt,
|
encryptField: options.encryptField || encrypt,
|
||||||
decryptField: options.decryptField || decrypt,
|
decryptField: options.decryptField || decrypt,
|
||||||
isEncrypted: options.isEncrypted || isCanonicalEnvelope,
|
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.version, 2);
|
||||||
assert.equal(document.generation, 1);
|
assert.equal(document.generation, 1);
|
||||||
assert.equal(document.keys.length, 1);
|
assert.equal(document.keys.length, 1);
|
||||||
|
assert.equal(document.keys[0].expiresAt, null);
|
||||||
assert.equal(fs.readFileSync(filePath, 'utf8').includes(key), false);
|
assert.equal(fs.readFileSync(filePath, 'utf8').includes(key), false);
|
||||||
assert.deepEqual(snapshot.issues, []);
|
assert.deepEqual(snapshot.issues, []);
|
||||||
assert.deepEqual(snapshot.entries, [{
|
assert.deepEqual(snapshot.entries, [{
|
||||||
id: parseOnlineBackupKey(key).id,
|
id: parseOnlineBackupKey(key).id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
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), true);
|
||||||
assert.equal(Object.isFrozen(snapshot.entries), 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);
|
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 () => {
|
it('keeps a valid v1 primary authoritative over an older v1 backup and migrates to v2', async () => {
|
||||||
const { filePath, backupPath, keyring } = fixture();
|
const { filePath, backupPath, keyring } = fixture();
|
||||||
const backupKey = validKey();
|
const backupKey = validKey();
|
||||||
@@ -342,7 +364,8 @@ describe('encrypted online backup keyring', () => {
|
|||||||
assert.deepEqual((await keyring.list()).entries, [{
|
assert.deepEqual((await keyring.list()).entries, [{
|
||||||
id: parseOnlineBackupKey(key).id,
|
id: parseOnlineBackupKey(key).id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
||||||
createdAt: timestamp
|
createdAt: timestamp,
|
||||||
|
expiresAt: null
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ function createFixture(overrides = {}) {
|
|||||||
entries.set(id, {
|
entries.set(id, {
|
||||||
id,
|
id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
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 createdAt;
|
||||||
|
let expiresAt;
|
||||||
let createArguments;
|
let createArguments;
|
||||||
const removalPlans = new WeakMap();
|
const removalPlans = new WeakMap();
|
||||||
const keyring = {
|
const keyring = {
|
||||||
@@ -43,11 +45,12 @@ function createFixture(overrides = {}) {
|
|||||||
issues: overrides.listIssues || []
|
issues: overrides.listIssues || []
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
prepare: (value, timestamp) => {
|
prepare: (value, timestamp, expiration) => {
|
||||||
events.push('prepare');
|
events.push('prepare');
|
||||||
if (overrides.prepareError) throw overrides.prepareError;
|
if (overrides.prepareError) throw overrides.prepareError;
|
||||||
createdAt = timestamp;
|
createdAt = timestamp;
|
||||||
return { id, encryptedKey: 'encrypted-key', createdAt: timestamp };
|
expiresAt = expiration ?? null;
|
||||||
|
return { id, encryptedKey: 'encrypted-key', createdAt: timestamp, expiresAt };
|
||||||
},
|
},
|
||||||
commit: async (entry) => {
|
commit: async (entry) => {
|
||||||
events.push('commit');
|
events.push('commit');
|
||||||
@@ -56,7 +59,8 @@ function createFixture(overrides = {}) {
|
|||||||
entries.set(entry.id, {
|
entries.set(entry.id, {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
||||||
createdAt: entry.createdAt
|
createdAt: entry.createdAt,
|
||||||
|
expiresAt: entry.expiresAt ?? null
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getKey: async (entryId) => {
|
getKey: async (entryId) => {
|
||||||
@@ -90,7 +94,8 @@ function createFixture(overrides = {}) {
|
|||||||
appVersion: () => '2.1.31',
|
appVersion: () => '2.1.31',
|
||||||
createBackup: (...args) => {
|
createBackup: (...args) => {
|
||||||
createArguments = 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) => {
|
uploadBackup: async (uploadedRecord) => {
|
||||||
events.push('upload');
|
events.push('upload');
|
||||||
@@ -112,6 +117,9 @@ function createFixture(overrides = {}) {
|
|||||||
get createdAt() {
|
get createdAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
},
|
},
|
||||||
|
get expiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
},
|
||||||
get createArguments() {
|
get createArguments() {
|
||||||
return createArguments;
|
return createArguments;
|
||||||
}
|
}
|
||||||
@@ -174,14 +182,15 @@ describe('transactional online backup manager', () => {
|
|||||||
const result = await fixture.manager.createManaged();
|
const result = await fixture.manager.createManaged();
|
||||||
|
|
||||||
assert.deepEqual(fixture.events, ['prepare', 'upload', 'commit']);
|
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.match(fixture.createdAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||||
assert.deepEqual(result, {
|
assert.deepEqual(result, {
|
||||||
ok: true,
|
ok: true,
|
||||||
entry: {
|
entry: {
|
||||||
id,
|
id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
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);
|
assert.equal(JSON.stringify(result).includes(key), false);
|
||||||
@@ -202,7 +211,8 @@ describe('transactional online backup manager', () => {
|
|||||||
entry: {
|
entry: {
|
||||||
id,
|
id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
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: [{
|
entries: [{
|
||||||
id,
|
id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
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 () => {
|
fixture.keyring.list = async () => {
|
||||||
@@ -464,7 +475,8 @@ describe('transactional online backup manager', () => {
|
|||||||
entries: [{
|
entries: [{
|
||||||
id,
|
id,
|
||||||
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
|
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',
|
warningCode: 'KEYRING_DECRYPT_FAILED',
|
||||||
warning: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden'
|
warning: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden'
|
||||||
|
|||||||
@@ -49,7 +49,28 @@ describe('online backup key', () => {
|
|||||||
assert.equal(serialized.includes('secret-api-key'), false);
|
assert.equal(serialized.includes('secret-api-key'), false);
|
||||||
assert.equal(serialized.includes('private-webhook'), false);
|
assert.equal(serialized.includes('private-webhook'), false);
|
||||||
assert.equal(serialized.includes(parsed.masterKey.toString('base64url')), 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', () => {
|
it('rejects corrupted keys, ciphertext and oversized settings', () => {
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ test('exposes managed online backup operations through narrow IPC boundaries', (
|
|||||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
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, /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, /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, /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);
|
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.match(mainSource, /decoded\.toString\('base64url'\)\s*!==\s*id/u);
|
||||||
assert.doesNotMatch(mainSource, /ipcMain\.handle\('online-backup:create'/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: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:copy-managed',[\s\S]*?onlineBackupManager\.copyManaged\(/u);
|
||||||
assert.match(mainSource, /ipcMain\.handle\('online-backup:delete-managed',[\s\S]*?onlineBackupManager\.deleteManaged\(/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) },
|
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||||
mainWindow,
|
mainWindow,
|
||||||
onlineBackupManager,
|
onlineBackupManager,
|
||||||
|
normalizeOnlineBackupRetention: value => value || '7d',
|
||||||
path,
|
path,
|
||||||
pathToFileURL
|
pathToFileURL
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,22 @@ describe('settings backup snapshot', () => {
|
|||||||
const input = {
|
const input = {
|
||||||
hosters: { 'voe.sx': [{ id: 'v1', username: 'user', password: 'secret', enabled: true }] },
|
hosters: { 'voe.sx': [{ id: 'v1', username: 'user', password: 'secret', enabled: true }] },
|
||||||
hosterSettings: { 'voe.sx': { retries: 7 } },
|
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' }],
|
history: [{ file: 'done.mkv' }],
|
||||||
rotationCursors: { 'voe.sx': 4 }
|
rotationCursors: { 'voe.sx': 4 }
|
||||||
};
|
};
|
||||||
@@ -17,13 +32,27 @@ describe('settings backup snapshot', () => {
|
|||||||
assert.deepEqual(snapshot, {
|
assert.deepEqual(snapshot, {
|
||||||
hosters: input.hosters,
|
hosters: input.hosters,
|
||||||
hosterSettings: input.hosterSettings,
|
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: []
|
history: []
|
||||||
});
|
});
|
||||||
assert.notEqual(snapshot.hosters, input.hosters);
|
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 { prepareImportedSettings } = require('../lib/settings-backup');
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
hosters: { 'byse.sx': [{ id: 'b1', apiKey: 'secret', enabled: true }] },
|
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.equal(imported.globalSettings.logFilePath, 'Z:\\missing\\upload.log');
|
||||||
assert.deepEqual(imported.globalSettings.folderMonitor, { enabled: false, folderPath: '' });
|
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.pendingQueue, null);
|
||||||
|
assert.equal(imported.globalSettings.uploadRecovery, null);
|
||||||
|
assert.equal(imported.globalSettings.lastBrowseDirectory, '');
|
||||||
assert.deepEqual(imported.history, []);
|
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);
|
assert.throws(() => prepareImportedSettings({ hosters: {} }), /ungültige Struktur/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+90
-8
@@ -63,18 +63,21 @@ let initialConfigReadDelayed = false;
|
|||||||
let startupLanguagePendingSnapshot = null;
|
let startupLanguagePendingSnapshot = null;
|
||||||
let managedOnlineBackupEntries = [
|
let managedOnlineBackupEntries = [
|
||||||
{ id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' },
|
{ 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 managedOnlineBackupCopyIds = [];
|
||||||
const managedOnlineBackupDeleteIds = [];
|
const managedOnlineBackupDeleteIds = [];
|
||||||
let managedOnlineBackupCreateCalls = 0;
|
let managedOnlineBackupCreateCalls = 0;
|
||||||
|
const managedOnlineBackupCreateRetentions = [];
|
||||||
let managedOnlineBackupDeleteMode = 'failure';
|
let managedOnlineBackupDeleteMode = 'failure';
|
||||||
let releaseManagedOnlineBackupDelete = null;
|
let releaseManagedOnlineBackupDelete = null;
|
||||||
const managedOnlineBackupHandlers = {
|
const managedOnlineBackupHandlers = {
|
||||||
'online-backup:list-managed': async () => ({ ok: true, entries: managedOnlineBackupEntries.map(entry => ({ ...entry })) }),
|
'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++;
|
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];
|
managedOnlineBackupEntries = [...managedOnlineBackupEntries, entry];
|
||||||
return { ok: true, entry: { ...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);
|
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');
|
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;
|
let resolveStaleAccountCheck = null;
|
||||||
ipcMain.removeHandler('run-health-check');
|
ipcMain.removeHandler('run-health-check');
|
||||||
ipcMain.handle('run-health-check', () => new Promise(resolve => { resolveStaleAccountCheck = resolve; }));
|
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")');
|
const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")');
|
||||||
check('Settings tab active', settingsActive);
|
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;
|
let updateCheckCallCount = 0;
|
||||||
const updateCheckResolvers = [];
|
const updateCheckResolvers = [];
|
||||||
ipcMain.removeHandler('app:check-updates');
|
ipcMain.removeHandler('app:check-updates');
|
||||||
@@ -1405,9 +1471,12 @@ setTimeout(async () => {
|
|||||||
check('Global parallel uploads default 0', parallel === '0');
|
check('Global parallel uploads default 0', parallel === '0');
|
||||||
|
|
||||||
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'backup\\']")?.click()');
|
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);
|
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")');
|
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}');
|
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');
|
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 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) }))()');
|
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 canonical masked IDs with accessible actions', managedOnlineBackupLoaded === true && managedOnlineBackupInitialState.keys.join('|') === 'MHU2-ZYXW…9876|MHU2-ABCD…1234' && managedOnlineBackupInitialState.described === true && managedOnlineBackupInitialState.secretInBody === false);
|
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 wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-copy-btn").click()');
|
||||||
await waitUntil(() => managedOnlineBackupCopyIds.length === 1);
|
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) }))()');
|
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);
|
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 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) }))()');
|
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');
|
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.');
|
check('Invalid online backup keys stay blocked with visible guidance', invalidOnlineBackup === 'true|Der Schlüssel muss exakt 75 Zeichen lang sein.');
|
||||||
|
|||||||
Reference in New Issue
Block a user