#!/usr/bin/env node import fs from 'fs'; import fetch from 'node-fetch'; import { Headers } from 'node-fetch'; import express from 'express'; import bodyParser from 'body-parser'; import multer from 'multer'; import sharp from 'sharp'; import { v4 as uuid } from 'uuid'; import cookieParser from 'cookie-parser'; import FormData from 'form-data'; import cron from "node-cron"; import PQueue from 'p-queue'; import * as helpers from './lib/helpers.js'; import { createWebexClient } from './lib/webex.js'; import { createTranslator } from './lib/translation.js'; import { createJobsPipeline } from './lib/jobs.js'; const queue = new PQueue({ concurrency: 10 }); //Load the config files from storage. // config.json = committed, structural (server port, bot labels, integration ids). // authorized.json = gitignored, mutable per-user data: // { admins: [personId...], bot: { : { : { ...profile, groups: [...] } } } } // jobs.json = gitignored, runtime queue state. // userPrefs.json = gitignored, per-recipient preferences (language, ...). var config = JSON.parse(fs.readFileSync('./config/config.json')); var jobs = JSON.parse(fs.readFileSync('./config/jobs.json')); var userPrefs = JSON.parse(fs.readFileSync('./config/userPrefs.json')); var authorized = loadAuthorized(); function loadAuthorized() { try { var doc = JSON.parse(fs.readFileSync('./config/authorized.json')); doc.admins = Array.isArray(doc.admins) ? doc.admins : []; doc.bot = (doc.bot && typeof doc.bot === 'object') ? doc.bot : {}; return doc; } catch (err) { // Missing file (fresh deploy) is fine — we start empty and the admin // page can populate. Any other parse error is fatal because otherwise // the whole authorization layer silently permits nothing. if (err && err.code === 'ENOENT') { console.log('loadAuthorized: authorized.json not found, starting empty.'); return { admins: [], bot: {} }; } throw err; } } // Returns the mutable per-user record for (appName, personId), creating the // bot bucket if this is the first write. Never returns null when personId is // truthy — callers use this to add favorites / users without null-checking. // Read-only callers should prefer helpers.getAuthorizedEntry(authorized, ...). function getOrCreateAuthorizedEntry(appName, personId) { if (!authorized.bot[appName]) authorized.bot[appName] = {}; return authorized.bot[appName][personId] || null; } // Defensive init so code below can safely `.push`, `.filter`, and index into // these regardless of what the on-disk jobs.json happens to contain. jobs.building = jobs.building || {}; jobs.running = Array.isArray(jobs.running) ? jobs.running : []; jobs.scheduled = Array.isArray(jobs.scheduled) ? jobs.scheduled : []; jobs.completed = Array.isArray(jobs.completed) ? jobs.completed : []; if (typeof jobs.lastJobNumber !== 'number') jobs.lastJobNumber = 0; // Per-bot tokens live in their own gitignored file so adding a new bot is one // JSON entry + an env-free restart. Shape: // { "": { "token": "...", "enabled": true } } var botTokens = loadBotTokens(); function loadBotTokens() { try { return JSON.parse(fs.readFileSync('./config/botTokens.json')); } catch (err) { console.log('loadBotTokens failed: ' + err.message); return {}; } } function getBotToken(appName) { return helpers.getBotToken(botTokens, appName); } function isBotEnabled(appName) { return helpers.isBotEnabled(botTokens, appName); } // Returns the bot's config block if the bot is both defined in config.json AND // has an enabled token entry. Returns null otherwise. Use this everywhere // instead of reaching into config.webex.bot[...] directly so unknown or // disabled bots can't crash the request. function getBotConfig(appName) { return helpers.getBotConfig(config, botTokens, appName); } // Cached bot profiles populated from Webex /people/me at startup so the // front-end and the completion card can use the bot's actual displayName and // avatar without hardcoding either in config.json. // botProfiles[appName] = { displayName, avatar } var botProfiles = {}; async function loadBotProfile(appName) { var token = getBotToken(appName); if (!token) return; try { var response = await webex.fetchWithRateLimit('https://webexapis.com/v1/people/me', { method: 'GET', headers: { 'Authorization': 'Bearer ' + token } }); if (!response.ok) { logger('loadBotProfile', appName + ' /people/me failed: ' + response.status + ' ' + response.statusText); return; } var data = await response.json(); botProfiles[appName] = { displayName: data.displayName, avatar: data.avatar, personId: data.id, emails: data.emails }; logger('loadBotProfile', appName + ' = ' + data.displayName); } catch (err) { logger('loadBotProfile', appName + ' error: ' + (err && err.message || err)); } } function loadBotProfiles() { var apps = Object.keys((config.webex && config.webex.bot) || {}); return Promise.allSettled(apps.filter(isBotEnabled).map(loadBotProfile)); } function getBotProfile(appName) { return botProfiles[appName] || null; } // Cached list of every Webex group in the org, used by the "Additional groups" // dropdown on the compose page. findWebexGroup() paginates through ~10k groups // 500 at a time, which used to run on every /user/groups/find request; we now // refresh once at startup and once a day and serve everything else from // memory. `refreshing` is a shared in-flight Promise so a concurrent refresh // (e.g. the cron firing during a slow startup fetch) can't stampede. var groupsCache = { data: null, lastRefreshed: null, refreshing: null, }; function refreshGroupsCache() { if (groupsCache.refreshing) return groupsCache.refreshing; var startedAt = Date.now(); logger('groupsCache', 'Refreshing group list from Webex...'); groupsCache.refreshing = webex.findWebexGroup() .then(function (groups) { groupsCache.data = groups; groupsCache.lastRefreshed = new Date(); logger('groupsCache', 'Cached ' + groups.length + ' groups in ' + (Date.now() - startedAt) + 'ms.'); return groups; }) .catch(function (err) { logger('groupsCache', 'Refresh failed: ' + (err && err.message || err)); throw err; }) .finally(function () { groupsCache.refreshing = null; }); return groupsCache.refreshing; } // Returns the cached groups if we have any; otherwise waits for whatever // refresh is currently in flight (or kicks one off). Requests only ever block // on the very first call after startup — every subsequent call resolves // synchronously from memory. function getGroupsFromCache() { if (groupsCache.data) return Promise.resolve(groupsCache.data); return refreshGroupsCache(); } // Express middleware: 404s any /CollabCentral/:app/* request whose `:app` is // not a known + enabled bot. Attaches the bot config to req.botConfig for // downstream handlers. function requireBot(req, res, next) { var botCfg = getBotConfig(req.params.app); if (!botCfg) { logger("requireBot", "Unknown or disabled bot: " + req.params.app); return res.status(404).send("Unknown bot: " + req.params.app); } req.botConfig = botCfg; next(); } // Building (draft) jobs are keyed by cookieId AND appName so a user who is // authorized for more than one bot can have one independent draft per bot. function buildingKey(req) { return helpers.buildingKey(req.cookies && req.cookies.id, req.params.app); } function jobsForApp(arr, appName) { return helpers.jobsForApp(arr, appName); } // Service-account OAuth tokens are rewritten in place by refreshToken(), so // they live in their own file rather than mixed into config.json. var serviceAccountToken = loadServiceAccountToken(); function loadServiceAccountToken() { try { return JSON.parse(fs.readFileSync('./config/token.json')); } catch (err) { console.log('loadServiceAccountToken failed: ' + err.message); return {}; } } function saveServiceAccountToken(tokenObj) { // Keep the in-memory copy in sync even if the disk write fails so the // refreshed token is still usable for the rest of the process lifetime. serviceAccountToken = tokenObj; try { fs.writeFileSync('./config/token.json', JSON.stringify(tokenObj, null, 4)); } catch (err) { logger("saveServiceAccountToken", "Failed to persist token.json: " + err.message); } } function getServiceAccountAccessToken() { return serviceAccountToken && serviceAccountToken.access_token; } // Builds the Webex OAuth authorize URL for a given bot. The redirect_uri must // match one of the URIs registered with the Webex integration in the Webex // developer portal (one URI per bot). Returns null if required env vars are // missing so callers can return an actionable error instead of a malformed URL. function buildAuthUrl(appName) { var url = helpers.buildAuthUrl({ clientId: process.env.WEBEX_INTEGRATION_CLIENT_ID, template: process.env.OAUTH_CALLBACK_URL_TEMPLATE, appName: appName, }); if (!url) logger('buildAuthUrl', 'Missing WEBEX_INTEGRATION_CLIENT_ID or OAUTH_CALLBACK_URL_TEMPLATE env var.'); return url; } function getOAuthRedirectUri(appName) { return helpers.getOAuthRedirectUri(process.env.OAUTH_CALLBACK_URL_TEMPLATE, appName); } // --------------------------------------------------------------------------- // Wire the lib/* modules. Each takes a dependency bag so the modules // themselves stay free of module-level mutable state — everything mutable // (tokens, jobs, queue) still lives here in index.js. // --------------------------------------------------------------------------- var webex = createWebexClient({ getBotToken: getBotToken, getServiceAccountAccessToken: getServiceAccountAccessToken, getServiceAccountRefreshToken: function () { return serviceAccountToken && serviceAccountToken.refresh_token; }, saveServiceAccountToken: saveServiceAccountToken, env: process.env, logger: logger, }); var translator = createTranslator({ languages: config.languages || [], apiKey: process.env.GOOGLE_TRANSLATE_API_KEY || '', fetchWithRateLimit: webex.fetchWithRateLimit, logger: logger, }); var jobsPipeline = createJobsPipeline({ jobs: jobs, queue: queue, userPrefs: userPrefs, webex: webex, getBotConfig: function (appName) { return helpers.getBotConfig(config, botTokens, appName); }, getBotProfile: getBotProfile, saveJobs: function () { saveConfig(jobs, './config/jobs.json'); }, msToTime: helpers.msToTime, logger: logger, }); //Load Express Server var app = express(); app.use(bodyParser.json()); app.use(cookieParser()); var serverPort = process.env.SERVER_PORT || config.server.port; var server = app.listen(serverPort, function () { logger("startup", config.server.name + " running on port " + serverPort + "."); loadBotProfiles() .then(() => logger("startup", "Loaded " + Object.keys(botProfiles).length + " bot profile(s).")) .catch(err => logger("startup", "loadBotProfiles error: " + (err && err.message || err))); // Warm the groups cache in the background so the very first user hitting // /user/groups/find gets an instant response. Failures are non-fatal: // getGroupsFromCache() will simply try again on the first request. refreshGroupsCache().catch(function () { /* logged inside */ }); }); server.setTimeout(3000000); const upload = multer({ limits: { fileSize: 4000000 } }).fields( [ { name: 'uploadImage' }, { name: 'uploadCSV' } ] ); // Anchor both crons to a specific timezone so the schedule is deterministic // regardless of the host/container clock. Override with CRON_TIMEZONE if the // deployment moves to a different region. var cronTimezone = process.env.CRON_TIMEZONE || 'America/New_York'; // Daily at 01:10 local time: prune completed jobs older than the retention // window and rewrite jobs.json. cron.schedule('0 10 1 * * *', async function () { var jobFile = JSON.parse(fs.readFileSync('./config/jobs.json')); var cleanJobs = await cleanCompletedJobs(jobFile); saveConfig(cleanJobs, './config/jobs.json'); jobs = JSON.parse(fs.readFileSync('./config/jobs.json')); }, { timezone: cronTimezone }) // Every minute: refresh the service-account token if it's inside the renewal // window, and dispatch any scheduled jobs whose time has arrived. cron.schedule('0 * * * * *', () => { var renewBy = serviceAccountToken && serviceAccountToken.renewBy; if (renewBy && new Date(new Date(renewBy) - 7200000) < new Date()) { logger("refreshToken", "Renew by: " + new Date(renewBy).toLocaleString()) logger("refreshToken", "Renew after: " + new Date(new Date(renewBy) - 7200000).toLocaleString()); logger("refreshToken", "Refreshing token.") webex.refreshToken() .then(response => { logger("tokenRefresh", response); }) .catch(error => logger("refreshToken", error)); } jobsPipeline.checkScheduledJobs() .catch(error => logger("checkScheduledJobs", error)); }, { timezone: cronTimezone }) // Daily at 03:00 local time: rebuild the org-wide groups cache. Offset from // the 01:10 jobs cleanup so the two crons don't fight over the event loop. cron.schedule('0 0 3 * * *', function () { refreshGroupsCache().catch(function () { /* logged inside */ }); }, { timezone: cronTimezone }) //Routes to be used // Validate the :app segment for every /CollabCentral/:app/* request. This // runs before the static mount and all the per-route handlers below, so any // unknown or disabled bot gets a clean 404 instead of crashing on undefined. app.use('/CollabCentral/:app', requireBot) app.use('/CollabCentral/:app', express.static('html')) app.get('/status', function (req, res) { res.status(200).send({ "status": "I'm alive!'" }); }); app.get('/CollabCentral/:app/authUrl', function (req, res) { var url = buildAuthUrl(req.params.app); if (!url) { return res.status(500).send('OAuth is not configured. Check WEBEX_INTEGRATION_CLIENT_ID and OAUTH_CALLBACK_URL_TEMPLATE.'); } res.send(url); }); // Returns the bot's display metadata + whether the current cookie is authorized. // The front-end uses this to drive page labels, icons, and the // "you don't have access" view, so adding a new bot doesn't require any code // changes in the HTML/JS. app.get('/CollabCentral/:app/info', function (req, res) { var appName = req.params.app; var botCfg = req.botConfig; // populated by requireBot var profile = getBotProfile(appName) || {}; var iconBase = botCfg.iconBase || appName; var personId = req.cookies && req.cookies.id; res.status(200).send({ appName: appName, label: botCfg.label || appName, displayName: profile.displayName || botCfg.label || appName, iconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.png', faviconUrl: '/CollabCentral/' + appName + '/' + iconBase + '.ico', avatarUrl: profile.avatar || null, authorized: isAuthorized(appName, personId), isAdmin: isAdmin(personId) }); }); app.post('/CollabCentral/:app/jobs/:action', (req, res) => { if (isAuthorized(req.params.app, req.cookies.id)) { req.setTimeout(3000000); if (req.params.action == "edit") { logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/edit"); buildJob(req, res) .then(job => { webex.sendDirectMessage(job.senderId, job.message.raw, job.imageName, req.params.app) .then(async function (result) { jobs.building[buildingKey(req)].message.english = { "text": result.text, "markdown": result.markdown, "html": result.html } // Remember the preview DM's Webex message id so // /jobs/cancel can retract it and /jobs/runNow can // treat it as "already delivered" (future). if (result && result.id) { jobs.building[buildingKey(req)].previewMessageId = result.id; } await translator.buildTranslations(jobs.building[buildingKey(req)].message.english) .then(result => { jobs.building[buildingKey(req)].message = result; }) .catch(error => console.log("Error translating: " + error)); res.status(200).send(job) saveConfig(jobs, "./config/jobs.json") }) .catch(error => console.log("Error sending message: " + error)); }) .catch(error => console.log("Error buildJob: " + error)); } else if (req.params.action == "runNow") { logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/runNow"); jobs.running.push(jobs.building[buildingKey(req)]); delete jobs.building[buildingKey(req)]; jobsPipeline.processRunningQueue() .catch(error => console.log("sendRunningMessages error: " + error)) res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html"); } else if (req.params.action == "schedule") { logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/schedule"); jobs.scheduled.push(jobs.building[buildingKey(req)]); delete jobs.building[buildingKey(req)]; saveConfig(jobs, './config/jobs.json') res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html"); } else if (req.params.action == "cancel") { // Abandon the caller's in-progress "building" job for this bot. // A building entry exists as soon as the user clicks "Review & // send" (POST /jobs/edit) and lingers on the server until the // user promotes it to running/scheduled — or until now, where // this route just drops it on the floor. Idempotent: 200 whether // or not a building entry actually existed for (personId, app), // so the client can always fire this on "cancel" without needing // to check state first. // // If the building job has a previewMessageId (captured during // /jobs/edit), also retract that DM from the sender's own space // so the review message doesn't hang around after they discarded // the draft. Best-effort — a failed Webex delete is logged but // does not fail the cancel. logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/cancel"); var key = buildingKey(req); var job = jobs.building[key]; var hadJob = !!job; if (hadJob) { var previewId = job.previewMessageId; delete jobs.building[key]; try { saveConfig(jobs, './config/jobs.json'); } catch (err) { logger('apiEndpoint(' + req.params.app + ')', 'jobs/cancel save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to cancel job.'); } if (previewId) { // Fire-and-forget; we've already committed the cancel // to disk and we don't want a slow Webex round trip to // hold the response. webex.deleteMessage(previewId, req.params.app); } } return res.status(200).send({ cancelled: hadJob }); } else { res.status(404) } } else { res.status(401).send("You are not authorized.") } }) app.get('/CollabCentral/:app/jobs/list/:scope', (req, res) => { if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) { return res.status(401).send("You are not authorized."); } var appName = req.params.app; if (req.params.scope == "all") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/all"); return res.status(200).send({ building: jobs.building[buildingKey(req)], running: jobsForApp(jobs.running, appName), scheduled: jobsForApp(jobs.scheduled, appName), completed: jobsForApp(jobs.completed, appName) }); } if (req.params.scope == "building") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/building"); return res.status(200).send(jobs.building[buildingKey(req)]); } if (req.params.scope == "running") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/running"); var runningJobs = []; for (var job of jobsForApp(jobs.running, appName)) { var completedSends = 0; for (var member of (job.memberList || [])) { if (member.results) completedSends++; } runningJobs.push({ jobId: job.jobId, senderDisplayName: job.senderDisplayName, appName: job.appName, startTime: job.startTime, totalRecipients: (job.memberList || []).length, completedRecipients: completedSends }); } return res.status(200).send(runningJobs); } if (req.params.scope == "scheduled") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/scheduled"); var scheduledJobs = []; for (var job of jobsForApp(jobs.scheduled, appName)) { scheduledJobs.push({ senderDisplayName: job.senderDisplayName, appName: job.appName, message: job.message && job.message.english && job.message.english.html, totalRecipients: (job.memberList || []).length, scheduledFor: job.scheduledFor }); } return res.status(200).send(scheduledJobs); } if (req.params.scope == "completed") { logger("apiEndpoint(" + appName + ")", "GET /jobs/list/completed"); return res.status(200).send(jobsForApp(jobs.completed, appName)); } return res.status(404).send(); }); // Returns the full job object for a given jobId, but only if the job belongs // to the requested bot. Looks across completed/running/scheduled so the same // URL keeps working as a job moves through its lifecycle. app.get('/CollabCentral/:app/jobs/detail/:jobId', (req, res) => { if (!req.cookies || !isAuthorized(req.params.app, req.cookies.id)) { return res.status(401).send("You are not authorized."); } var appName = req.params.app; var jobId = req.params.jobId; logger("apiEndpoint(" + appName + ")", "GET /jobs/detail/" + jobId); var all = (jobs.completed || []).concat(jobs.running || [], jobs.scheduled || []); var match = all.find(function (j) { return j && j.appName === appName && String(j.jobId) === String(jobId); }); if (!match) return res.status(404).send("Job not found."); res.status(200).send(match); }); app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => { console.log("Got /user/:scope/:action request."); //console.log(req.cookies); if (isAuthorized(req.params.app, req.cookies.id)) { //console.log("Person is authorized for the request.") if (req.params.scope == "groups") { if (req.params.action == "list") { logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/list"); var listEntry = helpers.getAuthorizedEntry(authorized, req.params.app, req.cookies.id); res.status(200).send((listEntry && listEntry.groups) || []); } else if (req.params.action == "find") { logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find"); getGroupsFromCache() .then(function (groups) { res.status(200).send(groups); }) .catch(function (err) { logger('apiEndpoint(' + req.params.app + ')', 'groups/find failed: ' + (err && err.message || err)); res.status(502).send('Failed to load groups from Webex.'); }); } } else { res.status(404).send('Not found.'); } } else { res.status(401).send('Unauthorized.'); } }) // Add / remove a favorite group for the calling user. Favorites live inside // authorized.json under `bot[app][personId].groups` so they survive restarts // and are picklable in the compose page's Favorite Groups selector. Both // endpoints validate that: // - the caller is authorized for :app // - :id looks like a Webex SCIM group id we know about (present in the // cached org-wide group list) — this stops a bad actor from stuffing // arbitrary strings into the config. // Because index.js is single-process and node is single-threaded, concurrent // requests here can't corrupt authorized.json — every read/mutate/write // executes atomically within one handler. app.post('/CollabCentral/:app/user/groups/add', async function (req, res) { if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.'); var appName = req.params.app; var personId = req.cookies.id; var groupId = req.body && req.body.id; if (!groupId) return res.status(400).send('Missing group id.'); var group; try { var allGroups = await getGroupsFromCache(); group = allGroups.find(function (g) { return g.id === groupId; }); } catch (err) { logger('apiEndpoint(' + appName + ')', 'groups/add cache lookup failed: ' + (err && err.message || err)); return res.status(502).send('Group list unavailable.'); } if (!group) return res.status(404).send('Unknown group id.'); var entry = helpers.getAuthorizedEntry(authorized, appName, personId); // isAuthorized guarantees the entry exists but be defensive; a race // between an admin removal and this request could otherwise crash. if (!entry) return res.status(401).send('Unauthorized.'); var favorites = Array.isArray(entry.groups) ? entry.groups : []; if (favorites.find(function (g) { return g.id === groupId; })) { return res.status(200).send(favorites); } favorites.push({ name: group.displayName, id: group.id }); entry.groups = favorites; try { saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'groups/add save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to save favorite.'); } logger('apiEndpoint(' + appName + ')', 'groups/add ' + personId.slice(-8) + ' + "' + group.displayName + '"'); res.status(200).send(favorites); }); app.post('/CollabCentral/:app/user/groups/remove', function (req, res) { if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.'); var appName = req.params.app; var personId = req.cookies.id; var groupId = req.body && req.body.id; if (!groupId) return res.status(400).send('Missing group id.'); var entry = helpers.getAuthorizedEntry(authorized, appName, personId); if (!entry) return res.status(401).send('Unauthorized.'); var favorites = Array.isArray(entry.groups) ? entry.groups : []; var before = favorites.length; var removed = favorites.find(function (g) { return g.id === groupId; }); favorites = favorites.filter(function (g) { return g.id !== groupId; }); if (favorites.length === before) return res.status(200).send(favorites); entry.groups = favorites; try { saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'groups/remove save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to remove favorite.'); } logger('apiEndpoint(' + appName + ')', 'groups/remove ' + personId.slice(-8) + ' - "' + (removed && removed.name || groupId) + '"'); res.status(200).send(favorites); }); // ---- Admin: manage the authorized-user list for a bot --------------------- // // Admin authority lives in authorized.admins (personIds). Every admin route // rejects with 403 for non-admin callers — 403 rather than 401 so the client // can tell an admin-only route apart from a signed-out state (which would // 401). Admin actions are cross-bot in concept but the routes are still // :app-scoped because the resource being edited is per-bot (authorized users // on THAT bot) and it keeps the OAuth session model unchanged (admin uses // the same session cookie as any other page). // // requireAdmin returns null on success or an Express-response-sending // function on failure, so each handler stays a straight-line function. function requireAdmin(req, res) { if (!req.cookies || !req.cookies.id) return res.status(401).send('Unauthorized.'); if (!isAdmin(req.cookies.id)) return res.status(403).send('Admin only.'); return null; } // Shape of a single row returned by the admin users endpoints. Kept in one // helper so add / delete / list all produce identical output — the client // can treat every response as an authoritative "here's the current state". function adminUserRow(entry) { var groups = Array.isArray(entry && entry.groups) ? entry.groups : []; return { id: entry.id, displayName: entry.displayName || '', email: entry.email || '', avatar: entry.avatar || null, groupCount: groups.length }; } function adminUsersList(appName) { var bucket = (authorized.bot && authorized.bot[appName]) || {}; return Object.keys(bucket).map(function (id) { return adminUserRow(bucket[id]); }); } app.get('/CollabCentral/:app/admin/users', function (req, res) { if (requireAdmin(req, res)) return; logger('apiEndpoint(' + req.params.app + ')', 'GET /admin/users'); res.status(200).send(adminUsersList(req.params.app)); }); app.post('/CollabCentral/:app/admin/users', async function (req, res) { if (requireAdmin(req, res)) return; var appName = req.params.app; var email = req.body && req.body.email && String(req.body.email).trim(); if (!email) return res.status(400).send('Missing email.'); var person; try { person = await webex.findPersonByEmail(email); } catch (err) { logger('apiEndpoint(' + appName + ')', 'admin/users lookup failed for "' + email + '": ' + (err && err.message || err)); return res.status(502).send('Failed to reach Webex directory.'); } if (!person) return res.status(404).send('No Webex user found for that email.'); if (!authorized.bot[appName]) authorized.bot[appName] = {}; var bucket = authorized.bot[appName]; if (!bucket[person.id]) { bucket[person.id] = { id: person.id, displayName: person.displayName, email: person.email, avatar: person.avatar, groups: [] }; try { saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'admin/users save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to save.'); } logger('apiEndpoint(' + appName + ')', 'admin/users + "' + person.displayName + '" <' + person.email + '>'); } res.status(200).send({ user: adminUserRow(bucket[person.id]), users: adminUsersList(appName) }); }); app.delete('/CollabCentral/:app/admin/users/:id', function (req, res) { if (requireAdmin(req, res)) return; var appName = req.params.app; var targetId = req.params.id; var bucket = (authorized.bot && authorized.bot[appName]) || {}; if (!bucket[targetId]) { return res.status(200).send({ removed: false, users: adminUsersList(appName) }); } var name = bucket[targetId].displayName || targetId; delete bucket[targetId]; try { saveConfig(authorized, './config/authorized.json'); } catch (err) { logger('apiEndpoint(' + appName + ')', 'admin/users delete save failed: ' + (err && err.message || err)); return res.status(500).send('Failed to save.'); } logger('apiEndpoint(' + appName + ')', 'admin/users - "' + name + '"'); res.status(200).send({ removed: true, users: adminUsersList(appName) }); }); // Options for the session cookies set after a successful OAuth round-trip. // // httpOnly is deliberately false: js/app.js reads `id` (to decide whether to // redirect to OAuth) and `displayName` (to render the user chip in the header) // via document.cookie. Enabling httpOnly here hides the cookies from JS, which // causes an infinite auth loop where every page load thinks the user is not // signed in and kicks off a fresh OAuth exchange — burning Webex tokens until // the CTS token limit is reached. // // sameSite is 'lax' rather than 'strict' so the cookie survives the OAuth // redirect chain webexapis.com -> /oauth -> /sendMessage.html on all browsers. const SESSION_COOKIE_OPTIONS = { httpOnly: false, secure: true, sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000 }; app.get(`/CollabCentral/:app/oauth`, async function (req, res) { var appName = req.params.app; var authCode = req.query.code; if (!process.env.WEBEX_INTEGRATION_CLIENT_ID || !process.env.WEBEX_INTEGRATION_CLIENT_SECRET || !process.env.OAUTH_CALLBACK_URL_TEMPLATE) { logger("oauth", "Missing Webex integration env vars; cannot complete OAuth."); return res.status(500).send("OAuth is not configured on the server."); } if (!authCode) { return res.status(400).send("Missing OAuth `code` query parameter."); } var url = new URL("https://webexapis.com/v1/access_token"); var params = new URLSearchParams(); params.append('grant_type', 'authorization_code'); params.append('client_id', process.env.WEBEX_INTEGRATION_CLIENT_ID); params.append('client_secret', process.env.WEBEX_INTEGRATION_CLIENT_SECRET); params.append('code', authCode); params.append('redirect_uri', getOAuthRedirectUri(appName)); var response; try { response = await webex.fetchWithRateLimit(url, { method: 'POST', body: params }); } catch (error) { logger("oauth", "Token exchange fetch failed: " + (error && error.message || error)); return res.status(502).send("Failed to reach Webex to complete OAuth."); } if (!response.ok) { var errText = await response.text().catch(() => ''); logger("oauth", "Token exchange returned " + response.status + " " + response.statusText + " " + errText); return res.status(401).send(response.statusText || "OAuth token exchange failed."); } var jsonData = await response.json(); var whoami; try { whoami = await webex.whoAmI(jsonData.access_token); } catch (err) { logger("oauth", "whoAmI failed: " + (err && err.message || err)); return res.status(502).send("Failed to read your Webex profile after OAuth."); } logger("oauth", whoami.displayName + " successfully authed for " + appName + "."); // Only the two cookies that the server (req.cookies.id, .displayName) and // the client (getCookie('id'), getCookie('displayName')) actually read. // Access/refresh tokens deliberately stay out of the browser: they never // need to leave the server, and putting them in cookies would expose them // to any XSS that might slip in later. res .cookie('id', whoami.id, SESSION_COOKIE_OPTIONS) .cookie('displayName', whoami.displayName, SESSION_COOKIE_OPTIONS) .redirect(301, '/CollabCentral/' + appName + '/sendMessage.html'); }); function buildJob(req, res) { return new Promise(async function (resolve, reject) { res.setTimeout(3000000) var memberList = []; var memberListPromises = []; upload(req, res, async function (err) { //console.log(req.files); //console.log(req.body); jobs.building[buildingKey(req)] = { "appName": req.body.appName, "senderId": req.cookies.id, "senderDisplayName": req.cookies.displayName, "groups": req.body.groups, "message": { "raw": req.body.message, "english": {} }, "memberList": [] } // check for error thrown by multer- file size etc if (err || req.files === undefined) { //no file } else { if (req.files["uploadImage"]) { for (var file of req.files["uploadImage"]) { let fileName = uuid() + ".jpeg" var image = await sharp(file.buffer).jpeg({ quality: 40, }).toFile('./uploads/' + fileName).catch(err => { console.log('error: ', err) }) jobs.building[buildingKey(req)].imageName = fileName; logger("buildJob", "uploadImage " + file.originalname + " to " + fileName) } } if (req.files["uploadCSV"]) { for (var file of req.files["uploadCSV"]) { let fileName = uuid() + ".csv"; fs.writeFileSync('./uploads/' + fileName, file.buffer) jobs.building[buildingKey(req)].csv = fileName; logger("buildJob", "uploadCSV " + file.originalname + " to " + fileName) memberListPromises.push(jobsPipeline.buildPeopleList(fileName)); } } } if (req.body.groups) { logger("buildJob", "Locating groups: " + req.body.groups); memberListPromises.push(jobsPipeline.collectGroupMembers(req.body.groups)); } if (req.body.scheduledFor) { logger("buildJob", "Scheduling for " + new Date(req.body.scheduledFor).toLocaleString()) jobs.building[buildingKey(req)].scheduledFor = req.body.scheduledFor; saveConfig(jobs, "./config/jobs.json") } Promise.allSettled(memberListPromises) .then(peoplePromises => { console.log(JSON.stringify(peoplePromises)) for (var peopleList of peoplePromises) { if (peopleList.status == "fulfilled") { for (var person of peopleList.value) { memberList.push(person); } } } //Make user members unique var uniqueMembers = [...new Map(memberList.map((m) => [m.id, m])).values()]; jobs.building[buildingKey(req)].memberList = uniqueMembers; var numSending = uniqueMembers.length.toString(); saveConfig(jobs, "./config/jobs.json") resolve(jobs.building[buildingKey(req)]); }) }) }) } function saveConfig(jsonObject, configFile) { const jsonData = JSON.stringify(jsonObject, null, 4); fs.writeFileSync(configFile, jsonData, (err) => { if (err) { throw err; } else { logger("saveConfig", "Wrote " + configFile); } }) } // Retention window for completed jobs (re-exported from lib/helpers.js so // index.js has a single symbol name callers can reason about). Bump this by // editing helpers.js if you want a longer / shorter review window. const COMPLETED_RETENTION_DAYS = helpers.COMPLETED_RETENTION_DAYS; async function cleanCompletedJobs(jobs) { var result = helpers.cleanCompletedJobs(jobs, COMPLETED_RETENTION_DAYS); if (!Array.isArray(result.jobs.completed)) { logger('cleanCompletedJobs', 'No completed array found – nothing to do.'); } else { logger('cleanCompletedJobs', 'Removed ' + result.removed + ' job(s) older than ' + COMPLETED_RETENTION_DAYS + ' days (cutoff ' + result.cutoff.toISOString().split('T')[0] + '). Remaining: ' + result.jobs.completed.length); } return result.jobs; } function isAuthorized(appName, personId) { logger("isAuthorized", appName + " " + personId) return helpers.isAuthorized(config, botTokens, authorized, appName, personId); } function isAdmin(personId) { return helpers.isAdmin(authorized, personId); } function logger(activeFunction, logLine) { var d = new Date(); console.log(d.toLocaleString() + " " + activeFunction + ": " + logLine); } // gracefully shutdown (ctrl-c) process.on('SIGINT', function () { server.close(() => { logger("shutdown", config.server.name + ' stopped!') process.exit(); }); })