release: v2.0.6

Redesign the desktop workspace with task sidebars, live filters, clearer settings, and an accessible update dialog.

Harden encrypted backup imports, configuration persistence, history retention, queue snapshots, shutdown recovery, and update installation ordering.

Publish verified Windows artifacts and refreshed English documentation.
This commit is contained in:
Sucukdeluxe
2026-08-10 09:28:28 +02:00
commit 76a228fb7c
113 changed files with 36923 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
{
"name": "multi-hoster-uploader-backup-api",
"version": "2.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-hoster-uploader-backup-api",
"version": "2.0.3",
"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
View File
@@ -0,0 +1,16 @@
{
"name": "multi-hoster-uploader-backup-api",
"version": "2.0.3",
"private": true,
"type": "module",
"scripts": {
"start": "node src/cli.mjs",
"test": "node --test"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"proper-lockfile": "4.1.2"
}
}
+67
View File
@@ -0,0 +1,67 @@
import { resolve } from 'node:path'
import { createBackupServer } from './server.mjs'
const port = Number.parseInt(process.env.PORT ?? '8788', 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 requestRateLimit = {
max: Number.parseInt(process.env.REQUEST_RATE_LIMIT_MAX ?? '120', 10),
windowMs: Number.parseInt(process.env.REQUEST_RATE_LIMIT_WINDOW_MS ?? '60000', 10)
}
const maxStorageBytes = Number.parseInt(process.env.MAX_STORAGE_BYTES ?? String(10 * 1024 * 1024 * 1024), 10)
const maxRecords = Number.parseInt(process.env.MAX_RECORDS ?? '10000', 10)
const bodyTimeoutMs = Number.parseInt(process.env.BODY_TIMEOUT_MS ?? '10000', 10)
const healthCacheMs = Number.parseInt(process.env.HEALTH_CACHE_MS ?? '5000', 10)
const maxConcurrentPerClient = Number.parseInt(process.env.MAX_CONCURRENT_PER_CLIENT ?? '8', 10)
const maxConcurrentTotal = Number.parseInt(process.env.MAX_CONCURRENT_TOTAL ?? '64', 10)
const trustedProxy = process.env.TRUST_PROXY === 'true'
const trustedProxyAddresses = (process.env.TRUSTED_PROXY_ADDRESSES ?? '127.0.0.1,::1,::ffff:127.0.0.1')
.split(',')
.map((address) => address.trim())
.filter(Boolean)
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT')
const server = createBackupServer({
rootDir,
allowedOrigins,
rateLimit,
uploadRateLimit,
requestRateLimit,
maxStorageBytes,
maxRecords,
bodyTimeoutMs,
healthCacheMs,
maxConcurrentPerClient,
maxConcurrentTotal,
trustedProxy,
trustedProxyAddresses
})
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)
+552
View File
@@ -0,0 +1,552 @@
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 createConcurrencyLimiter({ perClient, total }) {
const clients = new Map()
let active = 0
return {
enter(address) {
const clientActive = clients.get(address) ?? 0
if (active >= total || clientActive >= perClient) return false
active += 1
clients.set(address, clientActive + 1)
return true
},
leave(address) {
const clientActive = clients.get(address) ?? 0
active = Math.max(0, active - 1)
if (clientActive <= 1) clients.delete(address)
else clients.set(address, clientActive - 1)
}
}
}
function readJsonBody(request, timeoutMs) {
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 = () => {
clearTimeout(timer)
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)
const timer = setTimeout(() => {
finish({ error: 408 })
request.resume()
}, timeoutMs)
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 cleanupTemporaryFiles(rootDir) {
const entries = await readdir(rootDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !/^\.[a-f0-9]{32}\.tmp$/.test(entry.name)) continue
try {
await unlink(join(rootDir, entry.name))
} catch (error) {
if (error.code !== 'ENOENT') throw error
}
}
}
async function directoryUsage(rootDir) {
let bytes = 0
let records = 0
const entries = await readdir(rootDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
try {
bytes += (await stat(join(rootDir, entry.name))).size
records += 1
} catch (error) {
if (error.code !== 'ENOENT') throw error
}
}
return { bytes, records }
}
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, maxRecords) {
await mkdir(rootDir, { recursive: true })
await cleanupTemporaryFiles(rootDir)
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')
const usage = await directoryUsage(rootDir)
if (usage.bytes + contents.length > maxStorageBytes || usage.records >= maxRecords) 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 createReadinessProbe(rootDir, cacheMs) {
let cached = null
let cachedAt = 0
let pending = null
return async () => {
const now = Date.now()
if (cached !== null && now - cachedAt < cacheMs) return cached
if (pending) return pending
pending = storageIsReady(rootDir).then((value) => {
cached = value
cachedAt = Date.now()
return value
}).finally(() => { pending = null })
return pending
}
}
function clientAddress(request, trustedProxy, trustedProxyAddresses) {
if (trustedProxy && trustedProxyAddresses.has(request.socket.remoteAddress ?? '')) {
const forwarded = request.headers['x-forwarded-for']
const value = Array.isArray(forwarded) ? forwarded.at(-1) : forwarded
const candidate = value?.split(',').at(-1)?.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 requestRateLimit = options.requestRateLimit ?? { max: 120, windowMs: 60_000 }
const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024
const maxRecords = options.maxRecords ?? 10_000
const bodyTimeoutMs = options.bodyTimeoutMs ?? 10_000
const healthCacheMs = options.healthCacheMs ?? 5_000
const maxConcurrentPerClient = options.maxConcurrentPerClient ?? 8
const maxConcurrentTotal = options.maxConcurrentTotal ?? 64
const trustedProxyAddresses = new Set(options.trustedProxyAddresses ?? [])
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(requestRateLimit.max) || requestRateLimit.max < 1 || !Number.isSafeInteger(requestRateLimit.windowMs) || requestRateLimit.windowMs < 1) {
throw new Error('Invalid request rate limit')
}
if (!Number.isSafeInteger(maxStorageBytes) || maxStorageBytes < 1) throw new Error('Invalid max storage size')
if (!Number.isSafeInteger(maxRecords) || maxRecords < 1) throw new Error('Invalid max records')
if (!Number.isSafeInteger(bodyTimeoutMs) || bodyTimeoutMs < 1) throw new Error('Invalid body timeout')
if (!Number.isSafeInteger(healthCacheMs) || healthCacheMs < 1) throw new Error('Invalid health cache')
if (!Number.isSafeInteger(maxConcurrentPerClient) || maxConcurrentPerClient < 1) throw new Error('Invalid per-client concurrency')
if (!Number.isSafeInteger(maxConcurrentTotal) || maxConcurrentTotal < maxConcurrentPerClient) throw new Error('Invalid total concurrency')
const consumeRateLimit = createRateLimiter(rateLimit)
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
const consumeRequestRateLimit = createRateLimiter(requestRateLimit)
const bodyConcurrency = createConcurrencyLimiter({ perClient: maxConcurrentPerClient, total: maxConcurrentTotal })
const runStorageMutation = createMutationQueue()
const checkReadiness = createReadinessProbe(options.rootDir, healthCacheMs)
const server = 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 checkReadiness()
sendJson(response, ready ? 200 : 503, { status: ready ? 'ok' : 'unavailable' })
return
}
const address = clientAddress(request, options.trustedProxy === true, trustedProxyAddresses)
if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') {
const retryAfter = consumeRateLimit(address)
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)) {
const requestRetryAfter = consumeRequestRateLimit(address)
if (requestRetryAfter !== null) {
response.setHeader('retry-after', String(requestRetryAfter))
sendJson(response, 429, { error: 'rate_limited' })
return
}
if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
sendJson(response, 415, { error: 'unsupported_media_type' })
return
}
if (!bodyConcurrency.enter(address)) {
sendJson(response, 429, { error: 'too_many_requests' })
return
}
try {
const parsed = await readJsonBody(request, bodyTimeoutMs)
if (parsed.error) {
if (parsed.error === 413) response.setHeader('connection', 'close')
const error = parsed.error === 413 ? 'payload_too_large' : parsed.error === 408 ? 'request_timeout' : 'invalid_request'
sendJson(response, parsed.error, { error })
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(address)
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, maxRecords)
))
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
} finally {
bodyConcurrency.leave(address)
}
}
sendNotFound(response)
} catch {
if (!response.headersSent) sendJson(response, 500, { error: 'internal_error' })
else response.destroy()
}
})
server.requestTimeout = bodyTimeoutMs + 5_000
server.headersTimeout = Math.min(10_000, bodyTimeoutMs)
server.keepAliveTimeout = 5_000
server.maxRequestsPerSocket = 100
return server
}
+390
View File
@@ -0,0 +1,390 @@
import assert from 'node:assert/strict'
import { createHash, randomBytes } from 'node:crypto'
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createConnection } from 'node:net'
import test from 'node:test'
import lockfile from 'proper-lockfile'
import { createBackupServer } from '../src/server.mjs'
const allowedOrigin = 'https://uploader.24-music.de'
function fixture() {
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(), 'mhu-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)
})
return {
rootDir,
server,
baseUrl: `http://127.0.0.1:${server.address().port}`,
async close() {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
await rm(rootDir, { recursive: true, force: true })
}
}
}
function request(api, path, options = {}) {
return fetch(`${api.baseUrl}${path}`, options)
}
test('health reports readiness without storage details and sends security headers', 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')
assert.equal(response.headers.get('x-content-type-options'), 'nosniff')
assert.equal(response.headers.get('content-security-policy'), "default-src 'none'")
})
test('creates immutable ciphertext records and restores them after a restart', async (t) => {
const api = await startApi()
const backup = fixture()
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' },
body: JSON.stringify(backup.payload)
})
assert.equal(created.status, 201)
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 restored = await request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: backup.payload.id })
})
assert.equal(restored.status, 200)
assert.deepEqual(await restored.json(), { blob: backup.payload.blob })
const duplicate = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...backup.payload, blob: randomBytes(96).toString('base64url') })
})
assert.equal(duplicate.status, 409)
})
test('validates payload shape, content type and decoded blob size', async (t) => {
const api = await startApi()
t.after(() => api.close())
const valid = fixture()
const invalid = [
{ ...valid.payload, id: 'short' },
{ ...valid.payload, blob: 'not+base64url' },
{ ...valid.payload, deleteVerifier: 'short' },
{ id: valid.payload.id, blob: valid.payload.blob },
{ ...valid.payload, extra: true }
]
for (const body of invalid) {
const response = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
})
assert.equal(response.status, 400)
}
const wrongType = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: JSON.stringify(valid.payload)
})
assert.equal(wrongType.status, 415)
const oversized = fixture()
oversized.payload.blob = randomBytes(262_145).toString('base64url')
const tooLarge = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(oversized.payload)
})
assert.equal(tooLarge.status, 413)
})
test('deletes only with the matching client secret and returns constant not-found responses', async (t) => {
const api = await startApi()
t.after(() => api.close())
const backup = fixture()
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.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 wrong = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: backup.payload.id, deleteSecret: randomBytes(32).toString('base64url') })
})
assert.equal(missing.status, 404)
assert.equal(wrong.status, 404)
assert.equal(await missing.text(), await wrong.text())
const deleted = await request(api, '/v1/backups/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: backup.payload.id, deleteSecret: backup.deleteSecret })
})
assert.equal(deleted.status, 204)
assert.equal((await readdir(api.rootDir)).length, 0)
})
test('allows only configured 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)
const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } })
assert.equal(denied.status, 403)
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)
})
test('separately rate limits uploads while restores and health remain available', async (t) => {
const api = await startApi({ uploadRateLimit: { max: 1, windowMs: 60_000 } })
t.after(() => api.close())
const first = fixture()
const second = fixture()
const create = (backup) => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.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)
assert.equal((await request(api, '/health')).status, 200)
})
test('rate limits invalid request bodies before validation', async (t) => {
const api = await startApi({ requestRateLimit: { max: 1, windowMs: 60_000 } })
t.after(() => api.close())
const sendInvalid = () => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ invalid: 'x'.repeat(300_000) })
})
assert.equal((await sendInvalid()).status, 400)
assert.equal((await sendInvalid()).status, 429)
})
test('ignores forwarded client addresses from untrusted socket peers', async (t) => {
const api = await startApi({
trustedProxy: true,
trustedProxyAddresses: [],
rateLimit: { max: 1, windowMs: 60_000 }
})
t.after(() => api.close())
const body = JSON.stringify({ id: randomBytes(16).toString('base64url') })
const restore = (forwarded) => request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded },
body
})
assert.equal((await restore('198.51.100.1')).status, 404)
assert.equal((await restore('198.51.100.2')).status, 429)
const created = await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': '198.51.100.3' },
body: JSON.stringify(fixture().payload)
})
assert.equal(created.status, 201)
})
test('uses the last forwarded address from an explicitly trusted proxy', async (t) => {
const api = await startApi({
trustedProxy: true,
trustedProxyAddresses: ['127.0.0.1'],
rateLimit: { max: 1, windowMs: 60_000 }
})
t.after(() => api.close())
const body = JSON.stringify({ id: randomBytes(16).toString('base64url') })
const restore = (forwarded) => request(api, '/v1/backups/restore', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded },
body
})
assert.equal((await restore('198.51.100.1, 203.0.113.9')).status, 404)
assert.equal((await restore('198.51.100.2, 203.0.113.9')).status, 429)
})
test('keeps concurrency leases until storage mutations finish', async (t) => {
const api = await startApi({ maxConcurrentPerClient: 1, maxConcurrentTotal: 1 })
t.after(() => api.close())
const release = await lockfile.lock(api.rootDir, {
realpath: false,
lockfilePath: join(api.rootDir, '.storage.lock')
})
const create = (backup) => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
const first = create(fixture())
await new Promise((resolve) => setTimeout(resolve, 40))
const second = create(fixture())
let secondStatus
try {
secondStatus = await Promise.race([
second.then((response) => response.status),
new Promise((resolve) => setTimeout(() => resolve('pending'), 100))
])
} finally {
await release()
}
assert.equal((await first).status, 201)
if (secondStatus === 'pending') await second
assert.equal(secondStatus, 429)
})
test('times out incomplete request bodies', async (t) => {
const api = await startApi({ bodyTimeoutMs: 30 })
t.after(() => api.close())
const response = await new Promise((resolve, reject) => {
const socket = createConnection(new URL(api.baseUrl).port, '127.0.0.1')
let data = ''
socket.setEncoding('utf8')
socket.once('error', reject)
socket.on('data', (chunk) => { data += chunk })
socket.on('end', () => resolve(data))
socket.once('connect', () => {
socket.write('POST /v1/backups HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 10\r\nConnection: close\r\n\r\n{')
})
})
assert.match(response, /^HTTP\/1\.1 408 /)
assert.match(response, /request_timeout/)
})
test('caches health readiness instead of writing on every request', async (t) => {
const api = await startApi({ healthCacheMs: 60_000 })
t.after(() => api.close())
assert.equal((await request(api, '/health')).status, 200)
await rm(api.rootDir, { recursive: true, force: true })
assert.equal((await request(api, '/health')).status, 200)
await assert.rejects(stat(api.rootDir), { code: 'ENOENT' })
})
test('cleans orphaned temporary files and enforces a record limit', async (t) => {
const api = await startApi({ maxRecords: 1 })
t.after(() => api.close())
const orphan = join(api.rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
await writeFile(orphan, 'orphan')
const create = (backup) => request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
assert.equal((await create(fixture())).status, 201)
assert.equal((await create(fixture())).status, 507)
const files = await readdir(api.rootDir)
assert.equal(files.some((name) => name.endsWith('.tmp')), false)
assert.equal(files.filter((name) => name.endsWith('.json')).length, 1)
})
test('enforces atomic 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)
})
t.after(() => api.close())
t.after(() => new Promise((resolve) => secondServer.close(resolve)))
const first = fixture()
const second = fixture()
const create = (backup, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
const results = await Promise.all([create(first), create(second, `http://127.0.0.1:${secondServer.address().port}`)])
assert.deepEqual(results.map((response) => response.status).sort(), [201, 507])
assert.equal((await readdir(api.rootDir)).length, 1)
})
test('never accepts record ids in URLs and stores no client delete secret', async (t) => {
const api = await startApi()
t.after(() => api.close())
const backup = fixture()
assert.equal((await request(api, `/v1/backups/${backup.payload.id}`)).status, 404)
assert.equal((await request(api, `/v1/backups?backup=${backup.payload.id}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})).status, 404)
await request(api, '/v1/backups', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(backup.payload)
})
const files = await readdir(api.rootDir)
const stored = await readFile(join(api.rootDir, files[0]), 'utf8')
assert.equal(stored.includes(backup.deleteSecret), false)
})