Merge pull request #27 from Yiling-J/feature-backup-restore-6292895868242646111
Add Backup and Restore System
This commit is contained in:
11 files changed
+538
-30
No files matched your search
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user