Merge pull request #27 from Yiling-J/feature-backup-restore-6292895868242646111

Add Backup and Restore System
This commit is contained in:
Yiling-J authored and GitHub committed 2026-02-15 15:38:37 +08:00
commit b2640a59e8
11 files changed
+538 -30

No files matched your search

+25 -1
View File
@@ -18,6 +18,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@types/tar": "^6.1.13",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -35,6 +36,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tar": "^7.5.7",
"tw-animate-css": "^1.4.0",
"ulidx": "^2.4.1",
"zod": "^4.3.6",
@@ -169,6 +171,8 @@
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@libsql/client": ["@libsql/client@0.17.0", "", { "dependencies": { "@libsql/core": "^0.17.0", "@libsql/hrana-client": "^0.9.0", "js-base64": "^3.7.5", "libsql": "^0.5.22", "promise-limit": "^2.7.0" } }, "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg=="],
"@libsql/core": ["@libsql/core@0.17.0", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-hnZRnJHiS+nrhHKLGYPoJbc78FE903MSDrFJTbftxo+e52X+E0Y0fHOCVYsKWcg6XgB7BbJYUrz/xEkVTSaipw=="],
@@ -411,6 +415,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/tar": ["@types/tar@6.1.13", "", { "dependencies": { "@types/node": "*", "minipass": "^4.0.0" } }, "sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
@@ -485,6 +491,8 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
@@ -759,7 +767,9 @@
"minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"motion-dom": ["motion-dom@12.33.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ=="],
@@ -943,6 +953,8 @@
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -1001,6 +1013,8 @@
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
@@ -1011,6 +1025,8 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@isaacs/fs-minipass/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"@prisma/adapter-libsql/@libsql/client": ["@libsql/client@0.8.1", "", { "dependencies": { "@libsql/core": "^0.8.1", "@libsql/hrana-client": "^0.6.2", "js-base64": "^3.7.5", "libsql": "^0.3.10", "promise-limit": "^2.7.0" } }, "sha512-xGg0F4iTDFpeBZ0r4pA6icGsYa5rG6RAG+i/iLDnpCAnSuTqEWMDdPlVseiq4Z/91lWI9jvvKKiKpovqJ1kZWA=="],
"@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
@@ -1183,10 +1199,16 @@
"glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"glob/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"minizlib/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
"path-scurry/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
@@ -1209,6 +1231,8 @@
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"tar/minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+2
View File
@@ -24,6 +24,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@types/tar": "^6.1.13",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -41,6 +42,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tar": "^7.5.7",
"tw-animate-css": "^1.4.0",
"ulidx": "^2.4.1",
"zod": "^4.3.6",
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test, mock } from 'bun:test'
import backup from './backup'
const mockBackupService = {
createBackupStream: mock(() => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('test'))
controller.close()
},
})
return stream
}),
}
mock.module('../service/backup', () => ({
backupService: mockBackupService,
}))
describe('Backup API', () => {
test('GET / should return a tar stream', async () => {
const res = await backup.request('/')
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/x-tar')
expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="backup.tar"')
const blob = await res.blob()
expect(await blob.text()).toBe('test')
expect(mockBackupService.createBackupStream).toHaveBeenCalled()
})
})
+18
View File
@@ -0,0 +1,18 @@
import { Hono } from 'hono'
import { backupService } from '../service/backup'
const app = new Hono()
app.get('/', async () => {
const stream = backupService.createBackupStream()
// @ts-ignore: Bun supports Readable in Response, but TS doesn't know fully
return new Response(stream, {
headers: {
'Content-Type': 'application/x-tar',
'Content-Disposition': 'attachment; filename="backup.tar"',
},
})
})
export default app
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test, mock, afterEach, beforeEach } from 'bun:test'
import restore from './restore'
const mockBackupService = {
restoreBackup: mock(() => Promise.resolve()),
}
mock.module('../service/backup', () => ({
backupService: mockBackupService,
}))
describe('Restore API', () => {
let originalConsoleError: typeof console.error
beforeEach(() => {
originalConsoleError = console.error
console.error = mock() // Suppress console.error
})
afterEach(() => {
console.error = originalConsoleError // Restore console.error
mockBackupService.restoreBackup.mockClear()
})
test('POST / should restore data', async () => {
const file = new File(['test'], 'backup.tar', { type: 'application/x-tar' })
const res = await restore.request('/', {
method: 'POST',
body: file,
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
expect(mockBackupService.restoreBackup).toHaveBeenCalled()
})
test('POST / should handle restore failure', async () => {
mockBackupService.restoreBackup.mockRejectedValueOnce(new Error('Test Error'))
const file = new File(['test'], 'backup.tar', { type: 'application/x-tar' })
const res = await restore.request('/', {
method: 'POST',
body: file,
})
expect(res.status).toBe(500)
expect(await res.json()).toEqual({ success: false, error: 'Test Error' })
// Verify error was logged (but suppressed from output)
expect(console.error).toHaveBeenCalled()
})
})
+33
View File
@@ -0,0 +1,33 @@
import { Hono } from 'hono'
import { Readable } from 'node:stream'
import { backupService } from '../service/backup'
const app = new Hono()
app.post('/', async (c) => {
try {
const webStream = c.req.raw.body
if (!webStream) {
throw new Error('No file uploaded')
}
// Use Readable.fromWeb to convert standard Web Stream to Node Stream
// @ts-ignore: Bun implements fromWeb but types might be outdated in some environments
const nodeStream = Readable.fromWeb(webStream)
await backupService.restoreBackup(nodeStream)
return c.json({ success: true })
} catch (error) {
console.error('Restore failed:', error)
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
500,
)
}
})
export default app
+4
View File
@@ -4,6 +4,7 @@ import { serveStatic } from 'hono/bun'
import { cors } from 'hono/cors'
import { existsSync, mkdirSync } from 'node:fs'
import asset from './api/asset'
import backup from './api/backup'
import character from './api/character'
import equipment from './api/equipment'
import extract from './api/extract'
@@ -11,6 +12,7 @@ import expression from './api/expression'
import generation from './api/generation'
import outfit from './api/outfit'
import pose from './api/pose'
import restore from './api/restore'
import setting from './api/setting'
import index from './ui/index.html'
@@ -28,12 +30,14 @@ app.use('/*', cors())
const api = new Hono()
const route = api
.route('/assets', asset)
.route('/backup', backup)
.route('/characters', character)
.route('/equipments', equipment)
.route('/generations', generation)
.route('/extract', extract)
.route('/outfits', outfit)
.route('/poses', pose)
.route('/restore', restore)
.route('/expressions', expression)
.route('/settings', setting)
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, mock, test } from 'bun:test'
import { PassThrough } from 'node:stream'
// 1. Mock Prisma
const mockPrisma = {
$connect: mock(() => Promise.resolve()),
$disconnect: mock(() => Promise.resolve()),
}
mock.module('../db', () => ({
prisma: mockPrisma,
}))
// 2. Mock node:fs to prevent side effects
const mockFs = {
existsSync: mock(() => true),
mkdirSync: mock(),
renameSync: mock(),
rmSync: mock(),
// For other functions if used
writeFileSync: mock(),
readFileSync: mock(),
}
mock.module('node:fs', () => mockFs)
// 3. Mock tar to avoid actual compression/extraction
mock.module('tar', () => ({
create: () => {
const stream = new PassThrough()
// Emit some data immediately to simulate a tar stream
stream.write(Buffer.from('mock-tar-content'))
stream.end()
return stream
},
extract: () => {
const stream = new PassThrough()
// Simulate async completion
setTimeout(() => stream.emit('finish'), 10)
return stream
}
}))
// Import service after mocking
// @ts-ignore
const { backupService } = await import(`./backup?v=${Date.now()}`)
describe('BackupService', () => {
test('createBackupStream should return a readable tar stream', async () => {
const stream = backupService.createBackupStream()
expect(stream).toBeDefined()
// Read stream
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
const buffer = Buffer.concat(chunks)
expect(buffer.toString()).toBe('mock-tar-content')
})
test('restoreBackup should attempt to move data, extract, and clean up', async () => {
const dummyStream = new PassThrough()
dummyStream.end()
await backupService.restoreBackup(dummyStream)
// Verify steps
expect(mockPrisma.$disconnect).toHaveBeenCalled()
expect(mockFs.existsSync).toHaveBeenCalledWith('data')
// backupService logic: if exists('data') -> renameSync('data', backupDir)
expect(mockFs.renameSync).toHaveBeenCalled()
expect(mockFs.mkdirSync).toHaveBeenCalledWith('data')
// extraction happens via mocked tar.extract
// clean up: rmSync(backupDir, ...)
expect(mockFs.rmSync).toHaveBeenCalled()
expect(mockPrisma.$connect).toHaveBeenCalled()
})
test('restoreBackup should rollback on failure', async () => {
// Force failure in extraction
mock.module('tar', () => ({
create: () => new PassThrough(),
extract: () => {
const stream = new PassThrough()
setTimeout(() => stream.emit('error', new Error('Extraction failed')), 10)
return stream
}
}))
// Re-import service to pick up new mock
// @ts-ignore
const { backupService: failService } = await import(`./backup?v=${Date.now() + 1}`)
const dummyStream = new PassThrough()
dummyStream.end()
try {
await failService.restoreBackup(dummyStream)
} catch (e) {
expect((e as Error).message).toBe('Extraction failed')
}
// Verify rollback attempt
// It should try to remove the broken 'data' and move backup back
// (mockFs.rmSync and mockFs.renameSync would be called again in catch block)
expect(mockFs.rmSync).toHaveBeenCalled()
expect(mockFs.renameSync).toHaveBeenCalled()
// Even after failure, it should try to reconnect prisma
expect(mockPrisma.$connect).toHaveBeenCalled()
})
})
+89
View File
@@ -0,0 +1,89 @@
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs'
import { Readable, PassThrough } from 'node:stream'
import { create as createTar, extract as extractTar } from 'tar'
import { prisma } from '../db'
export class BackupService {
/**
* Creates a tar stream of the data directory.
*/
createBackupStream(): Readable {
const stream = new PassThrough()
createTar(
{
gzip: false,
cwd: 'data',
},
['.']
).pipe(stream)
return stream
}
/**
* Restores the data directory from a tar stream.
* Handles database disconnection, backup of existing data, and rollback on failure.
*/
async restoreBackup(backupStream: Readable): Promise<void> {
// Disconnect prisma before starting restore
await prisma.$disconnect()
const backupDir = `data_backup_${Date.now()}`
try {
// Move current data directory to backup location
if (existsSync('data')) {
renameSync('data', backupDir)
}
// Create new data directory
mkdirSync('data')
// Extract the uploaded tar stream to the new data directory
await new Promise((resolve, reject) => {
const extractor = extractTar({
cwd: 'data',
gzip: false,
})
backupStream.pipe(extractor)
extractor.on('finish', resolve)
extractor.on('error', reject)
backupStream.on('error', reject)
})
// Remove the backup directory if successful
rmSync(backupDir, { recursive: true, force: true })
// Reconnect prisma
await prisma.$connect()
} catch (error) {
console.error('Restore failed:', error)
// Attempt to restore from backup
try {
if (existsSync('data')) {
rmSync('data', { recursive: true, force: true })
}
if (existsSync(backupDir)) {
renameSync(backupDir, 'data')
}
} catch (restoreError) {
console.error('Critical: Failed to restore backup after error:', restoreError)
}
// Reconnect prisma even if restore failed
try {
await prisma.$connect()
} catch (connectError) {
console.error('Failed to reconnect prisma:', connectError)
}
throw error
}
}
}
export const backupService = new BackupService()
+41 -29
View File
@@ -1,6 +1,8 @@
import { Backpack, Frame, Settings, Smile, Users } from 'lucide-react'
import logo from 'public/logo.webp'
import { useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { BackupRestoreDialog } from './BackupRestoreDialog'
import {
Sidebar,
SidebarContent,
@@ -43,36 +45,46 @@ const items = [
export function AppSidebar() {
const location = useLocation()
const [isBackupOpen, setIsBackupOpen] = useState(false)
return (
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title} className="mb-3">
<SidebarMenuButton
asChild
isActive={location.pathname === item.url}
className="flex h-auto flex-col items-center gap-1 py-3"
>
<Link to={item.url} className="flex flex-col items-center justify-center">
<item.icon className="!h-6 !w-6 mb-1" />
<span className="text-xs font-medium">{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<div className="w-full flex items-center justify-center mb-3">
<img src={logo} alt="Logo" className="w-14 h-14 rotate-10" />
</div>
</SidebarFooter>
</Sidebar>
<>
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title} className="mb-3">
<SidebarMenuButton
asChild
isActive={location.pathname === item.url}
className="flex h-auto flex-col items-center gap-1 py-3"
>
<Link to={item.url} className="flex flex-col items-center justify-center">
<item.icon className="!h-6 !w-6 mb-1" />
<span className="text-xs font-medium">{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<div className="w-full flex items-center justify-center mb-3">
<button
onClick={() => setIsBackupOpen(true)}
className="hover:scale-110 transition-transform cursor-pointer focus:outline-none"
title="Backup & Restore"
>
<img src={logo} alt="Logo" className="w-14 h-14 rotate-10" />
</button>
</div>
</SidebarFooter>
</Sidebar>
<BackupRestoreDialog open={isBackupOpen} onOpenChange={setIsBackupOpen} />
</>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { Archive, Download, RefreshCw, Upload } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from './ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Separator } from './ui/separator'
interface BackupRestoreDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function BackupRestoreDialog({ open, onOpenChange }: BackupRestoreDialogProps) {
const [isRestoring, setIsRestoring] = useState(false)
const handleDownloadBackup = () => {
window.location.href = '/api/backup'
}
const handleRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
if (!confirm('Warning: This will overwrite all existing data. Are you sure you want to proceed?')) {
event.target.value = '' // Reset input
return
}
setIsRestoring(true)
const toastId = toast.loading('Restoring backup...')
try {
const response = await fetch('/api/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/x-tar',
},
body: file,
})
if (!response.ok) {
throw new Error('Restore failed')
}
toast.success('Restore successful! Please refresh the page.', { id: toastId })
onOpenChange(false)
// Optionally reload the page to refresh data
setTimeout(() => window.location.reload(), 1500)
} catch (error) {
console.error(error)
toast.error('Failed to restore backup.', { id: toastId })
} finally {
setIsRestoring(false)
event.target.value = '' // Reset input
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Backup & Restore</DialogTitle>
<DialogDescription>
Manage your data backup and restoration.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6 py-4">
{/* Backup Section */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 font-medium">
<Archive className="h-4 w-4" />
<span>Backup</span>
</div>
<p className="text-sm text-muted-foreground">
Download a complete backup of your data, including the database and all files.
</p>
<Button onClick={handleDownloadBackup} variant="outline" className="w-full">
<Download className="mr-2 h-4 w-4" />
Download Backup (tar)
</Button>
</div>
<Separator />
{/* Restore Section */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 font-medium text-destructive">
<RefreshCw className="h-4 w-4" />
<span>Restore</span>
</div>
<p className="text-sm text-muted-foreground">
Restore data from a backup file. <span className="font-bold text-destructive">Warning: This will overwrite all current data.</span>
</p>
<div className="relative">
<input
type="file"
accept=".tar"
onChange={handleRestore}
disabled={isRestoring}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<Button
variant="destructive"
className="w-full"
disabled={isRestoring}
>
{isRestoring ? (
<>Restoring...</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Select Backup File to Restore
</>
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}