diff --git a/package.json b/package.json index fc91c25..86683ba 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap", "build:renderer": "vite build", "start": "cross-env NODE_ENV=production electron .", - "test": "vitest run", + "test": "npm run test:client && npm run test:backup-api", + "test:client": "vitest run", + "test:backup-api": "npm --prefix services/backup-api test", + "start:backup-api": "npm --prefix services/backup-api start", "self-check": "tsx tests/self-check.ts", "release:win": "npm run build && electron-builder --publish never --win nsis portable", "verify:release": "node scripts/verify_public_release.mjs" diff --git a/services/backup-api/.gitignore b/services/backup-api/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/services/backup-api/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/services/backup-api/README.md b/services/backup-api/README.md new file mode 100644 index 0000000..fa44b7d --- /dev/null +++ b/services/backup-api/README.md @@ -0,0 +1,36 @@ +# Multi-Debrid Backup API + +Die API speichert ausschließlich bereits clientseitig verschlüsselte, undurchsichtige Backups. Schlüssel und Klartext verlassen den Client nicht. + +Jeder Export wird als eigener unveränderlicher Datensatz gespeichert. Es gibt keine automatische Ablaufzeit und ein neuer Export überschreibt oder löscht keine älteren Sicherungen. + +## Konfiguration + +| Variable | Standard | Bedeutung | +|---|---:|---| +| `HOST` | `127.0.0.1` | Bind-Adresse | +| `PORT` | `8787` | HTTP-Port hinter einem TLS-Reverse-Proxy | +| `BACKUP_DATA_DIR` | `./data` | Persistentes Datenverzeichnis | +| `ALLOWED_ORIGINS` | leer | Kommagetrennte erlaubte Browser-Origins | +| `RATE_LIMIT_MAX` | `60` | Maximalzahl pro IP und Zeitfenster | +| `RATE_LIMIT_WINDOW_MS` | `60000` | Länge des Zeitfensters | +| `UPLOAD_RATE_LIMIT_MAX` | `10` | Maximale neue Sicherungen pro IP und Upload-Zeitfenster | +| `UPLOAD_RATE_LIMIT_WINDOW_MS` | `3600000` | Länge des separaten Upload-Zeitfensters | +| `MAX_STORAGE_BYTES` | `10737418240` | Globale Obergrenze des persistenten Speichers in Bytes | +| `TRUST_PROXY` | `false` | `true`, wenn der vertrauenswürdige Proxy `X-Forwarded-For` überschreibt | + +## Start + +```powershell +$env:BACKUP_DATA_DIR = 'C:\ProgramData\MultiDebridBackup' +$env:ALLOWED_ORIGINS = 'https://downloads.24-music.de' +$env:MAX_STORAGE_BYTES = '10737418240' +$env:TRUST_PROXY = 'true' +npm start +``` + +Der Dienst sollte nur hinter einem TLS-Reverse-Proxy öffentlich erreichbar sein. Bei `TRUST_PROXY=true` muss dieser den eingehenden `X-Forwarded-For`-Header vollständig ersetzen. Das Datenverzeichnis benötigt regelmäßige Dateisystem-Backups. + +## HTTP-Vertrag + +`POST /v1/backups` akzeptiert `id`, `blob` und `deleteVerifier`. `POST /v1/backups/restore` akzeptiert `id` und liefert ausschließlich `blob`. `POST /v1/backups/delete` akzeptiert `id` und `deleteSecret`. Fehlerhafte Löschgeheimnisse und unbekannte IDs sind nicht unterscheidbar. IDs erscheinen nie in URLs. diff --git a/services/backup-api/package-lock.json b/services/backup-api/package-lock.json new file mode 100644 index 0000000..a1e267b --- /dev/null +++ b/services/backup-api/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "multi-debrid-backup-api", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "multi-debrid-backup-api", + "version": "2.0.0", + "dependencies": { + "proper-lockfile": "4.1.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + } + } +} diff --git a/services/backup-api/package.json b/services/backup-api/package.json new file mode 100644 index 0000000..83be642 --- /dev/null +++ b/services/backup-api/package.json @@ -0,0 +1,16 @@ +{ + "name": "multi-debrid-backup-api", + "version": "2.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/cli.mjs", + "test": "node --test" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "proper-lockfile": "4.1.2" + } +} diff --git a/services/backup-api/src/cli.mjs b/services/backup-api/src/cli.mjs new file mode 100644 index 0000000..7419425 --- /dev/null +++ b/services/backup-api/src/cli.mjs @@ -0,0 +1,40 @@ +import { resolve } from 'node:path' +import { createBackupServer } from './server.mjs' + +const port = Number.parseInt(process.env.PORT ?? '8787', 10) +const host = process.env.HOST ?? '127.0.0.1' +const rootDir = resolve(process.env.BACKUP_DATA_DIR ?? './data') +const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? '') + .split(',') + .map(origin => origin.trim()) + .filter(Boolean) +const rateLimit = { + max: Number.parseInt(process.env.RATE_LIMIT_MAX ?? '60', 10), + windowMs: Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '60000', 10) +} +const uploadRateLimit = { + max: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_MAX ?? '10', 10), + windowMs: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS ?? '3600000', 10) +} +const maxStorageBytes = Number.parseInt(process.env.MAX_STORAGE_BYTES ?? String(10 * 1024 * 1024 * 1024), 10) +const trustedProxy = process.env.TRUST_PROXY === 'true' + +if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT') + +const server = createBackupServer({ rootDir, allowedOrigins, rateLimit, uploadRateLimit, maxStorageBytes, trustedProxy }) + +server.listen(port, host, () => { + process.stdout.write(`Backup API listening on ${host}:${port}\n`) +}) + +function shutdown() { + server.close(error => { + if (error) { + process.stderr.write('Backup API shutdown failed\n') + process.exitCode = 1 + } + }) +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) diff --git a/services/backup-api/src/server.d.mts b/services/backup-api/src/server.d.mts new file mode 100644 index 0000000..5e23c99 --- /dev/null +++ b/services/backup-api/src/server.d.mts @@ -0,0 +1,18 @@ +import type { Server } from "node:http"; + +export interface BackupServerOptions { + rootDir: string; + allowedOrigins?: string[]; + rateLimit?: { + max: number; + windowMs: number; + }; + uploadRateLimit?: { + max: number; + windowMs: number; + }; + maxStorageBytes?: number; + trustedProxy?: boolean; +} + +export function createBackupServer(options: BackupServerOptions): Server; diff --git a/services/backup-api/src/server.mjs b/services/backup-api/src/server.mjs new file mode 100644 index 0000000..834f88f --- /dev/null +++ b/services/backup-api/src/server.mjs @@ -0,0 +1,463 @@ +import { createHash, timingSafeEqual, randomBytes } from 'node:crypto' +import { createServer } from 'node:http' +import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises' +import { isIP } from 'node:net' +import { join } from 'node:path' +import lockfile from 'proper-lockfile' + +const maxBlobBytes = 256 * 1024 +const maxBodyBytes = 384 * 1024 +const idPattern = /^[A-Za-z0-9_-]{22}$/ +const verifierPattern = /^[A-Za-z0-9_-]{43}$/ +const blobPattern = /^[A-Za-z0-9_-]+$/ +const notFoundBody = '{"error":"not_found"}' + +function isCanonicalBase64Url(value, byteLength, pattern) { + if (typeof value !== 'string' || !pattern.test(value)) return false + const decoded = Buffer.from(value, 'base64url') + return decoded.length === byteLength && decoded.toString('base64url') === value +} + +function isValidBackup(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false + const keys = Object.keys(payload).sort() + if (keys.join(',') !== 'blob,deleteVerifier,id') return false + if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false + if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false + if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false + const decoded = Buffer.from(payload.blob, 'base64url') + return decoded.length <= maxBlobBytes && decoded.toString('base64url') === payload.blob +} + +function createRateLimiter({ max, windowMs }) { + const clients = new Map() + let requestCount = 0 + return address => { + const now = Date.now() + requestCount += 1 + if (requestCount % 1024 === 0) { + for (const [key, value] of clients) { + if (now - value.startedAt >= windowMs) clients.delete(key) + } + } + const current = clients.get(address) + if (!current || now - current.startedAt >= windowMs) { + clients.set(address, { startedAt: now, count: 1 }) + return null + } + if (current.count >= max) return Math.max(1, Math.ceil((windowMs - (now - current.startedAt)) / 1000)) + current.count += 1 + return null + } +} + +function readJsonBody(request) { + const declaredLength = Number.parseInt(request.headers['content-length'] ?? '', 10) + if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) { + request.resume() + return Promise.resolve({ error: 413 }) + } + return new Promise((resolve, reject) => { + let size = 0 + let settled = false + const chunks = [] + const cleanup = () => { + request.off('data', onData) + request.off('end', onEnd) + request.off('aborted', onAborted) + request.off('error', onError) + } + const finish = result => { + if (settled) return + settled = true + cleanup() + resolve(result) + } + const onData = chunk => { + size += chunk.length + if (size > maxBodyBytes) { + finish({ error: 413 }) + request.resume() + return + } + chunks.push(chunk) + } + const onEnd = () => { + try { + finish({ value: JSON.parse(Buffer.concat(chunks).toString('utf8')) }) + } catch { + finish({ error: 400 }) + } + } + const onAborted = () => reject(new Error('Request aborted')) + const onError = error => reject(error) + request.on('data', onData) + request.on('end', onEnd) + request.on('aborted', onAborted) + request.on('error', onError) + }) +} + +function recordPath(rootDir, id) { + return join(rootDir, `${id}.json`) +} + +function createMutationQueue() { + let pending = Promise.resolve() + return operation => { + const result = pending.then(operation, operation) + pending = result.catch(() => {}) + return result + } +} + +async function directoryUsage(rootDir) { + let total = 0 + const entries = await readdir(rootDir, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue + try { + total += (await stat(join(rootDir, entry.name))).size + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + } + return total +} + +async function syncDirectory(rootDir) { + let handle + try { + handle = await open(rootDir, 'r') + await handle.sync() + } catch (error) { + if (!['EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM', 'EBADF'].includes(error.code)) throw error + } finally { + await handle?.close().catch(() => {}) + } +} + +async function withStorageLock(rootDir, operation) { + await mkdir(rootDir, { recursive: true }) + const release = await lockfile.lock(rootDir, { + realpath: false, + lockfilePath: join(rootDir, '.storage.lock'), + stale: 30_000, + update: 10_000, + retries: { + retries: 100, + factor: 1.1, + minTimeout: 10, + maxTimeout: 100, + randomize: true + } + }) + try { + return await operation() + } finally { + let releaseError + try { + await release() + } catch (error) { + releaseError = error + } + try { + await syncDirectory(rootDir) + } catch (error) { + releaseError ??= error + } + if (releaseError) throw releaseError + } +} + +async function recordExists(rootDir, id) { + try { + await stat(recordPath(rootDir, id)) + return true + } catch (error) { + if (error.code === 'ENOENT') return false + throw error + } +} + +async function createRecord(rootDir, payload, maxStorageBytes) { + await mkdir(rootDir, { recursive: true }) + if (await recordExists(rootDir, payload.id)) return 'duplicate' + const contents = Buffer.from(JSON.stringify({ + version: 1, + blob: payload.blob, + deleteVerifier: payload.deleteVerifier, + createdAt: new Date().toISOString() + }), 'utf8') + if (await directoryUsage(rootDir) + contents.length > maxStorageBytes) return 'full' + const temporaryPath = join(rootDir, `.${randomBytes(16).toString('hex')}.tmp`) + let handle + let temporaryCreated = false + let published = false + try { + handle = await open(temporaryPath, 'wx', 0o600) + temporaryCreated = true + try { + await handle.writeFile(contents) + await handle.sync() + } finally { + await handle.close() + handle = undefined + } + try { + await link(temporaryPath, recordPath(rootDir, payload.id)) + } catch (error) { + if (error.code === 'EEXIST') return 'duplicate' + throw error + } + published = true + return 'created' + } finally { + let cleanupError + try { + await handle?.close() + } catch (error) { + cleanupError = error + } + if (temporaryCreated) { + try { + await unlink(temporaryPath) + } catch (error) { + cleanupError ??= error + } + } + if (published) { + try { + await syncDirectory(rootDir) + } catch (error) { + cleanupError ??= error + } + } + if (cleanupError) throw cleanupError + } +} + +async function readRecord(rootDir, id) { + try { + const raw = await readFile(recordPath(rootDir, id), 'utf8') + const record = JSON.parse(raw) + if (record?.version !== 1 || typeof record.blob !== 'string' || !isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)) { + throw new Error('Invalid stored record') + } + return record + } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } +} + +function securityHeaders(response) { + response.setHeader('cache-control', 'no-store') + response.setHeader('x-content-type-options', 'nosniff') + response.setHeader('content-security-policy', "default-src 'none'") + response.setHeader('referrer-policy', 'no-referrer') +} + +function sendJson(response, status, body) { + response.statusCode = status + response.setHeader('content-type', 'application/json; charset=utf-8') + response.end(JSON.stringify(body)) +} + +function sendNotFound(response) { + response.statusCode = 404 + response.setHeader('content-type', 'application/json; charset=utf-8') + response.end(notFoundBody) +} + +function authorizeOrigin(request, response, allowedOrigins) { + const origin = request.headers.origin + if (!origin) return true + if (!allowedOrigins.has(origin)) { + sendJson(response, 403, { error: 'origin_denied' }) + return false + } + response.setHeader('access-control-allow-origin', origin) + response.setHeader('vary', 'Origin') + return true +} + +function verifierMatches(secret, expectedVerifier) { + const actual = createHash('sha256').update(Buffer.from(secret, 'base64url')).digest() + const expected = Buffer.from(expectedVerifier, 'base64url') + return actual.length === expected.length && timingSafeEqual(actual, expected) +} + +async function storageIsReady(rootDir) { + const probePath = join(rootDir, `.${randomBytes(16).toString('hex')}.health`) + try { + await mkdir(rootDir, { recursive: true }) + const handle = await open(probePath, 'wx', 0o600) + await handle.close() + await unlink(probePath) + return true + } catch { + await unlink(probePath).catch(() => {}) + return false + } +} + +function clientAddress(request, trustedProxy) { + if (trustedProxy) { + const forwarded = request.headers['x-forwarded-for'] + const candidate = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',', 1)[0].trim() + if (candidate && isIP(candidate)) return candidate + } + return request.socket.remoteAddress ?? 'unknown' +} + +export function createBackupServer(options) { + if (!options?.rootDir) throw new Error('rootDir is required') + const allowedOrigins = new Set(options.allowedOrigins ?? []) + const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 } + const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 } + const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024 + if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) { + throw new Error('Invalid rate limit') + } + if (!Number.isSafeInteger(uploadRateLimit.max) || uploadRateLimit.max < 1 || !Number.isSafeInteger(uploadRateLimit.windowMs) || uploadRateLimit.windowMs < 1) { + throw new Error('Invalid upload rate limit') + } + if (!Number.isSafeInteger(maxStorageBytes) || maxStorageBytes < 1) throw new Error('Invalid max storage size') + const consumeRateLimit = createRateLimiter(rateLimit) + const consumeUploadRateLimit = createRateLimiter(uploadRateLimit) + const runStorageMutation = createMutationQueue() + + return createServer(async (request, response) => { + securityHeaders(response) + try { + const url = new URL(request.url, 'http://localhost') + if (!authorizeOrigin(request, response, allowedOrigins)) return + if (url.search) { + sendNotFound(response) + return + } + + if (request.method === 'OPTIONS') { + const requestedMethod = request.headers['access-control-request-method'] + if (!request.headers.origin || requestedMethod !== 'POST') { + sendJson(response, 400, { error: 'invalid_preflight' }) + return + } + response.statusCode = 204 + response.setHeader('access-control-allow-methods', 'POST, OPTIONS') + response.setHeader('access-control-allow-headers', 'content-type') + response.setHeader('access-control-max-age', '600') + response.end() + return + } + + if (request.method === 'GET' && url.pathname === '/health') { + const ready = await storageIsReady(options.rootDir) + sendJson(response, ready ? 200 : 503, { status: ready ? 'ok' : 'unavailable' }) + return + } + + if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') { + const retryAfter = consumeRateLimit(clientAddress(request, options.trustedProxy === true)) + if (retryAfter !== null) { + response.setHeader('retry-after', String(retryAfter)) + sendJson(response, 429, { error: 'rate_limited' }) + return + } + } + + if (request.method === 'POST' && ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete'].includes(url.pathname)) { + if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') { + sendJson(response, 415, { error: 'unsupported_media_type' }) + return + } + const parsed = await readJsonBody(request) + if (parsed.error) { + if (parsed.error === 413) response.setHeader('connection', 'close') + sendJson(response, parsed.error, { error: parsed.error === 413 ? 'payload_too_large' : 'invalid_request' }) + return + } + if (url.pathname === '/v1/backups/restore') { + const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value) + ? Object.keys(parsed.value) + : [] + if (keys.length !== 1 || keys[0] !== 'id' || !isCanonicalBase64Url(parsed.value.id, 16, idPattern)) { + sendNotFound(response) + return + } + const record = await readRecord(options.rootDir, parsed.value.id) + if (!record) { + sendNotFound(response) + return + } + sendJson(response, 200, { blob: record.blob }) + return + } + if (url.pathname === '/v1/backups/delete') { + const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value) + ? Object.keys(parsed.value).sort() + : [] + const valid = keys.join(',') === 'deleteSecret,id' + && isCanonicalBase64Url(parsed.value.id, 16, idPattern) + && isCanonicalBase64Url(parsed.value.deleteSecret, 32, verifierPattern) + if (!valid) { + sendNotFound(response) + return + } + const deleted = await runStorageMutation(() => withStorageLock(options.rootDir, async () => { + const record = await readRecord(options.rootDir, parsed.value.id) + if (!record || !verifierMatches(parsed.value.deleteSecret, record.deleteVerifier)) return false + try { + await unlink(recordPath(options.rootDir, parsed.value.id)) + return true + } catch (error) { + if (error.code === 'ENOENT') return false + throw error + } + })) + if (!deleted) { + sendNotFound(response) + return + } + response.statusCode = 204 + response.end() + return + } + if (typeof parsed.value?.blob === 'string' && blobPattern.test(parsed.value.blob) && Buffer.from(parsed.value.blob, 'base64url').length > maxBlobBytes) { + sendJson(response, 413, { error: 'payload_too_large' }) + return + } + if (!isValidBackup(parsed.value)) { + sendJson(response, 400, { error: 'invalid_request' }) + return + } + const uploadRetryAfter = consumeUploadRateLimit(clientAddress(request, options.trustedProxy === true)) + if (uploadRetryAfter !== null) { + response.setHeader('retry-after', String(uploadRetryAfter)) + sendJson(response, 429, { error: 'rate_limited' }) + return + } + const result = await runStorageMutation(() => withStorageLock( + options.rootDir, + () => createRecord(options.rootDir, parsed.value, maxStorageBytes) + )) + if (result === 'duplicate') { + sendJson(response, 409, { error: 'already_exists' }) + return + } + if (result === 'full') { + sendJson(response, 507, { error: 'insufficient_storage' }) + return + } + sendJson(response, 201, { created: true }) + return + } + + sendNotFound(response) + } catch { + if (!response.headersSent) sendJson(response, 500, { error: 'internal_error' }) + else response.destroy() + } + }) +} diff --git a/services/backup-api/test/server.test.mjs b/services/backup-api/test/server.test.mjs new file mode 100644 index 0000000..d8111b7 --- /dev/null +++ b/services/backup-api/test/server.test.mjs @@ -0,0 +1,511 @@ +import assert from 'node:assert/strict' +import { createHash, randomBytes } from 'node:crypto' +import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { request as createHttpRequest } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import lockfile from 'proper-lockfile' +import { createBackupServer } from '../src/server.mjs' + +const allowedOrigin = 'https://downloads.24-music.de' + +function backupFixture() { + const deleteSecret = randomBytes(32).toString('base64url') + return { + deleteSecret, + payload: { + id: randomBytes(16).toString('base64url'), + blob: randomBytes(96).toString('base64url'), + deleteVerifier: createHash('sha256').update(Buffer.from(deleteSecret, 'base64url')).digest('base64url') + } + } +} + +async function startApi(options = {}) { + const rootDir = await mkdtemp(join(tmpdir(), 'mdd-backup-api-')) + const server = createBackupServer({ + rootDir, + allowedOrigins: [allowedOrigin], + rateLimit: { max: 100, windowMs: 60_000 }, + ...options + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + return { + rootDir, + server, + baseUrl: `http://127.0.0.1:${address.port}`, + async close() { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + await rm(rootDir, { recursive: true, force: true }) + } + } +} + +async function request(api, path, options = {}) { + return fetch(`${api.baseUrl}${path}`, options) +} + +test('health endpoint reports readiness without exposing storage details', async t => { + const api = await startApi() + t.after(() => api.close()) + + const response = await request(api, '/health') + + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { status: 'ok' }) + assert.equal(response.headers.get('cache-control'), 'no-store') +}) + +test('health endpoint rejects an unusable storage path', async t => { + const container = await mkdtemp(join(tmpdir(), 'mdd-backup-health-')) + const rootDir = join(container, 'not-a-directory') + await writeFile(rootDir, 'occupied') + const server = createBackupServer({ rootDir, allowedOrigins: [allowedOrigin] }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const baseUrl = `http://127.0.0.1:${server.address().port}` + t.after(async () => { + await new Promise(resolve => server.close(resolve)) + await rm(container, { recursive: true, force: true }) + }) + + const response = await fetch(`${baseUrl}/health`) + + assert.equal(response.status, 503) + assert.deepEqual(await response.json(), { status: 'unavailable' }) +}) + +test('creates and retrieves an immutable opaque backup across server restarts', async t => { + const api = await startApi() + const fixture = backupFixture() + t.after(async () => { + if (api.server.listening) await new Promise(resolve => api.server.close(resolve)) + await rm(api.rootDir, { recursive: true, force: true }) + }) + + const created = await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: allowedOrigin }, + body: JSON.stringify(fixture.payload) + }) + + assert.equal(created.status, 201) + assert.deepEqual(await created.json(), { created: true }) + await new Promise(resolve => api.server.close(resolve)) + + api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] }) + await new Promise((resolve, reject) => { + api.server.once('error', reject) + api.server.listen(0, '127.0.0.1', resolve) + }) + api.baseUrl = `http://127.0.0.1:${api.server.address().port}` + + const retrieved = await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: allowedOrigin }, + body: JSON.stringify({ id: fixture.payload.id }) + }) + assert.equal(retrieved.status, 200) + assert.deepEqual(await retrieved.json(), { blob: fixture.payload.blob }) + + const duplicate = await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: allowedOrigin }, + body: JSON.stringify({ ...fixture.payload, blob: randomBytes(96).toString('base64url') }) + }) + assert.equal(duplicate.status, 409) + + const unchanged = await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: fixture.payload.id }) + }) + assert.deepEqual(await unchanged.json(), { blob: fixture.payload.blob }) +}) + +test('validates IDs, verifiers, content type, JSON and opaque blob encoding', async t => { + const api = await startApi() + t.after(() => api.close()) + const fixture = backupFixture() + const invalidPayloads = [ + { ...fixture.payload, id: 'short' }, + { ...fixture.payload, blob: 'not+base64url' }, + { ...fixture.payload, deleteVerifier: 'short' }, + { id: fixture.payload.id, blob: fixture.payload.blob }, + { ...fixture.payload, extra: true } + ] + + for (const payload of invalidPayloads) { + const response = await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload) + }) + assert.equal(response.status, 400) + } + + const malformed = await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{' + }) + assert.equal(malformed.status, 400) + + const wrongType = await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: JSON.stringify(fixture.payload) + }) + assert.equal(wrongType.status, 415) +}) + +test('accepts a 256 KiB decoded blob and rejects one byte more', async t => { + const api = await startApi() + t.after(() => api.close()) + const fixture = backupFixture() + + const accepted = await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...fixture.payload, blob: randomBytes(262_144).toString('base64url') }) + }) + const oversizedFixture = backupFixture() + const rejected = await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ...oversizedFixture.payload, blob: randomBytes(262_145).toString('base64url') }) + }) + + assert.equal(accepted.status, 201) + assert.equal(rejected.status, 413) +}) + +test('responds before an oversized request body finishes streaming', async t => { + const api = await startApi() + t.after(() => api.close()) + const url = new URL('/v1/backups', api.baseUrl) + const client = createHttpRequest(url, { + method: 'POST', + headers: { 'content-type': 'application/json' } + }) + t.after(() => client.destroy()) + + const responsePromise = new Promise((resolve, reject) => { + client.once('response', resolve) + client.once('error', reject) + }) + client.write('A'.repeat(393_217)) + const response = await Promise.race([ + responsePromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not reject streaming body')), 1_000)) + ]) + + assert.equal(response.statusCode, 413) + response.resume() +}) + +test('deletes only with the matching client secret and uses constant not-found responses', async t => { + const api = await startApi() + t.after(() => api.close()) + const fixture = backupFixture() + await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(fixture.payload) + }) + + const missing = await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: randomBytes(16).toString('base64url') }) + }) + const unauthorized = await request(api, '/v1/backups/delete', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: fixture.payload.id }) + }) + const wrongSecret = await request(api, '/v1/backups/delete', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: fixture.payload.id, deleteSecret: randomBytes(32).toString('base64url') }) + }) + + assert.equal(missing.status, 404) + assert.equal(unauthorized.status, 404) + assert.equal(wrongSecret.status, 404) + const missingBody = await missing.text() + const unauthorizedBody = await unauthorized.text() + const wrongSecretBody = await wrongSecret.text() + assert.equal(missingBody, unauthorizedBody) + assert.equal(unauthorizedBody, wrongSecretBody) + assert.equal(wrongSecretBody, '{"error":"not_found"}') + + const deleted = await request(api, '/v1/backups/delete', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: fixture.payload.id, deleteSecret: fixture.deleteSecret }) + }) + assert.equal(deleted.status, 204) + assert.equal((await readdir(api.rootDir)).length, 0) + + const afterDelete = await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: fixture.payload.id }) + }) + assert.equal(afterDelete.status, 404) +}) + +test('allows only configured browser origins and supports preflight', async t => { + const api = await startApi() + t.after(() => api.close()) + + const allowed = await request(api, '/health', { headers: { origin: allowedOrigin } }) + assert.equal(allowed.headers.get('access-control-allow-origin'), allowedOrigin) + assert.equal(allowed.headers.get('vary'), 'Origin') + + const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } }) + assert.equal(denied.status, 403) + assert.equal(denied.headers.get('access-control-allow-origin'), null) + + const preflight = await request(api, '/v1/backups', { + method: 'OPTIONS', + headers: { + origin: allowedOrigin, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'content-type' + } + }) + assert.equal(preflight.status, 204) + assert.equal(preflight.headers.get('access-control-allow-origin'), allowedOrigin) + assert.match(preflight.headers.get('access-control-allow-methods'), /POST/) +}) + +test('rate limits backup routes without limiting health checks', async t => { + const api = await startApi({ trustedProxy: true, rateLimit: { max: 2, windowMs: 60_000 } }) + t.after(() => api.close()) + + const restore = (id, address) => request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-for': address }, + body: JSON.stringify({ id }) + }) + const first = await restore(randomBytes(16).toString('base64url'), '198.51.100.1') + const independent = await restore(randomBytes(16).toString('base64url'), '198.51.100.2') + const second = await restore(randomBytes(16).toString('base64url'), '198.51.100.1') + const limited = await restore(randomBytes(16).toString('base64url'), '198.51.100.1') + const health = await request(api, '/health') + + assert.equal(first.status, 404) + assert.equal(independent.status, 404) + assert.equal(second.status, 404) + assert.equal(limited.status, 429) + assert.equal(limited.headers.get('retry-after'), '60') + assert.equal(health.status, 200) +}) + +test('enforces an atomic global storage capacity without blocking existing restores', async t => { + const api = await startApi({ maxStorageBytes: 420 }) + const secondServer = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin], maxStorageBytes: 420 }) + await new Promise((resolve, reject) => { + secondServer.once('error', reject) + secondServer.listen(0, '127.0.0.1', resolve) + }) + const secondBaseUrl = `http://127.0.0.1:${secondServer.address().port}` + t.after(() => api.close()) + t.after(() => new Promise(resolve => secondServer.close(resolve))) + const first = backupFixture() + const second = backupFixture() + const create = (fixture, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(fixture.payload) + }) + + const results = await Promise.all([create(first), create(second, secondBaseUrl)]) + + assert.deepEqual(results.map(response => response.status).sort(), [201, 507]) + const stored = results[0].status === 201 ? first : second + const restored = await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: stored.payload.id }) + }) + assert.equal(restored.status, 200) + const storedFiles = await readdir(api.rootDir) + assert.equal(storedFiles.length, 1) + assert.match(storedFiles[0], /^[A-Za-z0-9_-]{22}\.json$/) + + const deleted = await request(api, '/v1/backups/delete', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: stored.payload.id, deleteSecret: stored.deleteSecret }) + }) + assert.equal(deleted.status, 204) + const blocked = stored === first ? second : first + assert.equal((await create(blocked)).status, 201) +}) + +test('limits anonymous uploads separately while keeping restores available', async t => { + const api = await startApi({ + rateLimit: { max: 2, windowMs: 60_000 }, + uploadRateLimit: { max: 1, windowMs: 60_000 } + }) + t.after(() => api.close()) + const first = backupFixture() + const second = backupFixture() + const create = fixture => request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(fixture.payload) + }) + + assert.equal((await create(first)).status, 201) + assert.equal((await create(second)).status, 429) + const restored = await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: first.payload.id }) + }) + assert.equal(restored.status, 200) +}) + +test('never takes over an old storage lock that may still have an active owner', async t => { + const api = await startApi() + t.after(() => api.close()) + const lockPath = join(api.rootDir, '.storage.lock') + const release = await lockfile.lock(api.rootDir, { + realpath: false, + lockfilePath: lockPath, + stale: 30_000, + update: 10_000 + }) + t.after(() => release().catch(() => {})) + const fixture = backupFixture() + const pending = request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(fixture.payload) + }) + + const early = await Promise.race([ + pending.then(() => 'responded'), + new Promise(resolve => setTimeout(() => resolve('waiting'), 100)) + ]) + + assert.equal(early, 'waiting') + await release() + assert.equal((await pending).status, 201) +}) + +test('recovers a storage lock left by a terminated process', async t => { + const api = await startApi() + t.after(() => api.close()) + const lockPath = join(api.rootDir, '.storage.lock') + await mkdir(lockPath) + const old = new Date(Date.now() - 60_000) + await utimes(lockPath, old, old) + const fixture = backupFixture() + const pending = request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(fixture.payload) + }) + + const early = await Promise.race([ + pending.then(response => response.status), + new Promise(resolve => setTimeout(() => resolve('timeout'), 500)) + ]) + await pending + + assert.equal(early, 201) + assert.equal((await readdir(api.rootDir)).some(name => name.includes('.stale.')), false) +}) + +test('revalidates delete authorization inside the storage lock', async t => { + const api = await startApi() + t.after(() => api.close()) + const original = backupFixture() + const replacement = backupFixture() + replacement.payload.id = original.payload.id + await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(original.payload) + }) + const lockPath = join(api.rootDir, '.storage.lock') + const release = await lockfile.lock(api.rootDir, { + realpath: false, + lockfilePath: lockPath, + stale: 30_000, + update: 10_000 + }) + t.after(() => release().catch(() => {})) + const pendingDelete = request(api, '/v1/backups/delete', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: original.payload.id, deleteSecret: original.deleteSecret }) + }) + await new Promise(resolve => setTimeout(resolve, 50)) + await writeFile(join(api.rootDir, `${original.payload.id}.json`), JSON.stringify({ + version: 1, + blob: replacement.payload.blob, + deleteVerifier: replacement.payload.deleteVerifier, + createdAt: new Date().toISOString() + })) + await release() + + assert.equal((await pendingDelete).status, 404) + const restored = await request(api, '/v1/backups/restore', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: original.payload.id }) + }) + assert.equal(restored.status, 200) + assert.deepEqual(await restored.json(), { blob: replacement.payload.blob }) +}) + +test('never accepts backup IDs in URLs', async t => { + const api = await startApi() + t.after(() => api.close()) + const id = randomBytes(16).toString('base64url') + + const read = await request(api, `/v1/backups/${id}`) + const remove = await request(api, `/v1/backups/${id}`, { method: 'DELETE' }) + const query = await request(api, `/v1/backups?backup=${id}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(backupFixture().payload) + }) + + assert.equal(read.status, 404) + assert.equal(remove.status, 404) + assert.equal(query.status, 404) +}) + +test('stored records contain no delete secret and operational logs contain no IDs or blobs', async t => { + const messages = [] + const api = await startApi({ logger: message => messages.push(String(message)) }) + t.after(() => api.close()) + const fixture = backupFixture() + + await request(api, '/v1/backups', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(fixture.payload) + }) + + const files = await readdir(api.rootDir) + assert.equal(files.length, 1) + const stored = await readFile(join(api.rootDir, files[0]), 'utf8') + assert.equal(stored.includes(fixture.deleteSecret), false) + assert.equal(messages.some(message => message.includes(fixture.payload.id)), false) + assert.equal(messages.some(message => message.includes(fixture.payload.blob)), false) +}) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index d651991..69cc17d 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -25,7 +25,7 @@ import { } from "../shared/types"; import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits"; import { importDlcContainers } from "./container"; -import { APP_VERSION } from "./constants"; +import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants"; import { DownloadManager } from "./download-manager"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check"; @@ -57,7 +57,8 @@ import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog import { buildAccountSummary, diffAccountSummary } from "./support-data"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; -import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; +import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; +import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -415,7 +416,7 @@ export class AppController { // object about to be applied, so they are never rolled back to a stale snapshot. // All-time totals take the max; daily/total usage and account statuses are taken // live; per-key Debrid-Link usage is filtered to keys that still exist. - private overlayLiveUsageCounters(target: AppSettings): void { + private overlayLiveUsageCounters(target: AppSettings): void { const liveSettings = this.manager.getSettings(); target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); @@ -429,8 +430,19 @@ export class AppController { target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries( Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId)) ); - target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) }; - } + target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) }; + } + + private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void { + const restoredSettings = normalizeSettings(importedSettings); + this.overlayLiveUsageCounters(restoredSettings); + this.settings = restoredSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings, { settingsOnlyImport: true }); + if (restoreRemoteDiagnostics) { + this.restoreRemoteDiagnosticsFromBackup(remoteDiagnostics, true); + } + } public updateSettings(partial: Partial): AppSettings { const sanitizedPatch = sanitizeSettingsPatch(partial); @@ -747,7 +759,7 @@ public async checkDebridAccounts(): Promise { this.audit("INFO", "Download-Statistik zurückgesetzt"); } - public exportBackup(): Buffer { + public exportBackup(): Buffer { let remoteDiagnostics: BackupRemoteDiagnostics | undefined; if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) { const status = getDebugServerRuntimeStatus(); @@ -771,8 +783,25 @@ public async checkDebridAccounts(): Promise { sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0, sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0 }); - return encryptBackup(JSON.stringify(payloadObj)); - } + return encryptBackup(JSON.stringify(payloadObj)); + } + + public async exportOnlineBackup(): Promise<{ key: string }> { + const created = createOnlineBackup({ ...this.settings }, APP_VERSION); + await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL); + this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only" }); + return { key: created.key }; + } + + public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> { + const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL); + this.applySettingsOnlyBackup(payload.settings); + this.audit("INFO", "Online-Sicherung importiert", { + kind: "settings-only", + accountSummary: buildAccountSummary(this.settings) + }); + return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" }; + } public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> { this.audit("INFO", "Support-Bundle exportiert"); @@ -824,20 +853,16 @@ public async checkDebridAccounts(): Promise { importedSettingsRecord[key] = currentSettingsRecord[key]; } } - const restoredSettings = normalizeSettings(importedSettings); + const restoredSettings = normalizeSettings(importedSettings); // Settings-only backup: keep the running queue AND the live counters untouched. // Overlay the live usage/status counters so they don't roll back to the backup's // (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the // backup's cleanup policy can't purge the live completed queue here (BUG B) — the // policy still governs FUTURE completions through the normal path. Do NOT stop the - // manager, wipe the session, block persistence or relaunch. - if (!hasSession) { - this.overlayLiveUsageCounters(restoredSettings); - this.settings = restoredSettings; - saveSettings(this.storagePaths, this.settings); - this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true }); - this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, true); + // manager, wipe the session, block persistence or relaunch. + if (!hasSession) { + this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true); this.audit("INFO", "Backup importiert (nur Einstellungen)", { accountSummary: buildAccountSummary(this.settings) }); diff --git a/src/main/constants.ts b/src/main/constants.ts index 2c1564f..0b895e1 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -38,6 +38,7 @@ export const SPEED_WINDOW_SECONDS = 1; export const CLIPBOARD_POLL_INTERVAL_MS = 2000; export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader"; +export const ONLINE_BACKUP_API_URL = "https://backup.24-music.de"; export function defaultSettings(): AppSettings { const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 385e05b..2ca78d9 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -2156,7 +2156,7 @@ export class DownloadManager extends EventEmitter { this.emitState(); } - public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean }): void { + public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void { const previous = this.settings; next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); @@ -2174,7 +2174,7 @@ export class DownloadManager extends EventEmitter { const nextOrder = JSON.stringify(next.providerOrder ?? []); const prevRouting = JSON.stringify(previous.hosterRouting ?? {}); const nextRouting = JSON.stringify(next.hosterRouting ?? {}); - if (prevOrder !== nextOrder || prevRouting !== nextRouting) { + if (!opts?.settingsOnlyImport && (prevOrder !== nextOrder || prevRouting !== nextRouting)) { const activeItemIds = new Set([...this.activeTasks.values()].map((t) => t.itemId)); for (const item of Object.values(this.session.items)) { if (!activeItemIds.has(item.id) && item.status !== "completed" && item.status !== "failed") { @@ -2185,7 +2185,7 @@ export class DownloadManager extends EventEmitter { const previousArchivePasswords = String(previous.archivePasswordList || "").replace(/\r\n|\r/g, "\n"); const nextArchivePasswords = String(next.archivePasswordList || "").replace(/\r\n|\r/g, "\n"); - if (previousArchivePasswords !== nextArchivePasswords) { + if (!opts?.settingsOnlyImport && previousArchivePasswords !== nextArchivePasswords) { this.hybridExtractedPaths.clear(); this.hybridFailedArchives.clear(); const pwCount = nextArchivePasswords.split("\n").filter(Boolean).length; @@ -2218,10 +2218,12 @@ export class DownloadManager extends EventEmitter { logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`); } - this.resolveExistingQueuedOpaqueFilenames(); - void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`)); - if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") { - this.applyRetroactiveCleanupPolicy(); + if (!opts?.settingsOnlyImport) { + this.resolveExistingQueuedOpaqueFilenames(); + void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`)); + if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") { + this.applyRetroactiveCleanupPolicy(); + } } this.emitState(); } diff --git a/src/main/main.ts b/src/main/main.ts index 1be2cf1..b208323 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -569,7 +569,7 @@ function registerIpcHandlers(): void { app.quit(); }); - ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { + ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { const options = { defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`, filters: [{ name: "MDD Backup", extensions: ["mdd"] }] @@ -580,8 +580,18 @@ function registerIpcHandlers(): void { } const encrypted = controller.exportBackup(); await fs.promises.writeFile(result.filePath, encrypted); - return { saved: true }; - }); + return { saved: true }; + }); + + ipcMain.handle(IPC_CHANNELS.EXPORT_ONLINE_BACKUP, async () => controller.exportOnlineBackup()); + + ipcMain.handle(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, async (_event: IpcMainInvokeEvent, rawKey: unknown) => { + const key = validateString(rawKey, "key").trim(); + if (key.length > 128) { + throw new Error("Online-Sicherungsschlüssel ist ungültig"); + } + return controller.importOnlineBackup(key); + }); ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { const options = { diff --git a/src/main/online-backup.ts b/src/main/online-backup.ts new file mode 100644 index 0000000..6dfc213 --- /dev/null +++ b/src/main/online-backup.ts @@ -0,0 +1,273 @@ +import crypto from "node:crypto"; +import zlib from "node:zlib"; +import type { AppSettings } from "../shared/types"; + +const KEY_PREFIX = "MDD2-"; +const KEY_BODY_LENGTH = 70; +const RECORD_ID_LENGTH = 16; +const MASTER_KEY_LENGTH = 32; +const CHECKSUM_LENGTH = 4; +const NONCE_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const BLOB_VERSION = 1; +const MAX_BLOB_BYTES = 256 * 1024; +const MAX_RESPONSE_BYTES = 512 * 1024; +const MAX_PLAINTEXT_BYTES = 512 * 1024; +const REQUEST_TIMEOUT_MS = 12_000; +const KEY_CONTEXT = Buffer.from("MDD2-ONLINE-KEY-V1", "utf8"); +const AAD_CONTEXT = Buffer.from("MDD-ONLINE-BACKUP-V1", "utf8"); + +export interface OnlineSettingsPayload { + version: 1; + kind: "settings-only"; + appVersion: string; + exportedAt: string; + settings: AppSettings; +} + +export interface OnlineBackupRecord { + id: string; + blob: string; + deleteVerifier: string; +} + +export interface CreatedOnlineBackup { + key: string; + record: OnlineBackupRecord; +} + +export interface ParsedOnlineBackupKey { + id: string; + idBytes: Buffer; + masterKey: Buffer; +} + +function checksum(idBytes: Buffer, masterKey: Buffer): Buffer { + return crypto.createHash("sha256").update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH); +} + +function deriveSecret(masterKey: Buffer, idBytes: Buffer, purpose: string): Buffer { + return Buffer.from(crypto.hkdfSync("sha256", masterKey, idBytes, Buffer.from(`MDD-ONLINE-${purpose}-V1`, "utf8"), 32)); +} + +function deriveDeleteSecret(parsed: ParsedOnlineBackupKey): Buffer { + return deriveSecret(parsed.masterKey, parsed.idBytes, "DELETE"); +} + +function aad(idBytes: Buffer): Buffer { + return Buffer.concat([AAD_CONTEXT, idBytes]); +} + +function encodeKey(idBytes: Buffer, masterKey: Buffer): string { + const body = Buffer.concat([idBytes, masterKey, checksum(idBytes, masterKey)]).toString("base64url"); + return `${KEY_PREFIX}${body}`; +} + +function validatePayload(value: unknown): OnlineSettingsPayload { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Online-Sicherung enthält keine gültigen Einstellungen"); + } + const record = value as Record; + if ( + record.version !== 1 + || record.kind !== "settings-only" + || typeof record.appVersion !== "string" + || typeof record.exportedAt !== "string" + || !record.settings + || typeof record.settings !== "object" + || Array.isArray(record.settings) + || "session" in record + || "history" in record + ) { + throw new Error("Online-Sicherung enthält keine gültigen Einstellungen"); + } + return record as unknown as OnlineSettingsPayload; +} + +function endpoint(baseUrl: string, relativePath: string): string { + const normalized = String(baseUrl || "").trim().replace(/\/+$/, ""); + const url = new URL(`${normalized}${relativePath}`); + if (url.protocol !== "https:" && !["127.0.0.1", "localhost", "::1"].includes(url.hostname)) { + throw new Error("Online-Sicherungen benötigen eine sichere HTTPS-Verbindung"); + } + return url.toString(); +} + +async function request(url: string, init?: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (error) { + if (controller.signal.aborted) { + throw new Error("Online-Sicherungsdienst antwortet nicht"); + } + throw new Error(`Online-Sicherungsdienst nicht erreichbar: ${String((error as Error)?.message || error)}`); + } finally { + clearTimeout(timer); + } +} + +async function readLimitedText(response: Response): Promise { + const contentLength = Number(response.headers.get("content-length") || "0"); + if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { + throw new Error("Antwort des Online-Sicherungsdienstes ist zu groß"); + } + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const result = await reader.read(); + if (result.done) break; + total += result.value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error("Antwort des Online-Sicherungsdienstes ist zu groß"); + } + chunks.push(result.value); + } + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8"); +} + +export function parseOnlineBackupKey(key: string): ParsedOnlineBackupKey { + const normalized = String(key || "").trim(); + if (!new RegExp(`^${KEY_PREFIX}[A-Za-z0-9_-]{${KEY_BODY_LENGTH}}$`).test(normalized)) { + throw new Error("Online-Sicherungsschlüssel ist ungültig"); + } + const decoded = Buffer.from(normalized.slice(KEY_PREFIX.length), "base64url"); + if (decoded.length !== RECORD_ID_LENGTH + MASTER_KEY_LENGTH + CHECKSUM_LENGTH) { + throw new Error("Online-Sicherungsschlüssel ist ungültig"); + } + if (decoded.toString("base64url") !== normalized.slice(KEY_PREFIX.length)) { + throw new Error("Online-Sicherungsschlüssel ist ungültig"); + } + const idBytes = decoded.subarray(0, RECORD_ID_LENGTH); + const masterKey = decoded.subarray(RECORD_ID_LENGTH, RECORD_ID_LENGTH + MASTER_KEY_LENGTH); + const actualChecksum = decoded.subarray(RECORD_ID_LENGTH + MASTER_KEY_LENGTH); + const expectedChecksum = checksum(idBytes, masterKey); + if (!crypto.timingSafeEqual(actualChecksum, expectedChecksum)) { + throw new Error("Online-Sicherungsschlüssel ist beschädigt"); + } + return { id: idBytes.toString("base64url"), idBytes: Buffer.from(idBytes), masterKey: Buffer.from(masterKey) }; +} + +export function createOnlineBackup(settings: AppSettings, appVersion: string, exportedAt = new Date().toISOString()): CreatedOnlineBackup { + const idBytes = crypto.randomBytes(RECORD_ID_LENGTH); + const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH); + const key = encodeKey(idBytes, masterKey); + const encryptionKey = deriveSecret(masterKey, idBytes, "ENCRYPTION"); + const nonce = crypto.randomBytes(NONCE_LENGTH); + const payload: OnlineSettingsPayload = { + version: 1, + kind: "settings-only", + appVersion, + exportedAt, + settings: JSON.parse(JSON.stringify(settings)) as AppSettings + }; + const plaintext = Buffer.from(JSON.stringify(payload), "utf8"); + if (plaintext.length > MAX_PLAINTEXT_BYTES) { + throw new Error("Einstellungen sind für eine Online-Sicherung zu groß"); + } + const compressed = zlib.gzipSync(plaintext, { level: 9 }); + const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH }); + cipher.setAAD(aad(idBytes)); + const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]); + const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]); + if (blobBytes.length > MAX_BLOB_BYTES) { + throw new Error("Einstellungen sind für eine Online-Sicherung zu groß"); + } + const parsed = parseOnlineBackupKey(key); + const deleteVerifier = crypto.createHash("sha256").update(deriveDeleteSecret(parsed)).digest("base64url"); + return { + key, + record: { + id: parsed.id, + blob: blobBytes.toString("base64url"), + deleteVerifier + } + }; +} + +export function restoreOnlineBackup(key: string, blob: string): OnlineSettingsPayload { + const parsed = parseOnlineBackupKey(key); + if (!/^[A-Za-z0-9_-]+$/.test(blob) || blob.length > Math.ceil(MAX_BLOB_BYTES * 4 / 3) + 4) { + throw new Error("Online-Sicherung ist beschädigt"); + } + const bytes = Buffer.from(blob, "base64url"); + if (bytes.toString("base64url") !== blob) { + throw new Error("Online-Sicherung ist beschädigt"); + } + if (bytes.length < 1 + NONCE_LENGTH + AUTH_TAG_LENGTH || bytes[0] !== BLOB_VERSION) { + throw new Error("Online-Sicherung ist beschädigt"); + } + const nonce = bytes.subarray(1, 1 + NONCE_LENGTH); + const tag = bytes.subarray(1 + NONCE_LENGTH, 1 + NONCE_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = bytes.subarray(1 + NONCE_LENGTH + AUTH_TAG_LENGTH); + try { + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + deriveSecret(parsed.masterKey, parsed.idBytes, "ENCRYPTION"), + nonce, + { authTagLength: AUTH_TAG_LENGTH } + ); + decipher.setAAD(aad(parsed.idBytes)); + decipher.setAuthTag(tag); + const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + const plaintext = zlib.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES }).toString("utf8"); + return validatePayload(JSON.parse(plaintext)); + } catch (error) { + if (error instanceof Error && /keine gültigen Einstellungen/.test(error.message)) throw error; + throw new Error("Online-Sicherung konnte nicht entschlüsselt werden oder ist beschädigt"); + } +} + +export async function uploadOnlineBackup(record: OnlineBackupRecord, baseUrl: string): Promise { + const response = await request(endpoint(baseUrl, "/v1/backups"), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify(record) + }); + await readLimitedText(response); + if (response.status !== 201) { + throw new Error("Online-Sicherung konnte nicht gespeichert werden"); + } +} + +export async function downloadOnlineBackup(key: string, baseUrl: string): Promise { + const parsed = parseOnlineBackupKey(key); + const response = await request(endpoint(baseUrl, "/v1/backups/restore"), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ id: parsed.id }) + }); + const body = await readLimitedText(response); + if (response.status !== 200) { + throw new Error(response.status === 404 ? "Online-Sicherung wurde nicht gefunden" : "Online-Sicherung konnte nicht geladen werden"); + } + let value: unknown; + try { + value = JSON.parse(body); + } catch { + throw new Error("Online-Sicherungsdienst hat ungültige Daten geliefert"); + } + const blob = (value as { blob?: unknown })?.blob; + if (typeof blob !== "string") { + throw new Error("Online-Sicherungsdienst hat ungültige Daten geliefert"); + } + return restoreOnlineBackup(key, blob); +} + +export async function deleteOnlineBackup(key: string, baseUrl: string): Promise { + const parsed = parseOnlineBackupKey(key); + const deleteSecret = deriveDeleteSecret(parsed).toString("base64url"); + const response = await request(endpoint(baseUrl, "/v1/backups/delete"), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ id: parsed.id, deleteSecret }) + }); + if (response.status !== 204) { + await readLimitedText(response); + throw new Error(response.status === 404 ? "Online-Sicherung wurde nicht gefunden" : "Online-Sicherung konnte nicht gelöscht werden"); + } +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 0634cf1..54c76b1 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -60,8 +60,10 @@ const api: ElectronApi = { resetDownloadStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS), restart: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESTART), quit: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.QUIT), - exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), - importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), + exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), + importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), + exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP), + importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key), exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openAuditLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a593916..130739d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -53,7 +53,7 @@ interface StartConflictPromptState { applyToAll: boolean; } -interface ConfirmPromptState { +interface ConfirmPromptState { title: string; message: string; confirmLabel: string; @@ -61,7 +61,14 @@ interface ConfirmPromptState { danger?: boolean; details?: string; detailsLabel?: string; -} +} + +interface OnlineBackupDialogState { + mode: "export" | "import"; + key: string; + busy: boolean; + error: string; +} interface ContextMenuState { x: number; @@ -1761,7 +1768,8 @@ export function App(): ReactElement { const [openSubmenu, setOpenSubmenu] = useState(null); const [startConflictPrompt, setStartConflictPrompt] = useState(null); const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); - const [confirmPrompt, setConfirmPrompt] = useState(null); + const [confirmPrompt, setConfirmPrompt] = useState(null); + const [onlineBackupDialog, setOnlineBackupDialog] = useState(null); const [remoteDiag, setRemoteDiag] = useState(null); const [remoteDiagOpen, setRemoteDiagOpen] = useState(false); const [remoteDiagBusy, setRemoteDiagBusy] = useState(false); @@ -4227,7 +4235,7 @@ export function App(): ReactElement { }); }; - const onImportBackup = async (): Promise => { + const onImportBackup = async (): Promise => { closeMenus(); await performQuickAction(async () => { const result = await window.rd.importBackup(); @@ -4246,7 +4254,49 @@ export function App(): ReactElement { }, (error) => { showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600); }); - }; + }; + + const onCreateOnlineBackup = async (): Promise => { + closeMenus(); + setOnlineBackupDialog({ mode: "export", key: "", busy: true, error: "" }); + try { + const result = await window.rd.exportOnlineBackup(); + setOnlineBackupDialog({ mode: "export", key: result.key, busy: false, error: "" }); + showToast("Online-Schlüssel erstellt", 2600); + } catch { + setOnlineBackupDialog({ mode: "export", key: "", busy: false, error: "Online-Sicherung konnte nicht erstellt werden." }); + } + }; + + const onOpenOnlineBackupImport = (): void => { + closeMenus(); + setOnlineBackupDialog({ mode: "import", key: "", busy: false, error: "" }); + }; + + const onImportOnlineBackup = async (): Promise => { + const key = onlineBackupDialog?.mode === "import" ? onlineBackupDialog.key.trim() : ""; + if (!key) return; + setOnlineBackupDialog((current) => current ? { ...current, busy: true, error: "" } : current); + try { + const result = await window.rd.importOnlineBackup(key); + const fresh = await window.rd.getSnapshot(); + applyPersistedSettings(fresh.settings); + setOnlineBackupDialog(null); + showToast(result.message, 4000); + } catch { + setOnlineBackupDialog((current) => current ? { ...current, busy: false, error: "Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen." } : current); + } + }; + + const onCopyOnlineBackupKey = async (): Promise => { + if (!onlineBackupDialog?.key) return; + try { + await navigator.clipboard.writeText(onlineBackupDialog.key); + showToast("Online-Schlüssel kopiert", 2200); + } catch { + showToast("Schlüssel konnte nicht kopiert werden", 2600); + } + }; const onExportSupportBundle = async (): Promise => { closeMenus(); @@ -4649,8 +4699,11 @@ export function App(): ReactElement { {openSubmenu === "sicherung" && (
- - + + +
+ +
)}
@@ -6005,7 +6058,7 @@ export function App(): ReactElement { )} - {confirmPrompt && ( + {confirmPrompt && (
closeConfirmPrompt(false)}>
event.stopPropagation()}>

{confirmPrompt.title}

@@ -6027,7 +6080,43 @@ export function App(): ReactElement {
- )} + )} + + {onlineBackupDialog && ( +
{ if (!onlineBackupDialog.busy) setOnlineBackupDialog(null); }}> +
event.stopPropagation()}> +

{onlineBackupDialog.mode === "export" ? "Online-Schlüssel" : "Online-Schlüssel importieren"}

+

+ {onlineBackupDialog.mode === "export" + ? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf." + : "Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt."} +

+ {onlineBackupDialog.mode === "export" && onlineBackupDialog.busy &&
Online-Sicherung wird verschlüsselt und gespeichert …
} + {onlineBackupDialog.mode === "export" && onlineBackupDialog.key && ( +