Spaces:
Sleeping
Sleeping
| import { NextRequest, NextResponse } from 'next/server' | |
| import { requireRole } from '@/lib/auth' | |
| import { getDatabase, logAuditEvent } from '@/lib/db' | |
| import { config, ensureDirExists } from '@/lib/config' | |
| import { join, dirname } from 'path' | |
| import { readdirSync, statSync, unlinkSync } from 'fs' | |
| import { heavyLimiter } from '@/lib/rate-limit' | |
| import { logger } from '@/lib/logger' | |
| import { runOpenClaw } from '@/lib/command' | |
| const BACKUP_DIR = join(dirname(config.dbPath), 'backups') | |
| const MAX_BACKUPS = 10 | |
| /** | |
| * GET /api/backup - List existing backups (admin only) | |
| */ | |
| export async function GET(request: NextRequest) { | |
| const auth = requireRole(request, 'admin') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| ensureDirExists(BACKUP_DIR) | |
| try { | |
| const files = readdirSync(BACKUP_DIR) | |
| .filter(f => f.endsWith('.db')) | |
| .map(f => { | |
| const stat = statSync(join(BACKUP_DIR, f)) | |
| return { | |
| name: f, | |
| size: stat.size, | |
| created_at: Math.floor(stat.mtimeMs / 1000), | |
| } | |
| }) | |
| .sort((a, b) => b.created_at - a.created_at) | |
| return NextResponse.json({ backups: files, dir: BACKUP_DIR }) | |
| } catch { | |
| return NextResponse.json({ backups: [], dir: BACKUP_DIR }) | |
| } | |
| } | |
| /** | |
| * POST /api/backup - Create a new backup (admin only) | |
| */ | |
| export async function POST(request: NextRequest) { | |
| const auth = requireRole(request, 'admin') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| const rateCheck = heavyLimiter(request) | |
| if (rateCheck) return rateCheck | |
| const target = request.nextUrl.searchParams.get('target') | |
| // Gateway state backup via `openclaw backup create` | |
| if (target === 'gateway') { | |
| ensureDirExists(BACKUP_DIR) | |
| const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' | |
| try { | |
| let stdout: string | |
| let stderr: string | |
| try { | |
| const result = await runOpenClaw(['backup', 'create', '--output', BACKUP_DIR], { timeoutMs: 60000 }) | |
| stdout = result.stdout | |
| stderr = result.stderr | |
| } catch (error: any) { | |
| // openclaw backup may exit non-zero despite success — check output | |
| stdout = error.stdout || '' | |
| stderr = error.stderr || '' | |
| const combined = `${stdout}\n${stderr}` | |
| if (!combined.includes('Created')) { | |
| const message = stderr || error.message || 'Unknown error' | |
| logger.error({ err: error }, 'Gateway backup failed') | |
| return NextResponse.json({ error: `Gateway backup failed: ${message}` }, { status: 500 }) | |
| } | |
| } | |
| const output = (stdout || stderr).trim() | |
| logAuditEvent({ | |
| action: 'openclaw.backup', | |
| actor: auth.user.username, | |
| actor_id: auth.user.id, | |
| detail: { output }, | |
| ip_address: ipAddress, | |
| }) | |
| return NextResponse.json({ success: true, output }) | |
| } catch (error: any) { | |
| logger.error({ err: error }, 'Gateway backup failed') | |
| return NextResponse.json({ error: `Gateway backup failed: ${error.message}` }, { status: 500 }) | |
| } | |
| } | |
| // Default: MC SQLite backup | |
| ensureDirExists(BACKUP_DIR) | |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19) | |
| const backupPath = join(BACKUP_DIR, `mc-backup-${timestamp}.db`) | |
| try { | |
| const db = getDatabase() | |
| await db.backup(backupPath) | |
| const stat = statSync(backupPath) | |
| const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' | |
| logAuditEvent({ | |
| action: 'backup_create', | |
| actor: auth.user.username, | |
| actor_id: auth.user.id, | |
| detail: { path: backupPath, size: stat.size }, | |
| ip_address: ipAddress, | |
| }) | |
| // Prune old backups beyond MAX_BACKUPS | |
| pruneOldBackups() | |
| return NextResponse.json({ | |
| success: true, | |
| backup: { | |
| name: `mc-backup-${timestamp}.db`, | |
| size: stat.size, | |
| created_at: Math.floor(stat.mtimeMs / 1000), | |
| }, | |
| }) | |
| } catch (error: any) { | |
| logger.error({ err: error }, 'Backup failed') | |
| return NextResponse.json({ error: `Backup failed: ${error.message}` }, { status: 500 }) | |
| } | |
| } | |
| /** | |
| * DELETE /api/backup?name=<filename> - Delete a specific backup (admin only) | |
| */ | |
| export async function DELETE(request: NextRequest) { | |
| const auth = requireRole(request, 'admin') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| let body: any | |
| try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) } | |
| const name = body.name | |
| if (!name || !name.endsWith('.db') || name.includes('/') || name.includes('..')) { | |
| return NextResponse.json({ error: 'Invalid backup name' }, { status: 400 }) | |
| } | |
| try { | |
| const fullPath = join(BACKUP_DIR, name) | |
| unlinkSync(fullPath) | |
| const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' | |
| logAuditEvent({ | |
| action: 'backup_delete', | |
| actor: auth.user.username, | |
| actor_id: auth.user.id, | |
| detail: { name }, | |
| ip_address: ipAddress, | |
| }) | |
| return NextResponse.json({ success: true }) | |
| } catch { | |
| return NextResponse.json({ error: 'Backup not found' }, { status: 404 }) | |
| } | |
| } | |
| function pruneOldBackups() { | |
| try { | |
| const files = readdirSync(BACKUP_DIR) | |
| .filter(f => f.startsWith('mc-backup-') && f.endsWith('.db')) | |
| .map(f => ({ name: f, mtime: statSync(join(BACKUP_DIR, f)).mtimeMs })) | |
| .sort((a, b) => b.mtime - a.mtime) | |
| for (const file of files.slice(MAX_BACKUPS)) { | |
| unlinkSync(join(BACKUP_DIR, file.name)) | |
| } | |
| } catch { | |
| // Best-effort pruning | |
| } | |
| } | |