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:
Generated
+50
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user