index.js had grown to 1,642 lines / ~50 functions. This peels the three biggest self-contained concerns out into their own modules and wires them back in through a dependency-bag factory so each module stays free of module-level mutable state. lib/webex.js — every webexapis.com round-trip (fetchWithRateLimit, whoAmI, findWebexGroup, getGroupMembers, getPersonInfo, findPersonByEmail, sendDirectMessage, sendMessageWithRetry, sendDirectCard, deleteMessage, refreshToken). Factory closes over token getters and the logger. lib/translation.js — Google Translate fan-out (buildTranslations, translateMessage). Reuses the webex 429 helper so the app has one retry policy. lib/jobs.js — cron-driven pipeline (checkScheduledJobs, processRunningQueue, sendQueueJobMessages, buildJobCompletedCard) plus the two recipient-resolution helpers (collectGroupMembers, buildPeopleList). Factory takes jobs/queue/userPrefs/webex/etc so mutable state stays owned by index.js. index.js: instantiates webex/translator/jobsPipeline once, rewires every call site to go through them, and drops ~715 lines of moved code plus a dead msToTime wrapper. Down from 1,642 → 969 lines, 26 top-level functions instead of 50+. Tests: 53/53 helpers still green. Smoke tested /info, /admin/users, /user/groups/list, /user/groups/find, /jobs/list/all, and the unauth 401 path — all match pre-R2 behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
969 lines
41 KiB
JavaScript
969 lines
41 KiB
JavaScript
#!/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: { <appName>: { <personId>: { ...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:
|
||
// { "<appName>": { "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();
|
||
});
|
||
})
|