dce-expresskit 4.0.0-beta.10

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