brightspace-mcp-server 5.1.44 → 5.2.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.
- package/README.md +102 -87
- package/build/api/client.js +32 -13
- package/build/index.js +6 -2
- package/build/runtime-relay.js +24 -1
- package/build/server.js +3 -2
- package/build/sync/cleanup.js +61 -0
- package/build/sync/course-sync.js +649 -0
- package/build/sync/errors.js +8 -0
- package/build/sync/lock.js +123 -0
- package/build/sync/manifest.js +106 -0
- package/build/sync/names.js +158 -0
- package/build/sync/summary.js +70 -0
- package/build/sync/sync-cli.js +190 -0
- package/build/tools/download-course-files.js +86 -0
- package/build/tools/download-dropbox-submission-file.js +7 -1
- package/build/tools/download-file.js +24 -9
- package/build/tools/get-course-content.js +28 -11
- package/build/tools/get-syllabus.js +5 -0
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +18 -0
- package/build/tools/tool-helpers.js +5 -0
- package/build/utils/download-helpers.js +67 -4
- package/build/utils/sensitive-paths.js +237 -0
- package/package.json +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purdue Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
import { DownloadCourseFilesSchema } from "./schemas.js";
|
|
7
|
+
import { errorResponse, sanitizeError, withUpdateNotice } from "./tool-helpers.js";
|
|
8
|
+
import { log } from "../utils/logger.js";
|
|
9
|
+
import { validateContentId } from "../utils/file-validator.js";
|
|
10
|
+
import { SyncInputError, syncCourse } from "../sync/course-sync.js";
|
|
11
|
+
import { summarizeSync } from "../sync/summary.js";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
/**
|
|
15
|
+
* Register download_course_files tool
|
|
16
|
+
*/
|
|
17
|
+
export function registerDownloadCourseFiles(server, apiClient) {
|
|
18
|
+
server.registerTool("download_course_files", {
|
|
19
|
+
title: "Download Course Files",
|
|
20
|
+
description: "Download every file in a course's Content area to a folder on the host computer, in sub-folders named after the course's modules. Use this when the user wants a whole course (or one module) saved, instead of calling download_file once per file. Ask the user where to save it before calling; never guess a directory. It is safe to run again: files already on disk are skipped by default, so a rerun fetches only what is new and never overwrites the user's annotated copies. Use dryRun first to preview a large course. The answer counts what was downloaded, skipped, failed and not attempted, and what was not a file (web links, embedded videos, broken items, locked items), so do not report those as missing. If the Brightspace session expires mid-run it stops with sign-in instructions and the files already saved are kept; sign in and call it again.",
|
|
21
|
+
inputSchema: DownloadCourseFilesSchema,
|
|
22
|
+
}, async (args, extra) => {
|
|
23
|
+
try {
|
|
24
|
+
log("DEBUG", "download_course_files tool called", { args });
|
|
25
|
+
const { courseId, downloadPath, existing, moduleId, dryRun, maxFiles, maxMinutes, maxTotalMB } = DownloadCourseFilesSchema.parse(args);
|
|
26
|
+
validateContentId(courseId);
|
|
27
|
+
// Same rules as download_file's disk mode: an absolute path to a directory that exists.
|
|
28
|
+
if (!path.isAbsolute(downloadPath)) {
|
|
29
|
+
return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)", "invalidInput");
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
if (!(await fs.stat(downloadPath)).isDirectory()) {
|
|
33
|
+
return errorResponse(`Download path is not a directory: ${downloadPath}`, "destination");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error?.code === "ENOENT") {
|
|
38
|
+
return errorResponse(`Download directory does not exist: ${downloadPath}. Create it, then retry, or choose an existing directory.`, "destination");
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
let result;
|
|
43
|
+
try {
|
|
44
|
+
// A client that sent a progress token sees each file as it is handled, and a
|
|
45
|
+
// client that cancels the call (or a deadline) ends the run cleanly.
|
|
46
|
+
const progressToken = extra?._meta?.progressToken;
|
|
47
|
+
result = await syncCourse(apiClient, {
|
|
48
|
+
courseId,
|
|
49
|
+
destination: downloadPath,
|
|
50
|
+
existing,
|
|
51
|
+
moduleId,
|
|
52
|
+
dryRun,
|
|
53
|
+
maxFiles,
|
|
54
|
+
maxTotalBytes: maxTotalMB * 1024 * 1024,
|
|
55
|
+
deadlineMs: maxMinutes * 60_000,
|
|
56
|
+
signal: extra?.signal,
|
|
57
|
+
onProgress: progressToken === undefined || typeof extra?.sendNotification !== "function"
|
|
58
|
+
? undefined
|
|
59
|
+
: (event) => {
|
|
60
|
+
void extra.sendNotification({
|
|
61
|
+
method: "notifications/progress",
|
|
62
|
+
params: {
|
|
63
|
+
progressToken,
|
|
64
|
+
progress: event.done,
|
|
65
|
+
total: event.total,
|
|
66
|
+
message: `${event.type} ${JSON.stringify(event.path)}`,
|
|
67
|
+
},
|
|
68
|
+
}).catch(() => { });
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof SyncInputError)
|
|
74
|
+
return errorResponse(error.message, "invalidInput");
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const response = withUpdateNotice([{ type: "text", text: summarizeSync(result) }]);
|
|
78
|
+
if (result.authError)
|
|
79
|
+
response.isError = true;
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return sanitizeError(error);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
import { ApiError } from "../api/index.js";
|
|
7
7
|
import { DownloadDropboxSubmissionFileSchema } from "./schemas.js";
|
|
8
8
|
import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
|
|
9
|
+
import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
|
|
9
10
|
import { log } from "../utils/logger.js";
|
|
10
11
|
import { validateContentId, DISK_MAX_FILE_SIZE } from "../utils/file-validator.js";
|
|
11
|
-
import { secureStreamDownload } from "../utils/download-helpers.js";
|
|
12
|
+
import { secureStreamDownload, expectedLength } from "../utils/download-helpers.js";
|
|
12
13
|
import { DownloadError } from "../utils/download-errors.js";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import fs from "node:fs/promises";
|
|
@@ -53,6 +54,10 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
|
|
|
53
54
|
}
|
|
54
55
|
throw error;
|
|
55
56
|
}
|
|
57
|
+
const protectedReason = await sensitiveDestination(downloadPath);
|
|
58
|
+
if (protectedReason) {
|
|
59
|
+
return errorResponse(sensitiveDestinationMessage(downloadPath, protectedReason), "destination");
|
|
60
|
+
}
|
|
56
61
|
// Fetch submission metadata to get the file list and validate fileId.
|
|
57
62
|
const submissionsPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/`);
|
|
58
63
|
let targetFile = null;
|
|
@@ -119,6 +124,7 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
|
|
|
119
124
|
originalFilename,
|
|
120
125
|
body: response.body,
|
|
121
126
|
maxBytes: DISK_MAX_FILE_SIZE,
|
|
127
|
+
expectedBytes: expectedLength(response),
|
|
122
128
|
});
|
|
123
129
|
log("INFO", `download_dropbox_submission_file: saved ${result.path} (${result.size} bytes, ${result.mime})`);
|
|
124
130
|
return toolResponse({
|
|
@@ -13,8 +13,9 @@ import { checkTopicAvailability } from "./topic-availability.js";
|
|
|
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, nameForType, MAX_FILE_SIZE, DISK_MAX_FILE_SIZE, } from "../utils/file-validator.js";
|
|
16
|
-
import { secureStreamDownload, readBodyCapped } from "../utils/download-helpers.js";
|
|
16
|
+
import { secureStreamDownload, readBodyCapped, expectedLength } from "../utils/download-helpers.js";
|
|
17
17
|
import { DownloadError } from "../utils/download-errors.js";
|
|
18
|
+
import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
|
|
18
19
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
19
20
|
import { parseCoursePath, QUICKLINK_PATH } from "../utils/course-files.js";
|
|
20
21
|
import { extractPdfText } from "../utils/pdf-extractor.js";
|
|
@@ -232,6 +233,7 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
|
|
|
232
233
|
originalFilename,
|
|
233
234
|
body: response.body,
|
|
234
235
|
maxBytes: DISK_MAX_FILE_SIZE,
|
|
236
|
+
expectedBytes: expectedLength(response),
|
|
235
237
|
});
|
|
236
238
|
log("INFO", `${sourceLabel} downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
|
|
237
239
|
return toolResponse({
|
|
@@ -314,6 +316,10 @@ cookieRejectedMessage) {
|
|
|
314
316
|
}
|
|
315
317
|
throw error;
|
|
316
318
|
}
|
|
319
|
+
const protectedReason = await sensitiveDestination(downloadPath);
|
|
320
|
+
if (protectedReason) {
|
|
321
|
+
return errorResponse(sensitiveDestinationMessage(downloadPath, protectedReason), "destination");
|
|
322
|
+
}
|
|
317
323
|
}
|
|
318
324
|
// Determine download source
|
|
319
325
|
if (coursePath !== undefined) {
|
|
@@ -416,26 +422,35 @@ export function parseContentDispositionFilename(disposition) {
|
|
|
416
422
|
return null;
|
|
417
423
|
}
|
|
418
424
|
/**
|
|
419
|
-
*
|
|
425
|
+
* Request a content topic's file. Returns the response, or why the topic is
|
|
426
|
+
* not available: unreleased files can answer 403 or 404, and a release
|
|
427
|
+
* restriction is only explained when the metadata supports it, so every other
|
|
428
|
+
* failure is thrown intact. Shared with the course sync.
|
|
420
429
|
*/
|
|
421
|
-
async function
|
|
422
|
-
log("INFO", `Downloading content file: courseId=${courseId}, topicId=${topicId}`);
|
|
430
|
+
export async function openContentTopic(apiClient, courseId, topicId, signal) {
|
|
423
431
|
// Build download URL using D2L API path helper
|
|
424
432
|
const apiPath = apiClient.le(courseId, `/content/topics/${topicId}/file`);
|
|
425
|
-
// Unreleased files can return 404 as well as 403. Only explain a release
|
|
426
|
-
// restriction when the metadata supports it; keep other failures intact.
|
|
427
|
-
let response;
|
|
428
433
|
try {
|
|
429
|
-
response
|
|
434
|
+
return { response: await apiClient.getRaw(apiPath, signal ? { signal } : undefined) };
|
|
430
435
|
}
|
|
431
436
|
catch (error) {
|
|
432
437
|
if (error instanceof ApiError && (error.status === 403 || error.status === 404)) {
|
|
433
438
|
const unavailable = await checkTopicAvailability(apiClient, courseId, topicId, error.status);
|
|
434
439
|
if (unavailable)
|
|
435
|
-
return
|
|
440
|
+
return { unavailable };
|
|
436
441
|
}
|
|
437
442
|
throw error;
|
|
438
443
|
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Download a content file using topicId
|
|
447
|
+
*/
|
|
448
|
+
async function downloadContentFile(apiClient, courseId, topicId, downloadPath, customFilename) {
|
|
449
|
+
log("INFO", `Downloading content file: courseId=${courseId}, topicId=${topicId}`);
|
|
450
|
+
const opened = await openContentTopic(apiClient, courseId, topicId);
|
|
451
|
+
if ("unavailable" in opened)
|
|
452
|
+
return toolResponse(opened.unavailable);
|
|
453
|
+
const { response } = opened;
|
|
439
454
|
// Get filename from Content-Disposition header
|
|
440
455
|
const disposition = response.headers.get("Content-Disposition") ?? "";
|
|
441
456
|
const filename = parseContentDispositionFilename(disposition) ?? "download";
|
|
@@ -170,7 +170,9 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
|
|
|
170
170
|
if (typeFilter !== 'all' && !matchesTypeFilter(item, typeFilter)) {
|
|
171
171
|
continue;
|
|
172
172
|
}
|
|
173
|
-
|
|
173
|
+
// A null map means progress couldn't be read: completion is unknown,
|
|
174
|
+
// not false, so a failed read can't pass for "nothing completed".
|
|
175
|
+
const topicProgress = progressMap?.get(item.Id);
|
|
174
176
|
const topic = {
|
|
175
177
|
type: 'topic',
|
|
176
178
|
topicType,
|
|
@@ -181,7 +183,7 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
|
|
|
181
183
|
...availabilityFields(availability),
|
|
182
184
|
...(item.DueDate ? { dueDate: item.DueDate } : {}),
|
|
183
185
|
lastModified: item.LastModifiedDate ?? null,
|
|
184
|
-
isCompleted: topicProgress?.IsRead ?? false,
|
|
186
|
+
isCompleted: progressMap === null ? null : topicProgress?.IsRead ?? false,
|
|
185
187
|
...(topicProgress?.DateCompleted ? { completedDate: topicProgress.DateCompleted } : {}),
|
|
186
188
|
};
|
|
187
189
|
// Add type-specific content
|
|
@@ -194,6 +196,12 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
|
|
|
194
196
|
// Link topic — include URL
|
|
195
197
|
topic.url = item.Url ?? null;
|
|
196
198
|
}
|
|
199
|
+
// An embedded video (D2L's ContentService type) has no file behind it.
|
|
200
|
+
// Said outright so it is not taken for a file that is missing.
|
|
201
|
+
if (typeof item.TypeIdentifier === 'string' && item.TypeIdentifier.toLowerCase() === 'contentservice') {
|
|
202
|
+
topic.embeddedMedia = true;
|
|
203
|
+
topic.note = 'Embedded media: download_file cannot fetch it. Open it in Brightspace, or use get_video_transcript for its transcript.';
|
|
204
|
+
}
|
|
197
205
|
// HTML content — include body converted to markdown
|
|
198
206
|
if (item.Description?.Html) {
|
|
199
207
|
topic.content = convertHtmlToMarkdown(item.Description.Html).markdown;
|
|
@@ -301,22 +309,25 @@ export function registerGetCourseContent(server, apiClient) {
|
|
|
301
309
|
const searchTerm = moduleTitle.toLowerCase();
|
|
302
310
|
rootModules = rootModules.filter(m => m.Title.toLowerCase().includes(searchTerm));
|
|
303
311
|
}
|
|
304
|
-
// Fetch user progress for the course (graceful degradation)
|
|
305
|
-
|
|
312
|
+
// Fetch user progress for the course (graceful degradation). 403/404
|
|
313
|
+
// means this course has no progress data for the user. Any other
|
|
314
|
+
// failure leaves completion unknown, so the map stays null and every
|
|
315
|
+
// topic reports isCompleted: null rather than false.
|
|
316
|
+
let progressMap = new Map();
|
|
306
317
|
try {
|
|
307
|
-
progressArray = await apiClient.get(apiClient.le(courseId, '/content/userprogress/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
318
|
+
const progressArray = await apiClient.get(apiClient.le(courseId, '/content/userprogress/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
319
|
+
for (const p of Array.isArray(progressArray) ? progressArray : []) {
|
|
320
|
+
progressMap.set(p.ContentObjectId, p);
|
|
321
|
+
}
|
|
308
322
|
}
|
|
309
323
|
catch (error) {
|
|
310
|
-
|
|
324
|
+
if (isAuthUnavailable(error))
|
|
325
|
+
throw error;
|
|
311
326
|
if (error?.status !== 404 && error?.status !== 403) {
|
|
312
327
|
log('DEBUG', `Failed to fetch progress for course ${courseId}`, error);
|
|
328
|
+
progressMap = null;
|
|
313
329
|
}
|
|
314
330
|
}
|
|
315
|
-
// Build progress map for O(1) lookups
|
|
316
|
-
const progressMap = new Map();
|
|
317
|
-
for (const p of progressArray) {
|
|
318
|
-
progressMap.set(p.ContentObjectId, p);
|
|
319
|
-
}
|
|
320
331
|
// Recursively build content tree
|
|
321
332
|
let contentTree = await buildContentTree(apiClient, courseId, rootModules, progressMap, typeFilter, maxDepth);
|
|
322
333
|
// Apply modifiedSince filter, if requested, after the tree is built:
|
|
@@ -340,6 +351,12 @@ export function registerGetCourseContent(server, apiClient) {
|
|
|
340
351
|
topicCount,
|
|
341
352
|
moduleCount,
|
|
342
353
|
...(modifiedSince ? { modifiedSince, returned: topicCount, filteredOut } : {}),
|
|
354
|
+
...(progressMap === null
|
|
355
|
+
? {
|
|
356
|
+
progressUnavailable: "Couldn't read this user's completion progress, so every topic has isCompleted null. " +
|
|
357
|
+
"Do not report topics as not completed; try again later.",
|
|
358
|
+
}
|
|
359
|
+
: {}),
|
|
343
360
|
...(unreadable > 0
|
|
344
361
|
? {
|
|
345
362
|
notice: `The contents of ${unreadable} module(s) couldn't be read; they have children null and a ` +
|
|
@@ -13,6 +13,7 @@ import { DownloadError } from "../utils/download-errors.js";
|
|
|
13
13
|
import { MAX_FILE_SIZE } from "../utils/file-validator.js";
|
|
14
14
|
import { extractPdfText } from "../utils/pdf-extractor.js";
|
|
15
15
|
import { parseContentDispositionFilename } from "./download-file.js";
|
|
16
|
+
import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
|
|
16
17
|
import { log } from "../utils/logger.js";
|
|
17
18
|
import path from "node:path";
|
|
18
19
|
import fs from "node:fs/promises";
|
|
@@ -137,6 +138,10 @@ export function registerGetSyllabus(server, apiClient) {
|
|
|
137
138
|
}
|
|
138
139
|
throw error;
|
|
139
140
|
}
|
|
141
|
+
const protectedReason = await sensitiveDestination(downloadPath);
|
|
142
|
+
if (protectedReason) {
|
|
143
|
+
return errorResponse(sensitiveDestinationMessage(downloadPath, protectedReason), "destination");
|
|
144
|
+
}
|
|
140
145
|
}
|
|
141
146
|
// Fetch overview text
|
|
142
147
|
let overview = null;
|
package/build/tools/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { registerGetAssignmentFiles } from "./get-assignment-files.js";
|
|
|
15
15
|
export { registerGetAnnouncementFiles } from "./get-announcement-files.js";
|
|
16
16
|
export { registerGetCourseContent } from "./get-course-content.js";
|
|
17
17
|
export { registerDownloadFile, COURSE_FILE_NEEDS_COOKIE } from "./download-file.js";
|
|
18
|
+
export { registerDownloadCourseFiles } from "./download-course-files.js";
|
|
18
19
|
export { registerGetClasslistEmails } from "./get-classlist-emails.js";
|
|
19
20
|
export { registerGetRoster } from "./get-roster.js";
|
|
20
21
|
export { registerGetMyGroups } from "./get-my-groups.js";
|
package/build/tools/schemas.js
CHANGED
|
@@ -82,6 +82,24 @@ export const DownloadFileSchema = z.object({
|
|
|
82
82
|
customFilename: z.string().max(255).optional()
|
|
83
83
|
.describe("Custom filename for the downloaded file (include extension). If not provided, uses the original filename from Brightspace."),
|
|
84
84
|
});
|
|
85
|
+
export const DownloadCourseFilesSchema = z.object({
|
|
86
|
+
courseId: z.coerce.number().int().positive()
|
|
87
|
+
.describe("Course ID to download every file from."),
|
|
88
|
+
downloadPath: z.string().min(1)
|
|
89
|
+
.describe("Absolute path to an existing directory on the HOST filesystem where the course is saved. The directory must already exist; it is not created for you, so create it first if needed. Folders named after the course's modules are created inside it. Ask the user where to save the course before calling this; never guess a directory."),
|
|
90
|
+
existing: z.enum(["skip", "replace", "keep_both"]).default("skip")
|
|
91
|
+
.describe("What to do with a file that is already on disk. skip (default): leave it alone, so running this again only fetches what is new and never overwrites the user's annotated copies. replace: download it again over the old one. keep_both: save the new copy as 'name (1).ext' beside it, unless a copy of the same size is already there."),
|
|
92
|
+
moduleId: z.coerce.number().int().positive().optional()
|
|
93
|
+
.describe("Only download this module and the modules inside it (a module id from get_course_content). Omit to download the whole course."),
|
|
94
|
+
dryRun: z.boolean().default(false)
|
|
95
|
+
.describe("List the files that would be downloaded without downloading anything or creating any folder. Use it first on a large course."),
|
|
96
|
+
maxFiles: z.coerce.number().int().min(1).max(5000).default(300)
|
|
97
|
+
.describe("Most files to download in one call (default 300). Files already on disk do not count. The rest are reported as not attempted: call again to continue, since existing files are skipped."),
|
|
98
|
+
maxMinutes: z.coerce.number().int().min(1).max(120).default(8)
|
|
99
|
+
.describe("Wall-clock limit for this call in minutes (default 8). When it passes, the call stops cleanly and reports the rest as not attempted; call it again to continue, since files already on disk are skipped. Raise it only if the client allows long tool calls."),
|
|
100
|
+
maxTotalMB: z.coerce.number().int().min(1).max(1048576).default(5120)
|
|
101
|
+
.describe("Most megabytes to download in one call (default 5120, i.e. 5 GB). The rest is reported as not attempted; call again to continue."),
|
|
102
|
+
});
|
|
85
103
|
export const GetSyllabusSchema = z.object({
|
|
86
104
|
courseId: z.coerce.number().int().positive()
|
|
87
105
|
.describe("Course ID to get syllabus for."),
|
|
@@ -240,6 +240,11 @@ const DOWNLOAD_FAILURE_GUIDANCE = {
|
|
|
240
240
|
"directory and was refused. Pass customFilename to choose one yourself.",
|
|
241
241
|
tooLarge: "The file is larger than the download size limit. " +
|
|
242
242
|
"Open it from Brightspace in a browser instead.",
|
|
243
|
+
incomplete: "Download incomplete: the connection closed before the whole file arrived, so nothing was saved. " +
|
|
244
|
+
"Try again.",
|
|
245
|
+
empty: "The file is empty (0 bytes) on Brightspace, so nothing was saved.",
|
|
246
|
+
forbiddenDestination: "That folder is not an allowed place to save files: it is the home folder itself, a drive root, a hidden folder, a system folder or an application-data folder. " +
|
|
247
|
+
"Choose a folder such as ~/Documents/Brightspace, or have the person who runs the server add the folder to D2L_DOWNLOAD_ROOTS.",
|
|
243
248
|
};
|
|
244
249
|
/**
|
|
245
250
|
* True when an error means the session itself is unusable, not that one
|
|
@@ -12,6 +12,16 @@ import { pipeline } from "node:stream/promises";
|
|
|
12
12
|
import { validateDownloadPath, validateFileType, validateFileTypeOfFile, nameForType, MAX_FILE_SIZE, } from "./file-validator.js";
|
|
13
13
|
import { DownloadError } from "./download-errors.js";
|
|
14
14
|
import { log } from "./logger.js";
|
|
15
|
+
import { sensitiveDestination } from "./sensitive-paths.js";
|
|
16
|
+
/**
|
|
17
|
+
* Every write to disk starts here, so the protected-destination rule is
|
|
18
|
+
* enforced once for all tools (see sensitiveDestination).
|
|
19
|
+
*/
|
|
20
|
+
async function assertAllowedDestination(targetDir) {
|
|
21
|
+
if (await sensitiveDestination(targetDir)) {
|
|
22
|
+
throw new DownloadError("forbiddenDestination", "Destination folder is not allowed");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
/**
|
|
16
26
|
* Resolve filename conflicts by appending (1), (2), etc.
|
|
17
27
|
*
|
|
@@ -57,6 +67,7 @@ export async function resolveFilenameConflict(dir, filename) {
|
|
|
57
67
|
export async function secureDownload(options) {
|
|
58
68
|
const { targetDir, filename, data, allowedTypes } = options;
|
|
59
69
|
log("DEBUG", `secureDownload: starting download of ${filename} to ${targetDir}`);
|
|
70
|
+
await assertAllowedDestination(targetDir);
|
|
60
71
|
// Validate target directory exists and is a directory
|
|
61
72
|
try {
|
|
62
73
|
const stats = await fs.stat(targetDir);
|
|
@@ -102,6 +113,14 @@ export async function secureDownload(options) {
|
|
|
102
113
|
mime,
|
|
103
114
|
};
|
|
104
115
|
}
|
|
116
|
+
/** A planned name, checked to be exactly one safe path component and nothing else. */
|
|
117
|
+
function verbatimComponent(targetDir, name) {
|
|
118
|
+
const unsafe = name === "" || name === "." || name === ".." || /[/\\\u0000]/.test(name) || path.basename(name) !== name || Buffer.byteLength(name) > 255;
|
|
119
|
+
const rel = path.relative(path.resolve(targetDir), path.resolve(targetDir, name));
|
|
120
|
+
if (unsafe || rel !== name)
|
|
121
|
+
throw new DownloadError("badFilename", "Invalid filename");
|
|
122
|
+
return name;
|
|
123
|
+
}
|
|
105
124
|
/**
|
|
106
125
|
* secureDownload for a response body that is streamed to disk rather than
|
|
107
126
|
* held in memory, so a file's size is bounded by `maxBytes` instead of by
|
|
@@ -109,11 +128,18 @@ export async function secureDownload(options) {
|
|
|
109
128
|
* type-checked there, and only then published under its final name; a refused or
|
|
110
129
|
* failed download leaves nothing behind.
|
|
111
130
|
*
|
|
131
|
+
* With `expectedBytes` (the response's Content-Length, see expectedLength), a
|
|
132
|
+
* body that ends short of it fails and leaves nothing behind: a dropped
|
|
133
|
+
* connection must not publish a truncated file that a later sync would take
|
|
134
|
+
* for the whole one.
|
|
135
|
+
*
|
|
112
136
|
* @throws DownloadError("tooLarge") once the body passes maxBytes
|
|
137
|
+
* @throws DownloadError("incomplete") when fewer than expectedBytes arrive
|
|
113
138
|
*/
|
|
114
139
|
export async function secureStreamDownload(options) {
|
|
115
|
-
const { targetDir, filename, originalFilename, body, maxBytes } = options;
|
|
116
|
-
|
|
140
|
+
const { targetDir, filename, originalFilename, body, maxBytes, expectedBytes, replaceExisting, replaceGuard, signal, verbatimName } = options;
|
|
141
|
+
await assertAllowedDestination(targetDir);
|
|
142
|
+
const safeFilename = verbatimName ? verbatimComponent(targetDir, filename) : path.basename(validateDownloadPath(targetDir, filename));
|
|
117
143
|
const partialPath = path.join(targetDir, `.download-${randomUUID()}.part`);
|
|
118
144
|
let size = 0;
|
|
119
145
|
const limit = new Transform({
|
|
@@ -127,11 +153,34 @@ export async function secureStreamDownload(options) {
|
|
|
127
153
|
}
|
|
128
154
|
},
|
|
129
155
|
});
|
|
156
|
+
const incomplete = () => new DownloadError("incomplete", `Download incomplete: ${size} of ${expectedBytes} bytes arrived`);
|
|
130
157
|
try {
|
|
131
|
-
|
|
158
|
+
try {
|
|
159
|
+
await pipeline(Readable.fromWeb(body), limit, createWriteStream(partialPath, { flags: "wx" }), ...(signal ? [{ signal }] : []));
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
// fetch reports a connection cut mid-body as a bare "terminated".
|
|
163
|
+
if (expectedBytes !== undefined && size < expectedBytes && !(error instanceof DownloadError)) {
|
|
164
|
+
throw incomplete();
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (expectedBytes !== undefined && size < expectedBytes)
|
|
169
|
+
throw incomplete();
|
|
170
|
+
if (size === 0)
|
|
171
|
+
throw new DownloadError("empty", "File is empty (0 bytes)");
|
|
132
172
|
const type = await validateFileTypeOfFile(partialPath, undefined, safeFilename, originalFilename);
|
|
133
173
|
const { mime } = type;
|
|
134
|
-
const
|
|
174
|
+
const finalName = nameForType(safeFilename, type);
|
|
175
|
+
let finalPath;
|
|
176
|
+
const replaceTarget = path.join(targetDir, finalName);
|
|
177
|
+
if (replaceExisting && finalName === safeFilename && (!replaceGuard || (await replaceGuard(replaceTarget)))) {
|
|
178
|
+
finalPath = replaceTarget;
|
|
179
|
+
await fs.rename(partialPath, finalPath);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
finalPath = await publishExclusively(partialPath, targetDir, finalName);
|
|
183
|
+
}
|
|
135
184
|
log("INFO", `Downloaded file to ${finalPath} (${size} bytes, ${mime})`);
|
|
136
185
|
return { path: finalPath, size, mime };
|
|
137
186
|
}
|
|
@@ -139,6 +188,20 @@ export async function secureStreamDownload(options) {
|
|
|
139
188
|
await fs.rm(partialPath, { force: true });
|
|
140
189
|
}
|
|
141
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* The byte count a response promises, or undefined when it promises none we
|
|
193
|
+
* can hold it to: no Content-Length, or a Content-Encoding, where fetch hands
|
|
194
|
+
* back the decoded body and the header counts the encoded one.
|
|
195
|
+
*/
|
|
196
|
+
export function expectedLength(response) {
|
|
197
|
+
const encoding = (response.headers.get("Content-Encoding") ?? "").trim().toLowerCase();
|
|
198
|
+
if (encoding && encoding !== "identity")
|
|
199
|
+
return undefined;
|
|
200
|
+
const header = response.headers.get("Content-Length");
|
|
201
|
+
if (header === null || !/^\d+$/.test(header.trim()))
|
|
202
|
+
return undefined;
|
|
203
|
+
return Number(header);
|
|
204
|
+
}
|
|
142
205
|
/**
|
|
143
206
|
* Give a finished temporary file its final name without ever replacing an
|
|
144
207
|
* existing file. resolveFilenameConflict only reports a name that was free a
|