diff --git a/lib/online-backup-keyring.js b/lib/online-backup-keyring.js
index 31ee51f..3c4f853 100644
--- a/lib/online-backup-keyring.js
+++ b/lib/online-backup-keyring.js
@@ -5,133 +5,397 @@ const secretStore = require('./secret-store');
const { parseOnlineBackupKey } = require('./online-backup');
const STORED_ENTRY_KEYS = ['createdAt', 'encryptedKey', 'id'];
+const STORED_DOCUMENT_KEYS = ['keys', 'version'];
+const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EACCES', 'EBADF', 'EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM']);
+const KEYRING_ERROR_CODES = Object.freeze({
+ structure: 'KEYRING_STRUCTURE_INVALID',
+ unavailable: 'KEYRING_SECURE_STORAGE_UNAVAILABLE',
+ decrypt: 'KEYRING_DECRYPT_FAILED',
+ mismatch: 'KEYRING_ID_MISMATCH',
+ duplicate: 'KEYRING_DUPLICATE_ID',
+ recovered: 'KEYRING_RECOVERED',
+ encrypt: 'KEYRING_ENCRYPT_FAILED',
+ plan: 'KEYRING_REMOVE_PLAN_INVALID'
+});
+const ERROR_MESSAGES = Object.freeze({
+ [KEYRING_ERROR_CODES.structure]: 'Gespeicherter Online-Schlüsselbund ist beschädigt',
+ [KEYRING_ERROR_CODES.unavailable]: 'Sichere Schlüsselspeicherung ist nicht verfügbar',
+ [KEYRING_ERROR_CODES.decrypt]: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden',
+ [KEYRING_ERROR_CODES.mismatch]: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein',
+ [KEYRING_ERROR_CODES.duplicate]: 'Gespeicherte Online-Sicherungskennung ist mehrdeutig',
+ [KEYRING_ERROR_CODES.recovered]: 'Online-Schlüsselbund wurde aus einer Wiederherstellungsdatei geladen',
+ [KEYRING_ERROR_CODES.encrypt]: 'Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden',
+ [KEYRING_ERROR_CODES.plan]: 'Online-Sicherung konnte lokal nicht eindeutig entfernt werden'
+});
+const ISSUE_ORDER = Object.freeze([
+ KEYRING_ERROR_CODES.unavailable,
+ KEYRING_ERROR_CODES.duplicate,
+ KEYRING_ERROR_CODES.decrypt,
+ KEYRING_ERROR_CODES.mismatch,
+ KEYRING_ERROR_CODES.structure,
+ KEYRING_ERROR_CODES.recovered
+]);
+
+class OnlineBackupKeyringError extends Error {
+ constructor(code) {
+ super(ERROR_MESSAGES[code] || ERROR_MESSAGES[KEYRING_ERROR_CODES.structure]);
+ this.name = 'OnlineBackupKeyringError';
+ this.code = code;
+ }
+}
function isObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
+function hasExactKeys(value, expected) {
+ if (!isObject(value)) return false;
+ const keys = Object.keys(value).sort();
+ return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
+}
+
+function isCanonicalId(value) {
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/u.test(value)) return false;
+ const decoded = Buffer.from(value, 'base64url');
+ return decoded.length === 16 && decoded.toString('base64url') === value;
+}
+
function normalizeTimestamp(value) {
const timestamp = new Date(value);
- if (!Number.isFinite(timestamp.getTime())) throw new Error('Ungültiger Erstellungszeitpunkt');
+ if (!Number.isFinite(timestamp.getTime())) throw new OnlineBackupKeyringError(KEYRING_ERROR_CODES.structure);
return timestamp.toISOString();
}
+function uniqueIssues(issues) {
+ const values = [...new Set(issues)];
+ values.sort((left, right) => ISSUE_ORDER.indexOf(left) - ISSUE_ORDER.indexOf(right));
+ return values;
+}
+
function createOnlineBackupKeyring({
filePath,
encryptField = secretStore.encryptField,
decryptField = secretStore.decryptField,
+ isEncrypted = secretStore.isEncrypted,
parseKey = parseOnlineBackupKey,
fsImpl = fs.promises
}) {
+ const directory = path.dirname(filePath);
+ const basename = path.basename(filePath);
+ const backupPath = `${filePath}.bak`;
+ const temporaryPrefix = `.${basename}.`;
+ const removalPlans = new WeakMap();
let mutation = Promise.resolve();
- function validateEntry(entry) {
- if (!isObject(entry)) return null;
- const keys = Object.keys(entry).sort();
- if (
- keys.length !== STORED_ENTRY_KEYS.length
- || !keys.every((key, index) => key === STORED_ENTRY_KEYS[index])
- || typeof entry.id !== 'string'
- || !entry.id
- || typeof entry.encryptedKey !== 'string'
- || !entry.encryptedKey
- || typeof entry.createdAt !== 'string'
- ) {
- return null;
- }
- let key;
- let parsed;
- try {
- key = decryptField(entry.encryptedKey);
- parsed = parseKey(key);
- } catch {
- return null;
- }
- if (typeof key !== 'string' || parsed?.id !== entry.id) return null;
- let createdAt;
- try {
- createdAt = normalizeTimestamp(entry.createdAt);
- } catch {
- return null;
- }
- if (createdAt !== entry.createdAt) return null;
- return { id: entry.id, encryptedKey: entry.encryptedKey, createdAt, key };
+ function issueError(code) {
+ return new OnlineBackupKeyringError(code);
}
- async function readEntries(rejectInvalidEntries = false) {
- let contents;
- try {
- contents = await fsImpl.readFile(filePath, 'utf8');
- } catch (error) {
- if (error?.code === 'ENOENT') return [];
- throw error;
- }
+ function isTemporaryFileName(value) {
+ return value.startsWith(temporaryPrefix)
+ && /^\d+\.[0-9a-f-]+\.(?:primary|recovery)\.tmp$/u.test(value.slice(temporaryPrefix.length));
+ }
+
+ function encryptionError(error) {
+ return issueError(error?.code === 'SECRET_STORE_UNAVAILABLE' ? KEYRING_ERROR_CODES.unavailable : KEYRING_ERROR_CODES.encrypt);
+ }
+
+ function decryptionIssue(error) {
+ return error?.code === 'SECRET_STORE_UNAVAILABLE' ? KEYRING_ERROR_CODES.unavailable : KEYRING_ERROR_CODES.decrypt;
+ }
+
+ function parseDocument(contents) {
let document;
try {
document = JSON.parse(contents);
} catch {
- throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
+ throw issueError(KEYRING_ERROR_CODES.structure);
}
- if (!isObject(document) || document.version !== 1 || !Array.isArray(document.entries)) {
- throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
+ if (!hasExactKeys(document, STORED_DOCUMENT_KEYS) || document.version !== 1 || !Array.isArray(document.keys)) {
+ throw issueError(KEYRING_ERROR_CODES.structure);
}
- const entries = document.entries.map(validateEntry);
- if (rejectInvalidEntries && entries.some((entry) => !entry)) {
- throw new Error('Gespeicherter Online-Schlüsselbund ist ungültig');
- }
- return entries.filter(Boolean);
+ return document;
}
- async function writeEntries(entries) {
- const directory = path.dirname(filePath);
- const temporaryPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
- const document = JSON.stringify({
- version: 1,
- entries: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
- });
- await fsImpl.mkdir(directory, { recursive: true });
+ async function readCandidate(candidatePath) {
try {
- await fsImpl.writeFile(temporaryPath, document, { encoding: 'utf8', flag: 'wx' });
- await fsImpl.rename(temporaryPath, filePath);
+ const contents = await fsImpl.readFile(candidatePath, 'utf8');
+ return { status: 'valid', path: candidatePath, contents, document: parseDocument(contents) };
} catch (error) {
- try {
- await fsImpl.unlink(temporaryPath);
- } catch (cleanupError) {
- if (cleanupError?.code !== 'ENOENT') throw cleanupError;
- }
- throw error;
+ if (error?.code === 'ENOENT') return { status: 'missing', path: candidatePath };
+ return { status: 'invalid', path: candidatePath };
}
}
+ async function recoveryCandidates() {
+ const candidates = new Set([backupPath]);
+ try {
+ const entries = await fsImpl.readdir(directory, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.isFile() && isTemporaryFileName(entry.name)) candidates.add(path.join(directory, entry.name));
+ }
+ } catch (error) {
+ if (error?.code !== 'ENOENT') throw issueError(KEYRING_ERROR_CODES.structure);
+ }
+ const ranked = [];
+ for (const candidatePath of candidates) {
+ try {
+ const stats = await fsImpl.stat(candidatePath);
+ ranked.push({ candidatePath, modified: stats.mtimeMs });
+ } catch (error) {
+ if (error?.code !== 'ENOENT') ranked.push({ candidatePath, modified: 0 });
+ }
+ }
+ ranked.sort((left, right) => right.modified - left.modified);
+ return ranked.map(candidate => candidate.candidatePath);
+ }
+
+ function validateEntry(entry) {
+ if (
+ !hasExactKeys(entry, STORED_ENTRY_KEYS)
+ || !isCanonicalId(entry.id)
+ || typeof entry.encryptedKey !== 'string'
+ || !isEncrypted(entry.encryptedKey)
+ || typeof entry.createdAt !== 'string'
+ ) {
+ return { issue: KEYRING_ERROR_CODES.structure, id: typeof entry?.id === 'string' ? entry.id : null };
+ }
+ let createdAt;
+ try {
+ createdAt = normalizeTimestamp(entry.createdAt);
+ } catch {
+ return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
+ }
+ if (createdAt !== entry.createdAt) return { issue: KEYRING_ERROR_CODES.structure, id: entry.id };
+ let key;
+ try {
+ key = decryptField(entry.encryptedKey);
+ } catch (error) {
+ return { issue: decryptionIssue(error), id: entry.id };
+ }
+ if (typeof key !== 'string') return { issue: KEYRING_ERROR_CODES.decrypt, id: entry.id };
+ let parsed;
+ try {
+ parsed = parseKey(key);
+ } catch {
+ return { issue: KEYRING_ERROR_CODES.decrypt, id: entry.id };
+ }
+ if (parsed?.id !== entry.id) return { issue: KEYRING_ERROR_CODES.mismatch, id: entry.id };
+ return {
+ entry: {
+ id: entry.id,
+ encryptedKey: entry.encryptedKey,
+ createdAt,
+ key
+ }
+ };
+ }
+
+ function inspectSource(source) {
+ const idCounts = new Map();
+ for (const entry of source.document.keys) {
+ if (isCanonicalId(entry?.id)) idCounts.set(entry.id, (idCounts.get(entry.id) || 0) + 1);
+ }
+ const duplicateIds = new Set([...idCounts].filter(([, count]) => count > 1).map(([id]) => id));
+ const entries = [];
+ const problems = [];
+ for (const entry of source.document.keys) {
+ if (duplicateIds.has(entry?.id)) continue;
+ const result = validateEntry(entry);
+ if (result.entry) entries.push(result.entry);
+ else problems.push({ code: result.issue, id: result.id });
+ }
+ for (const id of duplicateIds) problems.push({ code: KEYRING_ERROR_CODES.duplicate, id });
+ const issues = uniqueIssues([
+ ...problems.map(problem => problem.code),
+ ...(source.recovered ? [KEYRING_ERROR_CODES.recovered] : [])
+ ]);
+ return { source, entries, problems, duplicateIds, issues };
+ }
+
+ async function readState() {
+ const primary = await readCandidate(filePath);
+ if (primary.status === 'valid') {
+ let primaryModified = 0;
+ try {
+ primaryModified = (await fsImpl.stat(filePath)).mtimeMs;
+ } catch {}
+ const candidates = await recoveryCandidates();
+ for (const candidatePath of candidates) {
+ if (!isTemporaryFileName(path.basename(candidatePath))) continue;
+ let candidateModified = 0;
+ try {
+ candidateModified = (await fsImpl.stat(candidatePath)).mtimeMs;
+ } catch {}
+ if (candidateModified <= primaryModified) continue;
+ const candidate = await readCandidate(candidatePath);
+ if (candidate.status !== 'valid') continue;
+ const state = inspectSource({ ...candidate, recovered: true });
+ if (!firstBlockingIssue(state)) return state;
+ }
+ return inspectSource({ ...primary, recovered: false });
+ }
+ const candidates = await recoveryCandidates();
+ let fallback = null;
+ for (const candidatePath of candidates) {
+ const candidate = await readCandidate(candidatePath);
+ if (candidate.status !== 'valid') continue;
+ const state = inspectSource({ ...candidate, recovered: true });
+ fallback ||= state;
+ if (!firstBlockingIssue(state)) return state;
+ }
+ if (fallback) return fallback;
+ if (primary.status === 'missing' && candidates.length === 0) {
+ const document = { version: 1, keys: [] };
+ return inspectSource({
+ status: 'valid',
+ path: filePath,
+ contents: JSON.stringify(document),
+ document,
+ recovered: false
+ });
+ }
+ throw issueError(KEYRING_ERROR_CODES.structure);
+ }
+
+ function firstBlockingIssue(state) {
+ return state.issues.find(issue => issue !== KEYRING_ERROR_CODES.recovered) || null;
+ }
+
function serialize(operation) {
const next = mutation.catch(() => {}).then(operation);
mutation = next;
return next;
}
+ function temporaryPath(kind) {
+ return path.join(directory, `.${basename}.${process.pid}.${crypto.randomUUID()}.${kind}.tmp`);
+ }
+
+ async function removeFile(target) {
+ try {
+ await fsImpl.unlink(target);
+ } catch (error) {
+ if (error?.code !== 'ENOENT') return false;
+ }
+ return true;
+ }
+
+ async function writeAndSync(target, contents) {
+ let handle;
+ let failure;
+ try {
+ handle = await fsImpl.open(target, 'wx', 0o600);
+ await handle.writeFile(contents, { encoding: 'utf8' });
+ await handle.sync();
+ } catch (error) {
+ failure = error;
+ }
+ if (handle) {
+ try {
+ await handle.close();
+ } catch (error) {
+ failure ||= error;
+ }
+ }
+ if (failure) throw failure;
+ }
+
+ async function syncDirectory() {
+ let handle;
+ let failure;
+ try {
+ handle = await fsImpl.open(directory, 'r');
+ await handle.sync();
+ } catch (error) {
+ if (!DIRECTORY_SYNC_UNSUPPORTED.has(error?.code)) failure = error;
+ }
+ if (handle) {
+ try {
+ await handle.close();
+ } catch (error) {
+ if (!DIRECTORY_SYNC_UNSUPPORTED.has(error?.code)) failure ||= error;
+ }
+ }
+ if (failure) throw failure;
+ }
+
+ async function cleanupTemporaryFiles(except = null) {
+ let entries;
+ try {
+ entries = await fsImpl.readdir(directory, { withFileTypes: true });
+ } catch (error) {
+ if (error?.code === 'ENOENT') return;
+ throw error;
+ }
+ for (const entry of entries) {
+ const candidatePath = path.join(directory, entry.name);
+ if (!entry.isFile() || !isTemporaryFileName(entry.name) || candidatePath === except) continue;
+ await removeFile(candidatePath);
+ }
+ }
+
+ async function writeEntries(entries) {
+ const contents = JSON.stringify({
+ version: 1,
+ keys: entries.map(({ id, encryptedKey, createdAt }) => ({ id, encryptedKey, createdAt }))
+ });
+ const primaryTemporaryPath = temporaryPath('primary');
+ const recoveryTemporaryPath = temporaryPath('recovery');
+ await fsImpl.mkdir(directory, { recursive: true });
+ let primaryPublished = false;
+ try {
+ await writeAndSync(primaryTemporaryPath, contents);
+ await writeAndSync(recoveryTemporaryPath, contents);
+ await fsImpl.rename(primaryTemporaryPath, filePath);
+ primaryPublished = true;
+ } catch (error) {
+ await removeFile(primaryTemporaryPath);
+ await removeFile(recoveryTemporaryPath);
+ throw error;
+ }
+ let recoveryPath = recoveryTemporaryPath;
+ try {
+ await syncDirectory();
+ await fsImpl.rename(recoveryTemporaryPath, backupPath);
+ recoveryPath = null;
+ await syncDirectory();
+ } catch {
+ if (!primaryPublished) throw issueError(KEYRING_ERROR_CODES.structure);
+ }
+ await cleanupTemporaryFiles(recoveryPath);
+ }
+
async function list() {
- const entries = await readEntries();
- const sanitized = entries
+ const state = await readState();
+ const entries = state.entries
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.map(({ id, key, createdAt }) => Object.freeze({
id,
displayKey: `${key.slice(0, 9)}…${key.slice(-4)}`,
createdAt
}));
- return Object.freeze(sanitized);
+ return Object.freeze({
+ entries: Object.freeze(entries),
+ issues: Object.freeze([...state.issues])
+ });
}
function prepare(key, createdAt) {
let parsed;
- let encryptedKey;
try {
parsed = parseKey(key);
- encryptedKey = encryptField(key);
} catch {
- throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden');
+ throw issueError(KEYRING_ERROR_CODES.structure);
}
- if (typeof encryptedKey !== 'string' || !encryptedKey || encryptedKey === key) {
- throw new Error('Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden');
+ let encryptedKey;
+ try {
+ encryptedKey = encryptField(key);
+ } catch (error) {
+ throw encryptionError(error);
+ }
+ if (typeof encryptedKey !== 'string' || encryptedKey === key || !isEncrypted(encryptedKey)) {
+ throw issueError(KEYRING_ERROR_CODES.encrypt);
}
return Object.freeze({
id: parsed.id,
@@ -143,29 +407,60 @@ function createOnlineBackupKeyring({
function commit(entry) {
return serialize(async () => {
const validated = validateEntry(entry);
- if (!validated) throw new Error('Online-Sicherungsschlüssel ist ungültig');
- const entries = await readEntries(true);
- if (entries.some((current) => current.id === validated.id)) return;
- await writeEntries([...entries, validated]);
- });
- }
-
- function remove(id) {
- return serialize(async () => {
- const entries = await readEntries(true);
- const remaining = entries.filter((entry) => entry.id !== id);
- if (remaining.length === entries.length) return false;
- await writeEntries(remaining);
+ if (!validated.entry) throw issueError(validated.issue);
+ const state = await readState();
+ const blockingIssue = firstBlockingIssue(state);
+ if (blockingIssue) throw issueError(blockingIssue);
+ if (state.entries.some(current => current.id === validated.entry.id)) return false;
+ await writeEntries([...state.entries, validated.entry]);
return true;
});
}
async function getKey(id) {
- const entries = await readEntries();
- return entries.find((entry) => entry.id === id)?.key ?? null;
+ const state = await readState();
+ if (state.duplicateIds.has(id)) throw issueError(KEYRING_ERROR_CODES.duplicate);
+ const entry = state.entries.find(current => current.id === id);
+ if (entry) return entry.key;
+ const matchingProblem = state.problems.find(problem => problem.id === id);
+ if (matchingProblem) throw issueError(matchingProblem.code);
+ const blockingIssue = firstBlockingIssue(state);
+ if (blockingIssue) throw issueError(blockingIssue);
+ return null;
}
- return Object.freeze({ list, prepare, commit, remove, getKey });
+ async function prepareRemove(id) {
+ const state = await readState();
+ const blockingIssue = firstBlockingIssue(state);
+ if (blockingIssue) throw issueError(blockingIssue);
+ const entry = state.entries.find(current => current.id === id);
+ if (!entry) return null;
+ const plan = Object.freeze({ id: entry.id, key: entry.key });
+ removalPlans.set(plan, state.entries.filter(current => current.id !== id));
+ return plan;
+ }
+
+ function commitRemove(plan) {
+ return serialize(async () => {
+ const remaining = removalPlans.get(plan);
+ if (!remaining) throw issueError(KEYRING_ERROR_CODES.plan);
+ await writeEntries(remaining);
+ removalPlans.delete(plan);
+ return true;
+ });
+ }
+
+ async function remove(id) {
+ const plan = await prepareRemove(id);
+ if (!plan) return false;
+ return commitRemove(plan);
+ }
+
+ return Object.freeze({ list, prepare, commit, remove, getKey, prepareRemove, commitRemove });
}
-module.exports = { createOnlineBackupKeyring };
+module.exports = {
+ KEYRING_ERROR_CODES,
+ OnlineBackupKeyringError,
+ createOnlineBackupKeyring
+};
diff --git a/lib/online-backup-manager.js b/lib/online-backup-manager.js
index a56ff45..fe555c3 100644
--- a/lib/online-backup-manager.js
+++ b/lib/online-backup-manager.js
@@ -3,6 +3,7 @@ const {
deleteOnlineBackup,
uploadOnlineBackup
} = require('./online-backup');
+const { KEYRING_ERROR_CODES } = require('./online-backup-keyring');
const ERRORS = Object.freeze({
list: 'Online-Sicherungen konnten nicht geladen werden',
@@ -11,6 +12,16 @@ const ERRORS = Object.freeze({
delete: 'Online-Sicherung konnte nicht gelöscht werden',
notFound: 'Online-Sicherungsschlüssel wurde nicht gefunden'
});
+const KEYRING_MESSAGES = Object.freeze({
+ [KEYRING_ERROR_CODES.structure]: 'Gespeicherter Online-Schlüsselbund ist beschädigt',
+ [KEYRING_ERROR_CODES.unavailable]: 'Sichere Schlüsselspeicherung ist nicht verfügbar',
+ [KEYRING_ERROR_CODES.decrypt]: 'Gespeicherter Online-Sicherungsschlüssel konnte nicht entschlüsselt werden',
+ [KEYRING_ERROR_CODES.mismatch]: 'Gespeicherte Online-Sicherungskennung stimmt nicht mit dem Schlüssel überein',
+ [KEYRING_ERROR_CODES.duplicate]: 'Gespeicherte Online-Sicherungskennung ist mehrdeutig',
+ [KEYRING_ERROR_CODES.recovered]: 'Online-Schlüsselbund wurde aus einer Wiederherstellungsdatei geladen',
+ [KEYRING_ERROR_CODES.encrypt]: 'Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden',
+ [KEYRING_ERROR_CODES.plan]: 'Online-Sicherung konnte lokal nicht eindeutig entfernt werden'
+});
function sanitizeEntry(entry) {
return {
@@ -28,6 +39,12 @@ function sanitizeCreatedEntry(entry, key) {
};
}
+function keyringFailure(error, fallback) {
+ const message = KEYRING_MESSAGES[error?.code];
+ if (!message) return { ok: false, error: fallback };
+ return { ok: false, code: error.code, error: message };
+}
+
function createOnlineBackupManager({
keyring,
loadSettings,
@@ -46,8 +63,12 @@ function createOnlineBackupManager({
}
async function listEntries() {
- const entries = await keyring.list();
- return entries.map(sanitizeEntry);
+ const snapshot = await keyring.list();
+ if (!snapshot || !Array.isArray(snapshot.entries) || !Array.isArray(snapshot.issues)) throw new Error(ERRORS.list);
+ return {
+ entries: snapshot.entries.map(sanitizeEntry),
+ issues: snapshot.issues
+ };
}
async function createTransaction() {
@@ -68,27 +89,39 @@ function createOnlineBackupManager({
}
async function deleteTransaction(id) {
- const key = await keyring.getKey(id);
- if (!key) return { ok: false, notFound: true, error: ERRORS.notFound };
- const outcome = await deleteBackup(key);
+ const plan = await keyring.prepareRemove(id);
+ if (!plan) return { ok: false, notFound: true, error: ERRORS.notFound };
+ const outcome = await deleteBackup(plan.key);
if (!outcome?.deleted && !outcome?.notFound) throw new Error(ERRORS.delete);
- await keyring.remove(id);
+ await keyring.commitRemove(plan);
return { ok: true, removedId: id, notFound: outcome.notFound };
}
async function listManaged() {
try {
- return { ok: true, entries: await listEntries() };
- } catch {
- return { ok: false, error: ERRORS.list };
+ const snapshot = await listEntries();
+ const issue = snapshot.issues[0];
+ if (!issue) return { ok: true, entries: snapshot.entries };
+ const message = KEYRING_MESSAGES[issue] || ERRORS.list;
+ if (snapshot.entries.length || issue === KEYRING_ERROR_CODES.recovered) {
+ return {
+ ok: true,
+ entries: snapshot.entries,
+ warningCode: issue,
+ warning: message
+ };
+ }
+ return { ok: false, entries: [], code: issue, error: message };
+ } catch (error) {
+ return keyringFailure(error, ERRORS.list);
}
}
async function createManaged() {
try {
return await serialize(createTransaction);
- } catch {
- return { ok: false, error: ERRORS.create };
+ } catch (error) {
+ return keyringFailure(error, ERRORS.create);
}
}
@@ -98,16 +131,16 @@ function createOnlineBackupManager({
if (!key) return { ok: false, notFound: true, error: ERRORS.notFound };
await copyText(key);
return { ok: true };
- } catch {
- return { ok: false, error: ERRORS.copy };
+ } catch (error) {
+ return keyringFailure(error, ERRORS.copy);
}
}
async function deleteManaged(id) {
try {
return await serialize(() => deleteTransaction(id));
- } catch {
- return { ok: false, error: ERRORS.delete };
+ } catch (error) {
+ return keyringFailure(error, ERRORS.delete);
}
}
diff --git a/lib/secret-store.js b/lib/secret-store.js
index 6179cad..c11c124 100644
--- a/lib/secret-store.js
+++ b/lib/secret-store.js
@@ -35,7 +35,11 @@ function getSafeStorage() {
}
function isEncrypted(value) {
- return typeof value === 'string' && value.startsWith(SENTINEL);
+ if (typeof value !== 'string' || !value.startsWith(SENTINEL)) return false;
+ const encoded = value.slice(SENTINEL.length);
+ if (!encoded || encoded.length % 4 !== 0) return false;
+ const decoded = Buffer.from(encoded, 'base64');
+ return decoded.length > 0 && decoded.toString('base64') === encoded;
}
function encryptField(value) {
@@ -55,7 +59,12 @@ function encryptField(value) {
function decryptField(value) {
if (!value || typeof value !== 'string') return value;
- if (!isEncrypted(value)) return value;
+ if (!isEncrypted(value)) {
+ if (value.startsWith(SENTINEL)) {
+ throw new SecretStoreError('SECRET_STORE_DECRYPT_FAILED', 'Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden');
+ }
+ return value;
+ }
const ss = getSafeStorage();
if (!ss) {
throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar');
diff --git a/renderer/app.js b/renderer/app.js
index 67e9c52..1703840 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -42,6 +42,7 @@ function refreshLocalizedRuntimeUi() {
const hint = document.getElementById('recentFilesHint');
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
renderManagedOnlineBackups();
+ renderManagedOnlineBackupRefreshIssue();
}
// Dropdown options for "Add Account" modal: value -> label
@@ -64,11 +65,12 @@ let uploading = false;
let healthCheckRunning = false;
let managedOnlineBackups = [];
let managedOnlineBackupsAuthoritative = false;
-let managedOnlineBackupOperationGeneration = 0;
+let managedOnlineBackupMutationGeneration = 0;
let managedOnlineBackupNavigationLoadGeneration = 0;
let managedOnlineBackupAuthoritativeLoadGeneration = 0;
-let managedOnlineBackupActiveOperations = 0;
+let managedOnlineBackupActiveMutations = 0;
let onlineBackupStatusContextGeneration = 0;
+let managedOnlineBackupRefreshIssue = null;
const managedOnlineBackupOperationQueues = new Map();
let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0;
@@ -2647,15 +2649,18 @@ function beginOnlineBackupStatusContext() {
return ++onlineBackupStatusContextGeneration;
}
-function beginManagedOnlineBackupOperation() {
- managedOnlineBackupActiveOperations++;
- managedOnlineBackupOperationGeneration++;
+function beginManagedOnlineBackupMutation() {
+ managedOnlineBackupActiveMutations++;
+ managedOnlineBackupMutationGeneration++;
managedOnlineBackupNavigationLoadGeneration++;
- return beginOnlineBackupStatusContext();
+ return Object.freeze({
+ mutationGeneration: managedOnlineBackupMutationGeneration,
+ statusContext: beginOnlineBackupStatusContext()
+ });
}
-function endManagedOnlineBackupOperation() {
- managedOnlineBackupActiveOperations = Math.max(0, managedOnlineBackupActiveOperations - 1);
+function endManagedOnlineBackupMutation() {
+ managedOnlineBackupActiveMutations = Math.max(0, managedOnlineBackupActiveMutations - 1);
}
function setOnlineBackupStatus(message, state = '', statusContext = null) {
@@ -2675,34 +2680,84 @@ function syncOnlineBackupRestoreButton(busy = false) {
restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || '');
}
-function normalizeManagedOnlineBackups(entries) {
- const sanitized = new Map();
- for (const entry of Array.isArray(entries) ? entries : []) {
- if (!entry || typeof entry !== 'object') continue;
- if (typeof entry.id !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(entry.id)) continue;
- if (typeof entry.displayKey !== 'string' || !entry.displayKey || /MHU2-[A-Za-z0-9_-]{70}/.test(entry.displayKey)) continue;
- const createdAt = new Date(entry.createdAt);
- if (Number.isNaN(createdAt.getTime())) continue;
- sanitized.set(entry.id, { id: entry.id, displayKey: entry.displayKey, createdAt: createdAt.toISOString() });
+function isCanonicalManagedOnlineBackupId(value) {
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(value)) return false;
+ try {
+ const decoded = window.atob(`${value.replace(/-/g, '+').replace(/_/g, '/')}==`);
+ if (decoded.length !== 16) return false;
+ const encoded = window.btoa(decoded).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
+ return encoded === value;
+ } catch {
+ return false;
}
- return [...sanitized.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
-function replaceManagedOnlineBackups(entries) {
+function normalizeManagedOnlineBackups(entries) {
+ const candidates = [];
+ for (const entry of Array.isArray(entries) ? entries : []) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
+ if (Object.keys(entry).sort().join(',') !== 'createdAt,displayKey,id') continue;
+ if (!isCanonicalManagedOnlineBackupId(entry.id)) continue;
+ if (typeof entry.displayKey !== 'string' || !/^MHU2-[A-Za-z0-9_-]{4}…[A-Za-z0-9_-]{4}$/.test(entry.displayKey)) continue;
+ const createdAt = new Date(entry.createdAt);
+ if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== entry.createdAt) continue;
+ candidates.push({ id: entry.id, displayKey: entry.displayKey, createdAt: entry.createdAt });
+ }
+ const counts = new Map();
+ for (const entry of candidates) counts.set(entry.id, (counts.get(entry.id) || 0) + 1);
+ return candidates
+ .filter(entry => counts.get(entry.id) === 1)
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
+}
+
+function managedOnlineBackupFocusTarget() {
+ const active = document.activeElement;
+ const id = active?.dataset?.managedOnlineBackupId;
+ const action = active?.dataset?.managedOnlineBackupAction;
+ if (!id || !action) return null;
+ return { id, action, fallbackIndex: managedOnlineBackups.findIndex(entry => entry.id === id) };
+}
+
+function restoreManagedOnlineBackupFocus(target) {
+ if (!target) return;
+ let button = [...document.querySelectorAll('[data-managed-online-backup-action]')]
+ .find(candidate => candidate.dataset.managedOnlineBackupId === target.id && candidate.dataset.managedOnlineBackupAction === target.action);
+ if (!button && Number.isInteger(target.fallbackIndex)) {
+ const rows = [...document.querySelectorAll('.online-backup-managed-row')];
+ const row = rows[Math.min(Math.max(0, target.fallbackIndex), Math.max(0, rows.length - 1))];
+ button = row?.querySelector(`[data-managed-online-backup-action="${target.action}"]`);
+ }
+ button ||= document.getElementById('createOnlineBackupBtn');
+ if (button && !button.disabled) button.focus();
+}
+
+function replaceManagedOnlineBackups(entries, focusTarget = undefined) {
+ const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget;
managedOnlineBackups = normalizeManagedOnlineBackups(entries);
managedOnlineBackupsAuthoritative = true;
- renderManagedOnlineBackups();
+ renderManagedOnlineBackups(target);
}
-function invalidateManagedOnlineBackups() {
- managedOnlineBackups = [];
- managedOnlineBackupsAuthoritative = false;
- renderManagedOnlineBackups();
+function upsertManagedOnlineBackup(entry) {
+ const normalized = normalizeManagedOnlineBackups([entry]);
+ if (normalized.length !== 1) return false;
+ replaceManagedOnlineBackups([
+ ...managedOnlineBackups.filter(current => current.id !== normalized[0].id),
+ normalized[0]
+ ]);
+ return true;
}
-function renderManagedOnlineBackups() {
+function removeManagedOnlineBackup(id, focusTarget = null) {
+ if (!hasManagedOnlineBackup(id)) return false;
+ replaceManagedOnlineBackups(managedOnlineBackups.filter(entry => entry.id !== id), focusTarget);
+ return true;
+}
+
+function renderManagedOnlineBackups(focusTarget = undefined) {
const list = document.getElementById('managedOnlineBackupList');
if (!list) return;
+ const target = focusTarget === undefined ? managedOnlineBackupFocusTarget() : focusTarget;
const content = document.createDocumentFragment();
if (!managedOnlineBackupsAuthoritative) {
list.replaceChildren(content);
@@ -2717,8 +2772,10 @@ function renderManagedOnlineBackups() {
for (const entry of managedOnlineBackups) {
const row = document.createElement('article');
row.className = 'online-backup-managed-row';
+ row.dataset.managedOnlineBackupId = entry.id;
const key = document.createElement('span');
key.className = 'online-backup-managed-key';
+ key.id = `managedOnlineBackupKey-${entry.id}`;
key.textContent = entry.displayKey;
const created = document.createElement('span');
created.className = 'online-backup-managed-created';
@@ -2729,10 +2786,16 @@ function renderManagedOnlineBackups() {
copyButton.type = 'button';
copyButton.className = 'btn btn-secondary online-backup-copy-btn';
copyButton.textContent = localizeUiText('Schlüssel kopieren');
+ copyButton.dataset.managedOnlineBackupId = entry.id;
+ copyButton.dataset.managedOnlineBackupAction = 'copy';
+ copyButton.setAttribute('aria-describedby', key.id);
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'btn btn-danger online-backup-delete-btn';
deleteButton.textContent = localizeUiText('Online-Backup löschen');
+ deleteButton.dataset.managedOnlineBackupId = entry.id;
+ deleteButton.dataset.managedOnlineBackupAction = 'delete';
+ deleteButton.setAttribute('aria-describedby', key.id);
const pending = managedOnlineBackupOperationQueues.has(entry.id);
copyButton.disabled = pending;
deleteButton.disabled = pending;
@@ -2744,39 +2807,61 @@ function renderManagedOnlineBackups() {
}
}
list.replaceChildren(content);
+ restoreManagedOnlineBackupFocus(target);
}
-async function loadManagedOnlineBackups({ statusContext = null } = {}) {
- const authoritative = statusContext !== null;
- const operationGeneration = managedOnlineBackupOperationGeneration;
- const startedDuringOperation = managedOnlineBackupActiveOperations > 0;
+function renderManagedOnlineBackupRefreshIssue() {
+ const container = document.getElementById('managedOnlineBackupRefreshStatus');
+ const message = document.getElementById('managedOnlineBackupRefreshMessage');
+ const retry = document.getElementById('reloadManagedOnlineBackupsBtn');
+ if (!container || !message || !retry) return;
+ const issue = managedOnlineBackupRefreshIssue;
+ container.hidden = !issue;
+ container.dataset.state = issue?.state || '';
+ message.textContent = issue ? localizeUiText(issue.message) : '';
+ retry.textContent = localizeUiText('Erneut laden');
+ retry.onclick = issue ? async () => {
+ retry.disabled = true;
+ await loadManagedOnlineBackups();
+ if (!retry.isConnected) return;
+ retry.disabled = false;
+ if (!container.hidden) retry.focus();
+ else restoreManagedOnlineBackupFocus({ id: managedOnlineBackups[0]?.id, action: 'copy', fallbackIndex: 0 });
+ } : null;
+}
+
+function setManagedOnlineBackupRefreshIssue(message = '', state = 'warning') {
+ const sanitized = String(message || '').replace(/MHU2-[A-Za-z0-9_-]{70}/gu, localizeUiText('Geschützter Schlüssel'));
+ managedOnlineBackupRefreshIssue = sanitized ? { message: sanitized, state } : null;
+ renderManagedOnlineBackupRefreshIssue();
+}
+
+async function loadManagedOnlineBackups({ mutationGeneration = null } = {}) {
+ const authoritative = mutationGeneration !== null;
+ const capturedMutationGeneration = managedOnlineBackupMutationGeneration;
+ const startedDuringMutation = managedOnlineBackupActiveMutations > 0;
const generation = authoritative
? ++managedOnlineBackupAuthoritativeLoadGeneration
: ++managedOnlineBackupNavigationLoadGeneration;
- const loaderStatusContext = authoritative || !startedDuringOperation ? statusContext ?? beginOnlineBackupStatusContext() : null;
- const mayUpdateStatus = loaderStatusContext !== null;
try {
const result = await window.api.listManagedOnlineBackups();
const stale = authoritative
- ? generation !== managedOnlineBackupAuthoritativeLoadGeneration || operationGeneration !== managedOnlineBackupOperationGeneration
- : generation !== managedOnlineBackupNavigationLoadGeneration || operationGeneration !== managedOnlineBackupOperationGeneration || startedDuringOperation;
+ ? generation !== managedOnlineBackupAuthoritativeLoadGeneration || mutationGeneration !== managedOnlineBackupMutationGeneration
+ : generation !== managedOnlineBackupNavigationLoadGeneration || capturedMutationGeneration !== managedOnlineBackupMutationGeneration || startedDuringMutation;
if (stale) return false;
if (!result?.ok || !Array.isArray(result.entries)) {
- invalidateManagedOnlineBackups();
- if (mayUpdateStatus) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error', loaderStatusContext);
+ setManagedOnlineBackupRefreshIssue(result?.error || 'Online-Sicherungen konnten nicht geladen werden', 'error');
return false;
}
replaceManagedOnlineBackups(result.entries);
- if (mayUpdateStatus) setOnlineBackupStatus('', '', loaderStatusContext);
+ if (result.warning) setManagedOnlineBackupRefreshIssue(result.warning, 'warning');
+ else setManagedOnlineBackupRefreshIssue();
return true;
} catch {
const current = authoritative
- ? generation === managedOnlineBackupAuthoritativeLoadGeneration && operationGeneration === managedOnlineBackupOperationGeneration
- : generation === managedOnlineBackupNavigationLoadGeneration && operationGeneration === managedOnlineBackupOperationGeneration && !startedDuringOperation;
- if (current) {
- invalidateManagedOnlineBackups();
- if (mayUpdateStatus) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error', loaderStatusContext);
- }
+ ? generation === managedOnlineBackupAuthoritativeLoadGeneration && mutationGeneration === managedOnlineBackupMutationGeneration
+ : generation === managedOnlineBackupNavigationLoadGeneration && capturedMutationGeneration === managedOnlineBackupMutationGeneration && !startedDuringMutation;
+ if (current) setManagedOnlineBackupRefreshIssue('Online-Sicherungen konnten nicht geladen werden', 'error');
return false;
}
}
@@ -2785,15 +2870,21 @@ function hasManagedOnlineBackup(id) {
return managedOnlineBackups.some(entry => entry.id === id);
}
-function enqueueManagedOnlineBackupOperation(id, action, operation) {
- const statusContext = beginManagedOnlineBackupOperation();
+function enqueueManagedOnlineBackupOperation(id, action, operation, mutation = false) {
const previous = managedOnlineBackupOperationQueues.get(id)?.promise || Promise.resolve();
- const promise = previous.catch(() => {}).then(() => operation(statusContext));
- const state = { action, promise };
+ const state = { action, promise: null };
+ const promise = previous.catch(() => {}).then(async () => {
+ const authority = mutation ? beginManagedOnlineBackupMutation() : null;
+ try {
+ return await operation(authority);
+ } finally {
+ if (authority) endManagedOnlineBackupMutation();
+ }
+ });
+ state.promise = promise;
managedOnlineBackupOperationQueues.set(id, state);
renderManagedOnlineBackups();
return promise.finally(() => {
- endManagedOnlineBackupOperation();
if (managedOnlineBackupOperationQueues.get(id) !== state) return;
managedOnlineBackupOperationQueues.delete(id);
renderManagedOnlineBackups();
@@ -2803,18 +2894,20 @@ function enqueueManagedOnlineBackupOperation(id, action, operation) {
async function copyManagedOnlineBackup(entry) {
const id = entry.id;
if (!hasManagedOnlineBackup(id) || managedOnlineBackupOperationQueues.has(id)) return;
- await enqueueManagedOnlineBackupOperation(id, 'copy', async (statusContext) => {
+ const focusTarget = { id, action: 'copy', fallbackIndex: managedOnlineBackups.findIndex(current => current.id === id) };
+ await enqueueManagedOnlineBackupOperation(id, 'copy', async () => {
try {
const result = await window.api.copyManagedOnlineBackup(id);
if (!result?.ok) {
- setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error', statusContext);
+ showCopyToast(result?.error || 'Online-Sicherung konnte nicht kopiert werden');
return;
}
showCopyToast('Online-Schlüssel kopiert');
} catch {
- setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error', statusContext);
+ showCopyToast('Online-Sicherung konnte nicht kopiert werden');
}
});
+ renderManagedOnlineBackups(focusTarget);
}
async function deleteManagedOnlineBackup(entry) {
@@ -2827,32 +2920,35 @@ async function deleteManagedOnlineBackup(entry) {
});
if (!confirmed) return;
if (!hasManagedOnlineBackup(id)) {
- const statusContext = beginManagedOnlineBackupOperation();
+ const statusContext = beginOnlineBackupStatusContext();
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
- endManagedOnlineBackupOperation();
return;
}
if (managedOnlineBackupOperationQueues.get(id)?.action === 'delete') {
setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error');
return;
}
- await enqueueManagedOnlineBackupOperation(id, 'delete', async (statusContext) => {
+ const fallbackIndex = managedOnlineBackups.findIndex(current => current.id === id);
+ let removed = false;
+ await enqueueManagedOnlineBackupOperation(id, 'delete', async (authority) => {
if (!hasManagedOnlineBackup(id)) {
- setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
+ setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', authority.statusContext);
return;
}
try {
const result = await window.api.deleteManagedOnlineBackup(id);
if (!result?.ok || result.removedId !== id) {
- setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
+ setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht gelöscht werden', 'error', authority.statusContext);
return;
}
- if (!await loadManagedOnlineBackups({ statusContext })) return;
- setOnlineBackupStatus('Schlüssel gelöscht', 'success', statusContext);
+ removed = removeManagedOnlineBackup(id, { id, action: 'delete', fallbackIndex });
+ await loadManagedOnlineBackups({ mutationGeneration: authority.mutationGeneration });
+ setOnlineBackupStatus('Schlüssel gelöscht', 'success', authority.statusContext);
} catch {
- setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', statusContext);
+ setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error', authority.statusContext);
}
- });
+ }, true);
+ renderManagedOnlineBackups({ id, action: 'delete', fallbackIndex: removed ? fallbackIndex : managedOnlineBackups.findIndex(current => current.id === id) });
}
async function doOnlineBackupCreate() {
@@ -2863,29 +2959,29 @@ async function doOnlineBackupCreate() {
openOnlineBackupView();
} catch {
openOnlineBackupView();
- const statusContext = beginManagedOnlineBackupOperation();
+ const statusContext = beginOnlineBackupStatusContext();
setOnlineBackupStatus('Nicht alle Einstellungen konnten gespeichert werden', 'error', statusContext);
- endManagedOnlineBackupOperation();
doOnlineBackupCreate.busy = false;
return;
}
- const statusContext = beginManagedOnlineBackupOperation();
+ const authority = beginManagedOnlineBackupMutation();
const createButton = document.getElementById('createOnlineBackupBtn');
if (createButton) createButton.disabled = true;
- setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', statusContext);
+ setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy', authority.statusContext);
try {
const result = await window.api.createManagedOnlineBackup();
if (!result?.ok) {
- setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', statusContext);
+ setOnlineBackupStatus(result?.error || 'Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext);
return;
}
- if (!await loadManagedOnlineBackups({ statusContext })) return;
- setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success', statusContext);
+ if (!upsertManagedOnlineBackup(result.entry)) setManagedOnlineBackupRefreshIssue('Online-Sicherungen konnten nicht geladen werden', 'error');
+ await loadManagedOnlineBackups({ mutationGeneration: authority.mutationGeneration });
+ setOnlineBackupStatus('Neuer Schlüssel erstellt.', 'success', authority.statusContext);
showCopyToast('Online-Schlüssel erstellt');
} catch {
- setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', statusContext);
+ setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error', authority.statusContext);
} finally {
- endManagedOnlineBackupOperation();
+ endManagedOnlineBackupMutation();
if (createButton?.isConnected) createButton.disabled = false;
doOnlineBackupCreate.busy = false;
}
@@ -5088,6 +5184,10 @@ function renderSettings() {
Auf diesem Gerät erstellt
+