brightspace-mcp-server 3.10.9 → 3.10.11
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.
package/build/api/client.js
CHANGED
|
@@ -54,6 +54,43 @@ function isExpiredSessionRedirect(body, baseUrl) {
|
|
|
54
54
|
* Braces are used because D2L paths never contain them, so a substitution can
|
|
55
55
|
* never collide with a real path segment.
|
|
56
56
|
*/
|
|
57
|
+
/**
|
|
58
|
+
* An abort signal that fires after `ms` without progress. Each touch() restarts
|
|
59
|
+
* the countdown, so a slow transfer that keeps moving is never cut off while a
|
|
60
|
+
* stalled one still fails.
|
|
61
|
+
*/
|
|
62
|
+
function idleTimeout(ms) {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
let timer;
|
|
65
|
+
const clear = () => clearTimeout(timer);
|
|
66
|
+
const touch = () => {
|
|
67
|
+
clear();
|
|
68
|
+
timer = setTimeout(() => controller.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError")), ms);
|
|
69
|
+
};
|
|
70
|
+
touch();
|
|
71
|
+
return { signal: controller.signal, touch, clear };
|
|
72
|
+
}
|
|
73
|
+
/** Restart the idle timer on every chunk of the body; stop it at the end. */
|
|
74
|
+
function touchOnProgress(response, idle) {
|
|
75
|
+
if (!response.body) {
|
|
76
|
+
idle.clear();
|
|
77
|
+
return response;
|
|
78
|
+
}
|
|
79
|
+
const body = response.body.pipeThrough(new TransformStream({
|
|
80
|
+
transform(chunk, controller) {
|
|
81
|
+
idle.touch();
|
|
82
|
+
controller.enqueue(chunk);
|
|
83
|
+
},
|
|
84
|
+
flush() {
|
|
85
|
+
idle.clear();
|
|
86
|
+
},
|
|
87
|
+
}));
|
|
88
|
+
return new Response(body, {
|
|
89
|
+
status: response.status,
|
|
90
|
+
statusText: response.statusText,
|
|
91
|
+
headers: response.headers,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
57
94
|
const LP_VERSION = "{lp}";
|
|
58
95
|
const LE_VERSION = "{le}";
|
|
59
96
|
/**
|
|
@@ -429,12 +466,15 @@ export class D2LApiClient {
|
|
|
429
466
|
async makeRawRequest(path, token) {
|
|
430
467
|
const url = `${this.baseUrl}${path}`;
|
|
431
468
|
const headers = this.buildAuthHeaders(token);
|
|
469
|
+
// A file can take far longer than timeoutMs to arrive. Time out on a
|
|
470
|
+
// stall rather than on total duration, for the headers and the body alike.
|
|
471
|
+
const idle = idleTimeout(this.timeoutMs);
|
|
432
472
|
try {
|
|
433
473
|
log("DEBUG", `Requesting GET ${path} (raw)`);
|
|
434
474
|
const response = await fetch(url, {
|
|
435
475
|
method: "GET",
|
|
436
476
|
headers,
|
|
437
|
-
signal:
|
|
477
|
+
signal: idle.signal,
|
|
438
478
|
});
|
|
439
479
|
this.recordStatus(response.status);
|
|
440
480
|
// Preserve cookie material for the shared HTTP refresh path.
|
|
@@ -473,6 +513,7 @@ export class D2LApiClient {
|
|
|
473
513
|
}
|
|
474
514
|
// A legitimate HTML page: hand back an equivalent response with the
|
|
475
515
|
// body we already consumed.
|
|
516
|
+
idle.clear();
|
|
476
517
|
return new Response(body, {
|
|
477
518
|
status: response.status,
|
|
478
519
|
statusText: response.statusText,
|
|
@@ -480,9 +521,10 @@ export class D2LApiClient {
|
|
|
480
521
|
});
|
|
481
522
|
}
|
|
482
523
|
// Return raw response for caller to process
|
|
483
|
-
return response;
|
|
524
|
+
return touchOnProgress(response, idle);
|
|
484
525
|
}
|
|
485
526
|
catch (error) {
|
|
527
|
+
idle.clear();
|
|
486
528
|
// Re-throw our own errors
|
|
487
529
|
if (error instanceof ApiError ||
|
|
488
530
|
error instanceof RateLimitError ||
|
|
@@ -8,16 +8,35 @@ import { DownloadFileSchema } from "./schemas.js";
|
|
|
8
8
|
import { toolResponse, sanitizeError, errorResponse, withUpdateNotice } from "./tool-helpers.js";
|
|
9
9
|
import { log } from "../utils/logger.js";
|
|
10
10
|
import { checkTopicAvailability } from "./topic-availability.js";
|
|
11
|
-
// Path containment checks belong to
|
|
11
|
+
// Path containment checks belong to secureStreamDownload, which disk-mode downloads
|
|
12
12
|
// go through; importing it here only made it look as though this file
|
|
13
13
|
// validated anything itself. Inline mode never writes to disk, so it calls
|
|
14
14
|
// validateFileType directly to enforce the same magic-byte allowlist.
|
|
15
15
|
import { validateContentId, validateFileType, MAX_FILE_SIZE } from "../utils/file-validator.js";
|
|
16
|
-
import {
|
|
16
|
+
import { secureStreamDownload } from "../utils/download-helpers.js";
|
|
17
|
+
import { DownloadError } from "../utils/download-errors.js";
|
|
17
18
|
import { extractPdfText } from "../utils/pdf-extractor.js";
|
|
18
19
|
import { officeDocumentText } from "../utils/zip-extract.js";
|
|
19
20
|
import fs from "node:fs/promises";
|
|
20
21
|
import path from "node:path";
|
|
22
|
+
/**
|
|
23
|
+
* Maximum bytes of a file disk mode will save. Disk downloads stream straight
|
|
24
|
+
* to the file, so memory no longer bounds them the way MAX_FILE_SIZE (50 MB)
|
|
25
|
+
* bounds inline mode; this only stops a runaway body filling the disk.
|
|
26
|
+
* Lecture decks and recordings routinely pass 50 MB.
|
|
27
|
+
*/
|
|
28
|
+
const DISK_MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2 GB
|
|
29
|
+
/** The size cap for a download: inline mode buffers the file, disk mode streams it. */
|
|
30
|
+
function maxFileSize(downloadPath) {
|
|
31
|
+
return downloadPath === undefined ? MAX_FILE_SIZE : DISK_MAX_FILE_SIZE;
|
|
32
|
+
}
|
|
33
|
+
/** The refusal for a file over its mode's cap, pointing inline callers at disk mode. */
|
|
34
|
+
function tooLargeResponse(bytes, downloadPath) {
|
|
35
|
+
const hint = downloadPath === undefined
|
|
36
|
+
? " Provide an absolute downloadPath to save it to disk instead."
|
|
37
|
+
: "";
|
|
38
|
+
return errorResponse(`File too large (${Math.round(bytes / 1024 / 1024)}MB). Maximum allowed: ${maxFileSize(downloadPath) / 1024 / 1024}MB.${hint}`);
|
|
39
|
+
}
|
|
21
40
|
/**
|
|
22
41
|
* Maximum bytes of a file we'll embed inline in a tool response. Base64
|
|
23
42
|
* encoding (for images) adds ~33% overhead, so this stays well under typical
|
|
@@ -94,8 +113,8 @@ async function respondInline(buffer, originalFilename, customFilename) {
|
|
|
94
113
|
if (buffer.length > INLINE_MAX_SIZE) {
|
|
95
114
|
return errorResponse(`File too large for inline delivery (${Math.round(buffer.length / 1024 / 1024)}MB, inline max ${INLINE_MAX_SIZE / 1024 / 1024}MB). Provide an absolute downloadPath to save it to disk instead.`);
|
|
96
115
|
}
|
|
97
|
-
// Enforces the same magic-byte allowlist as disk mode (
|
|
98
|
-
//
|
|
116
|
+
// Enforces the same magic-byte allowlist as disk mode (secureStreamDownload calls
|
|
117
|
+
// validateFileTypeOfFile internally); inline mode never writes to disk, so it has
|
|
99
118
|
// to call this itself. Throws DownloadError on an unsupported or
|
|
100
119
|
// undetectable type, handled by the caller's sanitizeError.
|
|
101
120
|
const { mime } = await validateFileType(buffer, undefined, effectiveFilename);
|
|
@@ -179,20 +198,35 @@ async function respondInline(buffer, originalFilename, customFilename) {
|
|
|
179
198
|
return withUpdateNotice(content);
|
|
180
199
|
}
|
|
181
200
|
/**
|
|
182
|
-
* Finish a download once the
|
|
201
|
+
* Finish a download once the response is in hand: stream it to disk when
|
|
183
202
|
* `downloadPath` is given (unchanged response shape, plus a new `mode: "disk"`
|
|
184
|
-
* field), otherwise return the file inline in the tool response.
|
|
203
|
+
* field), otherwise buffer it and return the file inline in the tool response.
|
|
185
204
|
*/
|
|
186
|
-
async function finishDownload(
|
|
205
|
+
async function finishDownload(response, originalFilename, downloadPath, customFilename, sourceLabel) {
|
|
206
|
+
// Check Content-Length BEFORE reading the body (prevent memory exhaustion)
|
|
207
|
+
const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
|
|
208
|
+
if (contentLength > maxFileSize(downloadPath)) {
|
|
209
|
+
return tooLargeResponse(contentLength, downloadPath);
|
|
210
|
+
}
|
|
187
211
|
if (downloadPath === undefined) {
|
|
212
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
213
|
+
// Double-check actual size
|
|
214
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
215
|
+
return tooLargeResponse(buffer.length, downloadPath);
|
|
216
|
+
}
|
|
188
217
|
return respondInline(buffer, originalFilename, customFilename);
|
|
189
218
|
}
|
|
190
219
|
const effectiveFilename = customFilename || originalFilename;
|
|
191
|
-
|
|
192
|
-
|
|
220
|
+
if (!response.body) {
|
|
221
|
+
throw new DownloadError("undetectableType", "File is empty (0 bytes)");
|
|
222
|
+
}
|
|
223
|
+
// Streams to disk with path traversal prevention, file type validation,
|
|
224
|
+
// conflict resolution, and the disk size cap
|
|
225
|
+
const result = await secureStreamDownload({
|
|
193
226
|
targetDir: downloadPath,
|
|
194
227
|
filename: effectiveFilename,
|
|
195
|
-
|
|
228
|
+
body: response.body,
|
|
229
|
+
maxBytes: DISK_MAX_FILE_SIZE,
|
|
196
230
|
});
|
|
197
231
|
log("INFO", `${sourceLabel} downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
|
|
198
232
|
return toolResponse({
|
|
@@ -314,23 +348,12 @@ async function downloadContentFile(apiClient, courseId, topicId, downloadPath, c
|
|
|
314
348
|
}
|
|
315
349
|
throw error;
|
|
316
350
|
}
|
|
317
|
-
// Check Content-Length BEFORE downloading body (prevent memory exhaustion)
|
|
318
|
-
const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
|
|
319
|
-
if (contentLength > MAX_FILE_SIZE) {
|
|
320
|
-
return errorResponse(`File too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
321
|
-
}
|
|
322
351
|
// Get filename from Content-Disposition header
|
|
323
352
|
const disposition = response.headers.get("Content-Disposition") ?? "";
|
|
324
353
|
const filename = parseContentDispositionFilename(disposition) ?? "download";
|
|
325
354
|
log("DEBUG", `Content-Disposition filename: ${filename}`);
|
|
326
|
-
// Download body as buffer
|
|
327
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
328
|
-
// Double-check actual size
|
|
329
|
-
if (buffer.length > MAX_FILE_SIZE) {
|
|
330
|
-
return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
331
|
-
}
|
|
332
355
|
const originalFilename = filename;
|
|
333
|
-
return finishDownload(
|
|
356
|
+
return finishDownload(response, originalFilename, downloadPath, customFilename, "Content file");
|
|
334
357
|
}
|
|
335
358
|
/**
|
|
336
359
|
* Download a submission/feedback file using folderId + fileId
|
|
@@ -368,22 +391,16 @@ async function downloadSubmissionFile(apiClient, courseId, folderId, fileId, dow
|
|
|
368
391
|
return errorResponse(`File ID ${fileId} not found in submission. Available files: ${available.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`);
|
|
369
392
|
}
|
|
370
393
|
// Check file size before downloading
|
|
371
|
-
if (file.Size >
|
|
372
|
-
return
|
|
394
|
+
if (file.Size > maxFileSize(downloadPath)) {
|
|
395
|
+
return tooLargeResponse(file.Size, downloadPath);
|
|
373
396
|
}
|
|
374
397
|
// D2L file download URL pattern for submission files
|
|
375
398
|
// GET /d2l/api/le/(version)/(orgUnitId)/dropbox/folders/(folderId)/submissions/(submissionId)/files/(fileId)/download
|
|
376
399
|
const downloadApiPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/${submission.Id}/files/${fileId}/download`);
|
|
377
400
|
// Fetch file
|
|
378
401
|
const response = await apiClient.getRaw(downloadApiPath);
|
|
379
|
-
// Download body as buffer
|
|
380
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
381
|
-
// Double-check actual size
|
|
382
|
-
if (buffer.length > MAX_FILE_SIZE) {
|
|
383
|
-
return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
384
|
-
}
|
|
385
402
|
const originalFilename = file.FileName;
|
|
386
|
-
return finishDownload(
|
|
403
|
+
return finishDownload(response, originalFilename, downloadPath, customFilename, "Submission file");
|
|
387
404
|
}
|
|
388
405
|
/**
|
|
389
406
|
* Download an announcement attachment using newsId + fileId
|
|
@@ -396,24 +413,13 @@ async function downloadNewsAttachment(apiClient, courseId, newsId, fileId, downl
|
|
|
396
413
|
if (!file) {
|
|
397
414
|
return errorResponse(`File ID ${fileId} not found on this announcement. Available files: ${attachments.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`);
|
|
398
415
|
}
|
|
399
|
-
if (file.Size >
|
|
400
|
-
return
|
|
416
|
+
if (file.Size > maxFileSize(downloadPath)) {
|
|
417
|
+
return tooLargeResponse(file.Size, downloadPath);
|
|
401
418
|
}
|
|
402
419
|
// GET /d2l/api/le/(version)/(orgUnitId)/news/(newsItemId)/attachments/(fileId)
|
|
403
420
|
const response = await apiClient.getRaw(apiClient.le(courseId, `/news/${newsId}/attachments/${fileId}`));
|
|
404
|
-
// Check Content-Length BEFORE downloading body (prevent memory exhaustion)
|
|
405
|
-
const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
|
|
406
|
-
if (contentLength > MAX_FILE_SIZE) {
|
|
407
|
-
return errorResponse(`File too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
408
|
-
}
|
|
409
421
|
const disposition = response.headers.get("Content-Disposition") ?? "";
|
|
410
422
|
const filename = parseContentDispositionFilename(disposition) ?? file.FileName;
|
|
411
|
-
// Download body as buffer
|
|
412
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
413
|
-
// Double-check actual size
|
|
414
|
-
if (buffer.length > MAX_FILE_SIZE) {
|
|
415
|
-
return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
416
|
-
}
|
|
417
423
|
const originalFilename = filename;
|
|
418
|
-
return finishDownload(
|
|
424
|
+
return finishDownload(response, originalFilename, downloadPath, customFilename, "Announcement attachment");
|
|
419
425
|
}
|
package/build/tools/schemas.js
CHANGED
|
@@ -37,7 +37,7 @@ export const GetMyGradesSchema = z.object({
|
|
|
37
37
|
});
|
|
38
38
|
export const GetAnnouncementsSchema = z.object({
|
|
39
39
|
courseId: z.coerce.number().int().positive().optional().describe("Course ID to get announcements for. If omitted, returns recent announcements across all courses."),
|
|
40
|
-
count: z.coerce.number().int().min(1).max(50).default(10).describe("Maximum number of announcements to return"),
|
|
40
|
+
count: z.coerce.number().int().min(1).max(50).default(10).describe("Maximum number of announcements to return, newest first (1-50, default 10). There is no pagination or offset: when more announcements exist than count, only the newest are returned. To get fuller coverage, call once per course with courseId so courses do not compete for the same slots, and use modifiedSince to return only announcements changed since a cutoff."),
|
|
41
41
|
modifiedSince: z.string().datetime({ offset: true, message: "modifiedSince must be an ISO 8601 datetime, e.g. 2026-01-15T00:00:00Z" }).optional()
|
|
42
42
|
.describe("Only return announcements last modified at or after this ISO 8601 datetime (e.g. 2026-01-15T00:00:00Z). Announcements with no modified timestamp are always included. When set, the response reports how many announcements were filtered out."),
|
|
43
43
|
});
|
|
@@ -133,6 +133,8 @@ const DOWNLOAD_FAILURE_GUIDANCE = {
|
|
|
133
133
|
"Pass customFilename to choose one yourself.",
|
|
134
134
|
pathTraversal: "The name Brightspace gave this file pointed outside the download " +
|
|
135
135
|
"directory and was refused. Pass customFilename to choose one yourself.",
|
|
136
|
+
tooLarge: "The file is larger than the download size limit. " +
|
|
137
|
+
"Open it from Brightspace in a browser instead.",
|
|
136
138
|
};
|
|
137
139
|
/**
|
|
138
140
|
* True when an error means the session itself is unusable, not that one
|
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs/promises";
|
|
7
|
+
import { createWriteStream } from "node:fs";
|
|
7
8
|
import path from "node:path";
|
|
8
|
-
import {
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { Readable, Transform } from "node:stream";
|
|
11
|
+
import { pipeline } from "node:stream/promises";
|
|
12
|
+
import { validateDownloadPath, validateFileType, validateFileTypeOfFile, MAX_FILE_SIZE, } from "./file-validator.js";
|
|
13
|
+
import { DownloadError } from "./download-errors.js";
|
|
9
14
|
import { log } from "./logger.js";
|
|
10
15
|
/**
|
|
11
16
|
* Resolve filename conflicts by appending (1), (2), etc.
|
|
@@ -97,3 +102,42 @@ export async function secureDownload(options) {
|
|
|
97
102
|
mime,
|
|
98
103
|
};
|
|
99
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* secureDownload for a response body that is streamed to disk rather than
|
|
107
|
+
* held in memory, so a file's size is bounded by `maxBytes` instead of by
|
|
108
|
+
* MAX_FILE_SIZE. The body is written to a hidden temporary file in targetDir,
|
|
109
|
+
* type-checked there, and only then renamed to its final name; a refused or
|
|
110
|
+
* failed download leaves nothing behind.
|
|
111
|
+
*
|
|
112
|
+
* @throws DownloadError("tooLarge") once the body passes maxBytes
|
|
113
|
+
*/
|
|
114
|
+
export async function secureStreamDownload(options) {
|
|
115
|
+
const { targetDir, filename, body, maxBytes } = options;
|
|
116
|
+
const safeFilename = path.basename(validateDownloadPath(targetDir, filename));
|
|
117
|
+
const partialPath = path.join(targetDir, `.download-${randomUUID()}.part`);
|
|
118
|
+
let size = 0;
|
|
119
|
+
const limit = new Transform({
|
|
120
|
+
transform(chunk, _encoding, callback) {
|
|
121
|
+
size += chunk.length;
|
|
122
|
+
if (size > maxBytes) {
|
|
123
|
+
callback(new DownloadError("tooLarge", `File exceeds ${maxBytes} bytes`));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
callback(null, chunk);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
await pipeline(Readable.fromWeb(body), limit, createWriteStream(partialPath, { flags: "wx" }));
|
|
132
|
+
const { mime } = await validateFileTypeOfFile(partialPath, undefined, safeFilename);
|
|
133
|
+
const resolvedFilename = await resolveFilenameConflict(targetDir, safeFilename);
|
|
134
|
+
const finalPath = path.join(targetDir, resolvedFilename);
|
|
135
|
+
await fs.rename(partialPath, finalPath);
|
|
136
|
+
log("INFO", `Downloaded file to ${finalPath} (${size} bytes, ${mime})`);
|
|
137
|
+
return { path: finalPath, size, mime };
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
await fs.rm(partialPath, { force: true });
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
|
+
import fs from "node:fs/promises";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import sanitizeFilename from "sanitize-filename";
|
|
8
9
|
import { DownloadError } from "./download-errors.js";
|
|
@@ -21,12 +22,12 @@ import { DownloadError } from "./download-errors.js";
|
|
|
21
22
|
*/
|
|
22
23
|
// file-type v21 is ESM-only and costs ~50ms; defer the import to first use
|
|
23
24
|
// since validateFileType is already async.
|
|
24
|
-
let
|
|
25
|
-
function
|
|
26
|
-
if (!
|
|
27
|
-
|
|
25
|
+
let fileTypePromise;
|
|
26
|
+
function getFileType() {
|
|
27
|
+
if (!fileTypePromise) {
|
|
28
|
+
fileTypePromise = import("file-type");
|
|
28
29
|
}
|
|
29
|
-
return
|
|
30
|
+
return fileTypePromise;
|
|
30
31
|
}
|
|
31
32
|
const CFB_MIME = "application/x-cfb";
|
|
32
33
|
const CFB_EXTENSION_MIMES = {
|
|
@@ -147,33 +148,61 @@ export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES
|
|
|
147
148
|
throw new DownloadError("undetectableType", "File is empty (0 bytes)");
|
|
148
149
|
}
|
|
149
150
|
// Try magic byte detection first
|
|
150
|
-
const fileTypeFromBuffer = await
|
|
151
|
+
const { fileTypeFromBuffer } = await getFileType();
|
|
151
152
|
const detected = await fileTypeFromBuffer(buffer);
|
|
152
|
-
if (detected)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
153
|
+
if (detected)
|
|
154
|
+
return allowDetectedType(detected, allowedTypes, filename);
|
|
155
|
+
return allowTextType(buffer, allowedTypes);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* validateFileType for a file already on disk, so a download streamed there
|
|
159
|
+
* is never read back into memory whole. Magic bytes are read from the file
|
|
160
|
+
* itself; only a file with none, the text fallback's case, is read in, and
|
|
161
|
+
* only when it is within MAX_FILE_SIZE.
|
|
162
|
+
*/
|
|
163
|
+
export async function validateFileTypeOfFile(filePath, allowedTypes = ALLOWED_MIME_TYPES, filename) {
|
|
164
|
+
const { size } = await fs.stat(filePath);
|
|
165
|
+
if (size === 0) {
|
|
166
|
+
throw new DownloadError("undetectableType", "File is empty (0 bytes)");
|
|
167
|
+
}
|
|
168
|
+
const { fileTypeFromFile } = await getFileType();
|
|
169
|
+
const detected = await fileTypeFromFile(filePath);
|
|
170
|
+
if (detected)
|
|
171
|
+
return allowDetectedType(detected, allowedTypes, filename);
|
|
172
|
+
if (size > MAX_FILE_SIZE) {
|
|
173
|
+
throw new DownloadError("undetectableType", "Could not determine file type or type not allowed");
|
|
174
|
+
}
|
|
175
|
+
return allowTextType(await fs.readFile(filePath), allowedTypes);
|
|
176
|
+
}
|
|
177
|
+
/** The allowlist decision for a type file-type recognised from magic bytes. */
|
|
178
|
+
function allowDetectedType(detected, allowedTypes, filename) {
|
|
179
|
+
if (detected.mime === XML_MIME) {
|
|
180
|
+
const ext = filename ? path.extname(filename).toLowerCase() : "";
|
|
181
|
+
const resolved = XML_EXTENSION_MIMES[ext];
|
|
182
|
+
if (resolved && allowedTypes.includes(resolved)) {
|
|
183
|
+
return { mime: resolved, ext: ext.slice(1) };
|
|
169
184
|
}
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
// Anything else falls through to the allowlist check, which refuses
|
|
186
|
+
// application/xml the way it always has.
|
|
187
|
+
}
|
|
188
|
+
if (detected.mime === CFB_MIME) {
|
|
189
|
+
const ext = filename ? path.extname(filename).toLowerCase() : "";
|
|
190
|
+
const resolved = CFB_EXTENSION_MIMES[ext];
|
|
191
|
+
if (resolved && allowedTypes.includes(resolved)) {
|
|
192
|
+
return { mime: resolved, ext: ext.slice(1) };
|
|
172
193
|
}
|
|
173
|
-
|
|
194
|
+
throw new DownloadError("unsupportedType", `Compound File Binary with extension '${ext || "none"}' is not an allowed Office format`, CFB_MIME);
|
|
195
|
+
}
|
|
196
|
+
if (!allowedTypes.includes(detected.mime)) {
|
|
197
|
+
throw new DownloadError("unsupportedType", `File type '${detected.mime}' not allowed`, detected.mime);
|
|
174
198
|
}
|
|
175
|
-
|
|
176
|
-
|
|
199
|
+
return { mime: detected.mime, ext: detected.ext };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Fallback for text-based files with no magic-byte signature: accept if the
|
|
203
|
+
* buffer decodes as UTF-8 (rejecting binaries and NUL bytes), then sniff HTML.
|
|
204
|
+
*/
|
|
205
|
+
function allowTextType(buffer, allowedTypes) {
|
|
177
206
|
if (!buffer.includes(0)) {
|
|
178
207
|
let decoded = null;
|
|
179
208
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.11",
|
|
4
4
|
"mcpName": "io.github.RohanMuppa/brightspace",
|
|
5
5
|
"description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
|
|
6
6
|
"type": "module",
|