mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
35bb74425b
* feat: guide missing external network creation during deploy Detect missing external networks before Compose runs, prompt or auto-create safe bridge networks, and keep unsupported declarations blocked with trusted deploy provenance. * test: align deploy context and settings fixtures with missing-network gate Update caller spies, EffResource expectations, StacksSection save keys, and git-source spy cleanup so CI matches the new deployStack context and auto-create setting. * fix: drop unused renderError binding in missing-network resolver Satisfies no-unused-vars so backend ESLint CI passes; callers already key only on model presence. * fix: use HTTP-safe clipboard helper in missing-network dialog navigator.clipboard fails on plain HTTP LAN hosts; route copy actions through copyToClipboard so Docker and Compose copy buttons work on self-hosted instances. * fix: simplify missing-network dialog actions and copy label Drop the Compose snippet escape hatch, move secondary actions under More, and rename the terminal copy action to Copy create command so the footer is a clear Cancel / Create decision.
169 lines
7.4 KiB
TypeScript
169 lines
7.4 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { z } from 'zod';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
import { authMiddleware } from '../middleware/auth';
|
|
import { requireAdmin, requirePaid } from '../middleware/tierGates';
|
|
|
|
// Strict allowlist of keys readable and writable via the generic settings
|
|
// API. This is the single source of truth for what the endpoint exposes:
|
|
// reads project only these keys, so secrets written to global_settings by
|
|
// other subsystems (the cloud_backup_* credentials stored by the cloud-backup
|
|
// route, the auth_* login secrets) are never returned here; writes are
|
|
// rejected for anything outside the list.
|
|
const ALLOWED_SETTING_KEYS = new Set([
|
|
'host_cpu_limit',
|
|
'host_ram_limit',
|
|
'host_disk_limit',
|
|
'host_alerts_enabled',
|
|
'host_alert_suppression_mins',
|
|
'docker_janitor_gb',
|
|
'global_crash',
|
|
'developer_mode',
|
|
'template_registry_url',
|
|
'metrics_retention_hours',
|
|
'log_retention_days',
|
|
'audit_retention_days',
|
|
'mesh_auto_recreate',
|
|
'scan_history_per_image_limit',
|
|
'prune_orphaned_scans',
|
|
'prune_on_update',
|
|
'reclaim_hero',
|
|
'snapshot_documentation',
|
|
'health_gate_enabled',
|
|
'health_gate_window_seconds',
|
|
'env_block_deploy_on_missing_required',
|
|
'auto_create_missing_external_networks',
|
|
'image_update_sidebar_indicators',
|
|
]);
|
|
|
|
// Keys whose write requires a paid license, not just an admin role.
|
|
// audit_retention_days configures the paid audit log, so a Community admin
|
|
// must not be able to set it.
|
|
const PAID_ONLY_SETTING_KEYS = new Set(['audit_retention_days']);
|
|
|
|
// Bulk PATCH schema. All keys optional; present keys are fully validated.
|
|
const SettingsPatchSchema = z.object({
|
|
host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String),
|
|
host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String),
|
|
host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String),
|
|
host_alerts_enabled: z.enum(['0', '1']),
|
|
host_alert_suppression_mins: z.coerce.number().int().min(1).max(1440).transform(String),
|
|
docker_janitor_gb: z.coerce.number().min(0).transform(String),
|
|
global_crash: z.enum(['0', '1']),
|
|
developer_mode: z.enum(['0', '1']),
|
|
template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }),
|
|
metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String),
|
|
log_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
|
|
audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
|
|
mesh_auto_recreate: z.enum(['0', '1']),
|
|
scan_history_per_image_limit: z.coerce.number().int().min(5).max(1000).transform(String),
|
|
prune_orphaned_scans: z.enum(['0', '1']),
|
|
prune_on_update: z.enum(['0', '1']),
|
|
reclaim_hero: z.enum(['0', '1']),
|
|
snapshot_documentation: z.enum(['0', '1']),
|
|
health_gate_enabled: z.enum(['0', '1']),
|
|
health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String),
|
|
env_block_deploy_on_missing_required: z.enum(['0', '1']),
|
|
auto_create_missing_external_networks: z.enum(['0', '1']),
|
|
image_update_sidebar_indicators: z.enum(['0', '1']),
|
|
}).partial();
|
|
|
|
export const settingsRouter = Router();
|
|
|
|
settingsRouter.get('/', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const all = DatabaseService.getInstance().getGlobalSettings();
|
|
// Project only allowlisted operational keys. A denylist would leak every
|
|
// future sensitive key written to global_settings by default (e.g. the
|
|
// cloud_backup_* credentials the cloud-backup route stores here); the
|
|
// allowlist fails closed.
|
|
const settings: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(all)) {
|
|
if (ALLOWED_SETTING_KEYS.has(key)) settings[key] = value;
|
|
}
|
|
res.json(settings);
|
|
} catch (error) {
|
|
console.error('Failed to fetch settings:', error);
|
|
res.status(500).json({ error: 'Failed to fetch settings' });
|
|
}
|
|
});
|
|
|
|
settingsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const { key, value } = req.body;
|
|
if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) {
|
|
res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` });
|
|
return;
|
|
}
|
|
if (PAID_ONLY_SETTING_KEYS.has(key) && !requirePaid(req, res)) return;
|
|
if (value === undefined || value === null) {
|
|
res.status(400).json({ error: 'Setting value is required' });
|
|
return;
|
|
}
|
|
// Route the single-key write through the same per-key schema used by
|
|
// the bulk PATCH so allowlisted-but-malformed values (e.g. `true`,
|
|
// `banana`, out-of-range integers) cannot bypass validation just
|
|
// because they came in via the single-key path. The schema coerces
|
|
// numeric settings to strings and rejects enum-shaped settings that
|
|
// are not one of the allowed literals.
|
|
const parsed = SettingsPatchSchema.safeParse({ [key]: value });
|
|
if (!parsed.success) {
|
|
res.status(400).json({
|
|
error: 'Validation failed',
|
|
details: parsed.error.flatten().fieldErrors,
|
|
});
|
|
return;
|
|
}
|
|
const validated = (parsed.data as Record<string, string>)[key];
|
|
if (validated === undefined) {
|
|
// Defensive: the schema is `.partial()`, so an unknown key would
|
|
// pass through silently. We already gated on ALLOWED_SETTING_KEYS,
|
|
// but reject explicitly if the key is somehow missing from the
|
|
// schema's shape (drift between the allowlist and the schema).
|
|
res.status(400).json({ error: `Setting key has no validator: ${key}` });
|
|
return;
|
|
}
|
|
DatabaseService.getInstance().updateGlobalSetting(key, validated);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to update setting:', error);
|
|
res.status(500).json({ error: 'Failed to update setting' });
|
|
}
|
|
});
|
|
|
|
settingsRouter.patch('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
// Reject unknown/disallowed keys outright rather than letting Zod silently
|
|
// strip them. This keeps the bulk path fail-closed and consistent with the
|
|
// single-key POST, so a client sending a stale or disallowed key (e.g. an
|
|
// auth_* secret) gets a 400, not a misleading 200.
|
|
const body = req.body;
|
|
if (body && typeof body === 'object' && !Array.isArray(body)) {
|
|
const unknownKeys = Object.keys(body).filter(k => !ALLOWED_SETTING_KEYS.has(k));
|
|
if (unknownKeys.length > 0) {
|
|
res.status(400).json({ error: `Invalid or disallowed setting key(s): ${unknownKeys.join(', ')}` });
|
|
return;
|
|
}
|
|
}
|
|
const parsed = SettingsPatchSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors });
|
|
return;
|
|
}
|
|
if (Object.keys(parsed.data).some(k => PAID_ONLY_SETTING_KEYS.has(k)) && !requirePaid(req, res)) return;
|
|
const db = DatabaseService.getInstance();
|
|
const updateMany = db.getDb().transaction((entries: [string, string][]) => {
|
|
for (const [k, v] of entries) {
|
|
db.updateGlobalSetting(k, v);
|
|
}
|
|
});
|
|
updateMany(Object.entries(parsed.data) as [string, string][]);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to bulk update settings:', error);
|
|
res.status(500).json({ error: 'Failed to update settings' });
|
|
}
|
|
});
|