feat: add encrypted online settings backup keys
Add immutable client-encrypted settings snapshots with independent MDD2 capability keys so fresh installs can restore configuration without transferring backup files. Keep credentials encrypted end to end, preserve queues and history during import, and avoid exposing identifiers in request URLs or errors. Include the persistent API with quota, rate limits, crash-safe storage locking, durability checks, and end-to-end race and recovery coverage.
This commit is contained in:
parent
5f19293ed2
commit
6e44e63167
@ -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"
|
||||
|
||||
1
services/backup-api/.gitignore
vendored
Normal file
1
services/backup-api/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
data/
|
||||
36
services/backup-api/README.md
Normal file
36
services/backup-api/README.md
Normal file
@ -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.
|
||||
50
services/backup-api/package-lock.json
generated
Normal file
50
services/backup-api/package-lock.json
generated
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
services/backup-api/package.json
Normal file
16
services/backup-api/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
40
services/backup-api/src/cli.mjs
Normal file
40
services/backup-api/src/cli.mjs
Normal file
@ -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)
|
||||
18
services/backup-api/src/server.d.mts
Normal file
18
services/backup-api/src/server.d.mts
Normal file
@ -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;
|
||||
463
services/backup-api/src/server.mjs
Normal file
463
services/backup-api/src/server.mjs
Normal file
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
511
services/backup-api/test/server.test.mjs
Normal file
511
services/backup-api/test/server.test.mjs
Normal file
@ -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)
|
||||
})
|
||||
@ -25,7 +25,7 @@ import {
|
||||
} from "../shared/types";
|
||||
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
|
||||
import { importDlcContainers } from "./container";
|
||||
import { APP_VERSION } from "./constants";
|
||||
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
|
||||
import { DownloadManager } from "./download-manager";
|
||||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
||||
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
|
||||
@ -57,7 +57,8 @@ import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog
|
||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||||
|
||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||
@ -415,7 +416,7 @@ export class AppController {
|
||||
// object about to be applied, so they are never rolled back to a stale snapshot.
|
||||
// All-time totals take the max; daily/total usage and account statuses are taken
|
||||
// live; per-key Debrid-Link usage is filtered to keys that still exist.
|
||||
private overlayLiveUsageCounters(target: AppSettings): void {
|
||||
private overlayLiveUsageCounters(target: AppSettings): void {
|
||||
const liveSettings = this.manager.getSettings();
|
||||
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
||||
@ -429,8 +430,19 @@ export class AppController {
|
||||
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
|
||||
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
|
||||
);
|
||||
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
|
||||
}
|
||||
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
|
||||
}
|
||||
|
||||
private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void {
|
||||
const restoredSettings = normalizeSettings(importedSettings);
|
||||
this.overlayLiveUsageCounters(restoredSettings);
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings, { settingsOnlyImport: true });
|
||||
if (restoreRemoteDiagnostics) {
|
||||
this.restoreRemoteDiagnosticsFromBackup(remoteDiagnostics, true);
|
||||
}
|
||||
}
|
||||
|
||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
||||
@ -747,7 +759,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
this.audit("INFO", "Download-Statistik zurückgesetzt");
|
||||
}
|
||||
|
||||
public exportBackup(): Buffer {
|
||||
public exportBackup(): Buffer {
|
||||
let remoteDiagnostics: BackupRemoteDiagnostics | undefined;
|
||||
if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) {
|
||||
const status = getDebugServerRuntimeStatus();
|
||||
@ -771,8 +783,25 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
|
||||
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
|
||||
});
|
||||
return encryptBackup(JSON.stringify(payloadObj));
|
||||
}
|
||||
return encryptBackup(JSON.stringify(payloadObj));
|
||||
}
|
||||
|
||||
public async exportOnlineBackup(): Promise<{ key: string }> {
|
||||
const created = createOnlineBackup({ ...this.settings }, APP_VERSION);
|
||||
await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL);
|
||||
this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only" });
|
||||
return { key: created.key };
|
||||
}
|
||||
|
||||
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
|
||||
const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL);
|
||||
this.applySettingsOnlyBackup(payload.settings);
|
||||
this.audit("INFO", "Online-Sicherung importiert", {
|
||||
kind: "settings-only",
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
});
|
||||
return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" };
|
||||
}
|
||||
|
||||
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
||||
this.audit("INFO", "Support-Bundle exportiert");
|
||||
@ -824,20 +853,16 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
importedSettingsRecord[key] = currentSettingsRecord[key];
|
||||
}
|
||||
}
|
||||
const restoredSettings = normalizeSettings(importedSettings);
|
||||
const restoredSettings = normalizeSettings(importedSettings);
|
||||
|
||||
// Settings-only backup: keep the running queue AND the live counters untouched.
|
||||
// Overlay the live usage/status counters so they don't roll back to the backup's
|
||||
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
|
||||
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
|
||||
// policy still governs FUTURE completions through the normal path. Do NOT stop the
|
||||
// manager, wipe the session, block persistence or relaunch.
|
||||
if (!hasSession) {
|
||||
this.overlayLiveUsageCounters(restoredSettings);
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
|
||||
this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, true);
|
||||
// manager, wipe the session, block persistence or relaunch.
|
||||
if (!hasSession) {
|
||||
this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true);
|
||||
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
});
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
273
src/main/online-backup.ts
Normal file
273
src/main/online-backup.ts
Normal file
@ -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<string, unknown>;
|
||||
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<Response> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<OnlineSettingsPayload> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -60,8 +60,10 @@ const api: ElectronApi = {
|
||||
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||
quit: (): Promise<void> => 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<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||
const [onlineBackupDialog, setOnlineBackupDialog] = useState<OnlineBackupDialogState | null>(null);
|
||||
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
|
||||
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
|
||||
const [remoteDiagBusy, setRemoteDiagBusy] = useState(false);
|
||||
@ -4227,7 +4235,7 @@ export function App(): ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
const onImportBackup = async (): Promise<void> => {
|
||||
const onImportBackup = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
closeMenus();
|
||||
@ -4649,8 +4699,11 @@ export function App(): ReactElement {
|
||||
<button className="menu-submenu-trigger">Sicherung</button>
|
||||
{openSubmenu === "sicherung" && (
|
||||
<div className="menu-submenu-dropdown">
|
||||
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button>
|
||||
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button>
|
||||
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button>
|
||||
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button>
|
||||
<div className="menu-separator" />
|
||||
<button className="menu-dropdown-item" onClick={() => { void onCreateOnlineBackup(); }}>Online-Schlüssel erstellen</button>
|
||||
<button className="menu-dropdown-item" onClick={onOpenOnlineBackupImport}>Online-Schlüssel importieren</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -6005,7 +6058,7 @@ export function App(): ReactElement {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{confirmPrompt && (
|
||||
{confirmPrompt && (
|
||||
<div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}>
|
||||
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>{confirmPrompt.title}</h3>
|
||||
@ -6027,7 +6080,43 @@ export function App(): ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{onlineBackupDialog && (
|
||||
<div className="modal-backdrop" onClick={() => { if (!onlineBackupDialog.busy) setOnlineBackupDialog(null); }}>
|
||||
<div className="modal-card online-backup-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>{onlineBackupDialog.mode === "export" ? "Online-Schlüssel" : "Online-Schlüssel importieren"}</h3>
|
||||
<p>
|
||||
{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."}
|
||||
</p>
|
||||
{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy && <div className="online-backup-status">Online-Sicherung wird verschlüsselt und gespeichert …</div>}
|
||||
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && (
|
||||
<textarea className="online-backup-key" value={onlineBackupDialog.key} readOnly spellCheck={false} aria-label="Online-Sicherungsschlüssel" />
|
||||
)}
|
||||
{onlineBackupDialog.mode === "import" && (
|
||||
<textarea
|
||||
className="online-backup-key"
|
||||
value={onlineBackupDialog.key}
|
||||
onChange={(event) => setOnlineBackupDialog((current) => current ? { ...current, key: event.target.value, error: "" } : current)}
|
||||
placeholder="MDD2-…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
disabled={onlineBackupDialog.busy}
|
||||
autoFocus
|
||||
aria-label="Online-Sicherungsschlüssel eingeben"
|
||||
/>
|
||||
)}
|
||||
{onlineBackupDialog.error && <div className="online-backup-error">{onlineBackupDialog.error}</div>}
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setOnlineBackupDialog(null)} disabled={onlineBackupDialog.busy}>Schließen</button>
|
||||
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && <button className="btn primary" onClick={() => { void onCopyOnlineBackupKey(); }}>Kopieren</button>}
|
||||
{onlineBackupDialog.mode === "import" && <button className="btn primary" onClick={() => { void onImportOnlineBackup(); }} disabled={onlineBackupDialog.busy || !onlineBackupDialog.key.trim()}>{onlineBackupDialog.busy ? "Wird geladen …" : "Importieren"}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remoteDiagOpen && (
|
||||
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
|
||||
|
||||
@ -3111,12 +3111,44 @@ td {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.online-backup-modal {
|
||||
width: min(620px, 100%);
|
||||
}
|
||||
|
||||
.online-backup-key {
|
||||
width: 100%;
|
||||
min-height: 86px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
font: 13px/1.55 "Cascadia Mono", "Consolas", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.online-backup-key:focus {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.online-backup-status {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.online-backup-error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.control-strip {
|
||||
|
||||
@ -36,8 +36,10 @@ export const IPC_CHANNELS = {
|
||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||
RESTART: "app:restart",
|
||||
QUIT: "app:quit",
|
||||
EXPORT_BACKUP: "app:export-backup",
|
||||
IMPORT_BACKUP: "app:import-backup",
|
||||
EXPORT_BACKUP: "app:export-backup",
|
||||
IMPORT_BACKUP: "app:import-backup",
|
||||
EXPORT_ONLINE_BACKUP: "app:export-online-backup",
|
||||
IMPORT_ONLINE_BACKUP: "app:import-online-backup",
|
||||
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
||||
OPEN_LOG: "app:open-log",
|
||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
||||
|
||||
@ -57,8 +57,10 @@ export interface ElectronApi {
|
||||
resetDownloadStats: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
quit: () => Promise<void>;
|
||||
exportBackup: () => Promise<{ saved: boolean }>;
|
||||
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
|
||||
exportBackup: () => Promise<{ saved: boolean }>;
|
||||
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
|
||||
exportOnlineBackup: () => Promise<{ key: string }>;
|
||||
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>;
|
||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
||||
openLog: () => Promise<void>;
|
||||
openAuditLog: () => Promise<void>;
|
||||
|
||||
@ -166,6 +166,31 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("download manager", () => {
|
||||
it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-"));
|
||||
tempDirs.push(root);
|
||||
const settings = { ...defaultSettings(), outputDir: path.join(root, "downloads") };
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
manager.addPackages([{ name: "queue", links: ["https://example.com/file.bin"] }]);
|
||||
const item = Object.values((manager as any).session.items)[0] as { provider: string | null };
|
||||
item.provider = "realdebrid";
|
||||
const resolveSpy = vi.spyOn(manager as any, "resolveExistingQueuedOpaqueFilenames");
|
||||
const cleanupSpy = vi.spyOn(manager as any, "cleanupExistingExtractedArchives");
|
||||
const retroactiveCleanupSpy = vi.spyOn(manager as any, "applyRetroactiveCleanupPolicy");
|
||||
|
||||
manager.setSettings({
|
||||
...settings,
|
||||
providerOrder: ["bestdebrid", "realdebrid"],
|
||||
hosterRouting: { "example.com": "bestdebrid" },
|
||||
completedCleanupPolicy: "immediate"
|
||||
}, { settingsOnlyImport: true });
|
||||
|
||||
expect(item.provider).toBe("realdebrid");
|
||||
expect(resolveSpy).not.toHaveBeenCalled();
|
||||
expect(cleanupSpy).not.toHaveBeenCalled();
|
||||
expect(retroactiveCleanupSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the BestDebrid circuit breaker when its token changes", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-best-token-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
45
tests/online-backup-service.test.ts
Normal file
45
tests/online-backup-service.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AppSettings } from "../src/shared/types";
|
||||
import { createBackupServer } from "../services/backup-api/src/server.mjs";
|
||||
import { createOnlineBackup, deleteOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "../src/main/online-backup";
|
||||
|
||||
const servers: ReturnType<typeof createBackupServer>[] = [];
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||
await Promise.all(directories.splice(0).map((directory) => fs.promises.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("online backup client and service", () => {
|
||||
it("keeps every export independently restorable and deletes only the selected snapshot", async () => {
|
||||
const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-online-backup-"));
|
||||
directories.push(rootDir);
|
||||
const server = createBackupServer({ rootDir, rateLimit: { max: 20, windowMs: 60_000 } });
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Testserver nicht verfügbar");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const firstSettings = { token: "first-secret", outputDir: "D:\\Erster Export" } as AppSettings;
|
||||
const secondSettings = { token: "second-secret", outputDir: "E:\\Zweiter Export" } as AppSettings;
|
||||
const first = createOnlineBackup(firstSettings, "2.0.0");
|
||||
const second = createOnlineBackup(secondSettings, "2.0.0");
|
||||
|
||||
await uploadOnlineBackup(first.record, baseUrl);
|
||||
await uploadOnlineBackup(second.record, baseUrl);
|
||||
|
||||
expect((await downloadOnlineBackup(first.key, baseUrl)).settings).toEqual(firstSettings);
|
||||
expect((await downloadOnlineBackup(second.key, baseUrl)).settings).toEqual(secondSettings);
|
||||
|
||||
await deleteOnlineBackup(second.key, baseUrl);
|
||||
|
||||
expect((await downloadOnlineBackup(first.key, baseUrl)).settings).toEqual(firstSettings);
|
||||
await expect(downloadOnlineBackup(second.key, baseUrl)).rejects.toThrow(/nicht gefunden/i);
|
||||
});
|
||||
});
|
||||
142
tests/online-backup.test.ts
Normal file
142
tests/online-backup.test.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import http from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AppSettings } from "../src/shared/types";
|
||||
import {
|
||||
createOnlineBackup,
|
||||
deleteOnlineBackup,
|
||||
downloadOnlineBackup,
|
||||
parseOnlineBackupKey,
|
||||
restoreOnlineBackup,
|
||||
uploadOnlineBackup
|
||||
} from "../src/main/online-backup";
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||
});
|
||||
|
||||
function settings(): AppSettings {
|
||||
return {
|
||||
token: "rd-secret-token",
|
||||
megaLogin: "backup-user",
|
||||
megaPassword: "backup-password",
|
||||
outputDir: "D:\\Downloads",
|
||||
backupIncludeDownloads: true
|
||||
} as AppSettings;
|
||||
}
|
||||
|
||||
describe("online backup key", () => {
|
||||
it("creates a compact key and restores every immutable settings snapshot independently", () => {
|
||||
const first = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
|
||||
const second = createOnlineBackup({ ...settings(), outputDir: "E:\\Neu" }, "2.0.0", "2026-08-08T00:00:00.000Z");
|
||||
|
||||
expect(first.key).toMatch(/^MDD2-[A-Za-z0-9_-]{70}$/);
|
||||
expect(first.key).toHaveLength(75);
|
||||
expect(second.key).not.toBe(first.key);
|
||||
expect(restoreOnlineBackup(first.key, first.record.blob).settings).toEqual(settings());
|
||||
expect(restoreOnlineBackup(second.key, second.record.blob).settings.outputDir).toBe("E:\\Neu");
|
||||
});
|
||||
|
||||
it("never places credentials or the decryption secret in the server record", () => {
|
||||
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
|
||||
const serialized = JSON.stringify(created.record);
|
||||
const parsed = parseOnlineBackupKey(created.key);
|
||||
|
||||
expect(serialized).not.toContain("rd-secret-token");
|
||||
expect(serialized).not.toContain("backup-password");
|
||||
expect(serialized).not.toContain(parsed.masterKey.toString("base64url"));
|
||||
expect(Object.keys(created.record).sort()).toEqual(["blob", "deleteVerifier", "id"]);
|
||||
});
|
||||
|
||||
it("rejects corrupted keys and encrypted payloads before returning settings", () => {
|
||||
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
|
||||
const keyTail = created.key.endsWith("A") ? "B" : "A";
|
||||
const corruptedKey = `${created.key.slice(0, -1)}${keyTail}`;
|
||||
const blobTail = created.record.blob.endsWith("A") ? "B" : "A";
|
||||
const corruptedBlob = `${created.record.blob.slice(0, -1)}${blobTail}`;
|
||||
|
||||
expect(() => parseOnlineBackupKey(corruptedKey)).toThrow(/Schlüssel/i);
|
||||
expect(() => restoreOnlineBackup(created.key, corruptedBlob)).toThrow(/entschlüsselt|beschädigt/i);
|
||||
});
|
||||
|
||||
it("rejects plaintext that cannot be restored before returning a key", () => {
|
||||
const oversized = { ...settings(), archivePasswordList: "x".repeat(600_000) };
|
||||
|
||||
expect(() => createOnlineBackup(oversized, "2.0.0")).toThrow(/zu groß/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("online backup transport", () => {
|
||||
it("never reflects service response bodies into client errors", async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(500, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "internal", leaked: "server-secret-value" }));
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Testserver nicht verfügbar");
|
||||
const created = createOnlineBackup(settings(), "2.0.0");
|
||||
|
||||
await expect(uploadOnlineBackup(created.record, `http://127.0.0.1:${address.port}`)).rejects.not.toThrow(/server-secret-value/);
|
||||
});
|
||||
|
||||
it("uploads and downloads through the real HTTP contract without sending the master key", async () => {
|
||||
let stored: Record<string, string> | null = null;
|
||||
let deleteRequest: Record<string, string> | null = null;
|
||||
const requestedUrls: string[] = [];
|
||||
const server = http.createServer(async (request, response) => {
|
||||
requestedUrls.push(String(request.url || ""));
|
||||
if (request.method === "POST" && request.url === "/v1/backups") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
stored = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, string>;
|
||||
response.writeHead(201, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ created: true }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && stored && request.url === "/v1/backups/restore") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
const restoreRequest = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, string>;
|
||||
if (restoreRequest.id !== stored.id) throw new Error("Falsche Restore-ID");
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ blob: stored.blob }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && stored && request.url === "/v1/backups/delete") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
deleteRequest = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, string>;
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "Nicht gefunden" }));
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Testserver nicht verfügbar");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
|
||||
|
||||
await uploadOnlineBackup(created.record, baseUrl);
|
||||
const restored = await downloadOnlineBackup(created.key, baseUrl);
|
||||
await deleteOnlineBackup(created.key, baseUrl);
|
||||
|
||||
expect(restored.settings).toEqual(settings());
|
||||
expect(stored).not.toBeNull();
|
||||
const uploadedRecord = stored as unknown as { id: string; blob: string; deleteVerifier: string };
|
||||
const capturedDeleteRequest = deleteRequest as unknown as Record<string, string>;
|
||||
expect(JSON.stringify(uploadedRecord)).not.toContain(parseOnlineBackupKey(created.key).masterKey.toString("base64url"));
|
||||
expect(capturedDeleteRequest.deleteSecret).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(requestedUrls).toEqual(["/v1/backups", "/v1/backups/restore", "/v1/backups/delete"]);
|
||||
expect(requestedUrls.join(" ")).not.toContain(uploadedRecord.id);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user