nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
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
}
}