feat(backup): tar.gz format with uploads + manifest, restore guard
- New tar.gz backup format bundling SQLite snapshot + uploads tree + manifest.json (version, app+schema versions, checksums, dbSize) - BACKUPS_DIR env override; defaults to /app/data/backups in prod, <cwd>/data/backups in dev (matches uploads convention) - 503 guard in hooks.server.ts while restore is mid-flight (DB file is being swapped); excludes static assets + /api/health; sets Retry-After: 15 - Legacy .db restore still supported (DB-only) - Restore endpoint adds schema-mismatch detection + force flag; download/schedule endpoints updated - 256 MiB free-disk safety margin before backup - tar dep added to package.json; 18 new backupService tests - i18n labels (en + ru) for new restore/format states
This commit is contained in:
@@ -50,6 +50,11 @@ ALLOW_PRIVATE_NETWORK_FETCH="false"
|
||||
# scaling horizontally so only one node runs schedulers.
|
||||
RUN_SCHEDULERS="true"
|
||||
|
||||
# Directory where backup archives are written. Defaults to /app/data/backups
|
||||
# in production and <repo>/data/backups in development. Override if you want
|
||||
# backups on a separate mount.
|
||||
BACKUPS_DIR=""
|
||||
|
||||
# Optional bearer token for /api/metrics. When set, scrapers must send
|
||||
# `Authorization: Bearer <token>`. When unset, the endpoint is open (typical
|
||||
# when the scraper lives on the same private network).
|
||||
|
||||
Generated
+124
-9
@@ -1,18 +1,16 @@
|
||||
{
|
||||
"name": "web-app-launcher",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "web-app-launcher",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.2.0",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bits-ui": "^1.3.0",
|
||||
"clsx": "^2.1.0",
|
||||
@@ -30,11 +28,14 @@
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"sveltekit-superforms": "^2.22.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tar": "^7.5.15",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@sveltejs/package": "^2.3.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/svelte": "^5.2.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -976,6 +977,17 @@
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1888,6 +1900,7 @@
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
@@ -1899,6 +1912,7 @@
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -2840,6 +2854,14 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
@@ -2967,6 +2989,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
@@ -4497,6 +4520,25 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -6130,7 +6172,8 @@
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
@@ -6145,6 +6188,21 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/timers-ext": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz",
|
||||
@@ -6867,7 +6925,8 @@
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
@@ -7179,6 +7238,14 @@
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
@@ -7741,6 +7808,14 @@
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"requires": {
|
||||
"minipass": "^7.0.4"
|
||||
}
|
||||
},
|
||||
"@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -8274,6 +8349,7 @@
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
@@ -8282,6 +8358,7 @@
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -8944,6 +9021,11 @@
|
||||
"readdirp": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="
|
||||
},
|
||||
"citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
@@ -9049,7 +9131,8 @@
|
||||
"cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true
|
||||
},
|
||||
"d": {
|
||||
"version": "1.0.2",
|
||||
@@ -10121,6 +10204,19 @@
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="
|
||||
},
|
||||
"minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"requires": {
|
||||
"minipass": "^7.1.2"
|
||||
}
|
||||
},
|
||||
"mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -11017,7 +11113,8 @@
|
||||
"tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"tapable": {
|
||||
"version": "2.3.2",
|
||||
@@ -11025,6 +11122,18 @@
|
||||
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||
"dev": true
|
||||
},
|
||||
"tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"requires": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"timers-ext": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz",
|
||||
@@ -11426,7 +11535,8 @@
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"uuid": {
|
||||
"version": "8.3.2",
|
||||
@@ -11581,6 +11691,11 @@
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
|
||||
},
|
||||
"yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="
|
||||
},
|
||||
"yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"sveltekit-superforms": "^2.22.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tar": "^7.5.15",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"prisma": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as apiTokenService from '$lib/server/services/apiTokenService.js';
|
||||
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
|
||||
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
|
||||
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
|
||||
import { isRestoring } from '$lib/server/services/backupService.js';
|
||||
import { startScheduler as startHealthcheckScheduler } from '$lib/server/jobs/healthcheckScheduler.js';
|
||||
import {
|
||||
clearSessionCookies,
|
||||
@@ -52,6 +53,35 @@ function isPublicPath(pathname: string): boolean {
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// While a restore is mid-flight, Prisma is disconnected and the live DB
|
||||
// file is being swapped. Any other request that touches the DB would
|
||||
// crash; return 503 instead. The restore endpoint itself doesn't reach
|
||||
// here a second time because the restore is serialized in
|
||||
// backupService.restoreBackup (the _restoring flag is set inside it).
|
||||
if (isRestoring()) {
|
||||
const { pathname: path } = event.url;
|
||||
const isPublicAsset =
|
||||
path.startsWith('/_app/') ||
|
||||
path.startsWith('/favicon') ||
|
||||
path === '/api/health';
|
||||
if (!isPublicAsset) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Database restore in progress. Please retry in a moment.'
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '15'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
event.locals.apiTokenScope = null;
|
||||
|
||||
+16
-2
@@ -290,13 +290,17 @@
|
||||
"admin.discovery_traefik_url_hint": "Traefik dashboard API base URL (e.g. http://traefik:8080). Set via TRAEFIK_API_URL env var.",
|
||||
|
||||
"admin.backup_title": "Database Backup",
|
||||
"admin.backup_description": "Create, restore, and schedule backups of your database. Backups are full copies of the SQLite database file.",
|
||||
"admin.backup_description": "Create, restore, and schedule backups. New backups include the SQLite database and all uploaded files (icons, wallpapers).",
|
||||
"admin.backup_create": "Create Backup",
|
||||
"admin.backup_creating": "Creating...",
|
||||
"admin.backup_create_success": "Backup created successfully.",
|
||||
"admin.backup_list_title": "Backups",
|
||||
"admin.backup_list_empty": "No backups yet. Create your first backup above.",
|
||||
"admin.backup_filename": "Filename",
|
||||
"admin.backup_format": "Format",
|
||||
"admin.backup_format_full": "Full (DB + uploads)",
|
||||
"admin.backup_format_legacy": "DB only (legacy)",
|
||||
"admin.backup_format_legacy_tooltip": "This backup was created by an older version and contains only the database. Uploaded files will not be restored.",
|
||||
"admin.backup_size": "Size",
|
||||
"admin.backup_date": "Created",
|
||||
"admin.backup_actions": "Actions",
|
||||
@@ -305,7 +309,13 @@
|
||||
"admin.backup_delete": "Delete",
|
||||
"admin.backup_restore_confirm_title": "Restore Backup",
|
||||
"admin.backup_restore_confirm": "Are you sure you want to restore from this backup? This will replace all current data with the backup contents. This action cannot be undone.",
|
||||
"admin.backup_restore_success": "Database restored successfully. Please reload the page.",
|
||||
"admin.backup_restore_legacy_warning": "Legacy backup format: only the database will be restored. Current uploaded files (icons, wallpapers) will remain untouched and may not match references in the restored DB.",
|
||||
"admin.backup_restore_logout_warning": "You will be logged out automatically after the restore completes, because your current session was issued after this backup was taken.",
|
||||
"admin.backup_restore_success": "Database restored successfully. You will be redirected to the login page.",
|
||||
"admin.backup_restore_schema_mismatch": "Backup schema version does not match the running application. Restoring may corrupt data.",
|
||||
"admin.backup_restore_schema_mismatch_title": "Schema version mismatch",
|
||||
"admin.backup_restore_schema_mismatch_intro": "This backup was taken against a different database schema. Restoring may produce a database that no longer matches the current application code.",
|
||||
"admin.backup_restore_schema_mismatch_force": "Restore anyway",
|
||||
"admin.backup_delete_confirm_title": "Delete Backup",
|
||||
"admin.backup_delete_confirm": "Are you sure you want to delete this backup? This action cannot be undone.",
|
||||
"admin.backup_delete_success": "Backup deleted.",
|
||||
@@ -320,6 +330,10 @@
|
||||
"admin.backup_schedule_save": "Save Schedule",
|
||||
"admin.backup_schedule_saving": "Saving...",
|
||||
"admin.backup_schedule_saved": "Backup schedule updated.",
|
||||
"admin.backup_stats_success_count": "Successful runs",
|
||||
"admin.backup_stats_failure_count": "Failed runs",
|
||||
"admin.backup_stats_last_success": "Last success",
|
||||
"admin.backup_stats_last_failure": "Last failure",
|
||||
|
||||
"search.placeholder": "Search apps and boards...",
|
||||
"search.trigger": "Search...",
|
||||
|
||||
+16
-2
@@ -279,13 +279,17 @@
|
||||
"admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.",
|
||||
|
||||
"admin.backup_title": "Резервное копирование",
|
||||
"admin.backup_description": "Создавайте, восстанавливайте и планируйте резервные копии базы данных. Копии — это полные дубликаты файла базы SQLite.",
|
||||
"admin.backup_description": "Создавайте, восстанавливайте и планируйте резервные копии. Новые копии включают базу SQLite и все загруженные файлы (иконки, обои).",
|
||||
"admin.backup_create": "Создать копию",
|
||||
"admin.backup_creating": "Создание...",
|
||||
"admin.backup_create_success": "Резервная копия успешно создана.",
|
||||
"admin.backup_list_title": "Резервные копии",
|
||||
"admin.backup_list_empty": "Копий пока нет. Создайте первую копию выше.",
|
||||
"admin.backup_filename": "Файл",
|
||||
"admin.backup_format": "Формат",
|
||||
"admin.backup_format_full": "Полный (БД + загрузки)",
|
||||
"admin.backup_format_legacy": "Только БД (устаревший)",
|
||||
"admin.backup_format_legacy_tooltip": "Эта копия создана старой версией и содержит только базу данных. Загруженные файлы восстановлены не будут.",
|
||||
"admin.backup_size": "Размер",
|
||||
"admin.backup_date": "Создана",
|
||||
"admin.backup_actions": "Действия",
|
||||
@@ -294,7 +298,13 @@
|
||||
"admin.backup_delete": "Удалить",
|
||||
"admin.backup_restore_confirm_title": "Восстановление из копии",
|
||||
"admin.backup_restore_confirm": "Вы уверены, что хотите восстановить базу из этой копии? Все текущие данные будут заменены содержимым копии. Это действие нельзя отменить.",
|
||||
"admin.backup_restore_success": "База данных восстановлена. Пожалуйста, перезагрузите страницу.",
|
||||
"admin.backup_restore_legacy_warning": "Устаревший формат: будет восстановлена только база данных. Текущие загруженные файлы (иконки, обои) останутся без изменений и могут не соответствовать ссылкам в восстановленной БД.",
|
||||
"admin.backup_restore_logout_warning": "После завершения восстановления вы будете автоматически выведены из системы, так как ваша текущая сессия была создана уже после момента этой копии.",
|
||||
"admin.backup_restore_success": "База данных восстановлена. Сейчас вы будете перенаправлены на страницу входа.",
|
||||
"admin.backup_restore_schema_mismatch": "Версия схемы в копии не совпадает с текущей версией приложения. Восстановление может повредить данные.",
|
||||
"admin.backup_restore_schema_mismatch_title": "Несовпадение версии схемы",
|
||||
"admin.backup_restore_schema_mismatch_intro": "Эта копия была создана на другой версии схемы базы данных. Восстановление может привести к рассогласованию БД и текущего кода приложения.",
|
||||
"admin.backup_restore_schema_mismatch_force": "Восстановить всё равно",
|
||||
"admin.backup_delete_confirm_title": "Удаление копии",
|
||||
"admin.backup_delete_confirm": "Вы уверены, что хотите удалить эту резервную копию? Это действие нельзя отменить.",
|
||||
"admin.backup_delete_success": "Копия удалена.",
|
||||
@@ -309,6 +319,10 @@
|
||||
"admin.backup_schedule_save": "Сохранить расписание",
|
||||
"admin.backup_schedule_saving": "Сохранение...",
|
||||
"admin.backup_schedule_saved": "Расписание резервного копирования обновлено.",
|
||||
"admin.backup_stats_success_count": "Успешных запусков",
|
||||
"admin.backup_stats_failure_count": "Неудачных запусков",
|
||||
"admin.backup_stats_last_success": "Последний успех",
|
||||
"admin.backup_stats_last_failure": "Последняя ошибка",
|
||||
"search.placeholder": "Поиск приложений и досок...",
|
||||
"search.trigger": "Поиск...",
|
||||
"search.min_chars": "Введите минимум 2 символа для поиска",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import cron from 'node-cron';
|
||||
import { createBackup, enforceRetention, getBackupSettings } from '$lib/server/services/backupService.js';
|
||||
import {
|
||||
createBackup,
|
||||
enforceRetention,
|
||||
getBackupSettings,
|
||||
recordScheduledBackupFailure,
|
||||
recordScheduledBackupSuccess
|
||||
} from '$lib/server/services/backupService.js';
|
||||
import { logAction } from '$lib/server/services/auditLogService.js';
|
||||
import { AuditAction } from '$lib/utils/constants.js';
|
||||
|
||||
@@ -11,6 +17,11 @@ const state = g.__walBackupScheduler;
|
||||
/**
|
||||
* Start the backup scheduler with the given settings.
|
||||
* If already running, does nothing — call restartBackupScheduler() to reconfigure.
|
||||
*
|
||||
* For multi-replica deployments, set RUN_SCHEDULERS=false on every replica
|
||||
* except the one designated as the scheduler. There is no leader election;
|
||||
* running this on multiple replicas will produce concurrent backups (with
|
||||
* filenames colliding at the second granularity).
|
||||
*/
|
||||
export function startBackupScheduler(settings: {
|
||||
readonly backupEnabled: boolean;
|
||||
@@ -37,14 +48,19 @@ export function startBackupScheduler(settings: {
|
||||
try {
|
||||
const backup = await createBackup();
|
||||
enforceRetention(settings.backupMaxCount);
|
||||
recordScheduledBackupSuccess();
|
||||
logAction(null, AuditAction.BACKUP_CREATED, 'backup', backup.filename, {
|
||||
trigger: 'scheduled'
|
||||
trigger: 'scheduled',
|
||||
size: backup.size,
|
||||
format: backup.format
|
||||
});
|
||||
} catch (err) {
|
||||
// Log failure to audit log so admins can see scheduled backups are failing
|
||||
logAction(null, AuditAction.BACKUP_CREATED, 'backup', 'failed', {
|
||||
const reason = err instanceof Error ? err.message : 'Unknown error';
|
||||
recordScheduledBackupFailure(reason);
|
||||
console.error('[backup] scheduled backup failed:', err);
|
||||
logAction(null, AuditAction.BACKUP_FAILED, 'backup', 'scheduled', {
|
||||
trigger: 'scheduled',
|
||||
error: err instanceof Error ? err.message : 'Unknown error'
|
||||
error: reason
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -83,7 +99,6 @@ export async function initBackupScheduler(): Promise<void> {
|
||||
const settings = await getBackupSettings();
|
||||
startBackupScheduler(settings);
|
||||
} catch (err) {
|
||||
|
||||
console.warn('[backup] initBackupScheduler failed:', err);
|
||||
console.warn('[backup] initBackupScheduler failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import * as tar from 'tar';
|
||||
|
||||
// --- Prisma + uploads mocks --------------------------------------------------
|
||||
//
|
||||
// backupService imports prisma which validates env. We mock the module so the
|
||||
// import never touches the real DB; individual tests set per-call behaviour.
|
||||
|
||||
const reapplyPragmasMock = vi.fn(async () => undefined);
|
||||
const executeRawUnsafeMock = vi.fn(async (sql: string): Promise<number> => {
|
||||
// VACUUM INTO writes a real SQLite header to the named file so downstream
|
||||
// integrity checks succeed.
|
||||
const match = sql.match(/VACUUM INTO '(.+?)'/);
|
||||
if (match) {
|
||||
// 4096-byte pages — matches SQLite default. Use 8 pages.
|
||||
const pageSize = 4096;
|
||||
const pages = 8;
|
||||
const header = Buffer.alloc(100);
|
||||
Buffer.from([
|
||||
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
|
||||
]).copy(header, 0);
|
||||
header.writeUInt16BE(pageSize, 16);
|
||||
const body = Buffer.alloc(pageSize * pages - 100);
|
||||
await fsp.writeFile(match[1], Buffer.concat([header, body]));
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const queryRawUnsafeMock = vi.fn(async (_sql: string) => [{ migration_name: 'test_migration' }]);
|
||||
const disconnectMock = vi.fn(async () => undefined);
|
||||
const connectMock = vi.fn(async () => undefined);
|
||||
const sessionDeleteManyMock = vi.fn(async () => ({ count: 0 }));
|
||||
const systemSettingsUpsertMock = vi.fn(async () => ({
|
||||
backupEnabled: false,
|
||||
backupCronExpression: '0 3 * * *',
|
||||
backupMaxCount: 3
|
||||
}));
|
||||
|
||||
vi.mock('../../prisma.js', () => ({
|
||||
prisma: {
|
||||
$executeRawUnsafe: (sql: string) => executeRawUnsafeMock(sql),
|
||||
$queryRawUnsafe: (sql: string) => queryRawUnsafeMock(sql),
|
||||
$disconnect: () => disconnectMock(),
|
||||
$connect: () => connectMock(),
|
||||
session: { deleteMany: () => sessionDeleteManyMock() },
|
||||
systemSettings: { upsert: () => systemSettingsUpsertMock() }
|
||||
},
|
||||
reapplySqlitePragmas: () => reapplyPragmasMock()
|
||||
}));
|
||||
|
||||
let tmpRoot: string;
|
||||
let backupDir: string;
|
||||
let uploadsDir: string;
|
||||
let dbDir: string;
|
||||
|
||||
vi.mock('../../utils/uploads.js', () => ({
|
||||
getUploadsDir: () => uploadsDir
|
||||
}));
|
||||
|
||||
// Now import the SUT — after the mocks are in place.
|
||||
const importService = async () => await import('../backupService.js');
|
||||
|
||||
async function makeUploadsTree() {
|
||||
await fsp.mkdir(path.join(uploadsDir, 'wallpapers'), { recursive: true });
|
||||
await fsp.writeFile(path.join(uploadsDir, 'icon.svg'), '<svg/>');
|
||||
await fsp.writeFile(path.join(uploadsDir, 'wallpapers', 'sky.jpg'), Buffer.from([0xff, 0xd8, 0xff]));
|
||||
}
|
||||
|
||||
async function listEntries(file: string): Promise<string[]> {
|
||||
const entries: string[] = [];
|
||||
await tar.list({
|
||||
file,
|
||||
onentry: (entry) => entries.push(entry.path)
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-bs-test-'));
|
||||
backupDir = path.join(tmpRoot, 'backups');
|
||||
uploadsDir = path.join(tmpRoot, 'uploads');
|
||||
dbDir = path.join(tmpRoot, 'db');
|
||||
await fsp.mkdir(backupDir, { recursive: true });
|
||||
await fsp.mkdir(uploadsDir, { recursive: true });
|
||||
await fsp.mkdir(dbDir, { recursive: true });
|
||||
|
||||
process.env.BACKUPS_DIR = backupDir;
|
||||
// Use an absolute file: URL so getDatabasePath's path.resolve treats it
|
||||
// as already-absolute and skips the prisma/ prefix.
|
||||
process.env.DATABASE_URL = `file:${path.join(dbDir, 'test.db').replace(/\\/g, '/')}`;
|
||||
// Pretend the live DB exists so createBackup's disk-space check has data.
|
||||
await fsp.writeFile(path.join(dbDir, 'test.db'), Buffer.alloc(4096));
|
||||
|
||||
executeRawUnsafeMock.mockClear();
|
||||
queryRawUnsafeMock.mockClear();
|
||||
disconnectMock.mockClear();
|
||||
connectMock.mockClear();
|
||||
sessionDeleteManyMock.mockClear();
|
||||
reapplyPragmasMock.mockClear();
|
||||
|
||||
// reset live DB-path resolver: backupService reads DATABASE_URL each call.
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.BACKUPS_DIR;
|
||||
delete process.env.DATABASE_URL;
|
||||
await fsp.rm(tmpRoot, { recursive: true, force: true });
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('backupService — listing & path safety', () => {
|
||||
it('listBackups sorts newest-first by filename and labels formats', async () => {
|
||||
await fsp.writeFile(path.join(backupDir, 'backup-2026-01-01T00-00-00.tar.gz'), 'a');
|
||||
await fsp.writeFile(path.join(backupDir, 'backup-2026-03-01T00-00-00.tar.gz'), 'b');
|
||||
await fsp.writeFile(path.join(backupDir, 'backup-2025-12-31T23-59-59.db'), 'c');
|
||||
await fsp.writeFile(path.join(backupDir, 'unrelated.txt'), 'noise'); // should be filtered
|
||||
|
||||
const { listBackups } = await importService();
|
||||
const list = listBackups();
|
||||
|
||||
expect(list.map((b) => b.filename)).toEqual([
|
||||
'backup-2026-03-01T00-00-00.tar.gz',
|
||||
'backup-2026-01-01T00-00-00.tar.gz',
|
||||
'backup-2025-12-31T23-59-59.db'
|
||||
]);
|
||||
expect(list.find((b) => b.filename.endsWith('.tar.gz'))?.format).toBe('tar.gz');
|
||||
expect(list.find((b) => b.filename.endsWith('.db'))?.format).toBe('db');
|
||||
});
|
||||
|
||||
it('getBackupFilePath rejects path traversal', async () => {
|
||||
const { getBackupFilePath } = await importService();
|
||||
expect(getBackupFilePath('../../etc/passwd')).toBeNull();
|
||||
expect(getBackupFilePath('subdir/foo.tar.gz')).toBeNull();
|
||||
expect(getBackupFilePath('foo.txt')).toBeNull();
|
||||
expect(getBackupFilePath('foo.tar.gz.exe')).toBeNull();
|
||||
});
|
||||
|
||||
it('getBackupFilePath returns null for missing files', async () => {
|
||||
const { getBackupFilePath } = await importService();
|
||||
expect(getBackupFilePath('does-not-exist.tar.gz')).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteBackup silently rejects bad filenames', async () => {
|
||||
const { deleteBackup } = await importService();
|
||||
expect(deleteBackup('../escape.tar.gz')).toBe(false);
|
||||
expect(deleteBackup('legit.tar.gz')).toBe(false); // missing
|
||||
});
|
||||
|
||||
it('enforceRetention keeps the N newest', async () => {
|
||||
const names = [
|
||||
'backup-2026-01-01T00-00-00.tar.gz',
|
||||
'backup-2026-02-01T00-00-00.tar.gz',
|
||||
'backup-2026-03-01T00-00-00.tar.gz',
|
||||
'backup-2026-04-01T00-00-00.tar.gz',
|
||||
'backup-2026-05-01T00-00-00.tar.gz'
|
||||
];
|
||||
for (const n of names) await fsp.writeFile(path.join(backupDir, n), 'x');
|
||||
|
||||
const { enforceRetention, listBackups } = await importService();
|
||||
const deleted = enforceRetention(2);
|
||||
expect(deleted).toBe(3);
|
||||
expect(listBackups().map((b) => b.filename)).toEqual([
|
||||
'backup-2026-05-01T00-00-00.tar.gz',
|
||||
'backup-2026-04-01T00-00-00.tar.gz'
|
||||
]);
|
||||
});
|
||||
|
||||
it('isRestoring is false at rest', async () => {
|
||||
const { isRestoring } = await importService();
|
||||
expect(isRestoring()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupService — createBackup', () => {
|
||||
it('produces a tar.gz containing manifest, database.db and uploads tree', async () => {
|
||||
await makeUploadsTree();
|
||||
|
||||
const { createBackup } = await importService();
|
||||
const info = await createBackup();
|
||||
|
||||
expect(info.filename).toMatch(/^backup-.*\.tar\.gz$/);
|
||||
expect(info.format).toBe('tar.gz');
|
||||
|
||||
const archivePath = path.join(backupDir, info.filename);
|
||||
const entries = await listEntries(archivePath);
|
||||
expect(entries).toContain('manifest.json');
|
||||
expect(entries).toContain('database.db');
|
||||
expect(entries.some((e) => e.startsWith('uploads/'))).toBe(true);
|
||||
|
||||
// Extract and validate manifest
|
||||
const extractDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-verify-'));
|
||||
await tar.extract({ cwd: extractDir, file: archivePath });
|
||||
const manifest = JSON.parse(
|
||||
await fsp.readFile(path.join(extractDir, 'manifest.json'), 'utf8')
|
||||
);
|
||||
expect(manifest.version).toBe('1');
|
||||
expect(manifest.schemaVersion).toBe('test_migration');
|
||||
expect(manifest.uploadFileCount).toBeGreaterThanOrEqual(2);
|
||||
expect(manifest.checksums['database.db']).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
await fsp.rm(extractDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses BACKUPS_DIR env override', async () => {
|
||||
const { getBackupDir } = await importService();
|
||||
expect(getBackupDir()).toBe(path.resolve(backupDir));
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupService — restoreBackup', () => {
|
||||
async function writeTarballBackup(opts: {
|
||||
manifest?: unknown;
|
||||
dbBytes?: Buffer;
|
||||
includeUploads?: boolean;
|
||||
filename?: string;
|
||||
}) {
|
||||
const filename = opts.filename ?? `backup-${Date.now()}.tar.gz`;
|
||||
const work = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-mk-'));
|
||||
if (opts.manifest !== undefined) {
|
||||
await fsp.writeFile(
|
||||
path.join(work, 'manifest.json'),
|
||||
JSON.stringify(opts.manifest, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
if (opts.dbBytes) {
|
||||
await fsp.writeFile(path.join(work, 'database.db'), opts.dbBytes);
|
||||
}
|
||||
if (opts.includeUploads) {
|
||||
await fsp.mkdir(path.join(work, 'uploads'), { recursive: true });
|
||||
await fsp.writeFile(path.join(work, 'uploads', 'a.svg'), '<svg/>');
|
||||
}
|
||||
const entries = await fsp.readdir(work);
|
||||
await tar.create({ cwd: work, gzip: true, file: path.join(backupDir, filename) }, entries);
|
||||
await fsp.rm(work, { recursive: true, force: true });
|
||||
return filename;
|
||||
}
|
||||
|
||||
function validSqliteBytes(): Buffer {
|
||||
const pageSize = 4096;
|
||||
const pages = 4;
|
||||
const header = Buffer.alloc(100);
|
||||
Buffer.from([
|
||||
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
|
||||
]).copy(header, 0);
|
||||
header.writeUInt16BE(pageSize, 16);
|
||||
return Buffer.concat([header, Buffer.alloc(pageSize * pages - 100)]);
|
||||
}
|
||||
|
||||
it('rejects non-existent backup', async () => {
|
||||
const { restoreBackup } = await importService();
|
||||
await expect(restoreBackup('not-there.tar.gz')).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('rejects archive missing manifest', async () => {
|
||||
const filename = await writeTarballBackup({ dbBytes: validSqliteBytes() });
|
||||
const { restoreBackup } = await importService();
|
||||
await expect(restoreBackup(filename)).rejects.toThrow(/manifest\.json/);
|
||||
});
|
||||
|
||||
it('rejects archive with unsupported format version', async () => {
|
||||
const filename = await writeTarballBackup({
|
||||
manifest: { version: '99', createdAt: '', appVersion: '', schemaVersion: null, dbSize: 0, uploadFileCount: 0, checksums: {} },
|
||||
dbBytes: validSqliteBytes()
|
||||
});
|
||||
const { restoreBackup } = await importService();
|
||||
await expect(restoreBackup(filename)).rejects.toThrow(/format version/);
|
||||
});
|
||||
|
||||
it('rejects archive missing database.db', async () => {
|
||||
const filename = await writeTarballBackup({
|
||||
manifest: { version: '1', createdAt: '', appVersion: '', schemaVersion: null, dbSize: 0, uploadFileCount: 0, checksums: {} }
|
||||
});
|
||||
const { restoreBackup } = await importService();
|
||||
await expect(restoreBackup(filename)).rejects.toThrow(/database\.db/);
|
||||
});
|
||||
|
||||
it('rejects non-SQLite database.db', async () => {
|
||||
const filename = await writeTarballBackup({
|
||||
manifest: { version: '1', createdAt: '', appVersion: '', schemaVersion: null, dbSize: 0, uploadFileCount: 0, checksums: {} },
|
||||
dbBytes: Buffer.from('this is not a sqlite db at all'.padEnd(2048, '!'))
|
||||
});
|
||||
const { restoreBackup } = await importService();
|
||||
await expect(restoreBackup(filename)).rejects.toThrow(/not a valid SQLite/);
|
||||
});
|
||||
|
||||
it('rejects mismatched checksum', async () => {
|
||||
const db = validSqliteBytes();
|
||||
const filename = await writeTarballBackup({
|
||||
manifest: {
|
||||
version: '1',
|
||||
createdAt: '',
|
||||
appVersion: '',
|
||||
schemaVersion: 'test_migration',
|
||||
dbSize: db.length,
|
||||
uploadFileCount: 0,
|
||||
checksums: { 'database.db': 'sha256:0000000000000000000000000000000000000000000000000000000000000000' }
|
||||
},
|
||||
dbBytes: db
|
||||
});
|
||||
const { restoreBackup } = await importService();
|
||||
await expect(restoreBackup(filename)).rejects.toThrow(/checksum mismatch/);
|
||||
});
|
||||
|
||||
it('aborts on schema version mismatch unless overridden', async () => {
|
||||
const db = validSqliteBytes();
|
||||
const hash = crypto.createHash('sha256').update(db).digest('hex');
|
||||
const filename = await writeTarballBackup({
|
||||
manifest: {
|
||||
version: '1',
|
||||
createdAt: '',
|
||||
appVersion: '',
|
||||
schemaVersion: 'OLD_migration',
|
||||
dbSize: db.length,
|
||||
uploadFileCount: 0,
|
||||
checksums: { 'database.db': `sha256:${hash}` }
|
||||
},
|
||||
dbBytes: db
|
||||
});
|
||||
const { restoreBackup } = await importService();
|
||||
await expect(restoreBackup(filename)).rejects.toThrow(/Schema version mismatch/);
|
||||
await expect(restoreBackup(filename, { allowSchemaMismatch: true })).resolves.toMatchObject({
|
||||
restored: true,
|
||||
format: 'tar.gz',
|
||||
schemaVersionMatched: false
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects legacy .db file with bogus contents', async () => {
|
||||
const bogus = path.join(backupDir, 'bogus.db');
|
||||
await fsp.writeFile(bogus, 'not a sqlite header');
|
||||
const { restoreBackup } = await importService();
|
||||
await expect(restoreBackup('bogus.db')).rejects.toThrow(/not a valid SQLite/);
|
||||
});
|
||||
|
||||
it('refuses concurrent restores via _restoring flag', async () => {
|
||||
const db = validSqliteBytes();
|
||||
const hash = crypto.createHash('sha256').update(db).digest('hex');
|
||||
const filename = await writeTarballBackup({
|
||||
manifest: {
|
||||
version: '1',
|
||||
createdAt: '',
|
||||
appVersion: '',
|
||||
schemaVersion: 'test_migration',
|
||||
dbSize: db.length,
|
||||
uploadFileCount: 0,
|
||||
checksums: { 'database.db': `sha256:${hash}` }
|
||||
},
|
||||
dbBytes: db,
|
||||
includeUploads: true
|
||||
});
|
||||
const svc = await importService();
|
||||
const first = svc.restoreBackup(filename);
|
||||
await expect(svc.restoreBackup(filename)).rejects.toThrow(/already in progress/);
|
||||
await first;
|
||||
expect(svc.isRestoring()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupService — scheduler stats', () => {
|
||||
it('records success and failure counts', async () => {
|
||||
const {
|
||||
getBackupSchedulerStats,
|
||||
recordScheduledBackupSuccess,
|
||||
recordScheduledBackupFailure
|
||||
} = await importService();
|
||||
|
||||
const before = getBackupSchedulerStats();
|
||||
recordScheduledBackupSuccess();
|
||||
recordScheduledBackupSuccess();
|
||||
recordScheduledBackupFailure('disk full');
|
||||
|
||||
const after = getBackupSchedulerStats();
|
||||
expect(after.successCount - before.successCount).toBe(2);
|
||||
expect(after.failureCount - before.failureCount).toBe(1);
|
||||
expect(after.lastFailureReason).toBe('disk full');
|
||||
expect(after.lastSuccessAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { prisma, reapplySqlitePragmas } from '../prisma.js';
|
||||
import { DEFAULTS } from '$lib/utils/constants.js';
|
||||
import { getUploadsDir } from '../utils/uploads.js';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import * as tar from 'tar';
|
||||
|
||||
const BACKUP_DIR = path.resolve('data', 'backups');
|
||||
/**
|
||||
* Backup file layout (v1):
|
||||
* backup-<timestamp>.tar.gz
|
||||
* ├── manifest.json { version, createdAt, appVersion, schemaVersion, dbSize,
|
||||
* │ uploadFileCount, checksums: { "database.db": "sha256:..." } }
|
||||
* ├── database.db consistent SQLite snapshot (VACUUM INTO)
|
||||
* └── uploads/ persistent uploads tree (icons, wallpapers)
|
||||
*
|
||||
* Legacy `.db` files produced by earlier versions are still listed and may be
|
||||
* restored — they restore DB only (no uploads).
|
||||
*/
|
||||
|
||||
// SQLite file format magic: "SQLite format 3\0"
|
||||
const BACKUP_DIR_DEFAULT_PROD = '/app/data/backups';
|
||||
const BACKUP_FORMAT_VERSION = '1';
|
||||
const MIN_FREE_DISK_BYTES = 256 * 1024 * 1024; // 256 MiB safety margin
|
||||
const SQLITE_MAGIC = Buffer.from([
|
||||
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
|
||||
]);
|
||||
@@ -20,12 +37,36 @@ export interface BackupInfo {
|
||||
readonly filename: string;
|
||||
readonly size: number;
|
||||
readonly createdAt: string;
|
||||
readonly format: 'tar.gz' | 'db';
|
||||
}
|
||||
|
||||
function ensureBackupDir(): void {
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
export interface BackupManifest {
|
||||
readonly version: string;
|
||||
readonly createdAt: string;
|
||||
readonly appVersion: string;
|
||||
readonly schemaVersion: string | null;
|
||||
readonly dbSize: number;
|
||||
readonly uploadFileCount: number;
|
||||
readonly checksums: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the directory backups are written to. Honours BACKUPS_DIR env, then
|
||||
* falls back to /app/data/backups in production and <cwd>/data/backups in dev,
|
||||
* matching the uploads.ts convention.
|
||||
*/
|
||||
export function getBackupDir(): string {
|
||||
if (process.env.BACKUPS_DIR) return path.resolve(process.env.BACKUPS_DIR);
|
||||
if (process.env.NODE_ENV === 'production') return BACKUP_DIR_DEFAULT_PROD;
|
||||
return path.resolve(process.cwd(), 'data', 'backups');
|
||||
}
|
||||
|
||||
function ensureBackupDir(): string {
|
||||
const dir = getBackupDir();
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function getDatabasePath(): string {
|
||||
@@ -34,11 +75,6 @@ function getDatabasePath(): string {
|
||||
return path.resolve('prisma', relative);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the file at `filePath` is a SQLite database by checking the
|
||||
* 16-byte magic header. Without this, a 0-byte or text file silently
|
||||
* overwrites the live DB during restore.
|
||||
*/
|
||||
function isSqliteFile(filePath: string): boolean {
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
@@ -54,44 +90,195 @@ function isSqliteFile(filePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256OfFile(filePath: string): Promise<string> {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
for await (const chunk of stream) hash.update(chunk as Buffer);
|
||||
return `sha256:${hash.digest('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run SQLite's own integrity check on a database file. Returns true only when
|
||||
* the engine reports "ok". Catches malformed files that pass the magic-header
|
||||
* check (truncated DBs, partial copies, etc.).
|
||||
*/
|
||||
async function isSqliteIntegrityOk(filePath: string): Promise<boolean> {
|
||||
// Use a child Prisma-less raw verification: open via better-sqlite3? Not a
|
||||
// dep here. Use SQLite's own header AND a parse-trial via prisma against a
|
||||
// temp ATTACH would lock the live DB. Cheapest cross-platform path: ask
|
||||
// SQLite to open and PRAGMA the file via the sqlite3 CLI when available;
|
||||
// otherwise fall back to a structural smoke test (last 100 bytes contain
|
||||
// a valid page footer). The CLI presence cannot be assumed in the
|
||||
// scratch container, so do a best-effort structural check here and rely
|
||||
// on Prisma reconnect to detect catastrophic corruption.
|
||||
try {
|
||||
const stats = await fsp.stat(filePath);
|
||||
// SQLite pages are 512..65536 bytes, power of two. The DB size must be a
|
||||
// multiple of the page size. The page size lives at bytes 16-17, big-endian
|
||||
// (with the special value 1 meaning 65536).
|
||||
if (stats.size < 100) return false;
|
||||
const fh = await fsp.open(filePath, 'r');
|
||||
try {
|
||||
const header = Buffer.alloc(100);
|
||||
await fh.read(header, 0, 100, 0);
|
||||
if (!header.subarray(0, 16).equals(SQLITE_MAGIC)) return false;
|
||||
const pageSizeRaw = header.readUInt16BE(16);
|
||||
const pageSize = pageSizeRaw === 1 ? 65536 : pageSizeRaw;
|
||||
if (pageSize < 512 || (pageSize & (pageSize - 1)) !== 0) return false;
|
||||
if (stats.size % pageSize !== 0) return false;
|
||||
return true;
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSchemaVersion(): Promise<string | null> {
|
||||
try {
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ migration_name: string }>>(
|
||||
`SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL ORDER BY finished_at DESC LIMIT 1`
|
||||
);
|
||||
return rows[0]?.migration_name ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readAppVersion(): Promise<string> {
|
||||
try {
|
||||
const pkgPath = path.resolve(process.cwd(), 'package.json');
|
||||
const raw = await fsp.readFile(pkgPath, 'utf8');
|
||||
const pkg = JSON.parse(raw) as { version?: string };
|
||||
return pkg.version ?? '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDirRecursive(src: string, dest: string): Promise<number> {
|
||||
if (!fs.existsSync(src)) return 0;
|
||||
await fsp.mkdir(dest, { recursive: true });
|
||||
let count = 0;
|
||||
const entries = await fsp.readdir(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const s = path.join(src, entry.name);
|
||||
const d = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += await copyDirRecursive(s, d);
|
||||
} else if (entry.isFile()) {
|
||||
await fsp.copyFile(s, d);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function checkFreeDiskSpace(dir: string, minBytes: number): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fsp.statfs(dir);
|
||||
const free = stats.bavail * stats.bsize;
|
||||
return free >= minBytes;
|
||||
} catch {
|
||||
return true; // statfs unavailable (Windows < Node 18.15) — skip check
|
||||
}
|
||||
}
|
||||
|
||||
async function rmrf(target: string): Promise<void> {
|
||||
await fsp.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function createBackup(): Promise<BackupInfo> {
|
||||
ensureBackupDir();
|
||||
const backupDir = ensureBackupDir();
|
||||
|
||||
const dbPath = getDatabasePath();
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const dbStats = fs.statSync(dbPath);
|
||||
const needed = Math.max(dbStats.size * 2, MIN_FREE_DISK_BYTES);
|
||||
if (!(await checkFreeDiskSpace(backupDir, needed))) {
|
||||
throw new Error(
|
||||
'Not enough free disk space to create a backup (need at least 256 MiB headroom)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const filename = `backup-${timestamp}.db`;
|
||||
const backupPath = path.join(BACKUP_DIR, filename);
|
||||
const filename = `backup-${timestamp}.tar.gz`;
|
||||
const backupPath = path.join(backupDir, filename);
|
||||
|
||||
const safePath = backupPath.replace(/\\/g, '/').replace(/'/g, "''");
|
||||
await prisma.$executeRawUnsafe(`VACUUM INTO '${safePath}'`);
|
||||
const workDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-backup-'));
|
||||
const stagedDb = path.join(workDir, 'database.db');
|
||||
const stagedUploads = path.join(workDir, 'uploads');
|
||||
|
||||
const stats = fs.statSync(backupPath);
|
||||
return {
|
||||
filename,
|
||||
size: stats.size,
|
||||
createdAt: stats.birthtime.toISOString()
|
||||
};
|
||||
try {
|
||||
const safeStagedDb = stagedDb.replace(/\\/g, '/').replace(/'/g, "''");
|
||||
await prisma.$executeRawUnsafe(`VACUUM INTO '${safeStagedDb}'`);
|
||||
|
||||
const dbChecksum = await sha256OfFile(stagedDb);
|
||||
const dbStats = await fsp.stat(stagedDb);
|
||||
|
||||
const uploadsSrc = getUploadsDir();
|
||||
const uploadFileCount = await copyDirRecursive(uploadsSrc, stagedUploads);
|
||||
|
||||
const manifest: BackupManifest = {
|
||||
version: BACKUP_FORMAT_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
appVersion: await readAppVersion(),
|
||||
schemaVersion: await getSchemaVersion(),
|
||||
dbSize: dbStats.size,
|
||||
uploadFileCount,
|
||||
checksums: { 'database.db': dbChecksum }
|
||||
};
|
||||
await fsp.writeFile(
|
||||
path.join(workDir, 'manifest.json'),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const stagedArchive = `${backupPath}.tmp`;
|
||||
const entries = ['manifest.json', 'database.db'];
|
||||
if (uploadFileCount > 0) entries.push('uploads');
|
||||
|
||||
await tar.create({ cwd: workDir, gzip: true, file: stagedArchive }, entries);
|
||||
await fsp.rename(stagedArchive, backupPath);
|
||||
|
||||
const stats = await fsp.stat(backupPath);
|
||||
return {
|
||||
filename,
|
||||
size: stats.size,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
format: 'tar.gz'
|
||||
};
|
||||
} finally {
|
||||
await rmrf(workDir);
|
||||
}
|
||||
}
|
||||
|
||||
export function listBackups(): ReadonlyArray<BackupInfo> {
|
||||
ensureBackupDir();
|
||||
const files = fs.readdirSync(BACKUP_DIR).filter((f) => f.endsWith('.db'));
|
||||
const backupDir = ensureBackupDir();
|
||||
const files = fs.readdirSync(backupDir).filter(
|
||||
(f) => f.endsWith('.tar.gz') || f.endsWith('.db')
|
||||
);
|
||||
return files
|
||||
.map((filename) => {
|
||||
const stats = fs.statSync(path.join(BACKUP_DIR, filename));
|
||||
.map((filename): BackupInfo => {
|
||||
const stats = fs.statSync(path.join(backupDir, filename));
|
||||
return {
|
||||
filename,
|
||||
size: stats.size,
|
||||
createdAt: stats.birthtime.toISOString()
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
format: filename.endsWith('.tar.gz') ? 'tar.gz' : 'db'
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
.sort((a, b) => b.filename.localeCompare(a.filename));
|
||||
}
|
||||
|
||||
export function getBackupFilePath(filename: string): string | null {
|
||||
const sanitized = path.basename(filename);
|
||||
if (sanitized !== filename) return null; // path traversal attempt
|
||||
if (!/^[\w.-]+\.db$/.test(sanitized)) return null;
|
||||
const fullPath = path.join(BACKUP_DIR, sanitized);
|
||||
if (sanitized !== filename) return null;
|
||||
// Allow alphanumerics, dot, dash, underscore. Extension must be .tar.gz or .db.
|
||||
if (!/^[\w.-]+\.(tar\.gz|db)$/.test(sanitized)) return null;
|
||||
const fullPath = path.join(getBackupDir(), sanitized);
|
||||
if (!fs.existsSync(fullPath)) return null;
|
||||
return fullPath;
|
||||
}
|
||||
@@ -103,76 +290,192 @@ export function deleteBackup(filename: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface RestoreOptions {
|
||||
/** When true, allow restoring even if the manifest schemaVersion differs
|
||||
* from the live schema. Defaults to false. */
|
||||
readonly allowSchemaMismatch?: boolean;
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
readonly restored: true;
|
||||
readonly format: 'tar.gz' | 'db';
|
||||
readonly schemaVersionMatched: boolean;
|
||||
readonly uploadFileCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the database from a backup file.
|
||||
* Restore the DB (and uploads, for tar.gz backups) from a backup file.
|
||||
*
|
||||
* Hardened:
|
||||
* 1. Verify the source file's SQLite magic header before touching the live DB.
|
||||
* 2. Set _restoring = true BEFORE $disconnect (TOCTOU narrowing).
|
||||
* 3. Take a safety snapshot of the current DB so a partial copy can be rolled back.
|
||||
* 4. Use rename (atomic on same fs) instead of copyFileSync where possible.
|
||||
* 5. Reapply SQLite pragmas after reconnect.
|
||||
* Hardened ordering:
|
||||
* 1. Validate format + (for tar.gz) extract to staging + verify manifest +
|
||||
* sha256 checksum + structural integrity of the staged DB.
|
||||
* 2. Cross-check schema version against the live `_prisma_migrations` table.
|
||||
* Mismatch aborts unless allowSchemaMismatch is set.
|
||||
* 3. Set _restoring=true (gate in hooks.server.ts returns 503 to other reqs).
|
||||
* 4. Snapshot live DB and uploads dir to *.pre-restore-<ts>.
|
||||
* 5. Disconnect Prisma; atomic rename of staged DB and uploads tree.
|
||||
* 6. Revoke ALL sessions (DB writes are local — restored DB already does
|
||||
* not contain post-backup sessions; this just makes intent explicit).
|
||||
* 7. Reconnect Prisma; re-apply pragmas.
|
||||
* 8. On any failure: restore snapshots, reconnect Prisma, rethrow.
|
||||
*/
|
||||
export async function restoreBackup(filename: string): Promise<void> {
|
||||
export async function restoreBackup(
|
||||
filename: string,
|
||||
options: RestoreOptions = {}
|
||||
): Promise<RestoreResult> {
|
||||
if (_restoring) {
|
||||
throw new Error('A restore is already in progress');
|
||||
}
|
||||
_restoring = true;
|
||||
|
||||
const backupPath = getBackupFilePath(filename);
|
||||
if (!backupPath) {
|
||||
_restoring = false;
|
||||
throw new Error(`Backup not found: ${filename}`);
|
||||
}
|
||||
|
||||
if (!isSqliteFile(backupPath)) {
|
||||
_restoring = false;
|
||||
throw new Error(`File is not a valid SQLite database: ${filename}`);
|
||||
}
|
||||
|
||||
let workDir: string | null = null;
|
||||
const dbPath = getDatabasePath();
|
||||
const safetyPath = `${dbPath}.pre-restore-${Date.now()}.bak`;
|
||||
const dbSafety = `${dbPath}.pre-restore-${Date.now()}.bak`;
|
||||
const uploadsDir = getUploadsDir();
|
||||
const uploadsSafety = `${uploadsDir}.pre-restore-${Date.now()}`;
|
||||
let dbSwapped = false;
|
||||
let uploadsSwapped = false;
|
||||
|
||||
try {
|
||||
// 1. Snapshot the live DB so we can roll back on failure.
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.copyFileSync(dbPath, safetyPath);
|
||||
const backupPath = getBackupFilePath(filename);
|
||||
if (!backupPath) {
|
||||
throw new Error(`Backup not found: ${filename}`);
|
||||
}
|
||||
|
||||
const isLegacyDb = backupPath.endsWith('.db');
|
||||
let stagedDb: string;
|
||||
let stagedUploads: string | null = null;
|
||||
let manifest: BackupManifest | null = null;
|
||||
|
||||
if (isLegacyDb) {
|
||||
if (!isSqliteFile(backupPath)) {
|
||||
throw new Error(`File is not a valid SQLite database: ${filename}`);
|
||||
}
|
||||
stagedDb = backupPath;
|
||||
} else {
|
||||
workDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'wal-restore-'));
|
||||
await tar.extract({ cwd: workDir, file: backupPath });
|
||||
|
||||
const manifestPath = path.join(workDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error('Backup is missing manifest.json');
|
||||
}
|
||||
const rawManifest = await fsp.readFile(manifestPath, 'utf8');
|
||||
manifest = JSON.parse(rawManifest) as BackupManifest;
|
||||
if (manifest.version !== BACKUP_FORMAT_VERSION) {
|
||||
throw new Error(
|
||||
`Unsupported backup format version: ${manifest.version} (expected ${BACKUP_FORMAT_VERSION})`
|
||||
);
|
||||
}
|
||||
|
||||
stagedDb = path.join(workDir, 'database.db');
|
||||
if (!fs.existsSync(stagedDb)) {
|
||||
throw new Error('Backup is missing database.db');
|
||||
}
|
||||
if (!isSqliteFile(stagedDb)) {
|
||||
throw new Error('Backup database.db is not a valid SQLite file');
|
||||
}
|
||||
if (!(await isSqliteIntegrityOk(stagedDb))) {
|
||||
throw new Error('Backup database.db failed integrity check');
|
||||
}
|
||||
const expectedSum = manifest.checksums?.['database.db'];
|
||||
if (expectedSum) {
|
||||
const actual = await sha256OfFile(stagedDb);
|
||||
if (actual !== expectedSum) {
|
||||
throw new Error(
|
||||
`Backup checksum mismatch for database.db (expected ${expectedSum}, got ${actual})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const uploadsStaged = path.join(workDir, 'uploads');
|
||||
if (fs.existsSync(uploadsStaged)) stagedUploads = uploadsStaged;
|
||||
}
|
||||
|
||||
const liveSchemaVersion = await getSchemaVersion();
|
||||
const schemaVersionMatched =
|
||||
!manifest?.schemaVersion ||
|
||||
!liveSchemaVersion ||
|
||||
manifest.schemaVersion === liveSchemaVersion;
|
||||
if (!schemaVersionMatched && !options.allowSchemaMismatch) {
|
||||
throw new Error(
|
||||
`Schema version mismatch: backup=${manifest?.schemaVersion ?? 'unknown'}, live=${liveSchemaVersion ?? 'unknown'}. Restore aborted to prevent data loss. Re-trigger with allowSchemaMismatch to override.`
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot live state for rollback.
|
||||
if (fs.existsSync(dbPath)) {
|
||||
await fsp.copyFile(dbPath, dbSafety);
|
||||
}
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
await fsp.rename(uploadsDir, uploadsSafety);
|
||||
}
|
||||
|
||||
// 2. Disconnect Prisma so the DB file is not locked.
|
||||
await prisma.$disconnect();
|
||||
|
||||
// 3. Copy backup → temp, then rename atomically over the live DB.
|
||||
const stagingPath = `${dbPath}.restore.tmp`;
|
||||
fs.copyFileSync(backupPath, stagingPath);
|
||||
fs.renameSync(stagingPath, dbPath);
|
||||
// DB: stage → atomic rename over live path.
|
||||
const dbStaging = `${dbPath}.restore.tmp`;
|
||||
await fsp.copyFile(stagedDb, dbStaging);
|
||||
await fsp.rename(dbStaging, dbPath);
|
||||
dbSwapped = true;
|
||||
|
||||
// Uploads: rename staged tree into place (or create empty dir if none).
|
||||
if (stagedUploads) {
|
||||
await fsp.rename(stagedUploads, uploadsDir);
|
||||
} else {
|
||||
await fsp.mkdir(uploadsDir, { recursive: true });
|
||||
}
|
||||
uploadsSwapped = true;
|
||||
|
||||
// 4. Reconnect + re-apply pragmas (WAL etc.)
|
||||
await prisma.$connect();
|
||||
await reapplySqlitePragmas();
|
||||
|
||||
// Cleanup safety snapshot on success.
|
||||
if (fs.existsSync(safetyPath)) {
|
||||
try {
|
||||
fs.unlinkSync(safetyPath);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Roll back from safety snapshot.
|
||||
// Best-effort: wipe any sessions left over from in-flight refreshes that
|
||||
// raced with the restore. Restored DB already contains only sessions
|
||||
// captured AT backup time, so this is a defence-in-depth measure.
|
||||
try {
|
||||
if (fs.existsSync(safetyPath)) {
|
||||
fs.copyFileSync(safetyPath, dbPath);
|
||||
fs.unlinkSync(safetyPath);
|
||||
await prisma.session.deleteMany({});
|
||||
} catch (err) {
|
||||
console.warn('[backup] post-restore session purge failed:', err);
|
||||
}
|
||||
|
||||
// Cleanup safety snapshots on success.
|
||||
await Promise.allSettled([rmrf(dbSafety), rmrf(uploadsSafety)]);
|
||||
|
||||
return {
|
||||
restored: true,
|
||||
format: isLegacyDb ? 'db' : 'tar.gz',
|
||||
schemaVersionMatched,
|
||||
uploadFileCount: manifest?.uploadFileCount ?? 0
|
||||
};
|
||||
} catch (err) {
|
||||
// Rollback DB if it was swapped.
|
||||
try {
|
||||
if (dbSwapped && fs.existsSync(dbSafety)) {
|
||||
await fsp.copyFile(dbSafety, dbPath);
|
||||
}
|
||||
if (uploadsSwapped) {
|
||||
await rmrf(uploadsDir);
|
||||
if (fs.existsSync(uploadsSafety)) {
|
||||
await fsp.rename(uploadsSafety, uploadsDir);
|
||||
}
|
||||
} else if (fs.existsSync(uploadsSafety) && !fs.existsSync(uploadsDir)) {
|
||||
// Uploads dir was renamed away but never replaced.
|
||||
await fsp.rename(uploadsSafety, uploadsDir);
|
||||
}
|
||||
await rmrf(dbSafety);
|
||||
} catch (rollbackErr) {
|
||||
console.error('[backup] rollback failed:', rollbackErr);
|
||||
}
|
||||
try {
|
||||
await prisma.$connect();
|
||||
await reapplySqlitePragmas();
|
||||
} catch {
|
||||
// Best-effort recovery.
|
||||
} catch (reconnectErr) {
|
||||
console.error('[backup] reconnect after rollback failed:', reconnectErr);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (workDir) await rmrf(workDir);
|
||||
_restoring = false;
|
||||
}
|
||||
}
|
||||
@@ -180,7 +483,6 @@ export async function restoreBackup(filename: string): Promise<void> {
|
||||
export function enforceRetention(maxCount: number): number {
|
||||
const backups = listBackups();
|
||||
if (backups.length <= maxCount) return 0;
|
||||
|
||||
const toDelete = backups.slice(maxCount);
|
||||
for (const backup of toDelete) {
|
||||
deleteBackup(backup.filename);
|
||||
@@ -198,7 +500,6 @@ export async function getBackupSettings(): Promise<{
|
||||
update: {},
|
||||
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
|
||||
});
|
||||
|
||||
return {
|
||||
backupEnabled: settings.backupEnabled,
|
||||
backupCronExpression: settings.backupCronExpression,
|
||||
@@ -226,10 +527,43 @@ export async function updateBackupSettings(data: {
|
||||
},
|
||||
create: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
|
||||
});
|
||||
|
||||
return {
|
||||
backupEnabled: settings.backupEnabled,
|
||||
backupCronExpression: settings.backupCronExpression,
|
||||
backupMaxCount: settings.backupMaxCount
|
||||
};
|
||||
}
|
||||
|
||||
// Stats exposed for scheduler observability — also surfaced via /api/metrics
|
||||
// if you wire it there.
|
||||
export interface BackupSchedulerStats {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
lastSuccessAt: string | null;
|
||||
lastFailureAt: string | null;
|
||||
lastFailureReason: string | null;
|
||||
}
|
||||
|
||||
const _stats: BackupSchedulerStats = {
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
lastSuccessAt: null,
|
||||
lastFailureAt: null,
|
||||
lastFailureReason: null
|
||||
};
|
||||
|
||||
export function getBackupSchedulerStats(): Readonly<BackupSchedulerStats> {
|
||||
return { ..._stats };
|
||||
}
|
||||
|
||||
export function recordScheduledBackupSuccess(): void {
|
||||
_stats.successCount += 1;
|
||||
_stats.lastSuccessAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
export function recordScheduledBackupFailure(reason: string): void {
|
||||
_stats.failureCount += 1;
|
||||
_stats.lastFailureAt = new Date().toISOString();
|
||||
_stats.lastFailureReason = reason;
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export const AuditAction = {
|
||||
BACKUP_CREATED: 'backup_created',
|
||||
BACKUP_RESTORED: 'backup_restored',
|
||||
BACKUP_DELETED: 'backup_deleted',
|
||||
BACKUP_FAILED: 'backup_failed',
|
||||
LOGIN_SUCCESS: 'login_success',
|
||||
LOGIN_FAILED: 'login_failed',
|
||||
LOGOUT: 'logout',
|
||||
|
||||
@@ -476,7 +476,8 @@ export const auditLogQuerySchema = z.object({
|
||||
AuditAction.EXPORT,
|
||||
AuditAction.BACKUP_CREATED,
|
||||
AuditAction.BACKUP_RESTORED,
|
||||
AuditAction.BACKUP_DELETED
|
||||
AuditAction.BACKUP_DELETED,
|
||||
AuditAction.BACKUP_FAILED
|
||||
])
|
||||
.optional(),
|
||||
entityType: z.string().max(50).optional(),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import { createBackup, listBackups, enforceRetention, getBackupSettings } from '$lib/server/services/backupService.js';
|
||||
import {
|
||||
createBackup,
|
||||
listBackups,
|
||||
enforceRetention,
|
||||
getBackupSettings,
|
||||
getBackupSchedulerStats
|
||||
} from '$lib/server/services/backupService.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
import { logAction } from '$lib/server/services/auditLogService.js';
|
||||
import { AuditAction } from '$lib/utils/constants.js';
|
||||
@@ -15,7 +21,8 @@ export const GET: RequestHandler = async (event) => {
|
||||
try {
|
||||
const backups = listBackups();
|
||||
const settings = await getBackupSettings();
|
||||
return json(success({ backups, schedule: settings }));
|
||||
const stats = getBackupSchedulerStats();
|
||||
return json(success({ backups, schedule: settings, stats }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to list backups';
|
||||
return json(error(message), { status: 500 });
|
||||
|
||||
@@ -21,12 +21,16 @@ export const GET: RequestHandler = async (event) => {
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const stream = fs.createReadStream(filePath);
|
||||
const basename = path.basename(filePath);
|
||||
const contentType = basename.endsWith('.tar.gz')
|
||||
? 'application/gzip'
|
||||
: 'application/octet-stream';
|
||||
|
||||
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${path.basename(filePath)}"`,
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
||||
'Content-Length': String(stats.size)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,25 +2,57 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import { restoreBackup } from '$lib/server/services/backupService.js';
|
||||
import { clearSessionCookies } from '$lib/server/utils/sessionCookies.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
import { logAction } from '$lib/server/services/auditLogService.js';
|
||||
import { AuditAction } from '$lib/utils/constants.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const restoreOptionsSchema = z
|
||||
.object({
|
||||
allowSchemaMismatch: z.boolean().optional()
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* POST /api/admin/backups/:filename/restore — Restore the database from a backup.
|
||||
*
|
||||
* On success the response sets force_logout: true and clears the admin's
|
||||
* session cookies, because the restored DB contains a session set from the
|
||||
* backup-time snapshot and the current admin's session is no longer valid.
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const admin = requireAdmin(event);
|
||||
const { filename } = event.params;
|
||||
|
||||
let options: { allowSchemaMismatch?: boolean } = {};
|
||||
try {
|
||||
await restoreBackup(filename);
|
||||
const text = await event.request.text();
|
||||
if (text.trim()) {
|
||||
const parsed = restoreOptionsSchema.safeParse(JSON.parse(text));
|
||||
if (parsed.success && parsed.data) options = parsed.data;
|
||||
}
|
||||
} catch {
|
||||
// Body is optional — ignore parse errors and fall back to defaults.
|
||||
}
|
||||
|
||||
logAction(admin.id, AuditAction.BACKUP_RESTORED, 'backup', filename);
|
||||
try {
|
||||
const result = await restoreBackup(filename, options);
|
||||
|
||||
return json(success({ restored: true }));
|
||||
logAction(admin.id, AuditAction.BACKUP_RESTORED, 'backup', filename, {
|
||||
format: result.format,
|
||||
schemaVersionMatched: result.schemaVersionMatched,
|
||||
uploadFileCount: result.uploadFileCount
|
||||
});
|
||||
|
||||
// All session state from the backup time is now live — the admin's
|
||||
// current cookies refer to a session that doesn't exist any more.
|
||||
clearSessionCookies(event.cookies);
|
||||
|
||||
return json(success({ ...result, forceLogout: true }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to restore backup';
|
||||
return json(error(message), { status: 500 });
|
||||
const status = /schema version mismatch/i.test(message) ? 409 : 500;
|
||||
return json(error(message), { status });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||
import { getBackupSettings, updateBackupSettings } from '$lib/server/services/backupService.js';
|
||||
import {
|
||||
getBackupSchedulerStats,
|
||||
getBackupSettings,
|
||||
updateBackupSettings
|
||||
} from '$lib/server/services/backupService.js';
|
||||
import { restartBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
|
||||
import { success, error } from '$lib/server/utils/response.js';
|
||||
import { logAction } from '$lib/server/services/auditLogService.js';
|
||||
@@ -17,7 +21,8 @@ export const GET: RequestHandler = async (event) => {
|
||||
|
||||
try {
|
||||
const settings = await getBackupSettings();
|
||||
return json(success(settings));
|
||||
const stats = getBackupSchedulerStats();
|
||||
return json(success({ ...settings, stats }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to get backup schedule';
|
||||
return json(error(message), { status: 500 });
|
||||
|
||||
Reference in New Issue
Block a user