alpha-version-dce-check-in-api
Version:
API for taking attendance and checking people in.
564 lines (525 loc) • 16.2 kB
text/typescript
// Import helpers
import getLaunchInfo from './getLaunchInfo';
// Import shared types
import ReactKitErrorCode from '../types/ReactKitErrorCode';
import ParamType from '../types/ParamType';
// Import helpers
import handleError from './handleError';
import handleSuccess from './handleSuccess';
import genErrorPage from '../html/genErrorPage';
import genInfoPage from '../html/genInfoPage';
/**
* 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.skipSessionCheck] if true, skip the session check (allow users
* to not be logged in and launched via LTI)
* @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),
* 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,
},
) => any,
skipSessionCheck?: boolean,
},
) => {
// Return a route handler
return async (req: any, res: any, next: () => void) => {
// Output params
const output: { [k in string]: any } = {};
/*----------------------------------------*/
/* ------------ 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 = (
req.params[name]
|| req.query[name]
|| req.body[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: ReactKitErrorCode.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: ReactKitErrorCode.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: ReactKitErrorCode.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: ReactKitErrorCode.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: ReactKitErrorCode.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: ReactKitErrorCode.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: ReactKitErrorCode.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: ReactKitErrorCode.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: ReactKitErrorCode.InvalidParameter,
status: 422,
},
);
}
}
/*----------------------------------------*/
/* ------------- Launch Info ------------ */
/*----------------------------------------*/
// Get launch info
const { launched, launchInfo } = getLaunchInfo(req);
if (
// Not launched
(!launched || !launchInfo)
// Not skipping the session check
&& !opts.skipSessionCheck
) {
return handleError(
res,
{
message: 'Your session has expired. Please refresh the page and try again.',
code: ReactKitErrorCode.SessionExpired,
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
&& !opts.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
: undefined
);
output.userFirstName = (
launchInfo
? launchInfo.userFirstName
: undefined
);
output.userLastName = (
launchInfo
? launchInfo.userLastName
: undefined
);
output.userEmail = (
launchInfo
? launchInfo.userEmail
: undefined
);
output.userAvatarURL = (
launchInfo
? (
launchInfo.userImage
?? 'http://www.gravatar.com/avatar/?d=identicon'
)
: undefined
);
output.isLearner = (
launchInfo
? !!launchInfo.isLearner
: undefined
);
output.isTTM = (
launchInfo
? !!launchInfo.isTTM
: undefined
);
output.isAdmin = (
launchInfo
? !!launchInfo.isAdmin
: undefined
);
output.courseId = (
launchInfo
? (output.courseId ?? launchInfo.courseId)
: undefined
);
output.courseName = (
launchInfo
? launchInfo.contextLabel
: 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: ReactKitErrorCode.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: ReactKitErrorCode.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: ReactKitErrorCode.NotAdmin,
status: 401,
},
);
}
/*------------------------------------------------------------------------*/
/* 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);
};
/**
* 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);
};
// Call the handler
try {
const results = await opts.handler({
params: output,
req,
send,
next: () => {
responseSent = true;
next();
},
redirect,
renderErrorPage,
renderInfoPage,
});
// Send results to client (only if next wasn't called)
if (!responseSent) {
return handleSuccess(res, results ?? undefined);
}
} catch (err) {
// Send error to client (only if next wasn't called)
if (!responseSent) {
handleError(res, 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;