mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
380ed6fd50
* feat(cloud-backup): make Custom S3-compatible target available on every tier Sencho Cloud Backup remains an Admiral feature; the bring-your-own-bucket Custom S3 target is now reachable on Community and Skipper as well. Backend splits the per-route Admiral gate into two helpers: operations that touch the saved provider use gateForCurrentProvider, PUT /config uses gateForRequestedProvider against the body. /provision and /usage stay requireAdmiral because they are Sencho-only by definition; GET /config is ungated so any tier can read its own stored configuration. Frontend drops the AdmiralGate wrapper on the Cloud Backup section, filters the Sencho provider option out of the dropdown for non-Admiral users, and gates the per-snapshot cloud-upload affordance on "cloud-backup configured" instead of Admiral tier. Dashboard Configuration row is no longer locked on lower tiers. Sidebar registry tier on cloud-backup goes from 'admiral' to null. Docs and licensing breakdown restate the rule once per page without fence-spec. * fix(cloud-backup): keep downgraded sencho config off the upload surface If an Admiral configured Sencho Cloud Backup and the license later drops to Skipper or Community, the saved provider is still 'sencho'. The FleetSnapshots cloud-upload affordance now requires either provider= custom (every tier) or provider=sencho with an active Admiral license, so a downgraded admin never sees an upload button that the backend would 403 on click. Also tidies the Fleet Backups doc, which still claimed the cloud-upload icon was Admiral only; the icon now renders whenever a Cloud Backup target is configured.
259 lines
11 KiB
TypeScript
259 lines
11 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']);
|
|
|
|
// Provider-aware tier gates. The managed Sencho Cloud Backup target requires
|
|
// Admiral; the bring-your-own-bucket Custom S3 target is available on every
|
|
// tier. These wrappers short-circuit to requireAdmiral only when the operation
|
|
// actually touches the 'sencho' provider.
|
|
|
|
function gateForCurrentProvider(req: Request, res: Response): boolean {
|
|
const provider = CloudBackupService.getInstance().getProvider();
|
|
if (provider === 'sencho') return requireAdmiral(req, res);
|
|
return true;
|
|
}
|
|
|
|
function gateForRequestedProvider(req: Request, res: Response, requested: string): boolean {
|
|
if (requested === 'sencho') return requireAdmiral(req, res);
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
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;
|
|
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;
|
|
}
|
|
if (!gateForRequestedProvider(req, res, provider)) 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 (!gateForCurrentProvider(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 (!gateForCurrentProvider(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 (!gateForCurrentProvider(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 (!gateForCurrentProvider(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 (!gateForCurrentProvider(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 (!gateForCurrentProvider(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') });
|
|
}
|
|
});
|