diff --git a/index.js b/index.js index d68b55a..e1487c4 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,11 @@ 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'; +import { registerInfoRoutes } from './routes/info.js'; +import { registerAuthRoutes } from './routes/auth.js'; +import { registerUserRoutes } from './routes/user.js'; +import { registerAdminRoutes } from './routes/admin.js'; +import { registerJobsRoutes } from './routes/jobs.js'; const queue = new PQueue({ concurrency: 10 }); @@ -351,572 +356,55 @@ cron.schedule('0 0 3 * * *', function () { 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!'" }); +// Route registrations. Each module receives a dep-bag so the routes +// themselves stay decoupled from the concrete state/utilities defined +// here. Order matters only for /oauth (must come after requireBot). +registerInfoRoutes(app, { + getBotProfile: getBotProfile, + isAuthorized: isAuthorized, + isAdmin: isAdmin, }); -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); +registerAuthRoutes(app, { + buildAuthUrl: buildAuthUrl, + getOAuthRedirectUri: getOAuthRedirectUri, + webex: webex, + env: process.env, + logger: logger, }); -// 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) - }); +registerUserRoutes(app, { + isAuthorized: isAuthorized, + authorized: authorized, + helpers: helpers, + getGroupsFromCache: getGroupsFromCache, + saveConfig: saveConfig, + logger: logger, }); -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(); +registerAdminRoutes(app, { + isAdmin: isAdmin, + authorized: authorized, + webex: webex, + saveConfig: saveConfig, + logger: logger, }); -// 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); +registerJobsRoutes(app, { + jobs: jobs, + isAuthorized: isAuthorized, + helpers: helpers, + webex: webex, + translator: translator, + jobsPipeline: jobsPipeline, + saveConfig: saveConfig, + upload: upload, + sharp: sharp, + uuid: uuid, + fs: fs, + logger: logger, }); -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) => { diff --git a/routes/admin.js b/routes/admin.js new file mode 100644 index 0000000..328bf67 --- /dev/null +++ b/routes/admin.js @@ -0,0 +1,124 @@ +// Admin endpoints for managing a bot's authorized-user list: +// GET /CollabCentral/:app/admin/users — list +// POST /CollabCentral/:app/admin/users — add (by email) +// DELETE /CollabCentral/:app/admin/users/:id — remove +// +// 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). +// +// registerAdminRoutes(app, { isAdmin, authorized, webex, saveConfig, +// logger }) + +// requireAdmin returns null on success or an Express-response-sending +// function on failure, so each handler stays a straight-line function. +function makeRequireAdmin(isAdmin) { + return 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 + }; +} + +export function registerAdminRoutes(app, deps) { + var isAdmin = deps.isAdmin; + var authorized = deps.authorized; + var webex = deps.webex; + var saveConfig = deps.saveConfig; + var logger = deps.logger || function () {}; + + var requireAdmin = makeRequireAdmin(isAdmin); + + 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) }); + }); +} diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..b4538bf --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,102 @@ +// OAuth endpoints. +// GET /CollabCentral/:app/authUrl — returns the Webex authorize URL +// the browser should redirect to. +// GET /CollabCentral/:app/oauth — Webex redirects the user back to +// this URL with a `code` query +// param; we exchange it for tokens +// and set the `id`+`displayName` +// session cookies. +// +// registerAuthRoutes(app, { buildAuthUrl, getOAuthRedirectUri, webex, +// env, logger }) + +// 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 +}; + +export function registerAuthRoutes(app, deps) { + var buildAuthUrl = deps.buildAuthUrl; + var getOAuthRedirectUri = deps.getOAuthRedirectUri; + var webex = deps.webex; + var env = deps.env || {}; + var logger = deps.logger || function () {}; + + 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); + }); + + app.get('/CollabCentral/:app/oauth', async function (req, res) { + var appName = req.params.app; + var authCode = req.query.code; + + if (!env.WEBEX_INTEGRATION_CLIENT_ID || !env.WEBEX_INTEGRATION_CLIENT_SECRET || !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', env.WEBEX_INTEGRATION_CLIENT_ID); + params.append('client_secret', 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'); + }); +} diff --git a/routes/info.js b/routes/info.js new file mode 100644 index 0000000..e079573 --- /dev/null +++ b/routes/info.js @@ -0,0 +1,39 @@ +// Two "no state to speak of" endpoints: +// GET /status — bare liveness probe (no auth). +// GET /CollabCentral/:app/info — bot metadata + session flags used +// by the frontend bootstrap to decide +// which page shell to render. +// +// registerInfoRoutes(app, { getBotProfile, isAuthorized, isAdmin }) + +export function registerInfoRoutes(app, deps) { + var getBotProfile = deps.getBotProfile; + var isAuthorized = deps.isAuthorized; + var isAdmin = deps.isAdmin; + + app.get('/status', function (req, res) { + res.status(200).send({ status: "I'm alive!'" }); + }); + + // 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) + }); + }); +} diff --git a/routes/jobs.js b/routes/jobs.js new file mode 100644 index 0000000..d31d236 --- /dev/null +++ b/routes/jobs.js @@ -0,0 +1,286 @@ +// Jobs endpoints — the core of the app: +// POST /CollabCentral/:app/jobs/:action — edit / runNow / schedule / +// cancel +// GET /CollabCentral/:app/jobs/list/:scope +// GET /CollabCentral/:app/jobs/detail/:jobId +// +// registerJobsRoutes(app, { jobs, isAuthorized, helpers, webex, +// translator, jobsPipeline, saveConfig, upload, +// sharp, uuid, fs, logger }) +// +// Building (draft) jobs are keyed by cookieId + appName so a user +// authorized on multiple bots can have one independent draft per bot. + +export function registerJobsRoutes(app, deps) { + var jobs = deps.jobs; + var isAuthorized = deps.isAuthorized; + var helpers = deps.helpers; + var webex = deps.webex; + var translator = deps.translator; + var jobsPipeline = deps.jobsPipeline; + var saveConfig = deps.saveConfig; + var upload = deps.upload; + var sharp = deps.sharp; + var uuid = deps.uuid; + var fs = deps.fs; + var logger = deps.logger || function () {}; + + function buildingKey(req) { + return helpers.buildingKey(req.cookies && req.cookies.id, req.params.app); + } + + // Takes the multipart/form-data submitted by the compose page and + // fans it into a `building` job entry: any uploaded image gets JPEG- + // compressed to ./uploads/, any uploaded CSV gets parsed into a + // recipient list, and any group ids get expanded through Webex. + // Resolves with the fully-formed building job entry once every side + // effect settles. + function buildJob(req, res) { + return new Promise(function (resolve) { + res.setTimeout(3000000); + var memberList = []; + var memberListPromises = []; + + upload(req, res, async function (err) { + 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: [] + }; + + if (!err && req.files) { + if (req.files['uploadImage']) { + for (var file of req.files['uploadImage']) { + let fileName = uuid() + '.jpeg'; + await sharp(file.buffer).jpeg({ quality: 40 }).toFile('./uploads/' + fileName) + .catch(err => logger('buildJob', 'sharp 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(function (peoplePromises) { + for (var peopleList of peoplePromises) { + if (peopleList.status === 'fulfilled') { + for (var person of peopleList.value) { + memberList.push(person); + } + } + } + var uniqueMembers = [...new Map(memberList.map(m => [m.id, m])).values()]; + jobs.building[buildingKey(req)].memberList = uniqueMembers; + saveConfig(jobs, './config/jobs.json'); + resolve(jobs.building[buildingKey(req)]); + }); + }); + }); + } + + app.post('/CollabCentral/:app/jobs/:action', function (req, res) { + if (!isAuthorized(req.params.app, req.cookies.id)) { + return res.status(401).send('You are not authorized.'); + } + req.setTimeout(3000000); + + if (req.params.action === 'edit') { + logger('apiEndpoint(' + req.params.app + ')', 'POST /jobs/edit'); + return 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(t => { jobs.building[buildingKey(req)].message = t; }) + .catch(error => logger('jobs/edit', 'Error translating: ' + error)); + + res.status(200).send(job); + saveConfig(jobs, './config/jobs.json'); + }) + .catch(error => logger('jobs/edit', 'Error sending message: ' + error)); + }) + .catch(error => logger('jobs/edit', 'Error buildJob: ' + error)); + } + + 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 => logger('jobs/runNow', 'sendRunningMessages error: ' + error)); + return res.status(204).redirect('/CollabCentral/' + req.params.app + '/monitorJobs.html'); + } + + 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'); + return res.status(204).redirect('/CollabCentral/' + req.params.app + '/monitorJobs.html'); + } + + 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 }); + } + + return res.status(404).send('Unknown jobs action.'); + }); + + app.get('/CollabCentral/:app/jobs/list/:scope', function (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: helpers.jobsForApp(jobs.running, appName), + scheduled: helpers.jobsForApp(jobs.scheduled, appName), + completed: helpers.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 helpers.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 helpers.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(helpers.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', function (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); + }); +} diff --git a/routes/user.js b/routes/user.js new file mode 100644 index 0000000..e0d74c9 --- /dev/null +++ b/routes/user.js @@ -0,0 +1,125 @@ +// Per-user data endpoints: +// GET /CollabCentral/:app/user/groups/list — this user's favorites +// GET /CollabCentral/:app/user/groups/find — org-wide groups +// (cached) +// POST /CollabCentral/:app/user/groups/add — save a favorite +// POST /CollabCentral/:app/user/groups/remove — drop a favorite +// +// All four require the caller to be authorized on :app. Favorites live +// in the mutable `authorized` doc (config/authorized.json) so they +// survive restarts and are picklable in the compose page's Favorite +// Groups selector. +// +// registerUserRoutes(app, { isAuthorized, authorized, helpers, +// getGroupsFromCache, saveConfig, logger }) + +export function registerUserRoutes(app, deps) { + var isAuthorized = deps.isAuthorized; + var authorized = deps.authorized; + var helpers = deps.helpers; + var getGroupsFromCache = deps.getGroupsFromCache; + var saveConfig = deps.saveConfig; + var logger = deps.logger || function () {}; + + app.get('/CollabCentral/:app/user/:scope/:action', function (req, res) { + if (!isAuthorized(req.params.app, req.cookies.id)) { + return res.status(401).send('Unauthorized.'); + } + if (req.params.scope !== 'groups') { + return res.status(404).send('Not found.'); + } + 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); + return res.status(200).send((listEntry && listEntry.groups) || []); + } + if (req.params.action === 'find') { + logger('apiEndpoint(' + req.params.app + ')', 'GET /user/groups/find'); + return 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.'); + }); + } + return res.status(404).send('Not found.'); + }); + + // Add / remove a favorite group for the calling user. Validation: + // - the caller is authorized for :app + // - :id looks like a Webex SCIM group id we know about (present in + // the cached org-wide list) — stops bad actors from stuffing + // arbitrary strings into the store. + // 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); + }); +}