dce-expresskit 4.0.0-beta-logreviewer.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.eslintrc.js +93 -0
  2. package/LICENSE +21 -0
  3. package/README.md +17 -0
  4. package/genEncodedSecret.ts +107 -0
  5. package/genSalt.ts +15 -0
  6. package/lib/constants/LOG_REVIEW_PAGE_SIZE.d.ts +6 -0
  7. package/lib/constants/LOG_REVIEW_PAGE_SIZE.js +9 -0
  8. package/lib/constants/LOG_REVIEW_PAGE_SIZE.js.map +1 -0
  9. package/lib/constants/LOG_REVIEW_ROUTE_PATH_PREFIX.d.ts +6 -0
  10. package/lib/constants/LOG_REVIEW_ROUTE_PATH_PREFIX.js +13 -0
  11. package/lib/constants/LOG_REVIEW_ROUTE_PATH_PREFIX.js.map +1 -0
  12. package/lib/constants/LOG_REVIEW_STATUS_ROUTE.d.ts +7 -0
  13. package/lib/constants/LOG_REVIEW_STATUS_ROUTE.js +14 -0
  14. package/lib/constants/LOG_REVIEW_STATUS_ROUTE.js.map +1 -0
  15. package/lib/constants/LOG_ROUTE_PATH.d.ts +6 -0
  16. package/lib/constants/LOG_ROUTE_PATH.js +13 -0
  17. package/lib/constants/LOG_ROUTE_PATH.js.map +1 -0
  18. package/lib/constants/ROUTE_PATH_PREFIX.d.ts +6 -0
  19. package/lib/constants/ROUTE_PATH_PREFIX.js +9 -0
  20. package/lib/constants/ROUTE_PATH_PREFIX.js.map +1 -0
  21. package/lib/errors/ErrorWithCode.d.ts +9 -0
  22. package/lib/errors/ErrorWithCode.js +33 -0
  23. package/lib/errors/ErrorWithCode.js.map +1 -0
  24. package/lib/helpers/addDBEditorEndpoints/generateEndpointPath.d.ts +9 -0
  25. package/lib/helpers/addDBEditorEndpoints/generateEndpointPath.js +17 -0
  26. package/lib/helpers/addDBEditorEndpoints/generateEndpointPath.js.map +1 -0
  27. package/lib/helpers/addDBEditorEndpoints/index.d.ts +41 -0
  28. package/lib/helpers/addDBEditorEndpoints/index.js +134 -0
  29. package/lib/helpers/addDBEditorEndpoints/index.js.map +1 -0
  30. package/lib/helpers/dataSigner.d.ts +40 -0
  31. package/lib/helpers/dataSigner.js +242 -0
  32. package/lib/helpers/dataSigner.js.map +1 -0
  33. package/lib/helpers/genRouteHandler.d.ts +75 -0
  34. package/lib/helpers/genRouteHandler.js +662 -0
  35. package/lib/helpers/genRouteHandler.js.map +1 -0
  36. package/lib/helpers/getLogReviewerLogs.d.ts +27 -0
  37. package/lib/helpers/getLogReviewerLogs.js +238 -0
  38. package/lib/helpers/getLogReviewerLogs.js.map +1 -0
  39. package/lib/helpers/handleError.d.ts +18 -0
  40. package/lib/helpers/handleError.js +51 -0
  41. package/lib/helpers/handleError.js.map +1 -0
  42. package/lib/helpers/handleSuccess.d.ts +8 -0
  43. package/lib/helpers/handleSuccess.js +20 -0
  44. package/lib/helpers/handleSuccess.js.map +1 -0
  45. package/lib/helpers/initCrossServerCredentialCollection.d.ts +11 -0
  46. package/lib/helpers/initCrossServerCredentialCollection.js +15 -0
  47. package/lib/helpers/initCrossServerCredentialCollection.js.map +1 -0
  48. package/lib/helpers/initLogCollection.d.ts +11 -0
  49. package/lib/helpers/initLogCollection.js +26 -0
  50. package/lib/helpers/initLogCollection.js.map +1 -0
  51. package/lib/helpers/initServer.d.ts +45 -0
  52. package/lib/helpers/initServer.js +292 -0
  53. package/lib/helpers/initServer.js.map +1 -0
  54. package/lib/helpers/parseUserAgent.d.ts +17 -0
  55. package/lib/helpers/parseUserAgent.js +108 -0
  56. package/lib/helpers/parseUserAgent.js.map +1 -0
  57. package/lib/helpers/visitEndpointOnAnotherServer/index.d.ts +18 -0
  58. package/lib/helpers/visitEndpointOnAnotherServer/index.js +89 -0
  59. package/lib/helpers/visitEndpointOnAnotherServer/index.js.map +1 -0
  60. package/lib/helpers/visitEndpointOnAnotherServer/sendServerToServerRequest.d.ts +23 -0
  61. package/lib/helpers/visitEndpointOnAnotherServer/sendServerToServerRequest.js +236 -0
  62. package/lib/helpers/visitEndpointOnAnotherServer/sendServerToServerRequest.js.map +1 -0
  63. package/lib/html/genErrorPage.d.ts +19 -0
  64. package/lib/html/genErrorPage.js +27 -0
  65. package/lib/html/genErrorPage.js.map +1 -0
  66. package/lib/html/genInfoPage.d.ts +13 -0
  67. package/lib/html/genInfoPage.js +16 -0
  68. package/lib/html/genInfoPage.js.map +1 -0
  69. package/lib/index.d.ts +11 -0
  70. package/lib/index.js +68 -0
  71. package/lib/index.js.map +1 -0
  72. package/lib/types/CrossServerCredential.d.ts +11 -0
  73. package/lib/types/CrossServerCredential.js +3 -0
  74. package/lib/types/CrossServerCredential.js.map +1 -0
  75. package/lib/types/ExpressKitErrorCode.d.ts +31 -0
  76. package/lib/types/ExpressKitErrorCode.js +38 -0
  77. package/lib/types/ExpressKitErrorCode.js.map +1 -0
  78. package/package.json +53 -0
  79. package/src/constants/LOG_REVIEW_PAGE_SIZE.ts +7 -0
  80. package/src/errors/ErrorWithCode.tsx +15 -0
  81. package/src/helpers/addDBEditorEndpoints/generateEndpointPath.ts +16 -0
  82. package/src/helpers/addDBEditorEndpoints/index.ts +130 -0
  83. package/src/helpers/dataSigner.ts +319 -0
  84. package/src/helpers/genRouteHandler.ts +920 -0
  85. package/src/helpers/getLogReviewerLogs.ts +259 -0
  86. package/src/helpers/handleError.ts +66 -0
  87. package/src/helpers/handleSuccess.ts +18 -0
  88. package/src/helpers/initCrossServerCredentialCollection.ts +19 -0
  89. package/src/helpers/initLogCollection.ts +30 -0
  90. package/src/helpers/initServer.ts +283 -0
  91. package/src/helpers/parseUserAgent.ts +108 -0
  92. package/src/helpers/visitEndpointOnAnotherServer/index.ts +70 -0
  93. package/src/helpers/visitEndpointOnAnotherServer/sendServerToServerRequest.ts +257 -0
  94. package/src/html/genErrorPage.ts +144 -0
  95. package/src/html/genInfoPage.ts +101 -0
  96. package/src/index.ts +125 -0
  97. package/src/types/CrossServerCredential.ts +16 -0
  98. package/src/types/ExpressKitErrorCode.ts +37 -0
  99. package/tsconfig.json +19 -0
