dce-expresskit
Version:
Shared functions, helpers, and tools for Harvard DCE Express-based servers
969 lines (897 loc) • 29.4 kB
text/typescript
// Import dce-reactkit
import {
getTimeInfoInET,
LogFunction,
Log,
LogType,
LogTypeSpecificInfo,
LogMainInfo,
LogSourceSpecificInfo,
LogBuiltInMetadata,
LogAction,
LogLevel,
ParamType,
ReactKitErrorCode,
LogSource,
} from 'dce-reactkit';
// Import caccl
import { getLaunchInfo } from 'caccl/server';
// Import caccl functions
import initExpressKitCollections, { internalGetLogCollection, internalGetSelectAdminCollection } from './initExpressKitCollections';
// Import shared types
import ExpressKitErrorCode from '../types/ExpressKitErrorCode';
// Import helpers
import handleError from './handleError';
import handleSuccess from './handleSuccess';
import genErrorPage from '../html/genErrorPage';
import genInfoPage from '../html/genInfoPage';
import parseUserAgent from './parseUserAgent';
import { validateSignedRequest } from './dataSigner';
/**
* Generate an express API route handler
* @author Gabe Abrams
* @param opts object containing all arguments
* @param opts.paramTypes map containing the types for each parameter that is
* included in the request (map: param name => type)
* @param opts.handler function that processes the request
* @param [opts.crossServerScope] the scope associated with this endpoint.
* If defined, this is a cross-server endpoint, which will never
* have any launch data, will never check Canvas roles or launch status, and will
* instead use scopes and reactkit credentials to sign and validate requests.
* Never start the path with /api/ttm or /api/admin if the endpoint is a cross-server
* endpoint because those roles will not be validated
* @param [opts.skipSessionCheck=true if crossServerScope defined] if true, skip
* the session check (allow users to not be logged in and launched via LTI).
* If crossServerScope is defined, this is always true
* @param [opts.unhandledErrorMessagePrefix] if included, when an error that
* is not of type ErrorWithCode is thrown, the client will receive an error
* where the error message is prefixed with this string. For example,
* if unhandledErrorMessagePrefix is
* 'While saving progress, we encountered an error:'
* and the error is 'progressInfo is not an object',
* the client will receive an error with the message
* 'While saving progress, we encountered an error: progressInfo is not an object'
* @returns express route handler that takes the following arguments:
* params (map: param name => value),
* req (express request object),
* next (express next function),
* send (a function that sends a string to the client),
* redirect (takes a url and redirects the user to that url),
* renderErrorPage (shows a static error page to the user),
* renderInfoPage (shows a static info page to the user),
* renderCustomHTML (renders custom html and sends it to the user),
* and returns the value to send to the client as a JSON API response, or
* calls next() or redirect(...) or send(...) or renderErrorPage(...).
* Note: params also has userId, userFirstName,
* userLastName, userEmail, userAvatarURL, isLearner, isTTM, isAdmin,
* and any other variables that
* are directly added to the session, if the user does have a session.
*/
const genRouteHandler = (
opts: {
paramTypes?: {
[k: string]: ParamType
},
handler: (
opts: {
params: {
[k: string]: any
},
req: any,
next: () => void,
redirect: (pathOrURL: string) => void,
send: (text: string, status?: number) => void,
renderErrorPage: (
opts?: {
title?: string,
description?: string,
code?: string,
pageTitle?: string,
status?: number,
},
) => void,
renderInfoPage: (
opts: {
title: string,
body: string,
},
) => void,
renderCustomHTML: (
opts: {
html: string,
status?: number,
},
) => void,
logServerEvent: LogFunction,
},
) => any,
crossServerScope?: string,
skipSessionCheck?: boolean,
unhandledErrorMessagePrefix?: string,
},
) => {
// Return a route handler
return async (req: any, res: any, next: () => void) => {
/*----------------------------------------*/
/* ------------- Preparation ------------ */
/*----------------------------------------*/
// Output params
const output: { [k in string]: any } = {};
// Determine cross server scopes
let crossServerScope: string | null = null;
if (opts.crossServerScope) {
crossServerScope = opts.crossServerScope ?? null;
}
// Determine whether we're skipping the session check
const skipSessionCheck = !!(
opts.skipSessionCheck
|| crossServerScope
);
// Get body from everywhere it can come from
const requestBody: {
[k: string]: any,
} = {
...req.body,
...req.query,
...req.params,
};
/*----------------------------------------*/
/* ------- Cross-Server Validation ------ */
/*----------------------------------------*/
if (crossServerScope) {
try {
// Create params to sign (specifically exclude path params)
const paramsToSign = {
...req.body,
...req.query,
};
// Validate the request body
await validateSignedRequest({
method: req.method ?? 'GET',
path: req.path,
scope: crossServerScope,
params: paramsToSign,
});
// Valid! Remove oauth values because they're no longer needed, and shouldn't be passed to the handler
Object.keys(requestBody).forEach((key) => {
if (key.startsWith('oauth_')) {
delete requestBody[key];
}
});
} catch (err) {
return handleError(
res,
{
message: `The authenticity of a cross-server request could not be validated because an error occurred: ${(err as any).message ?? 'unknown error'}`,
code: ((err as any).code ?? ExpressKitErrorCode.UnknownCrossServerError),
status: 401,
},
);
}
}
/*----------------------------------------*/
/* ------------ Parse Params ------------ */
/*----------------------------------------*/
// Process items one by one
const paramList = Object.entries(opts.paramTypes ?? {});
for (let i = 0; i < paramList.length; i++) {
const [name, type] = paramList[i];
// Find the value as a string
const value = requestBody[name];
// Parse
if (type === ParamType.Boolean || type === ParamType.BooleanOptional) {
// Boolean
// Handle case where value doesn't exist
if (value === undefined) {
if (type === ParamType.BooleanOptional) {
output[name] = undefined;
} else {
return handleError(
res,
{
message: `Parameter ${name} is required, but it was not included.`,
code: ExpressKitErrorCode.MissingParameter,
status: 422,
},
);
}
} else {
// Value exists
// Simplify value
const simpleVal = (
String(value)
.trim()
.toLowerCase()
);
// Parse
output[name] = (
[
'true',
'yes',
'y',
'1',
't',
].indexOf(simpleVal) >= 0
);
}
} else if (type === ParamType.Float || type === ParamType.FloatOptional) {
// Float
// Handle case where value doesn't exist
if (value === undefined) {
if (type === ParamType.FloatOptional) {
output[name] = undefined;
} else {
return handleError(
res,
{
message: `Parameter ${name} is required, but it was not included.`,
code: ExpressKitErrorCode.MissingParameter,
status: 422,
},
);
}
} else if (!Number.isNaN(Number.parseFloat(String(value)))) {
// Value is a number
output[name] = Number.parseFloat(String(value));
} else {
// Issue!
return handleError(
res,
{
message: `Request data was malformed: ${name} was not a valid float.`,
code: ExpressKitErrorCode.InvalidParameter,
status: 422,
},
);
}
} else if (type === ParamType.Int || type === ParamType.IntOptional) {
// Int
// Handle case where value doesn't exist
if (value === undefined) {
if (type === ParamType.IntOptional) {
output[name] = undefined;
} else {
return handleError(
res,
{
message: `Parameter ${name} is required, but it was not included.`,
code: ExpressKitErrorCode.MissingParameter,
status: 422,
},
);
}
} else if (!Number.isNaN(Number.parseInt(String(value), 10))) {
// Value is a number
output[name] = Number.parseInt(String(value), 10);
} else {
// Issue!
return handleError(
res,
{
message: `Request data was malformed: ${name} was not a valid int.`,
code: ExpressKitErrorCode.InvalidParameter,
status: 422,
},
);
}
} else if (type === ParamType.JSON || type === ParamType.JSONOptional) {
// Stringified JSON
// Handle case where value doesn't exist
if (value === undefined) {
if (type === ParamType.JSONOptional) {
output[name] = undefined;
} else {
return handleError(
res,
{
message: `Parameter ${name} is required, but it was not included.`,
code: ExpressKitErrorCode.MissingParameter,
status: 422,
},
);
}
} else {
// Value exists
// Parse
try {
output[name] = JSON.parse(String(value));
} catch (err) {
return handleError(
res,
{
message: `Request data was malformed: ${name} was not a valid JSON payload.`,
code: ExpressKitErrorCode.InvalidParameter,
status: 422,
},
);
}
}
} else if (type === ParamType.String || type === ParamType.StringOptional) {
// String
// Handle case where value doesn't exist
if (value === undefined) {
if (type === ParamType.StringOptional) {
output[name] = undefined;
} else {
return handleError(
res,
{
message: `Parameter ${name} is required, but it was not included.`,
code: ExpressKitErrorCode.MissingParameter,
status: 422,
},
);
}
} else {
// Value exists
// Leave as is
output[name] = value;
}
} else {
// No valid data type
return handleError(
res,
{
message: `An internal error occurred: we could not determine the type of ${name}.`,
code: ExpressKitErrorCode.InvalidParameter,
status: 422,
},
);
}
}
/*----------------------------------------*/
/* ------------- Launch Info ------------ */
/*----------------------------------------*/
// Get launch info
const { launched, launchInfo } = getLaunchInfo(req);
if (
// Not launched
(!launched || !launchInfo)
// Not skipping the session check
&& !skipSessionCheck
) {
return handleError(
res,
{
message: 'Your session has expired. Please refresh the page and try again.',
code: ReactKitErrorCode.SessionExpired,
status: 401,
},
);
}
// Make sure students don't try to get/change data for other students
if (
// launchInfo is defined
launchInfo
// launched is true
&& launched
// user is a student
&& launchInfo.isLearner
// output.userId is defined
&& output.userId
// the launchInfo userId is the "input" userId, check if the request
// output userId differs from the launchInfo userId
&& (launchInfo.userId !== output.userId)
) {
return handleError(
res,
{
message: 'We encountered a student ID mismatch. Please refresh or try the action again. Contact support if this issue persists.',
code: ExpressKitErrorCode.StudentIdMismatch,
status: 401,
},
);
}
// Error if user info cannot be found
if (
// User information is incomplete
(
!launchInfo
|| !launchInfo.userId
|| !launchInfo.userFirstName
|| !launchInfo.userLastName
|| (
launchInfo.notInCourse
&& !launchInfo.isAdmin
)
|| (
!launchInfo.isTTM
&& !launchInfo.isLearner
&& !launchInfo.isAdmin
)
)
// Not skipping the session check
&& !skipSessionCheck
) {
return handleError(
res,
{
message: 'Your session was invalid. Please refresh the page and try again.',
code: ReactKitErrorCode.SessionExpired,
status: 401,
},
);
}
// Add launch info to output
output.userId = (
launchInfo
? launchInfo.userId
: (output.userId ?? undefined)
);
output.userFirstName = (
launchInfo
? launchInfo.userFirstName
: (output.userFirstName ?? undefined)
);
output.userLastName = (
launchInfo
? launchInfo.userLastName
: (output.userLastName ?? undefined)
);
output.userEmail = (
launchInfo
? launchInfo.userEmail
: (output.userEmail ?? undefined)
);
output.userAvatarURL = (
launchInfo
? (
launchInfo.userImage
?? 'http://www.gravatar.com/avatar/?d=identicon'
)
: (output.userAvatarURL ?? undefined)
);
output.isLearner = (
launchInfo
? !!launchInfo.isLearner
: (output.isLearner ?? undefined)
);
output.isTTM = (
launchInfo
? !!launchInfo.isTTM
: (output.isTTM ?? undefined)
);
output.isAdmin = (
launchInfo
? !!launchInfo.isAdmin
: (output.isAdmin ?? undefined)
);
output.courseId = (
launchInfo
? (output.courseId ?? launchInfo.courseId)
: (output.courseId ?? undefined)
);
output.courseName = (
launchInfo
? launchInfo.contextLabel
: (output.courseName ?? undefined)
);
// Add other session variables
Object.keys(req.session).forEach((propName) => {
// Skip if prop already in output
if (output[propName] !== undefined) {
return;
}
// Add to output
const value = req.session[propName];
if (
typeof value === 'string'
|| typeof value === 'boolean'
|| typeof value === 'number'
) {
output[propName] = value;
}
});
/*----------------------------------------*/
/* ----- Require Course Consistency ----- */
/*----------------------------------------*/
// Make sure the user actually launched from the appropriate course
if (
output.courseId
&& launchInfo
&& launchInfo.courseId
&& output.courseId !== launchInfo.courseId
&& !output.isTTM
&& !output.isAdmin
) {
// Course of interest is not the launch course
return handleError(
res,
{
message: 'You switched sessions by opening this app in another tab. Please refresh the page and try again.',
code: ExpressKitErrorCode.WrongCourse,
status: 401,
},
);
}
/*----------------------------------------*/
/* Require Proper Permissions */
/*----------------------------------------*/
// Add TTM endpoint security
if (
// This is a TTM endpoint
req.path.startsWith('/api/ttm')
// User is not a TTM
&& (
// User is not a TTM
!output.isTTM
// User is not an admin
&& !output.isAdmin
)
) {
// User does not have access
return handleError(
res,
{
message: 'This action is only allowed if you are a teaching team member for the course. Please go back to Canvas, log in as a teaching team member, and try again.',
code: ExpressKitErrorCode.NotTTM,
status: 401,
},
);
}
// Add Admin endpoint security
if (
// This is an admin endpoint
req.path.startsWith('/api/admin')
// User is not an admin
&& !output.isAdmin
) {
// User does not have access
return handleError(
res,
{
message: 'This action is only allowed if you are a Canvas admin. Please go back to Canvas, log in as an admin, and try again.',
code: ExpressKitErrorCode.NotAdmin,
status: 401,
},
);
}
// Add Select Admin endpoint security
if (
// This is a select admin endpoint
req.path.startsWith('/api/admin/select')
) {
// Get select admin collection
const selectAdminCollection = await internalGetSelectAdminCollection();
const id = output.userId;
// Find match if exists in select admin collection
const [match] = await selectAdminCollection.find({ id });
// Check that user exists in select admin collection
if (!match) {
// User does not have access
return handleError(
res,
{
message: 'This action is only allowed for select Canvas admins. Please go back to Canvas, log in as a select admin, and try again.',
code: ExpressKitErrorCode.NotSelectAdmin,
status: 401,
},
);
}
}
/*----------------------------------------*/
/* ------------- Log Handler ------------ */
/*----------------------------------------*/
// Create a log handler function
/**
* Log an event on the server
* @author Gabe Abrams
*/
const logServerEvent: LogFunction = async (logOpts) => {
// NOTE: internally, we slip through an opts.overrideAsClientEvent boolean
// that indicates that this is actually a client event, but we don't
// include that in the LogFunction type because this is internal and
// hidden from users
try {
// Parse user agent
const {
browser,
device,
} = parseUserAgent(req.headers['user-agent']);
// Get time info in ET
const {
timestamp,
year,
month,
day,
hour,
minute,
} = getTimeInfoInET();
// Main log info
const mainLogInfo: LogMainInfo = {
id: `${launchInfo ? launchInfo.userId : 'unknown'}-${Date.now()}-${Math.floor(Math.random() * 100000)}-${Math.floor(Math.random() * 100000)}`,
userFirstName: (launchInfo ? launchInfo.userFirstName : 'unknown'),
userLastName: (launchInfo ? launchInfo.userLastName : 'unknown'),
userEmail: (launchInfo ? launchInfo.userEmail : 'unknown'),
userId: (launchInfo ? launchInfo.userId : -1),
isLearner: (launchInfo && !!launchInfo.isLearner),
isAdmin: (launchInfo && !!launchInfo.isAdmin),
isTTM: (launchInfo && !!launchInfo.isTTM),
courseId: (launchInfo ? launchInfo.courseId : -1),
courseName: (launchInfo ? launchInfo.contextLabel : 'unknown'),
browser,
device,
year,
month,
day,
hour,
minute,
timestamp,
context: (
typeof logOpts.context === 'string'
? logOpts.context
: (
((logOpts.context as any) ?? {})._
?? LogBuiltInMetadata.Context.Uncategorized
)
),
subcontext: (
logOpts.subcontext
?? LogBuiltInMetadata.Context.Uncategorized
),
tags: (logOpts.tags ?? []),
level: (logOpts.level ?? LogLevel.Info),
metadata: (logOpts.metadata ?? {}),
};
// Type-specific info
const typeSpecificInfo: LogTypeSpecificInfo = (
('error' in logOpts && logOpts.error)
? {
type: LogType.Error,
errorMessage: (logOpts as any).error.message ?? 'Unknown message',
errorCode: (logOpts as any).error.code ?? ReactKitErrorCode.NoCode,
errorStack: (logOpts as any).error.stack ?? 'No stack',
}
: {
type: LogType.Action,
target: (
(logOpts as any).target
?? LogBuiltInMetadata.Target.NoTarget
),
action: (
(logOpts as any).action
?? LogAction.Unknown
),
}
);
// Source-specific info
const sourceSpecificInfo: LogSourceSpecificInfo = (
(logOpts as any).overrideAsClientEvent
? {
source: LogSource.Client,
}
: {
source: LogSource.Server,
routePath: req.path,
routeTemplate: req.route.path,
}
);
// Build log event
const log: Log = {
...mainLogInfo,
...typeSpecificInfo,
...sourceSpecificInfo,
};
// Either print to console or save to db
const logCollection = await internalGetLogCollection();
if (logCollection) {
// Store to the log collection
await logCollection.insert(log);
} else if (log.type === LogType.Error) {
// Print to console
// eslint-disable-next-line no-console
console.error('dce-reactkit error log:', log);
} else {
// eslint-disable-next-line no-console
console.log('dce-reactkit action log:', log);
}
// Return log entry
return log;
} catch (err) {
// Print because we cannot store the error
// eslint-disable-next-line no-console
console.error(
'Could not log the following:',
logOpts,
'due to this error:',
(err as any ?? {}).message,
(err as any ?? {}).stack,
);
// Create a dummy log to return
const dummyMainInfo: LogMainInfo = {
id: '-1',
userFirstName: 'Unknown',
userLastName: 'Unknown',
userEmail: 'unknown@harvard.edu',
userId: 1,
isLearner: false,
isAdmin: false,
isTTM: false,
courseId: 1,
courseName: 'Unknown',
browser: {
name: 'Unknown',
version: 'Unknown',
},
device: {
isMobile: false,
os: 'Unknown',
},
year: 1,
month: 1,
day: 1,
hour: 1,
minute: 1,
timestamp: Date.now(),
tags: [],
level: LogLevel.Warn,
metadata: {},
context: LogBuiltInMetadata.Context.Uncategorized,
subcontext: LogBuiltInMetadata.Context.Uncategorized,
};
const dummyTypeSpecificInfo: LogTypeSpecificInfo = {
type: LogType.Error,
errorMessage: 'Unknown',
errorCode: 'Unknown',
errorStack: 'No Stack',
};
const dummySourceSpecificInfo: LogSourceSpecificInfo = {
source: LogSource.Server,
routePath: req.path,
routeTemplate: req.route.path,
};
const log: Log = {
...dummyMainInfo,
...dummyTypeSpecificInfo,
...dummySourceSpecificInfo,
};
return log;
}
};
/*------------------------------------------------------------------------*/
/* Call handler */
/*------------------------------------------------------------------------*/
// Keep track of whether a response was already sent
let responseSent = false;
/**
* Redirect the user to another path or url
* @author Gabe Abrams
* @param pathOrURL the path or url to redirect to
*/
const redirect = (pathOrURL: string) => {
responseSent = true;
res.redirect(pathOrURL);
};
/**
* Send text to the client (with an optional status code)
* @author Gabe Abrams
* @param text the text to send to the client
* @parm [status=200] the http status code to send
*/
const send = (text: string, status: number = 200) => {
responseSent = true;
res.status(status).send(text);
};
/**
* Render an error page
* @author Gabe Abrams
* @param renderOpts object containing all arguments
* @param [renderOpts.title=An Error Occurred] title of the error box
* @param [renderOpts.description=An unknown server error occurred. Please contact support.]
* a human-readable description of the error
* @param [renderOpts.code=ReactKitErrorCode.NoCode] error code to show
* @param [renderOpts.pageTitle=renderOpts.title] title of the page/tab if it differs from
* the title of the error
* @param [renderOpts.status=500] http status code
*/
const renderErrorPage = (
renderOpts: {
title?: string,
description?: string,
code?: string,
pageTitle?: string,
status?: number,
} = {},
) => {
const html = genErrorPage(renderOpts);
send(html, renderOpts.status ?? 500);
// Log server-side error if not a session expired error or 404
if (renderOpts.status && renderOpts.status === 404) {
return;
}
if (renderOpts.title?.toLowerCase().includes('session expired')) {
return;
}
logServerEvent({
context: LogBuiltInMetadata.Context.ServerRenderedErrorPage,
error: {
message: `${renderOpts.title}: ${renderOpts.description}`,
code: renderOpts.code,
},
metadata: {
title: renderOpts.title,
description: renderOpts.description,
code: renderOpts.code,
pageTitle: renderOpts.pageTitle,
status: renderOpts.status ?? 500,
},
});
};
/**
* Render an info page
* @author Gabe Abrams
* @param renderOpts object containing all arguments
* @param renderOpts.title title of the info box
* @param renderOpts.body a human-readable text body for the info alert
*/
const renderInfoPage = (
renderOpts: {
title: string,
body: string,
},
) => {
const html = genInfoPage(renderOpts);
send(html, 200);
};
/**
* Render custom HTML
* @author Gabe Abrams
* @param htmlOpts object containing all arguments
* @param htmlOpts.html the HTML to send to the client
* @param [ejsOpts.status=200] the http status code to send
*/
const renderCustomHTML = (
htmlOpts: {
html: string,
status?: number,
},
) => {
send(htmlOpts.html, htmlOpts.status ?? 200);
};
// Call the handler
try {
const results = await opts.handler({
params: output,
req,
send,
next: () => {
responseSent = true;
next();
},
redirect,
renderErrorPage,
renderInfoPage,
renderCustomHTML,
logServerEvent,
});
// Send results to client (only if next wasn't called)
if (!responseSent) {
return handleSuccess(res, results ?? undefined);
}
} catch (err) {
// Prefix error message if needed
if (
opts.unhandledErrorMessagePrefix
&& err instanceof Error
&& err.message
&& err.name !== 'ErrorWithCode'
) {
err.message = `${opts.unhandledErrorMessagePrefix.trim()} ${err.message.trim()}`;
}
// Send error to client (only if next wasn't called)
if (!responseSent) {
handleError(res, err);
// Log server-side error
logServerEvent({
context: LogBuiltInMetadata.Context.ServerEndpointError,
error: err,
});
return;
}
// Log error that was not responded with
// eslint-disable-next-line no-console
console.log('Error occurred but could not be sent to client because a response was already sent:', err);
}
};
};
export default genRouteHandler;