mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-28 18:43:32 +00:00
Refactored code to remove duplicate backend api endpoints for counting Improved connection persistence issues Improved database connection pooling issues Fixed redis connection efficiency Changed version to 1.3.0 Fixed GO binary detection based on package manager rather than OS
472 lines
13 KiB
JavaScript
472 lines
13 KiB
JavaScript
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,
|
|
} = 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;
|
|
|
|
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;
|