@better-forge/backup 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +12 -3
- package/dist/archive.d.ts +17 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +132 -0
- package/dist/archive.js.map +1 -0
- package/dist/cli.d.ts +28 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +54 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/restore.d.ts +42 -0
- package/dist/restore.d.ts.map +1 -0
- package/dist/restore.js +89 -0
- package/dist/restore.js.map +1 -0
- package/dist/routes.d.ts +23 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +111 -0
- package/dist/routes.js.map +1 -0
- package/dist/runner.d.ts +30 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +26 -0
- package/dist/runner.js.map +1 -0
- package/dist/scheduler.d.ts +16 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +27 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/service.d.ts +79 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +244 -0
- package/dist/service.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +12 -0
- package/dist/worker.js.map +1 -0
- package/package.json +57 -6
- package/src/archive.ts +114 -0
- package/src/cli.ts +62 -0
- package/src/index.ts +10 -0
- package/src/restore.ts +111 -0
- package/src/routes.ts +115 -0
- package/src/runner.ts +45 -0
- package/src/scheduler.ts +39 -0
- package/src/service.ts +250 -0
- package/src/worker.ts +18 -0
package/src/archive.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
const HASH_SIZE = 32
|
|
6
|
+
|
|
7
|
+
export interface ArchiveEntry { name: string; source: string }
|
|
8
|
+
|
|
9
|
+
function safeName(name: string): string {
|
|
10
|
+
const normalized = name.replaceAll('\\', '/')
|
|
11
|
+
if (!normalized || normalized.startsWith('/') || normalized.includes('../') || normalized === '..') {
|
|
12
|
+
throw new Error(`Unsafe archive path: ${name}`)
|
|
13
|
+
}
|
|
14
|
+
return normalized
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sha256(file: string): Buffer {
|
|
18
|
+
const hash = crypto.createHash('sha256')
|
|
19
|
+
const fd = fs.openSync(file, 'r')
|
|
20
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
|
21
|
+
try {
|
|
22
|
+
let count = 0
|
|
23
|
+
while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count))
|
|
24
|
+
} finally { fs.closeSync(fd) }
|
|
25
|
+
return hash.digest()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function copyBytes(input: number, output: number, size: number, hash?: crypto.Hash): void {
|
|
29
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
|
30
|
+
let remaining = size
|
|
31
|
+
while (remaining > 0) {
|
|
32
|
+
const count = fs.readSync(input, buffer, 0, Math.min(buffer.length, remaining), null)
|
|
33
|
+
if (count === 0) throw new Error('Unexpected end of backup archive')
|
|
34
|
+
const chunk = buffer.subarray(0, count)
|
|
35
|
+
if (hash) hash.update(chunk)
|
|
36
|
+
fs.writeSync(output, chunk)
|
|
37
|
+
remaining -= count
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readExact(fd: number, size: number): Buffer {
|
|
42
|
+
const buffer = Buffer.alloc(size)
|
|
43
|
+
let offset = 0
|
|
44
|
+
while (offset < size) {
|
|
45
|
+
const count = fs.readSync(fd, buffer, offset, size - offset, null)
|
|
46
|
+
if (!count) throw new Error('Truncated backup archive')
|
|
47
|
+
offset += count
|
|
48
|
+
}
|
|
49
|
+
return buffer
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The archive file format: a magic header, then for each file its name, size and SHA-256 followed by its bytes.
|
|
54
|
+
* Extraction checks every checksum and refuses names that would leave the destination. The `magic` is what identifies
|
|
55
|
+
* archives of one product, so an application keeps the one its existing archives start with.
|
|
56
|
+
*/
|
|
57
|
+
export function createArchiveFormat(options: { magic: string }) {
|
|
58
|
+
const MAGIC = Buffer.from(options.magic)
|
|
59
|
+
|
|
60
|
+
function createArchive(output: string, entries: ArchiveEntry[]): void {
|
|
61
|
+
const fd = fs.openSync(output, 'wx', 0o600)
|
|
62
|
+
try {
|
|
63
|
+
fs.writeSync(fd, MAGIC)
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const name = Buffer.from(safeName(entry.name), 'utf8')
|
|
66
|
+
const stat = fs.statSync(entry.source)
|
|
67
|
+
if (!stat.isFile()) throw new Error(`Archive source is not a file: ${entry.source}`)
|
|
68
|
+
const header = Buffer.alloc(4 + 8 + HASH_SIZE)
|
|
69
|
+
header.writeUInt32BE(name.length, 0)
|
|
70
|
+
header.writeBigUInt64BE(BigInt(stat.size), 4)
|
|
71
|
+
sha256(entry.source).copy(header, 12)
|
|
72
|
+
fs.writeSync(fd, header)
|
|
73
|
+
fs.writeSync(fd, name)
|
|
74
|
+
const input = fs.openSync(entry.source, 'r')
|
|
75
|
+
try { copyBytes(input, fd, stat.size) } finally { fs.closeSync(input) }
|
|
76
|
+
}
|
|
77
|
+
fs.writeSync(fd, Buffer.alloc(4))
|
|
78
|
+
fs.fsyncSync(fd)
|
|
79
|
+
} finally { fs.closeSync(fd) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractArchive(input: string, destination: string): string[] {
|
|
83
|
+
fs.mkdirSync(destination, { recursive: true, mode: 0o700 })
|
|
84
|
+
const root = path.resolve(destination)
|
|
85
|
+
const extracted: string[] = []
|
|
86
|
+
const fd = fs.openSync(input, 'r')
|
|
87
|
+
try {
|
|
88
|
+
if (!readExact(fd, MAGIC.length).equals(MAGIC)) throw new Error('Unsupported backup format')
|
|
89
|
+
for (let index = 0; index < 10000; index++) {
|
|
90
|
+
const nameLength = readExact(fd, 4).readUInt32BE(0)
|
|
91
|
+
if (nameLength === 0) return extracted
|
|
92
|
+
if (nameLength > 4096) throw new Error('Invalid archive entry name')
|
|
93
|
+
const metadata = readExact(fd, 8 + HASH_SIZE)
|
|
94
|
+
const sizeBig = metadata.readBigUInt64BE(0)
|
|
95
|
+
if (sizeBig > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Archive entry is too large')
|
|
96
|
+
const expectedHash = metadata.subarray(8)
|
|
97
|
+
const name = safeName(readExact(fd, nameLength).toString('utf8'))
|
|
98
|
+
const target = path.resolve(root, ...name.split('/'))
|
|
99
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error('Archive path escapes destination')
|
|
100
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
101
|
+
const output = fs.openSync(target, 'wx', 0o600)
|
|
102
|
+
const hash = crypto.createHash('sha256')
|
|
103
|
+
try { copyBytes(fd, output, Number(sizeBig), hash) } finally { fs.closeSync(output) }
|
|
104
|
+
if (!crypto.timingSafeEqual(hash.digest(), expectedHash)) throw new Error(`Checksum mismatch: ${name}`)
|
|
105
|
+
extracted.push(name)
|
|
106
|
+
}
|
|
107
|
+
throw new Error('Archive contains too many entries')
|
|
108
|
+
} finally { fs.closeSync(fd) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { createArchive, extractArchive }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ArchiveFormat = ReturnType<typeof createArchiveFormat>
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import type { BackupService } from './service.js'
|
|
3
|
+
|
|
4
|
+
function argument(argv: string[], name: string): string | undefined {
|
|
5
|
+
const index = argv.indexOf(name)
|
|
6
|
+
return index >= 0 ? argv[index + 1] : undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function fail(error: unknown): void {
|
|
10
|
+
console.error(error instanceof Error ? error.message : error)
|
|
11
|
+
process.exitCode = 1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BackupCliOptions {
|
|
15
|
+
service: Pick<BackupService, 'createBackup' | 'validateBackup'>
|
|
16
|
+
/** The application's database handle: opened for the backup and closed after it. */
|
|
17
|
+
database: { open: () => void; close: () => void }
|
|
18
|
+
/** A backup file name as the usage line shows it (`<name>-backup-timestamp.backup`). */
|
|
19
|
+
exampleFile: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The command line of `backup`: makes a backup (`--output <dir>` picks where) or, with `--verify <file>` /
|
|
24
|
+
* `--verify --input <file>`, checks one. Prints JSON; failures set a non-zero exit code.
|
|
25
|
+
*/
|
|
26
|
+
export async function runBackupCli(argv: string[], options: BackupCliOptions): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
const verifyMode = argv.includes('--verify')
|
|
29
|
+
const verify = argument(argv, '--input') ?? argument(argv, '--verify')
|
|
30
|
+
if (verifyMode) {
|
|
31
|
+
if (!verify || verify.startsWith('--')) throw new Error(`Usage: backup:verify -- --input <${options.exampleFile}>`)
|
|
32
|
+
const manifest = options.service.validateBackup(path.resolve(verify))
|
|
33
|
+
console.log(JSON.stringify({ valid: true, manifest }, null, 2))
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
options.database.open()
|
|
37
|
+
try {
|
|
38
|
+
const output = argument(argv, '--output')
|
|
39
|
+
console.log(JSON.stringify(await options.service.createBackup(output ? path.resolve(output) : undefined), null, 2))
|
|
40
|
+
} finally { options.database.close() }
|
|
41
|
+
} catch (error) { fail(error) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RestoreCliOptions {
|
|
45
|
+
restore: (input: string, extra: string) => Promise<unknown>
|
|
46
|
+
exampleFile: string
|
|
47
|
+
/** The one extra value the restore needs (for example the key the backup was made with). */
|
|
48
|
+
extra: { flag: string; placeholder: string }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The command line of `restore`: `--input <file>` and the extra flag are both required. */
|
|
52
|
+
export async function runRestoreCli(argv: string[], options: RestoreCliOptions): Promise<void> {
|
|
53
|
+
const input = argument(argv, '--input')
|
|
54
|
+
const extra = argument(argv, options.extra.flag)
|
|
55
|
+
if (!input || !extra) {
|
|
56
|
+
console.error(`Usage: restore --input <${options.exampleFile}> ${options.extra.flag} ${options.extra.placeholder}`)
|
|
57
|
+
process.exitCode = 1
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
try { console.log(JSON.stringify(await options.restore(path.resolve(input), extra), null, 2)) }
|
|
61
|
+
catch (error) { fail(error) }
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { createArchiveFormat, type ArchiveEntry, type ArchiveFormat } from './archive.js'
|
|
2
|
+
export {
|
|
3
|
+
collectFiles, createBackupService, DEFAULT_BACKUP_CONFIG, DEFAULT_BACKUP_STATUS, INTERRUPTED_BACKUP_ERROR,
|
|
4
|
+
type BackupConfig, type BackupInfo, type BackupManifest, type BackupService, type BackupServiceOptions, type BackupStatus, type ProcessOwner,
|
|
5
|
+
} from './service.js'
|
|
6
|
+
export { createRestore, replaceFile, type RestoreExtras, type RestoreOptions, type RestoreResult } from './restore.js'
|
|
7
|
+
export { createBackupRunner, spawnBackupWorker, type BackupJob, type BackupWorkerOptions } from './runner.js'
|
|
8
|
+
export { createBackupScheduler, scheduleExpression, type BackupSchedulerOptions } from './scheduler.js'
|
|
9
|
+
export { createBackupRoutes, type BackupRoutesOptions } from './routes.js'
|
|
10
|
+
export { runBackupCli, runRestoreCli, type BackupCliOptions, type RestoreCliOptions } from './cli.js'
|
package/src/restore.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
5
|
+
import type { BackupManifest, BackupService } from './service.js'
|
|
6
|
+
|
|
7
|
+
export interface RestoreResult { restoredAt: number; safetyBackup: string | null }
|
|
8
|
+
|
|
9
|
+
/** Puts `source` at `target` by copying beside it and renaming, so a failure never leaves half a file. */
|
|
10
|
+
export function replaceFile(source: string, target: string): void {
|
|
11
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
12
|
+
const incoming = `${target}.restore-incoming`
|
|
13
|
+
fs.copyFileSync(source, incoming)
|
|
14
|
+
fs.renameSync(incoming, target)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function removeSqliteSidecars(dbPath: string): void {
|
|
18
|
+
fs.rmSync(`${dbPath}-wal`, { force: true })
|
|
19
|
+
fs.rmSync(`${dbPath}-shm`, { force: true })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The files around the database that a restore replaces, and how to put the old ones back. */
|
|
23
|
+
export interface RestoreExtras {
|
|
24
|
+
/** Copies what is there now into `rollbackDir`. */
|
|
25
|
+
save: (rollbackDir: string) => void
|
|
26
|
+
/** Replaces what is there with what the backup holds (extracted into `extractedDir`). */
|
|
27
|
+
apply: (extractedDir: string) => void
|
|
28
|
+
/** Puts back what `save` kept (and removes what the failed restore created). */
|
|
29
|
+
rollback: (rollbackDir: string) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RestoreOptions<Manifest extends BackupManifest> {
|
|
33
|
+
service: BackupService<Manifest>
|
|
34
|
+
name: string
|
|
35
|
+
/** How the application is called in the messages shown to its operator. */
|
|
36
|
+
displayName: string
|
|
37
|
+
databasePath: () => string
|
|
38
|
+
/** Throws unless the application is stopped. */
|
|
39
|
+
assertStopped: () => void
|
|
40
|
+
/** The application's own database handle: opened to take the safety backup and to migrate what was restored. */
|
|
41
|
+
database: { open: () => void; close: () => void; migrate: () => void }
|
|
42
|
+
/** Called once the restore is about to replace files; the flags it captures describe what existed before. */
|
|
43
|
+
extras: () => RestoreExtras
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Restores a backup over a stopped application. A safety backup of what is there now is taken first, and a failure
|
|
48
|
+
* half way puts every replaced file back.
|
|
49
|
+
*/
|
|
50
|
+
export function createRestore<Manifest extends BackupManifest>(options: RestoreOptions<Manifest>) {
|
|
51
|
+
const { service, database } = options
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `prepare` runs before the backup is read (to set up the key it will need); `check` may refuse the backup by
|
|
55
|
+
* throwing, once its manifest is known.
|
|
56
|
+
*/
|
|
57
|
+
return async function restoreBackup(
|
|
58
|
+
input: string,
|
|
59
|
+
hooks: { prepare?: () => void; check?: (manifest: Manifest) => void } = {},
|
|
60
|
+
): Promise<RestoreResult> {
|
|
61
|
+
options.assertStopped()
|
|
62
|
+
hooks.prepare?.()
|
|
63
|
+
const manifest = service.validateBackup(input)
|
|
64
|
+
hooks.check?.(manifest)
|
|
65
|
+
|
|
66
|
+
const dbPath = options.databasePath()
|
|
67
|
+
if (fs.existsSync(dbPath)) {
|
|
68
|
+
const lockCheck = new DatabaseSync(dbPath)
|
|
69
|
+
try { lockCheck.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE; ROLLBACK') }
|
|
70
|
+
catch { lockCheck.close(); throw new Error(`Database is in use. Stop ${options.displayName} before restoring.`) }
|
|
71
|
+
lockCheck.close()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let safetyBackup: string | null = null
|
|
75
|
+
if (fs.existsSync(dbPath)) {
|
|
76
|
+
database.open()
|
|
77
|
+
try { safetyBackup = (await service.createBackup()).filename } finally { database.close() }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), `${options.name}-restore-`))
|
|
81
|
+
const rollback = fs.mkdtempSync(path.join(os.tmpdir(), `${options.name}-rollback-`))
|
|
82
|
+
const hadDatabase = fs.existsSync(dbPath)
|
|
83
|
+
const extras = options.extras()
|
|
84
|
+
try {
|
|
85
|
+
service.extractArchive(input, temp)
|
|
86
|
+
if (fs.existsSync(dbPath)) fs.copyFileSync(dbPath, path.join(rollback, 'database.sqlite'))
|
|
87
|
+
extras.save(rollback)
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
removeSqliteSidecars(dbPath)
|
|
91
|
+
replaceFile(path.join(temp, 'database.sqlite'), dbPath)
|
|
92
|
+
extras.apply(temp)
|
|
93
|
+
database.open()
|
|
94
|
+
database.migrate()
|
|
95
|
+
database.close()
|
|
96
|
+
} catch (error) {
|
|
97
|
+
database.close()
|
|
98
|
+
removeSqliteSidecars(dbPath)
|
|
99
|
+
const oldDb = path.join(rollback, 'database.sqlite')
|
|
100
|
+
if (fs.existsSync(oldDb)) replaceFile(oldDb, dbPath)
|
|
101
|
+
else if (!hadDatabase) fs.rmSync(dbPath, { force: true })
|
|
102
|
+
extras.rollback(rollback)
|
|
103
|
+
throw error
|
|
104
|
+
}
|
|
105
|
+
return { restoredAt: Date.now(), safetyBackup }
|
|
106
|
+
} finally {
|
|
107
|
+
fs.rmSync(temp, { recursive: true, force: true })
|
|
108
|
+
fs.rmSync(rollback, { recursive: true, force: true })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import type { Readable } from 'node:stream'
|
|
6
|
+
import { pipeline } from 'node:stream/promises'
|
|
7
|
+
import type { Audit } from '@better-forge/audit'
|
|
8
|
+
import { DEFAULT_BACKUP_CONFIG, type BackupConfig, type BackupInfo, type BackupManifest, type BackupService } from './service.js'
|
|
9
|
+
|
|
10
|
+
/** The part of `@fastify/multipart` the upload route uses; the application registers the plugin. */
|
|
11
|
+
interface UploadedFile { file: Readable & { truncated?: boolean } }
|
|
12
|
+
type FileReader = (options: { limits: { fileSize: number } }) => Promise<UploadedFile | undefined>
|
|
13
|
+
|
|
14
|
+
export interface BackupRoutesOptions<Manifest extends BackupManifest> {
|
|
15
|
+
service: BackupService<Manifest>
|
|
16
|
+
audit: Audit
|
|
17
|
+
/** The signed-in caller of a request (the routes sit behind the application's own guards). */
|
|
18
|
+
identity: (req: FastifyRequest) => { userId: number; email: string }
|
|
19
|
+
/** Makes a backup (on a worker thread, in the application). */
|
|
20
|
+
createBackup: () => Promise<BackupInfo>
|
|
21
|
+
/** Verifies a backup file (on a worker thread, in the application). */
|
|
22
|
+
validateBackup: (input: string) => Promise<Manifest>
|
|
23
|
+
/** Called after the schedule changed, to apply it. */
|
|
24
|
+
restartScheduler: () => void
|
|
25
|
+
/** Fields added to the answer of a verification, next to the manifest (e.g. whether a key matches). */
|
|
26
|
+
describeManifest?: (manifest: Manifest) => Record<string, unknown>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Largest backup file the verification accepts. */
|
|
30
|
+
const MAX_VALIDATION_BYTES = 2 * 1024 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
function parseConfig(value: Partial<BackupConfig>): BackupConfig {
|
|
33
|
+
const config = { ...DEFAULT_BACKUP_CONFIG, ...value }
|
|
34
|
+
if (!['daily', 'weekly'].includes(config.frequency)) throw new Error('Invalid frequency')
|
|
35
|
+
if (!Number.isInteger(config.hour) || config.hour < 0 || config.hour > 23) throw new Error('Hour must be between 0 and 23')
|
|
36
|
+
if (!Number.isInteger(config.minute) || config.minute < 0 || config.minute > 59) throw new Error('Minute must be between 0 and 59')
|
|
37
|
+
if (!Number.isInteger(config.weekday) || config.weekday < 0 || config.weekday > 6) throw new Error('Weekday must be between 0 and 6')
|
|
38
|
+
if (!Number.isInteger(config.retention) || config.retention < 1 || config.retention > 365) throw new Error('Retention must be between 1 and 365')
|
|
39
|
+
return config
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const messageOf = (error: unknown) => error instanceof Error ? error.message : String(error)
|
|
43
|
+
|
|
44
|
+
/** The admin routes of the backups: list, create, schedule, download, delete and verify an uploaded file. */
|
|
45
|
+
export function createBackupRoutes<Manifest extends BackupManifest>(options: BackupRoutesOptions<Manifest>) {
|
|
46
|
+
const { service, audit } = options
|
|
47
|
+
const actor = (req: FastifyRequest) => audit.actor(options.identity(req))
|
|
48
|
+
|
|
49
|
+
return async function backupRoutes(app: FastifyInstance) {
|
|
50
|
+
app.get('/', async () => ({ backups: service.listBackups(), config: service.readBackupConfig(), status: service.readBackupStatus() }))
|
|
51
|
+
|
|
52
|
+
app.post('/', async (req, reply) => {
|
|
53
|
+
try {
|
|
54
|
+
const result = await options.createBackup()
|
|
55
|
+
service.applyRetention(service.readBackupConfig().retention)
|
|
56
|
+
await audit.write(actor(req), 'create', 'backup', result.filename, result.filename)
|
|
57
|
+
return result
|
|
58
|
+
} catch (error) { return reply.code(409).send({ error: messageOf(error) }) }
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
app.put<{ Body: Partial<BackupConfig> }>('/config', async (req, reply) => {
|
|
62
|
+
try {
|
|
63
|
+
const config = parseConfig(req.body)
|
|
64
|
+
service.writeBackupConfig(config)
|
|
65
|
+
service.applyRetention(config.retention)
|
|
66
|
+
options.restartScheduler()
|
|
67
|
+
await audit.write(actor(req), 'update', 'backup-config', null, 'Backup schedule', {
|
|
68
|
+
enabled: config.enabled,
|
|
69
|
+
frequency: config.frequency,
|
|
70
|
+
hour: config.hour,
|
|
71
|
+
minute: config.minute,
|
|
72
|
+
weekday: config.weekday,
|
|
73
|
+
retention: config.retention,
|
|
74
|
+
})
|
|
75
|
+
return config
|
|
76
|
+
} catch (error) { return reply.code(400).send({ error: messageOf(error) }) }
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
app.get<{ Params: { filename: string } }>('/:filename/download', async (req, reply) => {
|
|
80
|
+
try {
|
|
81
|
+
const file = service.backupFile(req.params.filename)
|
|
82
|
+
if (!fs.existsSync(file)) return reply.code(404).send({ error: 'Backup not found' })
|
|
83
|
+
await audit.write(actor(req), 'allow', 'backup', req.params.filename, req.params.filename, { access: 'downloaded' })
|
|
84
|
+
reply.header('Content-Type', 'application/octet-stream')
|
|
85
|
+
reply.header('Content-Disposition', `attachment; filename="${path.basename(file)}"`)
|
|
86
|
+
return reply.send(fs.createReadStream(file))
|
|
87
|
+
} catch (error) { return reply.code(400).send({ error: messageOf(error) }) }
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
app.delete<{ Params: { filename: string }; Querystring: { confirm?: string } }>('/:filename', async (req, reply) => {
|
|
91
|
+
try {
|
|
92
|
+
if (req.query.confirm !== req.params.filename) return reply.code(400).send({ error: 'Type the exact backup filename to confirm deletion' })
|
|
93
|
+
const file = service.backupFile(req.params.filename)
|
|
94
|
+
if (!fs.existsSync(file)) return reply.code(404).send({ error: 'Backup not found' })
|
|
95
|
+
fs.rmSync(file)
|
|
96
|
+
await audit.write(actor(req), 'delete', 'backup', req.params.filename, req.params.filename)
|
|
97
|
+
return reply.code(204).send()
|
|
98
|
+
} catch (error) { return reply.code(400).send({ error: messageOf(error) }) }
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
app.post('/validate', async (req, reply) => {
|
|
102
|
+
const data = await (req as unknown as { file: FileReader }).file({ limits: { fileSize: MAX_VALIDATION_BYTES } })
|
|
103
|
+
if (!data) return reply.code(400).send({ error: 'No backup file' })
|
|
104
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'backup-upload-'))
|
|
105
|
+
const input = path.join(tempDir, 'backup.backup')
|
|
106
|
+
try {
|
|
107
|
+
await pipeline(data.file, fs.createWriteStream(input, { mode: 0o600 }))
|
|
108
|
+
if (data.file.truncated) throw new Error('Backup file exceeds the 2 GB validation limit')
|
|
109
|
+
const manifest = await options.validateBackup(input)
|
|
110
|
+
return { valid: true, manifest, ...options.describeManifest?.(manifest) }
|
|
111
|
+
} catch (error) { return reply.code(400).send({ error: messageOf(error) }) }
|
|
112
|
+
finally { fs.rmSync(tempDir, { recursive: true, force: true }) }
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads'
|
|
2
|
+
import type { BackupInfo, BackupManifest, BackupService } from './service.js'
|
|
3
|
+
|
|
4
|
+
export type BackupJob =
|
|
5
|
+
| { task: 'create'; outputDirectory?: string | undefined }
|
|
6
|
+
| { task: 'validate'; input: string }
|
|
7
|
+
|
|
8
|
+
export interface BackupWorkerOptions {
|
|
9
|
+
/** The worker script: the application's file that imports `@better-forge/backup/worker`. */
|
|
10
|
+
workerUrl: URL
|
|
11
|
+
/** The module the worker loads; it exports `createBackup` and `validateBackup`. */
|
|
12
|
+
moduleUrl: string
|
|
13
|
+
execArgv?: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Runs one backup job on a worker thread; exposed with explicit URLs so tests can drive the real worker. */
|
|
17
|
+
export function spawnBackupWorker<T>(job: BackupJob, options: BackupWorkerOptions): Promise<T> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const worker = new Worker(options.workerUrl, { workerData: { ...job, moduleUrl: options.moduleUrl }, execArgv: options.execArgv })
|
|
20
|
+
worker.once('message', (message: { result?: T; error?: string }) => message.error ? reject(new Error(message.error)) : resolve(message.result!))
|
|
21
|
+
worker.once('error', reject)
|
|
22
|
+
worker.once('exit', (code) => { if (code !== 0) reject(new Error(`Backup worker exited with code ${code}`)) })
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Backups and verification off the event loop. With `inline` (development and tests, where the worker thread cannot
|
|
28
|
+
* load TypeScript) the same core runs in the calling thread instead.
|
|
29
|
+
*/
|
|
30
|
+
export function createBackupRunner<Manifest extends BackupManifest>(options: BackupWorkerOptions & {
|
|
31
|
+
service: BackupService<Manifest>
|
|
32
|
+
inline: boolean
|
|
33
|
+
}) {
|
|
34
|
+
function run<T>(job: BackupJob, inline: () => Promise<T>): Promise<T> {
|
|
35
|
+
return options.inline ? inline() : spawnBackupWorker(job, options)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
createBackup: (outputDirectory?: string): Promise<BackupInfo> =>
|
|
40
|
+
run({ task: 'create', outputDirectory }, () => options.service.createBackup(outputDirectory)),
|
|
41
|
+
/** Extraction and the SQLite integrity check are synchronous, so keep them off the event loop. */
|
|
42
|
+
validateBackup: (input: string): Promise<Manifest> =>
|
|
43
|
+
run({ task: 'validate', input }, async () => options.service.validateBackup(input)),
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import cron, { type ScheduledTask } from 'node-cron'
|
|
2
|
+
import type { BackupConfig } from './service.js'
|
|
3
|
+
|
|
4
|
+
export interface BackupSchedulerOptions {
|
|
5
|
+
readConfig: () => BackupConfig
|
|
6
|
+
/** Makes one scheduled backup (and prunes old ones). A rejection is logged, never thrown. */
|
|
7
|
+
run: (config: BackupConfig) => Promise<unknown>
|
|
8
|
+
/** Prefix of the lines it logs. */
|
|
9
|
+
logPrefix?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The cron expression of a schedule: every day, or one weekday, at the given time. */
|
|
13
|
+
export function scheduleExpression(config: BackupConfig): string {
|
|
14
|
+
return config.frequency === 'weekly' ? `${config.minute} ${config.hour} * * ${config.weekday}` : `${config.minute} ${config.hour} * * *`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Runs a backup on the schedule the configuration describes; `restart` picks up a changed configuration. */
|
|
18
|
+
export function createBackupScheduler(options: BackupSchedulerOptions) {
|
|
19
|
+
const prefix = options.logPrefix ?? '[backup]'
|
|
20
|
+
let task: ScheduledTask | null = null
|
|
21
|
+
|
|
22
|
+
function stop(): void {
|
|
23
|
+
task?.destroy()
|
|
24
|
+
task = null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function restart(): void {
|
|
28
|
+
stop()
|
|
29
|
+
const config = options.readConfig()
|
|
30
|
+
if (!config.enabled) return
|
|
31
|
+
const expression = scheduleExpression(config)
|
|
32
|
+
task = cron.schedule(expression, () => {
|
|
33
|
+
options.run(config).catch((error) => console.error(`${prefix} scheduled backup failed:`, error))
|
|
34
|
+
})
|
|
35
|
+
console.log(`${prefix} Scheduler started: ${expression}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { restart, stop }
|
|
39
|
+
}
|