mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
feat(settings): harden settings API and overhaul SettingsModal
Security: - Strip auth credential keys (auth_username, auth_password_hash, auth_jwt_secret) from GET /api/settings response - Add allowlist guard to POST /api/settings — rejects unknown or auth-namespace keys with a 400 Backend: - Add PATCH /api/settings bulk endpoint with Zod schema validation (type coercion, range checks, URL format) and atomic SQLite transaction - Add system_state table — moves last_janitor_alert_timestamp out of global_settings; adds getSystemState/setSystemState on DatabaseService - Add metrics_retention_hours and log_retention_days configurable settings; MonitorService reads both dynamically each evaluation cycle - Add cleanupOldNotifications(days) to DatabaseService, called each cycle Frontend: - Replace single isLoading flag with per-operation states (isSavingSystem, isSavingDeveloper, isSavingPassword, isSavingRegistry, isSavingAgent/isTestingAgent per agent type) - Add skeleton loader that blocks interaction until fetchSettings resolves - Explicit key-picking in fetchSettings — auth keys cannot enter state - Unsaved-changes amber dot on System Limits and Developer sidebar items - Separate saveSystemSettings / saveDeveloperSettings — no cross-tab clobber - Developer tab gains Data Retention section (metrics hours, log days) - All settings saves use new PATCH /api/settings endpoint
This commit is contained in:
+68
-1
@@ -1246,11 +1246,48 @@ app.post('/api/agents', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Keys that contain auth credentials — never exposed to the frontend or writable via settings API
|
||||
const PRIVATE_SETTINGS_KEYS = new Set(['auth_username', 'auth_password_hash', 'auth_jwt_secret']);
|
||||
|
||||
// Strict allowlist of keys writable via the settings API (prevents overwriting auth credentials)
|
||||
const ALLOWED_SETTING_KEYS = new Set([
|
||||
'host_cpu_limit',
|
||||
'host_ram_limit',
|
||||
'host_disk_limit',
|
||||
'docker_janitor_gb',
|
||||
'global_crash',
|
||||
'global_logs_refresh',
|
||||
'developer_mode',
|
||||
'template_registry_url',
|
||||
'metrics_retention_hours',
|
||||
'log_retention_days',
|
||||
]);
|
||||
|
||||
// Zod schema for bulk PATCH — all keys optional, present keys fully validated
|
||||
import { z } from 'zod';
|
||||
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),
|
||||
docker_janitor_gb: z.coerce.number().min(0).transform(String),
|
||||
global_crash: z.enum(['0', '1']),
|
||||
global_logs_refresh: z.enum(['1', '3', '5', '10']),
|
||||
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),
|
||||
}).partial();
|
||||
|
||||
app.get('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
// Strip auth credentials — these are managed exclusively by /api/auth/* endpoints
|
||||
for (const key of PRIVATE_SETTINGS_KEYS) {
|
||||
delete settings[key];
|
||||
}
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
@@ -1258,13 +1295,43 @@ app.get('/api/settings', async (req: Request, res: Response) => {
|
||||
app.post('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { key, value } = req.body;
|
||||
DatabaseService.getInstance().updateGlobalSetting(key, value);
|
||||
if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) {
|
||||
res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` });
|
||||
return;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
res.status(400).json({ error: 'Setting value is required' });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().updateGlobalSetting(key, String(value));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to update setting:', error);
|
||||
res.status(500).json({ error: 'Failed to update setting' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const parsed = SettingsPatchSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors });
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/alerts', async (req: Request, res: Response) => {
|
||||
try {
|
||||
let stackName = req.query.stackName as string | undefined;
|
||||
|
||||
@@ -136,6 +136,11 @@ export class DatabaseService {
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Apply migrations safely (ignore if columns already exist)
|
||||
@@ -167,6 +172,8 @@ export class DatabaseService {
|
||||
stmt.run('docker_janitor_gb', '5');
|
||||
stmt.run('global_logs_refresh', '5');
|
||||
stmt.run('developer_mode', '0');
|
||||
stmt.run('metrics_retention_hours', '24');
|
||||
stmt.run('log_retention_days', '30');
|
||||
|
||||
// Seed the default local node if none exists
|
||||
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
|
||||
@@ -244,6 +251,17 @@ export class DatabaseService {
|
||||
stmt.run(key, value);
|
||||
}
|
||||
|
||||
// --- System State (operational/runtime values — not user-defined config) ---
|
||||
|
||||
public getSystemState(key: string): string | null {
|
||||
const row = this.db.prepare('SELECT value FROM system_state WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
public setSystemState(key: string, value: string): void {
|
||||
this.db.prepare('INSERT OR REPLACE INTO system_state (key, value) VALUES (?, ?)').run(key, value);
|
||||
}
|
||||
|
||||
// --- Stack Alerts ---
|
||||
|
||||
public getStackAlerts(stackName?: string): StackAlert[] {
|
||||
@@ -353,6 +371,11 @@ export class DatabaseService {
|
||||
stmt.run(cutoff);
|
||||
}
|
||||
|
||||
public cleanupOldNotifications(daysToKeep = 30): void {
|
||||
const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
|
||||
this.db.prepare('DELETE FROM notification_history WHERE timestamp < ?').run(cutoff);
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
|
||||
public getNodes(): Node[] {
|
||||
|
||||
@@ -187,13 +187,14 @@ export class MonitorService {
|
||||
// Only trigger once every while? To avoid spamming, we just check if it's over limit
|
||||
// Let's ensure we only spam once per limit breach. We can use a local static variable.
|
||||
const LAST_JANITOR_ALERT_KEY = 'last_janitor_alert_timestamp';
|
||||
const lastAlert = parseInt(settings[LAST_JANITOR_ALERT_KEY] || '0', 10);
|
||||
const lastAlertRaw = DatabaseService.getInstance().getSystemState(LAST_JANITOR_ALERT_KEY);
|
||||
const lastAlert = parseInt(lastAlertRaw || '0', 10);
|
||||
const janitorCooldown = 24 * 60 * 60 * 1000; // 24 hours cooldown for janitor
|
||||
|
||||
if (reclaimGb >= janitorLimitGb) {
|
||||
if (Date.now() - lastAlert > janitorCooldown) {
|
||||
await notifier.dispatchAlert('info', `Your system has accumulated ${reclaimGb.toFixed(1)} GB of unused Docker data. Consider using the Janitor tool.`);
|
||||
DatabaseService.getInstance().updateGlobalSetting(LAST_JANITOR_ALERT_KEY, Date.now().toString());
|
||||
DatabaseService.getInstance().setSystemState(LAST_JANITOR_ALERT_KEY, Date.now().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,8 +299,14 @@ export class MonitorService {
|
||||
}
|
||||
|
||||
try {
|
||||
db.cleanupOldMetrics(24);
|
||||
} catch (e) { }
|
||||
const settings = db.getGlobalSettings();
|
||||
const retentionHours = parseInt(settings['metrics_retention_hours'] || '24', 10);
|
||||
db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours);
|
||||
const retentionDays = parseInt(settings['log_retention_days'] || '30', 10);
|
||||
db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays);
|
||||
} catch (e) {
|
||||
console.error('MonitorService: failed to cleanup old data', e);
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateCondition(actual: number, operator: string, threshold: number): boolean {
|
||||
|
||||
Reference in New Issue
Block a user