real-debrid-downloader/services/backup-api/src/server.mjs
Sucukdeluxe 6e44e63167 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.
2026-08-07 18:29:06 +02:00

464 lines
15 KiB
JavaScript

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()
}
})
}