From 7381400d79862d702c85665bacf59ec66a70d2d7 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 05:07:36 +0000 Subject: [PATCH] Add backup and restore functionality - Add `GET /api/backup` endpoint to stream `data` directory as a tar file. - Add `POST /api/restore` endpoint to handle tar file upload and restore. - Implement `BackupRestoreDialog` in the UI for user interaction. - Integrate backup/restore dialog into the sidebar via the logo. - Ensure safe restore with rollback on failure and database connection management. --- src/api/backup.ts | 17 +++ src/api/restore.ts | 76 +++++++++++++ src/index.ts | 4 + src/ui/components/AppSidebar.tsx | 70 +++++++----- src/ui/components/BackupRestoreDialog.tsx | 126 ++++++++++++++++++++++ 5 files changed, 264 insertions(+), 29 deletions(-) create mode 100644 src/api/backup.ts create mode 100644 src/api/restore.ts create mode 100644 src/ui/components/BackupRestoreDialog.tsx diff --git a/src/api/backup.ts b/src/api/backup.ts new file mode 100644 index 0000000..48d9915 --- /dev/null +++ b/src/api/backup.ts @@ -0,0 +1,17 @@ +import { Hono } from 'hono' + +const app = new Hono() + +app.get('/', async () => { + // Stream the content of the data directory as a tar file + const proc = Bun.spawn(['tar', '-cf', '-', '-C', 'data', '.']) + + return new Response(proc.stdout, { + headers: { + 'Content-Type': 'application/x-tar', + 'Content-Disposition': 'attachment; filename="backup.tar"', + }, + }) +}) + +export default app diff --git a/src/api/restore.ts b/src/api/restore.ts new file mode 100644 index 0000000..1f39c02 --- /dev/null +++ b/src/api/restore.ts @@ -0,0 +1,76 @@ +import { Hono } from 'hono' +import { existsSync, renameSync, mkdirSync, rmSync } from 'node:fs' +import { prisma } from '../db' + +const app = new Hono() + +app.post('/', async (c) => { + // 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 + // We expect the body to be the tar file content directly + const proc = Bun.spawn(['tar', '-xf', '-', '-C', 'data'], { + stdin: c.req.raw.body, + stdout: 'pipe', + stderr: 'pipe', + }) + + const exitCode = await proc.exited + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + throw new Error(`Tar extraction failed with code ${exitCode}: ${stderr}`) + } + + // Remove the backup directory + rmSync(backupDir, { recursive: true, force: true }) + + // Reconnect prisma + await prisma.$connect() + + return c.json({ success: true }) + } 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) + } + + 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/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..1b6d0b5 --- /dev/null +++ b/src/ui/components/BackupRestoreDialog.tsx @@ -0,0 +1,126 @@ +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', + 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. +

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