mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
03f91cd5bb
* feat(cloud-backup): mirror fleet snapshots to S3-compatible storage
Add an Admiral-tier Cloud Backup feature that replicates every fleet
snapshot to off-site storage, with two provider modes that share the
same `@aws-sdk/client-s3` code path:
- Sencho Cloud Backup: zero-config, 500 MB allowance backed by
Cloudflare R2, provisioned via the sencho.io worker against the
user's Lemon Squeezy license.
- Custom S3 (BYOB): any S3-compatible bucket (AWS, MinIO, Backblaze
B2, Wasabi, R2 with own keys), with credentials encrypted via
`CryptoService` before storage.
API-triggered snapshots upload fire-and-forget so the UI returns
immediately; scheduled snapshots block on the upload so the task's
success/failure reflects cloud durability. Object keys include the
instance_id segment to prevent collisions when the same Admiral
license is activated on multiple Sencho instances.
* fix(cloud-backup): drop ES2022-only Error cause arg breaking ES2020 build
The backend tsconfig pins lib to ES2020. The two-argument
`Error(message, { cause })` form requires ES2022, so tsc rejected it
with TS2554. Revert to single-argument throw to match the
convention used elsewhere in the backend services.
244 lines
10 KiB
TypeScript
244 lines
10 KiB
TypeScript
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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') });
|
|
}
|
|
});
|