diff --git a/src/controllers/controller/controller.ts b/src/controllers/controller/controller.ts index 22a34a3..45b28ce 100644 --- a/src/controllers/controller/controller.ts +++ b/src/controllers/controller/controller.ts @@ -277,6 +277,8 @@ router.get( 'Deleted Exam', 'Assigned Exam', 'Deleted Exam Attempt', + 'Updated Discord Configuration', + 'Sent Discord Message', ]); } catch (e) { return next(e); diff --git a/src/controllers/discord/bot.ts b/src/controllers/discord/bot.ts new file mode 100644 index 0000000..72dec2c --- /dev/null +++ b/src/controllers/discord/bot.ts @@ -0,0 +1,410 @@ +import { Router, type NextFunction, type Request, type Response } from 'express'; +import { getCacheInstance } from '../../app.js'; +import discord from '../../helpers/discord.js'; +import { throwBadRequestException, throwForbiddenException } from '../../helpers/errors.js'; +import zau from '../../helpers/zau.js'; +import { isSeniorStaff, userOrInternalJwt } from '../../middleware/auth.js'; +import { jwtInternalAuth } from '../../middleware/internalAuth.js'; +import getUser from '../../middleware/user.js'; +import { ControllerHoursModel } from '../../models/controllerHours.js'; +import { DiscordConfigModel } from '../../models/discordConfig.js'; +import { ACTION_TYPE, DossierModel } from '../../models/dossier.js'; +import { UserModel } from '../../models/user.js'; +import status from '../../types/status.js'; + +const router = Router(); + +router.get('/users', jwtInternalAuth, async (_req: Request, res: Response, next: NextFunction) => { + try { + const users = await UserModel.find({ discordInfo: { $ne: null } }) + .select('fname lname cid discordInfo roleCodes oi rating member vis') + .exec(); + + return res.status(status.OK).json(users); + } catch (e) { + return next(e); + } +}); + +router.get( + '/user/:id', + jwtInternalAuth, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + if (!id || id.trim() === '') { + throwBadRequestException('Invalid request'); + } + + const user = await UserModel.findOne({ discord: id }) + .select('fname lname cid discordInfo roleCodes oi rating member vis') + .exec(); + + if (!user) { + throwBadRequestException('User not found'); + } + + return res.status(status.OK).json(user); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/ironmic', + jwtInternalAuth, + async (_req: Request, res: Response, next: NextFunction) => { + try { + const results = await ControllerHoursModel.aggregate([ + { + $match: { + $and: [ + { + timeStart: { $gte: zau.activity.period.startOfCurrent }, + }, + { timeStart: { $lte: zau.activity.period.endOfCurrent } }, + ], + position: { $not: /OBS/ }, + }, + }, + { + $lookup: { + from: 'users', + localField: 'cid', + foreignField: 'cid', + as: 'userDetails', + }, + }, + { + $unwind: { + path: '$userDetails', + preserveNullAndEmptyArrays: false, + }, + }, + { + $match: { + 'userDetails.member': true, + 'userDetails.vis': false, + }, + }, + { + $group: { + _id: '$cid', + fname: { $first: '$userDetails.fname' }, + lname: { $first: '$userDetails.lname' }, + rating: { $first: '$userDetails.rating' }, + totalSeconds: { + $sum: { + $dateDiff: { + startDate: '$timeStart', + endDate: '$timeEnd', + unit: 'second', + }, + }, + }, + }, + }, + { + $project: { + _id: 0, + controller: '$_id', + totalSeconds: 1, + fname: 1, + lname: 1, + rating: 1, + }, + }, + ]) + .cache('5 minutes') + .exec(); + + const center = []; + const approach = []; + const tower = []; + const ground = []; + + for (const result of results) { + if (result.rating >= 5) { + center.push(result); + } else if (result.rating === 4) { + approach.push(result); + } else if (result.rating === 3) { + tower.push(result); + } else if (result.rating === 2) { + ground.push(result); + } + } + + center.sort((a, b) => b.totalSeconds - a.totalSeconds); + approach.sort((a, b) => b.totalSeconds - a.totalSeconds); + tower.sort((a, b) => b.totalSeconds - a.totalSeconds); + ground.sort((a, b) => b.totalSeconds - a.totalSeconds); + return res.status(status.OK).json({ + results: { + center: center.slice(0, 3), + approach: approach.slice(0, 3), + tower: tower.slice(0, 3), + ground: ground.slice(0, 3), + }, + period: zau.activity.period, + }); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/configs', + userOrInternalJwt, + async (req: Request, res: Response, next: NextFunction) => { + try { + if ((req.user && !req.user.isSeniorStaff) || req.internal === false) { + throwForbiddenException('Forbidden'); + } + + const configs = await DiscordConfigModel.find({ type: 'discord' }) + .cache('6 hours', 'discord-configs') + .exec(); + return res.status(status.OK).json(configs); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/config/:id', + userOrInternalJwt, + async (req: Request, res: Response, next: NextFunction) => { + try { + if ((req.user && !req.user.isSeniorStaff) || req.internal === false) { + throwForbiddenException('Forbidden'); + } + + const { id } = req.params; + if (!id || id === 'undefined') { + throwBadRequestException('Invalid request'); + } + + const config = await DiscordConfigModel.findOne({ type: 'discord', id: id }) + .cache('6 hours', `discord-config-${id}`) + .exec(); + if (!config) { + return res.status(status.OK).json({ + id, + type: 'discord', + repostChannels: {}, + managedRoles: [], + ironMic: { channelId: '', messageId: '' }, + onlineControllers: { channelId: '', messageId: '' }, + cleanupChannels: {}, + }); + } + + return res.status(status.OK).json(config); + } catch (e) { + return next(e); + } + }, +); + +router.put( + '/config/:id', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + if (!id || id === 'undefined') { + throwBadRequestException('Invalid request'); + } + + const config = req.body; + if (!config) { + throwBadRequestException('Invalid request'); + } + + await DiscordConfigModel.findOneAndUpdate({ type: 'discord', id: id }, config, { + upsert: true, + }).exec(); + + await getCacheInstance().clear(`discord-config-${id}`); + + await DossierModel.create({ + by: req.user.cid, + affected: -1, + action: `Updated Discord Configuration for server ${id}`, + actionType: ACTION_TYPE.UPDATE_DISCORD_CONFIG, + }); + + return res.status(status.OK).json(); + } catch (e) { + return next(e); + } + }, +); + +router.patch( + '/config/:id', + jwtInternalAuth, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { id } = req.params; + if (!id || id === 'undefined') { + throwBadRequestException('Invalid request'); + } + + const { ironMic, onlineControllers } = req.body; + if (!ironMic || !onlineControllers) { + throwBadRequestException('Invalid request'); + } + + const config = await DiscordConfigModel.findOne({ type: 'discord', id: id }) + .cache('6 hours', `discord-config-${id}`) + .exec(); + if (!config) { + throwBadRequestException('Invalid request'); + } + + config.ironMic.messageId = ironMic; + config.onlineControllers.messageId = onlineControllers; + const updated = await config.save(); + + await getCacheInstance().clear(`discord-config-${id}`); + + return res.status(status.OK).json(updated); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/all-channels', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { guildId } = req.query; + if (!guildId || guildId === 'undefined') { + throwBadRequestException('Invalid request'); + } + + const channels = await discord.getAllTextChannels(guildId as string); + return res.status(status.OK).json(channels); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/all-roles', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { guildId } = req.query; + if (!guildId || guildId === 'undefined') { + throwBadRequestException('Invalid request'); + } + + const roles = await discord.getAllRoles(guildId as string); + return res.status(status.OK).json(roles); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/message-content', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { channelId, messageId } = req.query; + if (!channelId || !messageId) { + throwBadRequestException('Invalid request'); + } + + const content = await discord.getMessageContent(channelId as string, messageId as string); + return res.status(status.OK).json(content); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/all-messages', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { channelId } = req.query; + if (!channelId) { + throwBadRequestException('Invalid request'); + } + + const messages = await discord.getAllMessages(channelId as string); + return res.status(status.OK).json(messages); + } catch (e) { + return next(e); + } + }, +); + +router.get( + '/all-guilds', + getUser, + isSeniorStaff, + async (_req: Request, res: Response, next: NextFunction) => { + try { + const guilds = await discord.getAllGuilds(); + return res.status(status.OK).json(guilds); + } catch (e) { + return next(e); + } + }, +); + +router.post( + '/send-message', + getUser, + isSeniorStaff, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { channelId, content } = req.body; + if (!channelId || !content) { + throwBadRequestException('Invalid request'); + } + + await discord.sendMessage(channelId, { content }); + + DossierModel.create({ + by: req.user.cid, + affected: -1, + action: `Sent a Discord Message to ${channelId}`, + actionType: ACTION_TYPE.SEND_DISCORD_MESSAGE, + }); + + try { + // Post to the logs channel + await discord.sendMessage('1234367676240105493', { + content: `**${req.user.name}** sent a message in <#${channelId}> with the following content:\n\n${content.length > 1500 ? content.slice(0, 1500) + '...' : content}`, + }); + } catch (e) { + // Do nothing + } + + return res.status(status.OK).json(); + } catch (e) { + return next(e); + } + }, +); + +export default router; diff --git a/src/controllers/discord/discord.ts b/src/controllers/discord/discord.ts index 99bc70c..80884eb 100644 --- a/src/controllers/discord/discord.ts +++ b/src/controllers/discord/discord.ts @@ -6,26 +6,16 @@ import { throwInternalServerErrorException, throwUnauthorizedException, } from '../../helpers/errors.js'; -import internalAuth from '../../middleware/internalAuth.js'; import getUser from '../../middleware/user.js'; import { ACTION_TYPE, DossierModel } from '../../models/dossier.js'; import { UserModel } from '../../models/user.js'; import status from '../../types/status.js'; import { clearUserCache } from '../controller/utils.js'; +import discordBotRouter from './bot.js'; const router = Router(); -router.get('/users', internalAuth, async (_req: Request, res: Response, next: NextFunction) => { - try { - const users = await UserModel.find({ discordInfo: { $ne: null } }) - .select('fname lname cid discordInfo roleCodes oi rating member vis') - .exec(); - - return res.status(status.OK).json(users); - } catch (e) { - return next(e); - } -}); +router.use('/bot', discordBotRouter); router.get('/user', getUser, async (req: Request, res: Response, next: NextFunction) => { try { diff --git a/src/controllers/user/user.ts b/src/controllers/user/user.ts index b39726c..de7a8ca 100644 --- a/src/controllers/user/user.ts +++ b/src/controllers/user/user.ts @@ -256,7 +256,9 @@ router.post('/login', oAuth, async (req: Request, res: Response, next: NextFunct router.get('/logout', getUser, async (req: Request, res: Response, next: NextFunction) => { try { - if (!req.cookies['token']) { + const cookie = zau.isProd ? 'token' : 'dev-token'; + + if (!req.cookies[cookie]) { throwUnauthorizedException('Not Logged In'); } diff --git a/src/helpers/discord.ts b/src/helpers/discord.ts index 44aa21f..a20e9ce 100644 --- a/src/helpers/discord.ts +++ b/src/helpers/discord.ts @@ -57,7 +57,113 @@ async function getCurrentUser(tokenType: string, accessToken: string) { } } +async function getAllTextChannels(guildId: string) { + try { + const channels = await discord.get(`/guilds/${guildId}/channels`); + + const textChannels = channels.data.filter( + (channel: any) => channel.type === 0 || channel.type === 5, + ); + + const categoryMap = new Map( + channels.data.filter((c: any) => c.type === 4).map((cat: any) => [cat.id, cat.name]), + ); + + return textChannels.map((c: any) => ({ + id: c.id, + name: c.name, + topic: c.topic, + parent: c.parent_id, + parentName: categoryMap.get(c.parent_id) || 'No Category', + lastMessage: c.last_message_id, + })); + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + +async function getAllRoles(guildId: string) { + try { + const roles = await discord.get(`/guilds/${guildId}/roles`); + + return roles.data.map((r: any) => ({ id: r.id, name: r.name })); + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + +async function getMessageContent(channelId: string, messageId: string) { + try { + const message = await discord.get(`/channels/${channelId}/messages/${messageId}`); + + return { + author: message.data.author, + content: message.data.content, + }; + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + +async function getAllMessages(channelId: string) { + try { + const messages = await discord.get(`/channels/${channelId}/messages?limit=100`); + + return messages.data.map((message: any) => ({ + id: message.id, + author: message.author, + content: message.content, + })); + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + +async function getAllGuilds() { + try { + const guilds = await discord.get('/users/@me/guilds'); + + return guilds.data.map((g: any) => ({ id: g.id, name: g.name })); + } catch (e) { + if (axios.isAxiosError(e) && e.response) { + console.error(`Failed to get all text channels. Discord API Error:`, e.response.data); + throw new Error(`Discord API responded with status ${e.response.status}`); + } + console.error(`An unknown error occurred while getting all text channels:`, e); + throw e; + } +} + export default { sendMessage, getCurrentUser, + getAllTextChannels, + getAllRoles, + getMessageContent, + getAllMessages, + getAllGuilds, }; diff --git a/src/helpers/zau.ts b/src/helpers/zau.ts index 6272341..786f442 100644 --- a/src/helpers/zau.ts +++ b/src/helpers/zau.ts @@ -39,6 +39,7 @@ const activity = { unit: periodUnitName, periodsInYear, periodLength, + currentPeriod: getPeriodFromDate(new Date()), startOfCurrent: getPeriodStartFromDate(), endOfCurrent: getPeriodEndFromDate(), periodStartFromDate: function (date: Date = new Date()) { diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 8b09c99..c923620 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,7 +1,7 @@ import { captureMessage } from '@sentry/node'; import type { NextFunction, Request, Response } from 'express'; import status from '../types/status.js'; -import { isKeyValid } from './internalAuth.js'; +import { isJwtValid, isKeyValid } from './internalAuth.js'; import { isUserValid } from './user.js'; export async function userOrInternal(req: Request, res: Response, next: NextFunction) { @@ -16,6 +16,18 @@ export async function userOrInternal(req: Request, res: Response, next: NextFunc return res.status(status.FORBIDDEN).json(); } +export async function userOrInternalJwt(req: Request, res: Response, next: NextFunction) { + if (await isUserValid(req)) { + return next(); + } + + if (isJwtValid(req)) { + return next(); + } + + return res.status(status.FORBIDDEN).json(); +} + export function hasRole(roles: string[]) { return function (req: Request, res: Response, next: NextFunction) { if (!req.user) { diff --git a/src/middleware/internalAuth.ts b/src/middleware/internalAuth.ts index 012716f..77f241e 100644 --- a/src/middleware/internalAuth.ts +++ b/src/middleware/internalAuth.ts @@ -1,8 +1,16 @@ import * as Sentry from '@sentry/node'; import { captureMessage } from '@sentry/node'; import type { NextFunction, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import type { IApplication } from '../types/CustomRequest.js'; import status from '../types/status.js'; +interface InternalAuthPayload { + sub: string; + iat: number; + exp: number; +} + export default function (req: Request, res: Response, next: NextFunction) { setupSentry(req); @@ -13,6 +21,78 @@ export default function (req: Request, res: Response, next: NextFunction) { return res.status(status.FORBIDDEN).json(); } +export function jwtInternalAuth(req: Request, res: Response, next: NextFunction) { + if (!isJwtValid(req)) { + setupSentry(req); + + return res.status(status.FORBIDDEN).json(); + } + + setupSentry(req); + + return next(); +} + +export function isJwtValid(req: Request): boolean { + if (!process.env['MICRO_ACCESS_KEY']) { + captureMessage('MICRO_ACCESS_KEY not set.'); + + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } + + const key = req.headers.authorization?.replace('Bearer ', ''); + + if (!key) { + captureMessage('Attempted access to an internal protected route'); + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } + + try { + const decoded = jwt.verify(key, process.env['MICRO_ACCESS_KEY']) as InternalAuthPayload; + + if (decoded.exp && decoded.iat) { + const lifespan = decoded.exp - decoded.iat; + const age = Math.floor(Date.now() / 1000) - decoded.iat; + + if (lifespan > 60) { + captureMessage('Attempted access to an internal protected route with a short-lived key'); + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } + + if (age > 65) { + captureMessage( + 'Attempted access to an internal protected route with a key that is too old', + ); + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } + } + + req.application = { + name: decoded.sub, + } as unknown as IApplication; + req.internal = true; + + return true; + } catch (e) { + req.internal = false; + req.application = null as unknown as IApplication; + + return false; + } +} + export function isKeyValid(req: Request): boolean { if (!process.env['MICRO_ACCESS_KEY']) { captureMessage('MICRO_ACCESS_KEY not set.'); @@ -48,7 +128,7 @@ function setupSentry(req: Request) { if (req.user) { user.id = -1; - user.username = `Internal Application`; + user.username = req.application ? req.application.name : `Internal Application`; } Sentry.setUser(user); diff --git a/src/models/discordConfig.ts b/src/models/discordConfig.ts new file mode 100644 index 0000000..8b53331 --- /dev/null +++ b/src/models/discordConfig.ts @@ -0,0 +1,60 @@ +import { Document, model, Schema } from 'mongoose'; + +interface IUpdatableMessage { + channelId: string; + messageId: string; +} + +interface IManagedRole { + key: string; + roleId: string; +} + +interface IDiscordConfig extends Document { + id: string; + type: string; + repostChannels: object; + managedRoles: IManagedRole[]; + ironMic: IUpdatableMessage; + onlineControllers: IUpdatableMessage; + cleanupChannels: object; +} + +const IronMicSchema = new Schema( + { + channelId: { type: String, required: true }, + messageId: { type: String, required: true }, + }, + { _id: false }, +); + +const OnlineControllersSchema = new Schema( + { + channelId: { type: String, required: true }, + messageId: { type: String, required: true }, + }, + { _id: false }, +); + +const ManagedRoleSchema = new Schema( + { + key: { type: String, required: true }, + roleId: { type: String, required: true }, + }, + { _id: false }, +); + +const DiscordConfigSchema = new Schema( + { + id: { type: String, required: true }, + type: { type: String, required: true, default: 'discord' }, + repostChannels: { type: Object }, + managedRoles: [ManagedRoleSchema], + ironMic: IronMicSchema, + onlineControllers: OnlineControllersSchema, + cleanupChannels: { type: Object }, + }, + { collection: 'config' }, +); + +export const DiscordConfigModel = model('DiscordConfig', DiscordConfigSchema); diff --git a/src/models/dossier.ts b/src/models/dossier.ts index 2ae567a..12b1c92 100644 --- a/src/models/dossier.ts +++ b/src/models/dossier.ts @@ -56,6 +56,8 @@ export const ACTION_TYPE = { DELETE_EXAM: 50, ASSIGN_EXAM: 51, DELETE_EXAM_ATTEMPT: 52, + UPDATE_DISCORD_CONFIG: 53, + SEND_DISCORD_MESSAGE: 54, } as const; interface IDossier extends Document, ITimestamps { diff --git a/src/types/CustomRequest.ts b/src/types/CustomRequest.ts index 1a7f8b0..190edbf 100644 --- a/src/types/CustomRequest.ts +++ b/src/types/CustomRequest.ts @@ -5,3 +5,7 @@ export interface OauthRequest { refresh_token: string; scopes: string[]; } + +export interface IApplication { + name: string; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index c8d209c..fdcdb24 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,6 +1,6 @@ import { Redis } from 'ioredis'; import type { IUser } from 'models/user.ts'; -import type { OauthRequest } from 'types/CustomRequest.ts'; +import type { IApplication, OauthRequest } from 'types/CustomRequest.ts'; // Extend the Express Application interface declare global { @@ -20,6 +20,7 @@ declare global { user: IUser; oauth: OauthRequest; internal: boolean; + application: IApplication; } } }