Spaces:
Sleeping
Sleeping
File size: 5,817 Bytes
bbbc03f bfccd36 959f8d7 b6ecafa bbbc03f bfccd36 b6ecafa bbbc03f 959f8d7 bbbc03f 391842a bbbc03f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | 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
}
}
|