@@ -0,0 +1,920 @@
1
+ // Import dce-reactkit
2
+ import {
3
+ getTimeInfoInET,
4
+ LogFunction,
5
+ Log,
6
+ LogType,
7
+ LogTypeSpecificInfo,
8
+ LogMainInfo,
9
+ LogSourceSpecificInfo,
10
+ LogBuiltInMetadata,
11
+ LogAction,
12
+ LogLevel,
13
+ ParamType,
14
+ ReactKitErrorCode,
15
+ LogSource,
16
+ } from 'dce-reactkit';
17
+
18
+ // Import caccl
19
+ import { getLaunchInfo } from 'caccl/server';
20
+
21
+ // Import caccl functions
22
+ import {
23
+ internalGetLogCollection,
24
+ } from './initServer';
25
+
26
+ // Import shared types
27
+ import ExpressKitErrorCode from '../types/ExpressKitErrorCode';
28
+
29
+ // Import helpers
30
+ import handleError from './handleError';
31
+ import handleSuccess from './handleSuccess';
32
+ import genErrorPage from '../html/genErrorPage';
33
+ import genInfoPage from '../html/genInfoPage';
34
+ import parseUserAgent from './parseUserAgent';
35
+ import { validateSignedRequest } from './dataSigner';
36
+
37
+ /**
38
+ * Generate an express API route handler
39
+ * @author Gabe Abrams
40
+ * @param opts object containing all arguments
41
+ * @param opts.paramTypes map containing the types for each parameter that is
42
+ * included in the request (map: param name => type)
43
+ * @param opts.handler function that processes the request
44
+ * @param [opts.crossServerScope] the scope associated with this endpoint.
45
+ * If defined, this is a cross-server endpoint, which will never
46
+ * have any launch data, will never check Canvas roles or launch status, and will
47
+ * instead use scopes and reactkit credentials to sign and validate requests.
48
+ * Never start the path with /api/ttm or /api/admin if the endpoint is a cross-server
49
+ * endpoint because those roles will not be validated
50
+ * @param [opts.skipSessionCheck=true if crossServerScope defined] if true, skip
51
+ * the session check (allow users to not be logged in and launched via LTI).
52
+ * If crossServerScope is defined, this is always true
53
+ * @param [opts.unhandledErrorMessagePrefix] if included, when an error that
54
+ * is not of type ErrorWithCode is thrown, the client will receive an error
55
+ * where the error message is prefixed with this string. For example,
56
+ * if unhandledErrorMessagePrefix is
57
+ * 'While saving progress, we encountered an error:'
58
+ * and the error is 'progressInfo is not an object',
59
+ * the client will receive an error with the message
60
+ * 'While saving progress, we encountered an error: progressInfo is not an object'
61
+ * @returns express route handler that takes the following arguments:
62
+ * params (map: param name => value),
63
+ * req (express request object),
64
+ * next (express next function),
65
+ * send (a function that sends a string to the client),
66
+ * redirect (takes a url and redirects the user to that url),
67
+ * renderErrorPage (shows a static error page to the user),
68
+ * renderInfoPage (shows a static info page to the user),
69
+ * renderCustomHTML (renders custom html and sends it to the user),
70
+ * and returns the value to send to the client as a JSON API response, or
71
+ * calls next() or redirect(...) or send(...) or renderErrorPage(...).
72
+ * Note: params also has userId, userFirstName,
73
+ * userLastName, userEmail, userAvatarURL, isLearner, isTTM, isAdmin,
74
+ * and any other variables that
75
+ * are directly added to the session, if the user does have a session.
76
+ */
77
+ const genRouteHandler = (
78
+ opts: {
79
+ paramTypes?: {
80
+ [k: string]: ParamType
81
+ },
82
+ handler: (
83
+ opts: {
84
+ params: {
85
+ [k: string]: any
86
+ },
87
+ req: any,
88
+ next: () => void,
89
+ redirect: (pathOrURL: string) => void,
90
+ send: (text: string, status?: number) => void,
91
+ renderErrorPage: (
92
+ opts?: {
93
+ title?: string,
94
+ description?: string,
95
+ code?: string,
96
+ pageTitle?: string,
97
+ status?: number,
98
+ },
99
+ ) => void,
100
+ renderInfoPage: (
101
+ opts: {
102
+ title: string,
103
+ body: string,
104
+ },
105
+ ) => void,
106
+ renderCustomHTML: (
107
+ opts: {
108
+ html: string,
109
+ status?: number,
110
+ },
111
+ ) => void,
112
+ logServerEvent: LogFunction,
113
+ },
114
+ ) => any,
115
+ crossServerScope?: string,
116
+ skipSessionCheck?: boolean,
117
+ unhandledErrorMessagePrefix?: string,
118
+ },
119
+ ) => {
120
+ // Return a route handler
121
+ return async (req: any, res: any, next: () => void) => {
122
+ /*----------------------------------------*/
123
+ /* ------------- Preparation ------------ */
124
+ /*----------------------------------------*/
125
+
126
+ // Output params
127
+ const output: { [k in string]: any } = {};
128
+
129
+ // Determine cross server scopes
130
+ let crossServerScope: string | null = null;
131
+ if (opts.crossServerScope) {
132
+ crossServerScope = opts.crossServerScope ?? null;
133
+ }
134
+
135
+ // Determine whether we're skipping the session check
136
+ const skipSessionCheck = !!(
137
+ opts.skipSessionCheck
138
+ || crossServerScope
139
+ );
140
+
141
+ // Get body from everywhere it can come from
142
+ const requestBody: {
143
+ [k: string]: any,
144
+ } = {
145
+ ...req.body,
146
+ ...req.query,
147
+ ...req.params,
148
+ };
149
+
150
+ /*----------------------------------------*/
151
+ /* ------- Cross-Server Validation ------ */
152
+ /*----------------------------------------*/
153
+
154
+ if (crossServerScope) {
155
+ try {
156
+ // Create params to sign (specifically exclude path params)
157
+ const paramsToSign = {
158
+ ...req.body,
159
+ ...req.query,
160
+ };
161
+
162
+ // Validate the request body
163
+ await validateSignedRequest({
164
+ method: req.method ?? 'GET',
165
+ path: req.path,
166
+ scope: crossServerScope,
167
+ params: paramsToSign,
168
+ });
169
+
170
+ // Valid! Remove oauth values because they're no longer needed, and shouldn't be passed to the handler
171
+ Object.keys(requestBody).forEach((key) => {
172
+ if (key.startsWith('oauth_')) {
173
+ delete requestBody[key];
174
+ }
175
+ });
176
+ } catch (err) {
177
+ return handleError(
178
+ res,
179
+ {
180
+ message: `The authenticity of a cross-server request could not be validated because an error occurred: ${(err as any).message ?? 'unknown error'}`,
181
+ code: ((err as any).code ?? ExpressKitErrorCode.UnknownCrossServerError),
182
+ status: 401,
183
+ },
184
+ );
185
+ }
186
+ }
187
+
188
+ /*----------------------------------------*/
189
+ /* ------------ Parse Params ------------ */
190
+ /*----------------------------------------*/
191
+
192
+ // Process items one by one
193
+ const paramList = Object.entries(opts.paramTypes ?? {});
194
+ for (let i = 0; i < paramList.length; i++) {
195
+ const [name, type] = paramList[i];
196
+
197
+ // Find the value as a string
198
+ const value = requestBody[name];
199
+
200
+ // Parse
201
+ if (type === ParamType.Boolean || type === ParamType.BooleanOptional) {
202
+ // Boolean
203
+
204
+ // Handle case where value doesn't exist
205
+ if (value === undefined) {
206
+ if (type === ParamType.BooleanOptional) {
207
+ output[name] = undefined;
208
+ } else {
209
+ return handleError(
210
+ res,
211
+ {
212
+ message: `Parameter ${name} is required, but it was not included.`,
213
+ code: ExpressKitErrorCode.MissingParameter,
214
+ status: 422,
215
+ },
216
+ );
217
+ }
218
+ } else {
219
+ // Value exists
220
+
221
+ // Simplify value
222
+ const simpleVal = (
223
+ String(value)
224
+ .trim()
225
+ .toLowerCase()
226
+ );
227
+
228
+ // Parse
229
+ output[name] = (
230
+ [
231
+ 'true',
232
+ 'yes',
233
+ 'y',
234
+ '1',
235
+ 't',
236
+ ].indexOf(simpleVal) >= 0
237
+ );
238
+ }
239
+ } else if (type === ParamType.Float || type === ParamType.FloatOptional) {
240
+ // Float
241
+
242
+ // Handle case where value doesn't exist
243
+ if (value === undefined) {
244
+ if (type === ParamType.FloatOptional) {
245
+ output[name] = undefined;
246
+ } else {
247
+ return handleError(
248
+ res,
249
+ {
250
+ message: `Parameter ${name} is required, but it was not included.`,
251
+ code: ExpressKitErrorCode.MissingParameter,
252
+ status: 422,
253
+ },
254
+ );
255
+ }
256
+ } else if (!Number.isNaN(Number.parseFloat(String(value)))) {
257
+ // Value is a number
258
+ output[name] = Number.parseFloat(String(value));
259
+ } else {
260
+ // Issue!
261
+ return handleError(
262
+ res,
263
+ {
264
+ message: `Request data was malformed: ${name} was not a valid float.`,
265
+ code: ExpressKitErrorCode.InvalidParameter,
266
+ status: 422,
267
+ },
268
+ );
269
+ }
270
+ } else if (type === ParamType.Int || type === ParamType.IntOptional) {
271
+ // Int
272
+
273
+ // Handle case where value doesn't exist
274
+ if (value === undefined) {
275
+ if (type === ParamType.IntOptional) {
276
+ output[name] = undefined;
277
+ } else {
278
+ return handleError(
279
+ res,
280
+ {
281
+ message: `Parameter ${name} is required, but it was not included.`,
282
+ code: ExpressKitErrorCode.MissingParameter,
283
+ status: 422,
284
+ },
285
+ );
286
+ }
287
+ } else if (!Number.isNaN(Number.parseInt(String(value), 10))) {
288
+ // Value is a number
289
+ output[name] = Number.parseInt(String(value), 10);
290
+ } else {
291
+ // Issue!
292
+ return handleError(
293
+ res,
294
+ {
295
+ message: `Request data was malformed: ${name} was not a valid int.`,
296
+ code: ExpressKitErrorCode.InvalidParameter,
297
+ status: 422,
298
+ },
299
+ );
300
+ }
301
+ } else if (type === ParamType.JSON || type === ParamType.JSONOptional) {
302
+ // Stringified JSON
303
+
304
+ // Handle case where value doesn't exist
305
+ if (value === undefined) {
306
+ if (type === ParamType.JSONOptional) {
307
+ output[name] = undefined;
308
+ } else {
309
+ return handleError(
310
+ res,
311
+ {
312
+ message: `Parameter ${name} is required, but it was not included.`,
313
+ code: ExpressKitErrorCode.MissingParameter,
314
+ status: 422,
315
+ },
316
+ );
317
+ }
318
+ } else {
319
+ // Value exists
320
+
321
+ // Parse
322
+ try {
323
+ output[name] = JSON.parse(String(value));
324
+ } catch (err) {
325
+ return handleError(
326
+ res,
327
+ {
328
+ message: `Request data was malformed: ${name} was not a valid JSON payload.`,
329
+ code: ExpressKitErrorCode.InvalidParameter,
330
+ status: 422,
331
+ },
332
+ );
333
+ }
334
+ }
335
+ } else if (type === ParamType.String || type === ParamType.StringOptional) {
336
+ // String
337
+
338
+ // Handle case where value doesn't exist
339
+ if (value === undefined) {
340
+ if (type === ParamType.StringOptional) {
341
+ output[name] = undefined;
342
+ } else {
343
+ return handleError(
344
+ res,
345
+ {
346
+ message: `Parameter ${name} is required, but it was not included.`,
347
+ code: ExpressKitErrorCode.MissingParameter,
348
+ status: 422,
349
+ },
350
+ );
351
+ }
352
+ } else {
353
+ // Value exists
354
+
355
+ // Leave as is
356
+ output[name] = value;
357
+ }
358
+ } else {
359
+ // No valid data type
360
+ return handleError(
361
+ res,
362
+ {
363
+ message: `An internal error occurred: we could not determine the type of ${name}.`,
364
+ code: ExpressKitErrorCode.InvalidParameter,
365
+ status: 422,
366
+ },
367
+ );
368
+ }
369
+ }
370
+
371
+ /*----------------------------------------*/
372
+ /* ------------- Launch Info ------------ */
373
+ /*----------------------------------------*/
374
+
375
+ // Get launch info
376
+ const { launched, launchInfo } = getLaunchInfo(req);
377
+ if (
378
+ // Not launched
379
+ (!launched || !launchInfo)
380
+ // Not skipping the session check
381
+ && !skipSessionCheck
382
+ ) {
383
+ return handleError(
384
+ res,
385
+ {
386
+ message: 'Your session has expired. Please refresh the page and try again.',
387
+ code: ReactKitErrorCode.SessionExpired,
388
+ status: 401,
389
+ },
390
+ );
391
+ }
392
+
393
+ // Error if user info cannot be found
394
+ if (
395
+ // User information is incomplete
396
+ (
397
+ !launchInfo
398
+ || !launchInfo.userId
399
+ || !launchInfo.userFirstName
400
+ || !launchInfo.userLastName
401
+ || (
402
+ launchInfo.notInCourse
403
+ && !launchInfo.isAdmin
404
+ )
405
+ || (
406
+ !launchInfo.isTTM
407
+ && !launchInfo.isLearner
408
+ && !launchInfo.isAdmin
409
+ )
410
+ )
411
+ // Not skipping the session check
412
+ && !skipSessionCheck
413
+ ) {
414
+ return handleError(
415
+ res,
416
+ {
417
+ message: 'Your session was invalid. Please refresh the page and try again.',
418
+ code: ReactKitErrorCode.SessionExpired,
419
+ status: 401,
420
+ },
421
+ );
422
+ }
423
+
424
+ // Add launch info to output
425
+ output.userId = (
426
+ launchInfo
427
+ ? launchInfo.userId
428
+ : (output.userId ?? undefined)
429
+ );
430
+ output.userFirstName = (
431
+ launchInfo
432
+ ? launchInfo.userFirstName
433
+ : (output.userFirstName ?? undefined)
434
+ );
435
+ output.userLastName = (
436
+ launchInfo
437
+ ? launchInfo.userLastName
438
+ : (output.userLastName ?? undefined)
439
+ );
440
+ output.userEmail = (
441
+ launchInfo
442
+ ? launchInfo.userEmail
443
+ : (output.userEmail ?? undefined)
444
+ );
445
+ output.userAvatarURL = (
446
+ launchInfo
447
+ ? (
448
+ launchInfo.userImage
449
+ ?? 'http://www.gravatar.com/avatar/?d=identicon'
450
+ )
451
+ : (output.userAvatarURL ?? undefined)
452
+ );
453
+ output.isLearner = (
454
+ launchInfo
455
+ ? !!launchInfo.isLearner
456
+ : (output.isLearner ?? undefined)
457
+ );
458
+ output.isTTM = (
459
+ launchInfo
460
+ ? !!launchInfo.isTTM
461
+ : (output.isTTM ?? undefined)
462
+ );
463
+ output.isAdmin = (
464
+ launchInfo
465
+ ? !!launchInfo.isAdmin
466
+ : (output.isAdmin ?? undefined)
467
+ );
468
+ output.courseId = (
469
+ launchInfo
470
+ ? (output.courseId ?? launchInfo.courseId)
471
+ : (output.courseId ?? undefined)
472
+ );
473
+ output.courseName = (
474
+ launchInfo
475
+ ? launchInfo.contextLabel
476
+ : (output.courseName ?? undefined)
477
+ );
478
+
479
+ // Add other session variables
480
+ Object.keys(req.session).forEach((propName) => {
481
+ // Skip if prop already in output
482
+ if (output[propName] !== undefined) {
483
+ return;
484
+ }
485
+
486
+ // Add to output
487
+ const value = req.session[propName];
488
+ if (
489
+ typeof value === 'string'
490
+ || typeof value === 'boolean'
491
+ || typeof value === 'number'
492
+ ) {
493
+ output[propName] = value;
494
+ }
495
+ });
496
+
497
+ /*----------------------------------------*/
498
+ /* ----- Require Course Consistency ----- */
499
+ /*----------------------------------------*/
500
+
501
+ // Make sure the user actually launched from the appropriate course
502
+ if (
503
+ output.courseId
504
+ && launchInfo
505
+ && launchInfo.courseId
506
+ && output.courseId !== launchInfo.courseId
507
+ && !output.isTTM
508
+ && !output.isAdmin
509
+ ) {
510
+ // Course of interest is not the launch course
511
+ return handleError(
512
+ res,
513
+ {
514
+ message: 'You switched sessions by opening this app in another tab. Please refresh the page and try again.',
515
+ code: ExpressKitErrorCode.WrongCourse,
516
+ status: 401,
517
+ },
518
+ );
519
+ }
520
+
521
+ /*----------------------------------------*/
522
+ /* Require Proper Permissions */
523
+ /*----------------------------------------*/
524
+
525
+ // Add TTM endpoint security
526
+ if (
527
+ // This is a TTM endpoint
528
+ req.path.startsWith('/api/ttm')
529
+ // User is not a TTM
530
+ && (
531
+ // User is not a TTM
532
+ !output.isTTM
533
+ // User is not an admin
534
+ && !output.isAdmin
535
+ )
536
+ ) {
537
+ // User does not have access
538
+ return handleError(
539
+ res,
540
+ {
541
+ 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.',
542
+ code: ExpressKitErrorCode.NotTTM,
543
+ status: 401,
544
+ },
545
+ );
546
+ }
547
+
548
+ // Add Admin endpoint security
549
+ if (
550
+ // This is an admin endpoint
551
+ req.path.startsWith('/api/admin')
552
+ // User is not an admin
553
+ && !output.isAdmin
554
+ ) {
555
+ // User does not have access
556
+ return handleError(
557
+ res,
558
+ {
559
+ 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.',
560
+ code: ExpressKitErrorCode.NotAdmin,
561
+ status: 401,
562
+ },
563
+ );
564
+ }
565
+
566
+ /*----------------------------------------*/
567
+ /* ------------- Log Handler ------------ */
568
+ /*----------------------------------------*/
569
+
570
+ // Create a log handler function
571
+
572
+ /**
573
+ * Log an event on the server
574
+ * @author Gabe Abrams
575
+ */
576
+ const logServerEvent: LogFunction = async (logOpts) => {
577
+ // NOTE: internally, we slip through an opts.overrideAsClientEvent boolean
578
+ // that indicates that this is actually a client event, but we don't
579
+ // include that in the LogFunction type because this is internal and
580
+ // hidden from users
581
+ try {
582
+ // Parse user agent
583
+ const {
584
+ browser,
585
+ device,
586
+ } = parseUserAgent(req.headers['user-agent']);
587
+
588
+ // Get time info in ET
589
+ const {
590
+ timestamp,
591
+ year,
592
+ month,
593
+ day,
594
+ hour,
595
+ minute,
596
+ } = getTimeInfoInET();
597
+
598
+ // Main log info
599
+ const mainLogInfo: LogMainInfo = {
600
+ id: `${launchInfo ? launchInfo.userId : 'unknown'}-${Date.now()}-${Math.floor(Math.random() * 100000)}-${Math.floor(Math.random() * 100000)}`,
601
+ userFirstName: (launchInfo ? launchInfo.userFirstName : 'unknown'),
602
+ userLastName: (launchInfo ? launchInfo.userLastName : 'unknown'),
603
+ userEmail: (launchInfo ? launchInfo.userEmail : 'unknown'),
604
+ userId: (launchInfo ? launchInfo.userId : -1),
605
+ isLearner: (launchInfo && !!launchInfo.isLearner),
606
+ isAdmin: (launchInfo && !!launchInfo.isAdmin),
607
+ isTTM: (launchInfo && !!launchInfo.isTTM),
608
+ courseId: (launchInfo ? launchInfo.courseId : -1),
609
+ courseName: (launchInfo ? launchInfo.contextLabel : 'unknown'),
610
+ browser,
611
+ device,
612
+ year,
613
+ month,
614
+ day,
615
+ hour,
616
+ minute,
617
+ timestamp,
618
+ context: (
619
+ typeof logOpts.context === 'string'
620
+ ? logOpts.context
621
+ : (
622
+ ((logOpts.context as any) ?? {})._
623
+ ?? LogBuiltInMetadata.Context.Uncategorized
624
+ )
625
+ ),
626
+ subcontext: (
627
+ logOpts.subcontext
628
+ ?? LogBuiltInMetadata.Context.Uncategorized
629
+ ),
630
+ tags: (logOpts.tags ?? []),
631
+ level: (logOpts.level ?? LogLevel.Info),
632
+ metadata: (logOpts.metadata ?? {}),
633
+ };
634
+
635
+ // Type-specific info
636
+ const typeSpecificInfo: LogTypeSpecificInfo = (
637
+ ('error' in opts && opts.error)
638
+ ? {
639
+ type: LogType.Error,
640
+ errorMessage: (logOpts as any).error.message ?? 'Unknown message',
641
+ errorCode: (logOpts as any).error.code ?? ReactKitErrorCode.NoCode,
642
+ errorStack: (logOpts as any).error.stack ?? 'No stack',
643
+ }
644
+ : {
645
+ type: LogType.Action,
646
+ target: (
647
+ (logOpts as any).target
648
+ ?? LogBuiltInMetadata.Target.NoTarget
649
+ ),
650
+ action: (
651
+ (logOpts as any).action
652
+ ?? LogAction.Unknown
653
+ ),
654
+ }
655
+ );
656
+
657
+ // Source-specific info
658
+ const sourceSpecificInfo: LogSourceSpecificInfo = (
659
+ (logOpts as any).overrideAsClientEvent
660
+ ? {
661
+ source: LogSource.Client,
662
+ }
663
+ : {
664
+ source: LogSource.Server,
665
+ routePath: req.path,
666
+ routeTemplate: req.route.path,
667
+ }
668
+ );
669
+
670
+ // Build log event
671
+ const log: Log = {
672
+ ...mainLogInfo,
673
+ ...typeSpecificInfo,
674
+ ...sourceSpecificInfo,
675
+ };
676
+
677
+ // Either print to console or save to db
678
+ const logCollection = internalGetLogCollection();
679
+ if (logCollection) {
680
+ // Store to the log collection
681
+ await logCollection.insert(log);
682
+ } else if (log.type === LogType.Error) {
683
+ // Print to console
684
+ // eslint-disable-next-line no-console
685
+ console.error('dce-reactkit error log:', log);
686
+ } else {
687
+ // eslint-disable-next-line no-console
688
+ console.log('dce-reactkit action log:', log);
689
+ }
690
+
691
+ // Return log entry
692
+ return log;
693
+ } catch (err) {
694
+ // Print because we cannot store the error
695
+ // eslint-disable-next-line no-console
696
+ console.error(
697
+ 'Could not log the following:',
698
+ logOpts,
699
+ 'due to this error:',
700
+ (err as any ?? {}).message,
701
+ (err as any ?? {}).stack,
702
+ );
703
+
704
+ // Create a dummy log to return
705
+ const dummyMainInfo: LogMainInfo = {
706
+ id: '-1',
707
+ userFirstName: 'Unknown',
708
+ userLastName: 'Unknown',
709
+ userEmail: 'unknown@harvard.edu',
710
+ userId: 1,
711
+ isLearner: false,
712
+ isAdmin: false,
713
+ isTTM: false,
714
+ courseId: 1,
715
+ courseName: 'Unknown',
716
+ browser: {
717
+ name: 'Unknown',
718
+ version: 'Unknown',
719
+ },
720
+ device: {
721
+ isMobile: false,
722
+ os: 'Unknown',
723
+ },
724
+ year: 1,
725
+ month: 1,
726
+ day: 1,
727
+ hour: 1,
728
+ minute: 1,
729
+ timestamp: Date.now(),
730
+ tags: [],
731
+ level: LogLevel.Warn,
732
+ metadata: {},
733
+ context: LogBuiltInMetadata.Context.Uncategorized,
734
+ subcontext: LogBuiltInMetadata.Context.Uncategorized,
735
+ };
736
+
737
+ const dummyTypeSpecificInfo: LogTypeSpecificInfo = {
738
+ type: LogType.Error,
739
+ errorMessage: 'Unknown',
740
+ errorCode: 'Unknown',
741
+ errorStack: 'No Stack',
742
+ };
743
+
744
+ const dummySourceSpecificInfo: LogSourceSpecificInfo = {
745
+ source: LogSource.Server,
746
+ routePath: req.path,
747
+ routeTemplate: req.route.path,
748
+ };
749
+
750
+ const log: Log = {
751
+ ...dummyMainInfo,
752
+ ...dummyTypeSpecificInfo,
753
+ ...dummySourceSpecificInfo,
754
+ };
755
+
756
+ return log;
757
+ }
758
+ };
759
+
760
+ /*------------------------------------------------------------------------*/
761
+ /* Call handler */
762
+ /*------------------------------------------------------------------------*/
763
+
764
+ // Keep track of whether a response was already sent
765
+ let responseSent = false;
766
+
767
+ /**
768
+ * Redirect the user to another path or url
769
+ * @author Gabe Abrams
770
+ * @param pathOrURL the path or url to redirect to
771
+ */
772
+ const redirect = (pathOrURL: string) => {
773
+ responseSent = true;
774
+ res.redirect(pathOrURL);
775
+ };
776
+
777
+ /**
778
+ * Send text to the client (with an optional status code)
779
+ * @author Gabe Abrams
780
+ * @param text the text to send to the client
781
+ * @parm [status=200] the http status code to send
782
+ */
783
+ const send = (text: string, status: number = 200) => {
784
+ responseSent = true;
785
+ res.status(status).send(text);
786
+ };
787
+
788
+ /**
789
+ * Render an error page
790
+ * @author Gabe Abrams
791
+ * @param renderOpts object containing all arguments
792
+ * @param [renderOpts.title=An Error Occurred] title of the error box
793
+ * @param [renderOpts.description=An unknown server error occurred. Please contact support.]
794
+ * a human-readable description of the error
795
+ * @param [renderOpts.code=ReactKitErrorCode.NoCode] error code to show
796
+ * @param [renderOpts.pageTitle=renderOpts.title] title of the page/tab if it differs from
797
+ * the title of the error
798
+ * @param [renderOpts.status=500] http status code
799
+ */
800
+ const renderErrorPage = (
801
+ renderOpts: {
802
+ title?: string,
803
+ description?: string,
804
+ code?: string,
805
+ pageTitle?: string,
806
+ status?: number,
807
+ } = {},
808
+ ) => {
809
+ const html = genErrorPage(renderOpts);
810
+ send(html, renderOpts.status ?? 500);
811
+
812
+ // Log server-side error if not a session expired error or 404
813
+ if (renderOpts.status && renderOpts.status === 404) {
814
+ return;
815
+ }
816
+ if (renderOpts.title?.toLowerCase().includes('session expired')) {
817
+ return;
818
+ }
819
+ logServerEvent({
820
+ context: LogBuiltInMetadata.Context.ServerRenderedErrorPage,
821
+ error: {
822
+ message: `${renderOpts.title}: ${renderOpts.description}`,
823
+ code: renderOpts.code,
824
+ },
825
+ metadata: {
826
+ title: renderOpts.title,
827
+ description: renderOpts.description,
828
+ code: renderOpts.code,
829
+ pageTitle: renderOpts.pageTitle,
830
+ status: renderOpts.status ?? 500,
831
+ },
832
+ });
833
+ };
834
+
835
+ /**
836
+ * Render an info page
837
+ * @author Gabe Abrams
838
+ * @param renderOpts object containing all arguments
839
+ * @param renderOpts.title title of the info box
840
+ * @param renderOpts.body a human-readable text body for the info alert
841
+ */
842
+ const renderInfoPage = (
843
+ renderOpts: {
844
+ title: string,
845
+ body: string,
846
+ },
847
+ ) => {
848
+ const html = genInfoPage(renderOpts);
849
+ send(html, 200);
850
+ };
851
+
852
+ /**
853
+ * Render custom HTML
854
+ * @author Gabe Abrams
855
+ * @param htmlOpts object containing all arguments
856
+ * @param htmlOpts.html the HTML to send to the client
857
+ * @param [ejsOpts.status=200] the http status code to send
858
+ */
859
+ const renderCustomHTML = (
860
+ htmlOpts: {
861
+ html: string,
862
+ status?: number,
863
+ },
864
+ ) => {
865
+ send(htmlOpts.html, htmlOpts.status ?? 200);
866
+ };
867
+
868
+ // Call the handler
869
+ try {
870
+ const results = await opts.handler({
871
+ params: output,
872
+ req,
873
+ send,
874
+ next: () => {
875
+ responseSent = true;
876
+ next();
877
+ },
878
+ redirect,
879
+ renderErrorPage,
880
+ renderInfoPage,
881
+ renderCustomHTML,
882
+ logServerEvent,
883
+ });
884
+
885
+ // Send results to client (only if next wasn't called)
886
+ if (!responseSent) {
887
+ return handleSuccess(res, results ?? undefined);
888
+ }
889
+ } catch (err) {
890
+ // Prefix error message if needed
891
+ if (
892
+ opts.unhandledErrorMessagePrefix
893
+ && err instanceof Error
894
+ && err.message
895
+ && err.name !== 'ErrorWithCode'
896
+ ) {
897
+ err.message = `${opts.unhandledErrorMessagePrefix.trim()} ${err.message.trim()}`;
898
+ }
899
+
900
+ // Send error to client (only if next wasn't called)
901
+ if (!responseSent) {
902
+ handleError(res, err);
903
+
904
+ // Log server-side error
905
+ logServerEvent({
906
+ context: LogBuiltInMetadata.Context.ServerEndpointError,
907
+ error: err,
908
+ });
909
+
910
+ return;
911
+ }
912
+
913
+ // Log error that was not responded with
914
+ // eslint-disable-next-line no-console
915
+ console.log('Error occurred but could not be sent to client because a response was already sent:', err);
916
+ }
917
+ };
918
+ };
919
+
920
+ export default genRouteHandler;