import { Router, type Request, type Response } from 'express'; import { CloudBackupService } from '../services/CloudBackupService'; import { DatabaseService } from '../services/DatabaseService'; import { CryptoService } from '../services/CryptoService'; import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; import { rejectApiTokenScope } from '../middleware/apiTokenScope'; import { getErrorMessage } from '../utils/errors'; const SCOPE_MESSAGE = 'API tokens cannot manage cloud backup configuration.'; const SECRET_REDACTED = '***'; const VALID_PROVIDERS = new Set(['disabled', 'sencho', 'custom']); function parseSnapshotIdParam(req: Request, res: Response): number | null { const raw = req.params.id as string | undefined; const parsed = parseInt(raw ?? '', 10); if (isNaN(parsed) || parsed <= 0) { res.status(400).json({ error: 'Invalid snapshot ID' }); return null; } return parsed; } function decodeObjectKey(req: Request, res: Response): string | null { const raw = req.params.keyB64 as string | undefined; if (!raw) { res.status(400).json({ error: 'Missing object key' }); return null; } try { const decoded = Buffer.from(raw, 'base64url').toString('utf-8'); if (!decoded || decoded.includes('..') || decoded.startsWith('/')) { res.status(400).json({ error: 'Invalid object key' }); return null; } return decoded; } catch { res.status(400).json({ error: 'Invalid object key encoding' }); return null; } } export const cloudBackupRouter = Router(); cloudBackupRouter.get('/config', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmiral(req, res)) return; try { const db = DatabaseService.getInstance(); const settings = db.getGlobalSettings(); const provider = CloudBackupService.getInstance().getProvider(); const senchoProvisioned = !!db.getSystemState('sencho_cloud_backup_provisioned_at'); res.json({ provider, sencho_provisioned: senchoProvisioned, sencho_provisioned_at: db.getSystemState('sencho_cloud_backup_provisioned_at'), custom: { endpoint: settings.cloud_backup_endpoint || '', region: settings.cloud_backup_region || '', bucket: settings.cloud_backup_bucket || '', access_key: settings.cloud_backup_access_key || '', secret_key: settings.cloud_backup_secret_key ? SECRET_REDACTED : '', path_prefix: settings.cloud_backup_path_prefix || 'sencho/', auto_upload: settings.cloud_backup_auto_upload === '1', }, }); } catch (error) { console.error('[CloudBackup] config get error:', error); res.status(500).json({ error: 'Failed to load cloud backup config' }); } }); cloudBackupRouter.put('/config', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { const body = req.body ?? {}; const provider = body.provider as string | undefined; if (!provider || !VALID_PROVIDERS.has(provider)) { res.status(400).json({ error: 'provider must be one of: disabled, sencho, custom' }); return; } const db = DatabaseService.getInstance(); const crypto = CryptoService.getInstance(); db.updateGlobalSetting('cloud_backup_provider', provider); if (provider === 'custom') { const c = body.custom ?? {}; const endpoint = typeof c.endpoint === 'string' ? c.endpoint.trim() : ''; const region = typeof c.region === 'string' ? c.region.trim() : ''; const bucket = typeof c.bucket === 'string' ? c.bucket.trim() : ''; const accessKey = typeof c.access_key === 'string' ? c.access_key.trim() : ''; const pathPrefix = typeof c.path_prefix === 'string' ? c.path_prefix.trim() : 'sencho/'; const autoUpload = c.auto_upload === true || c.auto_upload === '1' ? '1' : '0'; if (!endpoint || !bucket || !accessKey) { res.status(400).json({ error: 'endpoint, bucket, and access_key are required for custom S3.' }); return; } if (!/^https?:\/\//i.test(endpoint)) { res.status(400).json({ error: 'endpoint must start with http:// or https://' }); return; } db.updateGlobalSetting('cloud_backup_endpoint', endpoint); db.updateGlobalSetting('cloud_backup_region', region); db.updateGlobalSetting('cloud_backup_bucket', bucket); db.updateGlobalSetting('cloud_backup_access_key', accessKey); db.updateGlobalSetting('cloud_backup_path_prefix', pathPrefix); db.updateGlobalSetting('cloud_backup_auto_upload', autoUpload); const incomingSecret = typeof c.secret_key === 'string' ? c.secret_key : ''; if (incomingSecret && incomingSecret !== SECRET_REDACTED) { db.updateGlobalSetting('cloud_backup_secret_key', crypto.encrypt(incomingSecret)); } } res.status(204).send(); } catch (error) { console.error('[CloudBackup] config update error:', error); res.status(500).json({ error: 'Failed to save cloud backup config' }); } }); cloudBackupRouter.post('/test', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { const result = await CloudBackupService.getInstance().testConnection(); res.json(result); } catch (error) { console.error('[CloudBackup] test error:', error); res.status(500).json({ success: false, error: getErrorMessage(error, 'Connection test failed') }); } }); cloudBackupRouter.post('/provision', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { const result = await CloudBackupService.getInstance().provisionSenchoCloudBackup(); if (!result.success) { res.status(400).json({ error: result.error || 'Provisioning failed' }); return; } res.json({ success: true, quota_bytes: result.quotaBytes }); } catch (error) { console.error('[CloudBackup] provision error:', error); res.status(500).json({ error: 'Failed to provision Sencho Cloud Backup' }); } }); cloudBackupRouter.get('/usage', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmiral(req, res)) return; try { const svc = CloudBackupService.getInstance(); if (svc.getProvider() !== 'sencho') { res.status(400).json({ error: 'Usage is only available for Sencho Cloud Backup' }); return; } const usage = await svc.getSenchoCloudBackupUsage(); res.json(usage); } catch (error) { console.error('[CloudBackup] usage error:', error); res.status(502).json({ error: getErrorMessage(error, 'Failed to fetch usage') }); } }); cloudBackupRouter.get('/snapshots', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmiral(req, res)) return; try { const entries = await CloudBackupService.getInstance().listCloudSnapshots(); res.json(entries); } catch (error) { console.error('[CloudBackup] list error:', error); res.status(502).json({ error: getErrorMessage(error, 'Failed to list cloud snapshots') }); } }); cloudBackupRouter.post('/upload/:id', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; const id = parseSnapshotIdParam(req, res); if (id == null) return; try { const db = DatabaseService.getInstance(); if (!db.getSnapshot(id)) { res.status(404).json({ error: 'Snapshot not found' }); return; } await CloudBackupService.getInstance().uploadSnapshot(id); res.status(202).json({ status: 'success', snapshot_id: id }); } catch (error) { console.error('[CloudBackup] upload error:', error); res.status(502).json({ error: getErrorMessage(error, 'Cloud upload failed') }); } }); cloudBackupRouter.get('/status/:id', (req: Request, res: Response): void => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmiral(req, res)) return; const id = parseSnapshotIdParam(req, res); if (id == null) return; res.json(CloudBackupService.getInstance().getUploadStatus(id)); }); cloudBackupRouter.get('/object/:keyB64/download', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmiral(req, res)) return; const objectKey = decodeObjectKey(req, res); if (!objectKey) return; try { const buffer = await CloudBackupService.getInstance().downloadSnapshot(objectKey); const filename = objectKey.split('/').pop() || 'snapshot.tar.gz'; res.setHeader('Content-Type', 'application/gzip'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Length', String(buffer.byteLength)); res.end(buffer); } catch (error) { console.error('[CloudBackup] download error:', error); res.status(502).json({ error: getErrorMessage(error, 'Failed to download cloud snapshot') }); } }); cloudBackupRouter.delete('/object/:keyB64', async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; const objectKey = decodeObjectKey(req, res); if (!objectKey) return; try { await CloudBackupService.getInstance().deleteCloudSnapshot(objectKey); res.status(204).send(); } catch (error) { console.error('[CloudBackup] delete error:', error); res.status(502).json({ error: getErrorMessage(error, 'Failed to delete cloud snapshot') }); } });