// sdp-request-watcher: help desk dashboard for ManageEngine SDP Cloud // Skylar Grant, with Claude (Anthropic). Page design by Isaac Godin. require('dotenv').config({ quiet: true }); const path = require('path'); const express = require('express'); const { createZohoAuth } = require('./auth'); const { createSessions } = require('./session'); const { createAuthRoutes, createApiRoutes } = require('./routes'); const { createWeatherService } = require('./weather'); const { createUserSettings } = require('./settings'); const { createNameCache } = require('./names'); const places = require('./places'); const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env; const PORT = process.env.PORT || 3000; // default 3000 // the address people open; zoho sends them back to PUBLIC_URL/auth/callback after sign-in const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, ''); const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean); // any Zoho account can approve our client, so sign-in alone doesn't mean someone is staff const allowedDomains = toList(process.env.ALLOWED_EMAIL_DOMAINS).map(d => d.replace(/^@/, '').toLowerCase()); const problems = [ !CLIENT_ID || !CLIENT_SECRET ? 'CLIENT_ID and CLIENT_SECRET are required' : null, !SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null, !allowedDomains.length ? 'ALLOWED_EMAIL_DOMAINS is required, e.g. mainecc.edu' : null ].filter(Boolean); if (problems.length) { problems.forEach(p => console.error(p)); process.exit(1); } const technicians = toList(TECHNICIAN_NAMES); const statuses = toList(STATUS_NAMES); // statuses that count as done; everything else shows up in the workload table const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled']; // the status rules every user shares; these aren't in the settings panel (the feed's statuses are, see feedStatuses) const settings = { closedStatuses, // statuses the unassigned counter ignores, to match the ManageEngine view unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined ? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES) : closedStatuses }; const timing = { windowMinutes: 10, // how far back each closed check looks, in minutes checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again }; const zoho = createZohoAuth({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, redirectUri: `${PUBLIC_URL}/auth/callback` }); const sessions = createSessions({ secret: SESSION_SECRET, // the proxy terminates https, so go by the public address rather than the request secure: PUBLIC_URL.startsWith('https://'), // exact domain match on the email Zoho reports; no email means no entry isAllowed: user => { const email = (user?.email || '').toLowerCase(); return allowedDomains.some(domain => email.endsWith('@' + domain)); }, sessionDays: timing.sessionDays, zoho }); // weather widget is optional; it stays hidden without an API key const weatherService = createWeatherService({ apiKey: OWM_API_KEY }); // the .env zip is the default location; make sure it's on the list people pick from if (OWM_ZIP_CODE && !places.some(p => p.zip === OWM_ZIP_CODE)) { places.unshift({ zip: OWM_ZIP_CODE, label: `Default (${OWM_ZIP_CODE})` }); } // what each user starts with; the settings panel overrides these per user const defaults = { sites: toList(SITE_NAMES), // empty means every site groups: toList(GROUP_NAMES), // empty means every group technicians, // empty means everyone, plus an Unassigned row workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table feedHours: 12, // how long a closed ticket stays in the feed weatherZip: OWM_ZIP_CODE || null, hiddenStatuses: [], // status columns left out of the table tableSort: 'list', // see TABLE_SORTS in settings.js // statuses that put a ticket in the closed feed and ding; some people want Resolved, // which comes days before SDP closes a ticket on its own feedStatuses: closedStatuses, hiddenPanels: [] // see PANELS in settings.js }; // settings and name suggestions are the only things the server keeps; mount a volume here in docker const dataDir = path.resolve(process.env.DATA_DIR || path.join(__dirname, '..', 'data')); const userSettings = createUserSettings({ dataDir, defaults, places }); const nameCache = createNameCache({ dataDir, seed: () => { const saved = userSettings.allNames(); return { sites: [...defaults.sites, ...saved.sites], groups: [...defaults.groups, ...saved.groups], technicians: [...defaults.technicians, ...saved.technicians] }; } }); const app = express(); app.disable('x-powered-by'); // behind nginx proxy manager: believe X-Forwarded-For from private addresses (the docker network) // so logs show the real client ip app.set('trust proxy', process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal'); app.use((req, res, next) => { res.set({ 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'same-origin' }); next(); }); // for the docker healthcheck; says nothing about sign-in or data app.get('/healthz', (req, res) => res.send('ok')); app.use(express.static(path.join(__dirname, 'public'))); app.use('/auth', createAuthRoutes({ zoho, sessions })); app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places })); //starts listening on port specified app.listen(PORT, () => { console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}, data in ${dataDir}`); });