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.
This commit is contained in:
google-labs-jules[bot] committed 2026-02-15 05:07:36 +00:00
1 parent 92742b1272
commit 7381400d79
5 files changed
+264 -29

No files matched your search

+17
View File
@@ -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
+76
View File
@@ -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
+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)
+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} />
</>
)
}
+126
View File
@@ -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<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',
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
</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>
)
}