feat(ui): TDS migration stage 1 — EJS layout, CSP, vendored design system, shared runtime
- Vendor web_template base.css/base.js (v1.2 bbec859) under public/web_template with sync script - Add views/layout.ejs (fixed upstream EJS comment delimiters, mobile drawer, cmd palette, keys help, WS status, theme toggle) and lib/render.js two-step renderer - Add page routes for /, /workers, /workflows, /executions, /quick, /scheduler (stub views) - helmet with strict nonce CSP (script-src-attr 'none'), /csp-report, report-only toggle - lib/pageauth.js: shared users upsert + HTML 401/403 for page routes - PULSE_DEV_READONLY guard disabling background writers for local testing - public/assets/app.js shared runtime (Pulse namespace, delegated actions, WS singleton, themed confirm modal) and app.css - eslint globals for browser files; ignore vendored assets Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
This commit is contained in:
@@ -6,16 +6,62 @@ const crypto = require('crypto');
|
||||
const vm = require('vm');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const cronParser = require('cron-parser');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const helmet = require('helmet');
|
||||
require('dotenv').config();
|
||||
const { validateWebhookUrl, applyParams, evalCondition, calculateNextRun } = require('./lib/utils');
|
||||
const { PAGES } = require('./lib/nav');
|
||||
const { renderPage, renderError } = require('./lib/render');
|
||||
const { upsertUser, makeAuthenticatePage } = require('./lib/pageauth');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocket.Server({ server });
|
||||
|
||||
// Dev/read-only guard: skip every background job that writes to the database.
|
||||
const DEV_READONLY = process.env.PULSE_DEV_READONLY === '1' || process.env.DISABLE_BACKGROUND_JOBS === '1';
|
||||
|
||||
// Middleware
|
||||
|
||||
// CSP nonce — must run before helmet so the directive can read it.
|
||||
app.use((req, res, next) => {
|
||||
res.locals.nonce = crypto.randomBytes(16).toString('base64');
|
||||
next();
|
||||
});
|
||||
|
||||
const CSP_REPORT_ONLY = process.env.PULSE_CSP_REPORT_ONLY === '1';
|
||||
const cspDirectives = {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
|
||||
scriptSrcAttr: ["'none'"],
|
||||
styleSrcElem: ["'self'", 'https://fonts.googleapis.com'],
|
||||
styleSrcAttr: ["'unsafe-inline'"],
|
||||
styleSrc: ["'self'", 'https://fonts.googleapis.com', "'unsafe-inline'"],
|
||||
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'", 'ws:', 'wss:'],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
upgradeInsecureRequests: null
|
||||
};
|
||||
if (CSP_REPORT_ONLY) {
|
||||
cspDirectives.reportUri = ['/csp-report'];
|
||||
}
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: cspDirectives,
|
||||
reportOnly: CSP_REPORT_ONLY
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: process.env.NODE_ENV === 'production' ? undefined : false
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Rate limiting
|
||||
const apiLimiter = rateLimit({
|
||||
@@ -32,7 +78,6 @@ const executionLimiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many execution requests, please slow down' }
|
||||
});
|
||||
app.use('/api/', apiLimiter);
|
||||
|
||||
// Named constants for timeouts and limits
|
||||
const PROMPT_TIMEOUT_MS = 60 * 60 * 1000; // 60 min — how long a prompt waits for user input
|
||||
@@ -53,6 +98,24 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// CSP violation reports (only useful with PULSE_CSP_REPORT_ONLY=1, but always mounted)
|
||||
app.post('/csp-report',
|
||||
express.json({ type: ['application/csp-report', 'application/reports+json', 'application/json'] }),
|
||||
(req, res) => {
|
||||
try {
|
||||
console.log('[CSP]', JSON.stringify(req.body));
|
||||
} catch (_) {
|
||||
console.log('[CSP] (unparseable report)');
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Static assets — the old root mount of public/ is gone on purpose.
|
||||
app.use('/web_template', express.static(path.join(__dirname, 'public/web_template'), { maxAge: '1h' }));
|
||||
app.use('/assets', express.static(path.join(__dirname, 'public/assets'), { maxAge: '1h' }));
|
||||
|
||||
app.use('/api/', apiLimiter);
|
||||
|
||||
// Content-Type guard for JSON endpoints
|
||||
function requireJSON(req, res, next) {
|
||||
const ct = req.headers['content-type'] || '';
|
||||
@@ -160,19 +223,23 @@ async function initDatabase() {
|
||||
`);
|
||||
|
||||
// Recover stale executions from a previous server crash
|
||||
const [staleExecs] = await connection.query("SELECT id FROM executions WHERE status = 'running'");
|
||||
if (staleExecs.length > 0) {
|
||||
for (const exec of staleExecs) {
|
||||
await connection.query(
|
||||
"UPDATE executions SET status = 'failed', completed_at = NOW() WHERE id = ?",
|
||||
[exec.id]
|
||||
);
|
||||
await connection.query(
|
||||
"UPDATE executions SET logs = JSON_ARRAY_APPEND(COALESCE(logs, '[]'), '$', JSON_EXTRACT(?, '$')) WHERE id = ?",
|
||||
[JSON.stringify({ action: 'server_restart_recovery', message: 'Execution marked failed due to server restart', timestamp: new Date().toISOString() }), exec.id]
|
||||
);
|
||||
if (!DEV_READONLY) {
|
||||
const [staleExecs] = await connection.query("SELECT id FROM executions WHERE status = 'running'");
|
||||
if (staleExecs.length > 0) {
|
||||
for (const exec of staleExecs) {
|
||||
await connection.query(
|
||||
"UPDATE executions SET status = 'failed', completed_at = NOW() WHERE id = ?",
|
||||
[exec.id]
|
||||
);
|
||||
await connection.query(
|
||||
"UPDATE executions SET logs = JSON_ARRAY_APPEND(COALESCE(logs, '[]'), '$', JSON_EXTRACT(?, '$')) WHERE id = ?",
|
||||
[JSON.stringify({ action: 'server_restart_recovery', message: 'Execution marked failed due to server restart', timestamp: new Date().toISOString() }), exec.id]
|
||||
);
|
||||
}
|
||||
console.log(`[Recovery] Marked ${staleExecs.length} stale execution(s) as failed`);
|
||||
}
|
||||
console.log(`[Recovery] Marked ${staleExecs.length} stale execution(s) as failed`);
|
||||
} else {
|
||||
console.log('[DEV] skipped: stale-execution recovery');
|
||||
}
|
||||
|
||||
console.log('Database tables initialized successfully');
|
||||
@@ -202,10 +269,14 @@ async function cleanupOldExecutions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Run cleanup hourly
|
||||
setInterval(cleanupOldExecutions, 60 * 60 * 1000);
|
||||
// Run cleanup on startup
|
||||
cleanupOldExecutions();
|
||||
if (!DEV_READONLY) {
|
||||
// Run cleanup hourly
|
||||
setInterval(cleanupOldExecutions, 60 * 60 * 1000);
|
||||
// Run cleanup on startup
|
||||
cleanupOldExecutions();
|
||||
} else {
|
||||
console.log('[DEV] skipped: cleanupOldExecutions interval + startup run');
|
||||
}
|
||||
|
||||
// Scheduled Commands Processor
|
||||
async function processScheduledCommands() {
|
||||
@@ -292,10 +363,14 @@ async function processScheduledCommands() {
|
||||
}
|
||||
|
||||
|
||||
// Run scheduler every minute
|
||||
setInterval(processScheduledCommands, 60 * 1000);
|
||||
// Initial run on startup
|
||||
setTimeout(processScheduledCommands, 5000);
|
||||
if (!DEV_READONLY) {
|
||||
// Run scheduler every minute
|
||||
setInterval(processScheduledCommands, 60 * 1000);
|
||||
// Initial run on startup
|
||||
setTimeout(processScheduledCommands, 5000);
|
||||
} else {
|
||||
console.log('[DEV] skipped: processScheduledCommands interval + startup run');
|
||||
}
|
||||
|
||||
// Mark workers offline when their heartbeat goes stale
|
||||
async function markStaleWorkersOffline() {
|
||||
@@ -314,7 +389,11 @@ async function markStaleWorkersOffline() {
|
||||
console.error('[Worker] Stale check error:', error);
|
||||
}
|
||||
}
|
||||
setInterval(markStaleWorkersOffline, 60_000);
|
||||
if (!DEV_READONLY) {
|
||||
setInterval(markStaleWorkersOffline, 60_000);
|
||||
} else {
|
||||
console.log('[DEV] skipped: markStaleWorkersOffline interval');
|
||||
}
|
||||
|
||||
// WebSocket connections
|
||||
const browserClients = new Set(); // Browser UI connections
|
||||
@@ -624,17 +703,7 @@ async function authenticateSSO(req, res, next) {
|
||||
|
||||
// Store/update user in database
|
||||
try {
|
||||
const userId = crypto.randomUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO users (id, username, display_name, email, groups, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name=VALUES(display_name),
|
||||
email=VALUES(email),
|
||||
groups=VALUES(groups),
|
||||
last_login=NOW()`,
|
||||
[userId, remoteUser, remoteName, remoteEmail, remoteGroups]
|
||||
);
|
||||
await upsertUser(pool, req.headers);
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
}
|
||||
@@ -1082,6 +1151,28 @@ async function waitForCommandResult(executionId, commandId, timeout) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// HTML page routes (registered before the API routes)
|
||||
const authenticatePage = makeAuthenticatePage(pool);
|
||||
PAGES.forEach((p) => {
|
||||
app.get(p.path, authenticatePage, async (req, res, next) => {
|
||||
try {
|
||||
await renderPage(req, res, p.view, {
|
||||
pageTitle: p.title,
|
||||
activeNav: p.key,
|
||||
pageScripts: [`/assets/pages/${p.view}.js`],
|
||||
pageStyles: fs.existsSync(path.join(__dirname, `public/assets/pages/${p.view}.css`))
|
||||
? [`/assets/pages/${p.view}.css`]
|
||||
: [],
|
||||
pageConfig: { isAdmin: req.user.isAdmin }
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Routes - All protected by SSO
|
||||
app.get('/api/user', authenticateSSO, (req, res) => {
|
||||
res.json(req.user);
|
||||
@@ -1717,6 +1808,24 @@ app.post('/api/workers/:id/command', authenticateSSO, requireJSON, async (req, r
|
||||
}
|
||||
});
|
||||
|
||||
// 404 — JSON under /api/, themed HTML elsewhere
|
||||
app.use((req, res) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
renderError(req, res, 404, 'Not found', `No page exists at ${req.path}`);
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
|
||||
console.error('[Error]', err && err.stack ? err.stack : err);
|
||||
if (res.headersSent) return;
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
renderError(req, res, 500, 'Internal server error', 'Something went wrong rendering this page.');
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = process.env.PORT || 8080;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
@@ -1727,6 +1836,12 @@ initDatabase().then(() => {
|
||||
console.log(`Connected to MariaDB at ${process.env.DB_HOST}`);
|
||||
console.log(`Authentication: Authelia SSO`);
|
||||
console.log(`Worker API Key configured: ${process.env.WORKER_API_KEY ? 'Yes' : 'No'}`);
|
||||
if (DEV_READONLY) {
|
||||
console.warn('************************************************************');
|
||||
console.warn('* PULSE_DEV_READONLY IS ON — background jobs are disabled *');
|
||||
console.warn('* No cleanup, scheduler, stale-worker or recovery writes. *');
|
||||
console.warn('************************************************************');
|
||||
}
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Failed to start server:', err);
|
||||
|
||||
Reference in New Issue
Block a user