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= - 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 } }