diff --git a/bun.lock b/bun.lock index bc4f284..510fab8 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index e47bc7e..a8d3514 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/backup.test.ts b/src/api/backup.test.ts new file mode 100644 index 0000000..60e747f --- /dev/null +++ b/src/api/backup.test.ts @@ -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() + }) +}) diff --git a/src/api/backup.ts b/src/api/backup.ts new file mode 100644 index 0000000..5074baa --- /dev/null +++ b/src/api/backup.ts @@ -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 diff --git a/src/api/restore.test.ts b/src/api/restore.test.ts new file mode 100644 index 0000000..adcfd17 --- /dev/null +++ b/src/api/restore.test.ts @@ -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() + }) +}) diff --git a/src/api/restore.ts b/src/api/restore.ts new file mode 100644 index 0000000..187f0c6 --- /dev/null +++ b/src/api/restore.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index 24df373..03a6da7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) diff --git a/src/service/backup.test.ts b/src/service/backup.test.ts new file mode 100644 index 0000000..2211fb5 --- /dev/null +++ b/src/service/backup.test.ts @@ -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() + }) +}) diff --git a/src/service/backup.ts b/src/service/backup.ts new file mode 100644 index 0000000..d115c5d --- /dev/null +++ b/src/service/backup.ts @@ -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 { + // 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() diff --git a/src/ui/components/AppSidebar.tsx b/src/ui/components/AppSidebar.tsx index 14f86e0..ad34041 100644 --- a/src/ui/components/AppSidebar.tsx +++ b/src/ui/components/AppSidebar.tsx @@ -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 ( - - - - - - {items.map((item) => ( - - - - - {item.title} - - - - ))} - - - - - -
- Logo -
-
-
+ <> + + + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + +
+ +
+
+
+ + ) } diff --git a/src/ui/components/BackupRestoreDialog.tsx b/src/ui/components/BackupRestoreDialog.tsx new file mode 100644 index 0000000..f0bf883 --- /dev/null +++ b/src/ui/components/BackupRestoreDialog.tsx @@ -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) => { + 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 ( + + + + Backup & Restore + + Manage your data backup and restoration. + + + +
+ {/* Backup Section */} +
+
+ + Backup +
+

+ Download a complete backup of your data, including the database and all files. +

+ +
+ + + + {/* Restore Section */} +
+
+ + Restore +
+

+ Restore data from a backup file. Warning: This will overwrite all current data. +

+
+ + +
+
+
+
+
+ ) +}