const express = require("express"); const { body, validationResult } = require("express-validator"); const { getPrismaClient } = require("../config/prisma"); const { authenticateToken } = require("../middleware/auth"); const { requireManageSettings } = require("../middleware/permissions"); const { getSettings, updateSettings } = require("../services/settingsService"); const router = express.Router(); const prisma = getPrismaClient(); // WebSocket broadcaster for agent policy updates (no longer used - queue-based delivery preferred) // const { broadcastSettingsUpdate } = require("../services/agentWs"); const { queueManager, QUEUE_NAMES } = require("../services/automation"); // Helpers function normalizeUpdateInterval(minutes) { let m = parseInt(minutes, 10); if (Number.isNaN(m)) return 60; if (m < 5) m = 5; if (m > 1440) m = 1440; if (m < 60) { // Clamp to 5-59, step 5 const snapped = Math.round(m / 5) * 5; return Math.min(59, Math.max(5, snapped)); } // Allowed hour-based presets const allowed = [60, 120, 180, 360, 720, 1440]; let nearest = allowed[0]; let bestDiff = Math.abs(m - nearest); for (const a of allowed) { const d = Math.abs(m - a); if (d < bestDiff) { bestDiff = d; nearest = a; } } return nearest; } function buildCronExpression(minutes) { const m = normalizeUpdateInterval(minutes); if (m < 60) { return `*/${m} * * * *`; } if (m === 60) { // Hourly at current minute is chosen by agent; default 0 here return `0 * * * *`; } const hours = Math.floor(m / 60); // Every N hours at minute 0 return `0 */${hours} * * *`; } // Get current settings router.get("/", authenticateToken, requireManageSettings, async (_req, res) => { try { const settings = await getSettings(); if (process.env.ENABLE_LOGGING === "true") { console.log("Returning settings:", settings); } res.json(settings); } catch (error) { console.error("Settings fetch error:", error); res.status(500).json({ error: "Failed to fetch settings" }); } }); // Update settings router.put( "/", authenticateToken, requireManageSettings, [ body("serverProtocol") .optional() .isIn(["http", "https"]) .withMessage("Protocol must be http or https"), body("serverHost") .optional() .isLength({ min: 1 }) .withMessage("Server host is required"), body("serverPort") .optional() .isInt({ min: 1, max: 65535 }) .withMessage("Port must be between 1 and 65535"), body("updateInterval") .optional() .isInt({ min: 5, max: 1440 }) .withMessage("Update interval must be between 5 and 1440 minutes"), body("autoUpdate") .optional() .isBoolean() .withMessage("Auto update must be a boolean"), body("ignoreSslSelfSigned") .optional() .isBoolean() .withMessage("Ignore SSL self-signed must be a boolean"), body("signupEnabled") .optional() .isBoolean() .withMessage("Signup enabled must be a boolean"), body("defaultUserRole") .optional() .isLength({ min: 1 }) .withMessage("Default user role must be a non-empty string"), body("githubRepoUrl") .optional() .isLength({ min: 1 }) .withMessage("GitHub repo URL must be a non-empty string"), body("repositoryType") .optional() .isIn(["public", "private"]) .withMessage("Repository type must be public or private"), body("sshKeyPath") .optional() .custom((value) => { if (value && value.trim().length === 0) { return true; // Allow empty string } if (value && value.trim().length < 1) { throw new Error("SSH key path must be a non-empty string"); } return true; }), body("logoDark") .optional() .isLength({ min: 1 }) .withMessage("Logo dark path must be a non-empty string"), body("logoLight") .optional() .isLength({ min: 1 }) .withMessage("Logo light path must be a non-empty string"), body("favicon") .optional() .isLength({ min: 1 }) .withMessage("Favicon path must be a non-empty string"), ], async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { console.log("Validation errors:", errors.array()); return res.status(400).json({ errors: errors.array() }); } const { serverProtocol, serverHost, serverPort, updateInterval, autoUpdate, ignoreSslSelfSigned, signupEnabled, defaultUserRole, githubRepoUrl, repositoryType, sshKeyPath, logoDark, logoLight, favicon, colorTheme, } = req.body; // Get current settings to check for update interval changes const currentSettings = await getSettings(); const oldUpdateInterval = currentSettings.update_interval; // Build update object with only provided fields const updateData = {}; if (serverProtocol !== undefined) updateData.server_protocol = serverProtocol; if (serverHost !== undefined) updateData.server_host = serverHost; if (serverPort !== undefined) updateData.server_port = serverPort; if (updateInterval !== undefined) { updateData.update_interval = normalizeUpdateInterval(updateInterval); } if (autoUpdate !== undefined) updateData.auto_update = autoUpdate; if (ignoreSslSelfSigned !== undefined) updateData.ignore_ssl_self_signed = ignoreSslSelfSigned; if (signupEnabled !== undefined) updateData.signup_enabled = signupEnabled; if (defaultUserRole !== undefined) updateData.default_user_role = defaultUserRole; if (githubRepoUrl !== undefined) updateData.github_repo_url = githubRepoUrl; if (repositoryType !== undefined) updateData.repository_type = repositoryType; if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath; if (logoDark !== undefined) updateData.logo_dark = logoDark; if (logoLight !== undefined) updateData.logo_light = logoLight; if (favicon !== undefined) updateData.favicon = favicon; if (colorTheme !== undefined) updateData.color_theme = colorTheme; const updatedSettings = await updateSettings( currentSettings.id, updateData, ); console.log("Settings updated successfully:", updatedSettings); // If update interval changed, enqueue persistent jobs for agents if ( updateInterval !== undefined && oldUpdateInterval !== updateData.update_interval ) { console.log( `Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Enqueueing agent settings updates...`, ); const hosts = await prisma.hosts.findMany({ where: { status: "active" }, select: { api_id: true }, }); const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS]; const jobs = hosts.map((h) => ({ name: "settings_update", data: { api_id: h.api_id, type: "settings_update", update_interval: updateData.update_interval, }, opts: { attempts: 10, backoff: { type: "exponential", delay: 5000 } }, })); // Bulk add jobs await queue.addBulk(jobs); // Note: Queue-based delivery handles retries and ensures reliable delivery // No need for immediate broadcast as it would cause duplicate messages } res.json({ message: "Settings updated successfully", settings: updatedSettings, }); } catch (error) { console.error("Settings update error:", error); res.status(500).json({ error: "Failed to update settings" }); } }, ); // Get server URL for public use (used by installation scripts) router.get("/server-url", async (_req, res) => { try { const settings = await getSettings(); const serverUrl = settings.server_url; res.json({ server_url: serverUrl }); } catch (error) { console.error("Server URL fetch error:", error); res.status(500).json({ error: "Failed to fetch server URL" }); } }); // Get update interval policy for agents (requires API authentication) router.get("/update-interval", async (req, res) => { try { // Verify API credentials const apiId = req.headers["x-api-id"]; const apiKey = req.headers["x-api-key"]; if (!apiId || !apiKey) { return res.status(401).json({ error: "API credentials required" }); } // Validate API credentials const host = await prisma.hosts.findUnique({ where: { api_id: apiId }, }); if (!host || host.api_key !== apiKey) { return res.status(401).json({ error: "Invalid API credentials" }); } const settings = await getSettings(); const interval = normalizeUpdateInterval(settings.update_interval || 60); res.json({ updateInterval: interval, cronExpression: buildCronExpression(interval), }); } catch (error) { console.error("Update interval fetch error:", error); res.json({ updateInterval: 60, cronExpression: "0 * * * *" }); } }); // Get auto-update policy for agents (public endpoint) router.get("/auto-update", async (_req, res) => { try { const settings = await getSettings(); res.json({ autoUpdate: settings.auto_update || false, }); } catch (error) { console.error("Auto-update fetch error:", error); res.json({ autoUpdate: false }); } }); // Upload logo files router.post( "/logos/upload", authenticateToken, requireManageSettings, async (req, res) => { try { const { logoType, fileContent, fileName } = req.body; if (!logoType || !fileContent) { return res.status(400).json({ error: "Logo type and file content are required", }); } if (!["dark", "light", "favicon"].includes(logoType)) { return res.status(400).json({ error: "Logo type must be 'dark', 'light', or 'favicon'", }); } // Validate file content (basic checks) if (typeof fileContent !== "string") { return res.status(400).json({ error: "File content must be a base64 string", }); } const fs = require("node:fs").promises; const path = require("node:path"); const _crypto = require("node:crypto"); // Create assets directory if it doesn't exist // In development: save to public/assets (served by Vite) // In production: save to dist/assets (served by built app) const isDevelopment = process.env.NODE_ENV !== "production"; const assetsDir = isDevelopment ? path.join(__dirname, "../../../frontend/public/assets") : path.join(__dirname, "../../../frontend/dist/assets"); await fs.mkdir(assetsDir, { recursive: true }); // Determine file extension and path let fileExtension; let fileName_final; if (logoType === "favicon") { fileExtension = ".svg"; fileName_final = fileName || "logo_square.svg"; } else { // Determine extension from file content or use default if (fileContent.startsWith("data:image/png")) { fileExtension = ".png"; } else if (fileContent.startsWith("data:image/svg")) { fileExtension = ".svg"; } else if ( fileContent.startsWith("data:image/jpeg") || fileContent.startsWith("data:image/jpg") ) { fileExtension = ".jpg"; } else { fileExtension = ".png"; // Default to PNG } fileName_final = fileName || `logo_${logoType}${fileExtension}`; } const filePath = path.join(assetsDir, fileName_final); // Handle base64 data URLs let fileBuffer; if (fileContent.startsWith("data:")) { const base64Data = fileContent.split(",")[1]; fileBuffer = Buffer.from(base64Data, "base64"); } else { // Assume it's already base64 fileBuffer = Buffer.from(fileContent, "base64"); } // Create backup of existing file try { const backupPath = `${filePath}.backup.${Date.now()}`; await fs.copyFile(filePath, backupPath); console.log(`Created backup: ${backupPath}`); } catch (error) { // Ignore if original doesn't exist if (error.code !== "ENOENT") { console.warn("Failed to create backup:", error.message); } } // Write new logo file await fs.writeFile(filePath, fileBuffer); // Update settings with new logo path const settings = await getSettings(); const logoPath = `/assets/${fileName_final}`; const updateData = {}; if (logoType === "dark") { updateData.logo_dark = logoPath; } else if (logoType === "light") { updateData.logo_light = logoPath; } else if (logoType === "favicon") { updateData.favicon = logoPath; } await updateSettings(settings.id, updateData); // Get file stats const stats = await fs.stat(filePath); res.json({ message: `${logoType} logo uploaded successfully`, fileName: fileName_final, path: logoPath, size: stats.size, sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`, }); } catch (error) { console.error("Upload logo error:", error); res.status(500).json({ error: "Failed to upload logo" }); } }, ); // Reset logo to default router.post( "/logos/reset", authenticateToken, requireManageSettings, async (req, res) => { try { const { logoType } = req.body; if (!logoType) { return res.status(400).json({ error: "Logo type is required", }); } if (!["dark", "light", "favicon"].includes(logoType)) { return res.status(400).json({ error: "Logo type must be 'dark', 'light', or 'favicon'", }); } // Get current settings const settings = await getSettings(); // Clear the custom logo path to revert to default const updateData = {}; if (logoType === "dark") { updateData.logo_dark = null; } else if (logoType === "light") { updateData.logo_light = null; } else if (logoType === "favicon") { updateData.favicon = null; } await updateSettings(settings.id, updateData); res.json({ message: `${logoType} logo reset to default successfully`, logoType, }); } catch (error) { console.error("Reset logo error:", error); res.status(500).json({ error: "Failed to reset logo" }); } }, ); module.exports = router;