brightspace-mcp-server 3.4.0 → 3.5.0
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 +3 -1
- package/build/api/cache.js +14 -1
- package/build/api/client.js +17 -5
- package/build/api/rate-limiter.js +24 -13
- package/build/auth/auth-cooldown.js +28 -4
- package/build/auth/auth-runner.js +194 -27
- package/build/auth/browser-auth.js +2 -2
- package/build/auth/credential-store.js +14 -1
- package/build/auth/duo-mfa.js +90 -6
- package/build/auth/purdue-sso.js +19 -2
- package/build/auth/session-store.js +22 -1
- package/build/auth/sso-flow.js +2 -1
- package/build/auth/suny-sso.js +8 -0
- package/build/auth-cli.js +8 -1
- package/build/setup.js +123 -25
- package/build/tools/download-file.js +24 -6
- package/build/tools/get-announcements.js +11 -5
- package/build/tools/get-assignments.js +11 -5
- package/build/tools/get-course-content.js +12 -2
- package/build/tools/get-my-grades.js +11 -5
- package/build/tools/get-upcoming-due-dates.js +15 -5
- package/build/tools/tool-helpers.js +8 -6
- package/build/update.js +38 -5
- package/build/utils/atomic-write.js +38 -12
- package/build/utils/deep-links.js +17 -3
- package/build/utils/download-helpers.js +11 -5
- package/build/utils/file-validator.js +71 -5
- package/build/utils/html-converter.js +66 -0
- package/build/utils/logger.js +19 -3
- package/package.json +1 -1
|
@@ -50,12 +50,22 @@ async function buildContentTree(apiClient, courseId, modules, progressMap, typeF
|
|
|
50
50
|
// Module: fetch children recursively (unless the depth limit is reached)
|
|
51
51
|
let processedChildren = [];
|
|
52
52
|
if (currentDepth < depthLimit) {
|
|
53
|
-
|
|
53
|
+
// The parent listing already embeds this module's immediate children
|
|
54
|
+
// in Structure. The dedicated endpoint is still asked first because it
|
|
55
|
+
// is the authoritative copy, but a module whose structure call fails —
|
|
56
|
+
// or answers with something that is not an array — must fall back to
|
|
57
|
+
// what the parent already handed us. Falling through to an empty list
|
|
58
|
+
// made a locked or erroring module look like an empty one, and under a
|
|
59
|
+
// typeFilter it dropped the module from the tree with no trace.
|
|
60
|
+
let children = null;
|
|
54
61
|
try {
|
|
55
62
|
children = await apiClient.get(apiClient.le(courseId, `/content/modules/${item.Id}/structure/`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
56
63
|
}
|
|
57
64
|
catch (e) {
|
|
58
|
-
log('DEBUG', `Failed to fetch children for module ${item.Id}:
|
|
65
|
+
log('DEBUG', `Failed to fetch children for module ${item.Id}: falling back to the embedded structure`);
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(children)) {
|
|
68
|
+
children = Array.isArray(item.Structure) ? item.Structure : [];
|
|
59
69
|
}
|
|
60
70
|
processedChildren = await buildContentTree(apiClient, courseId, children, progressMap, typeFilter, maxDepth, currentDepth + 1);
|
|
61
71
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
+
import { fetchAllItems } from "../api/paginate.js";
|
|
7
8
|
import { GetMyGradesSchema, } from "./schemas.js";
|
|
8
9
|
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
10
|
import { log } from "../utils/logger.js";
|
|
@@ -42,11 +43,16 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
42
43
|
return toolResponse({ courseId, grades });
|
|
43
44
|
}
|
|
44
45
|
// All courses case
|
|
45
|
-
// First, fetch enrolled courses
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// First, fetch enrolled courses. isActive=true has to track the
|
|
47
|
+
// configured policy rather than being pinned on: a user who set
|
|
48
|
+
// activeOnly:false is asking to see archived courses, and a query that
|
|
49
|
+
// withholds them leaves applyCourseFilter nothing to let through.
|
|
50
|
+
const enrollmentPath = apiClient.lp(`/enrollments/myenrollments/?orgUnitTypeId=3${config.courseFilter.activeOnly ? "&isActive=true" : ""}`);
|
|
51
|
+
// Enrollments arrive one page at a time; follow the bookmark chain so a
|
|
52
|
+
// long enrollment history does not silently lose its later courses.
|
|
53
|
+
const enrollmentItems = await fetchAllItems(apiClient, enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
|
|
48
54
|
// Apply course filter
|
|
49
|
-
const filteredEnrollments = applyCourseFilter(
|
|
55
|
+
const filteredEnrollments = applyCourseFilter(enrollmentItems.map(item => ({
|
|
50
56
|
id: item.OrgUnit.Id,
|
|
51
57
|
name: item.OrgUnit.Name,
|
|
52
58
|
code: item.OrgUnit.Code,
|
|
@@ -90,7 +96,7 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
90
96
|
const courses = results
|
|
91
97
|
.filter((r) => r.status === "fulfilled" && r.value !== null)
|
|
92
98
|
.map((r) => r.value);
|
|
93
|
-
log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses (out of ${
|
|
99
|
+
log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses (out of ${enrollmentItems.length} enrolled)`);
|
|
94
100
|
return toolResponse({ courses });
|
|
95
101
|
}
|
|
96
102
|
catch (error) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
+
import { fetchAllItems } from "../api/paginate.js";
|
|
7
8
|
import { GetUpcomingDueDatesSchema, } from "./schemas.js";
|
|
8
9
|
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
10
|
import { log } from "../utils/logger.js";
|
|
@@ -22,8 +23,13 @@ function unwrapList(raw) {
|
|
|
22
23
|
async function resolveCourses(apiClient, config, courseId) {
|
|
23
24
|
let items = [];
|
|
24
25
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
// isActive=true tracks the configured policy rather than being pinned on:
|
|
27
|
+
// a user who set activeOnly:false is asking to see archived courses, and a
|
|
28
|
+
// query that withholds them leaves applyCourseFilter nothing to let
|
|
29
|
+
// through. Enrollments are paged, so follow the bookmark chain — a long
|
|
30
|
+
// enrollment history would otherwise lose every course past the first page,
|
|
31
|
+
// and every deadline in those courses with it.
|
|
32
|
+
items = await fetchAllItems(apiClient, apiClient.lp(`/enrollments/myenrollments/?orgUnitTypeId=3${config.courseFilter.activeOnly ? "&isActive=true" : ""}`), { ttl: DEFAULT_CACHE_TTLS.enrollments });
|
|
27
33
|
}
|
|
28
34
|
catch (error) {
|
|
29
35
|
// Without enrollments there is no course list to walk, so only the explicit
|
|
@@ -48,14 +54,18 @@ async function resolveCourses(apiClient, config, courseId) {
|
|
|
48
54
|
/**
|
|
49
55
|
* Collect every graded, dated discussion topic for one course.
|
|
50
56
|
*
|
|
51
|
-
* Forums carry no due date themselves; it lives on each topic. A forum
|
|
52
|
-
*
|
|
53
|
-
*
|
|
57
|
+
* Forums carry no due date themselves; it lives on each topic. A hidden forum
|
|
58
|
+
* hides everything inside it, however visible its topics claim to be, so it is
|
|
59
|
+
* skipped without asking for its topics at all. A forum whose topics fail to
|
|
60
|
+
* load (e.g. no access) is skipped rather than failing the whole course,
|
|
61
|
+
* matching `getForumsOverview` in get-discussions.ts.
|
|
54
62
|
*/
|
|
55
63
|
async function fetchDiscussionDueTopics(apiClient, courseId) {
|
|
56
64
|
const forums = await apiClient.get(apiClient.le(courseId, "/discussions/forums/"), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
57
65
|
const topics = [];
|
|
58
66
|
for (const forum of unwrapList(forums)) {
|
|
67
|
+
if (forum.IsHidden === true)
|
|
68
|
+
continue;
|
|
59
69
|
try {
|
|
60
70
|
const forumTopics = await apiClient.get(apiClient.le(courseId, `/discussions/forums/${forum.ForumId}/topics/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
61
71
|
topics.push(...unwrapList(forumTopics));
|
|
@@ -59,19 +59,20 @@ export function errorResponse(message) {
|
|
|
59
59
|
const AUTH_FAILURE_GUIDANCE = {
|
|
60
60
|
busy: "A sign-in is already running in another process. Let it finish, then try again.",
|
|
61
61
|
cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered. " +
|
|
62
|
-
`Run \`${AUTH_COMMAND}\` in a terminal to retry now and see the number to enter.`,
|
|
62
|
+
`Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to retry now and see the number to enter.`,
|
|
63
63
|
unsupported: "This login needs something your AI client cannot supply, usually a code from an "
|
|
64
64
|
+ "authenticator app. " +
|
|
65
|
-
`Run \`${AUTH_COMMAND}\` in a terminal and sign in there.`,
|
|
65
|
+
`Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) and sign in there.`,
|
|
66
66
|
secureStorage: "The operating system credential store is locked or unavailable, so the saved " +
|
|
67
67
|
"password could not be read. Unlock your keychain or keyring, then try again.",
|
|
68
68
|
transport: "Brightspace could not be reached to sign in. The saved session was kept. " +
|
|
69
69
|
"Check your connection and try again in a few minutes.",
|
|
70
70
|
timeout: "The sign-in did not finish in time, usually a missed MFA prompt. " +
|
|
71
|
-
`Run \`${AUTH_COMMAND}\` in a terminal to complete it with the number visible.`,
|
|
72
|
-
failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal to see why, ` +
|
|
71
|
+
`Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to complete it with the number visible.`,
|
|
72
|
+
failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to see why, ` +
|
|
73
73
|
"or `brightspace-setup` if your saved school or username is wrong.",
|
|
74
|
-
mfaPending: "
|
|
74
|
+
mfaPending: "Approve the sign-in request on your phone (Microsoft Authenticator or Duo), " +
|
|
75
|
+
"then call this tool again — the sign-in is finishing in the background.",
|
|
75
76
|
};
|
|
76
77
|
/**
|
|
77
78
|
* mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
|
|
@@ -82,7 +83,8 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
82
83
|
*/
|
|
83
84
|
function authFailureMessage(error) {
|
|
84
85
|
if (error.kind === "mfaPending" && error.numberMatch) {
|
|
85
|
-
return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes,
|
|
86
|
+
return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes, ` +
|
|
87
|
+
"then call this tool again — the sign-in is finishing in the background.";
|
|
86
88
|
}
|
|
87
89
|
return AUTH_FAILURE_GUIDANCE[error.kind];
|
|
88
90
|
}
|
package/build/update.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* https://github.com/rohanmuppa/brightspace-mcp-server
|
|
8
8
|
*/
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
11
|
-
import { dirname, join } from "node:path";
|
|
10
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import dotenv from "dotenv";
|
|
14
14
|
dotenv.config({ quiet: true });
|
|
@@ -36,18 +36,46 @@ function getVersion() {
|
|
|
36
36
|
const pkg = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8"));
|
|
37
37
|
return pkg.version || "unknown";
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
/** Compare two directories as the filesystem sees them. */
|
|
40
|
+
export function samePath(a, b) {
|
|
41
|
+
const canonical = (p) => {
|
|
42
|
+
let full = resolve(p);
|
|
43
|
+
try {
|
|
44
|
+
full = realpathSync(full);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// A path that cannot be resolved is compared as written.
|
|
48
|
+
}
|
|
49
|
+
return process.platform === "win32" ? full.toLowerCase() : full;
|
|
50
|
+
};
|
|
51
|
+
return canonical(a) === canonical(b);
|
|
52
|
+
}
|
|
53
|
+
export function main() {
|
|
40
54
|
console.log("");
|
|
41
55
|
console.log(bold("=== Brightspace MCP Server — Update ==="));
|
|
42
56
|
console.log("");
|
|
43
57
|
// Check if we're in a git repo
|
|
58
|
+
let toplevel;
|
|
44
59
|
try {
|
|
45
|
-
run("git rev-parse --
|
|
60
|
+
toplevel = run("git rev-parse --show-toplevel", { silent: true });
|
|
46
61
|
}
|
|
47
62
|
catch {
|
|
48
63
|
console.error(red("Error: Not a git repository. Cannot update."));
|
|
49
64
|
console.error("Make sure you cloned this project with git.");
|
|
50
65
|
process.exit(1);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Git answers for the nearest enclosing repository, which for an installed
|
|
69
|
+
// copy under node_modules is the *user's own* project. Fetching and pulling
|
|
70
|
+
// origin/main there would rewrite a repository that has nothing to do with
|
|
71
|
+
// this package, so only a checkout that is itself the repository root is
|
|
72
|
+
// updated.
|
|
73
|
+
if (!samePath(toplevel, projectRoot)) {
|
|
74
|
+
console.error(red("Error: This copy is not a git checkout of brightspace-mcp-server."));
|
|
75
|
+
console.error(` It lives in ${projectRoot}, inside the repository at ${toplevel}.`);
|
|
76
|
+
console.error(" Update an installed copy with npm instead; this command is for a git clone.");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
return;
|
|
51
79
|
}
|
|
52
80
|
// Show current version
|
|
53
81
|
const currentVersion = getVersion();
|
|
@@ -126,4 +154,9 @@ function main() {
|
|
|
126
154
|
console.log(" Restart your MCP client to use the latest version.");
|
|
127
155
|
console.log("");
|
|
128
156
|
}
|
|
129
|
-
|
|
157
|
+
// `npm run update` runs this file directly. VITEST is set only by the test
|
|
158
|
+
// runner, which imports the module to exercise main() against a stubbed git;
|
|
159
|
+
// no user environment sets it.
|
|
160
|
+
if (!process.env.VITEST) {
|
|
161
|
+
main();
|
|
162
|
+
}
|
|
@@ -9,12 +9,15 @@ import * as fsSync from "node:fs";
|
|
|
9
9
|
/**
|
|
10
10
|
* Write a file so that a reader never sees a half-written one.
|
|
11
11
|
*
|
|
12
|
-
* The content is staged to a sibling temp file and then renamed over
|
|
13
|
-
* target. Rename is atomic on the same filesystem, so a crash, a signal, or
|
|
12
|
+
* The content is staged to a sibling temp file, flushed, and then renamed over
|
|
13
|
+
* the target. Rename is atomic on the same filesystem, so a crash, a signal, or
|
|
14
14
|
* a second writer mid-way leaves either the old file or the new one, never a
|
|
15
15
|
* truncated mix. The session store and the config store both hold secrets
|
|
16
16
|
* and are both written by more than one process, which is why they use this.
|
|
17
17
|
*
|
|
18
|
+
* A staging write that fails takes its temp file with it: the names are random,
|
|
19
|
+
* so an orphan is never reused and would sit next to the secret it half-wrote.
|
|
20
|
+
*
|
|
18
21
|
* On Windows a rename can fail transiently while antivirus or an indexer
|
|
19
22
|
* holds the target open, so those errors are retried a few times.
|
|
20
23
|
*/
|
|
@@ -31,12 +34,22 @@ function isTransient(error) {
|
|
|
31
34
|
export async function writeFileAtomic(target, data, options = {}) {
|
|
32
35
|
const { mode, renameImpl = (from, to) => fs.rename(from, to), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), } = options;
|
|
33
36
|
const tmp = tempPathFor(target);
|
|
34
|
-
await fs.writeFile(tmp, data, mode === undefined ? {} : { mode });
|
|
35
|
-
// writeFile's mode is subject to the umask; chmod is not.
|
|
36
|
-
if (mode !== undefined && process.platform !== "win32") {
|
|
37
|
-
await fs.chmod(tmp, mode);
|
|
38
|
-
}
|
|
39
37
|
try {
|
|
38
|
+
const handle = await fs.open(tmp, "wx", mode);
|
|
39
|
+
try {
|
|
40
|
+
await handle.writeFile(data);
|
|
41
|
+
// open's mode is subject to the umask; chmod is not.
|
|
42
|
+
if (mode !== undefined && process.platform !== "win32") {
|
|
43
|
+
await handle.chmod(mode);
|
|
44
|
+
}
|
|
45
|
+
// Flush before the rename. A rename that reaches disk ahead of the bytes
|
|
46
|
+
// it points at leaves a truncated file, which is the one outcome this
|
|
47
|
+
// module exists to rule out.
|
|
48
|
+
await handle.sync();
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await handle.close();
|
|
52
|
+
}
|
|
40
53
|
for (let attempt = 1;; attempt++) {
|
|
41
54
|
try {
|
|
42
55
|
await renameImpl(tmp, target);
|
|
@@ -58,11 +71,18 @@ export async function writeFileAtomic(target, data, options = {}) {
|
|
|
58
71
|
export function writeFileAtomicSync(target, data, options = {}) {
|
|
59
72
|
const { mode } = options;
|
|
60
73
|
const tmp = tempPathFor(target);
|
|
61
|
-
fsSync.writeFileSync(tmp, data, mode === undefined ? {} : { mode });
|
|
62
|
-
if (mode !== undefined && process.platform !== "win32") {
|
|
63
|
-
fsSync.chmodSync(tmp, mode);
|
|
64
|
-
}
|
|
65
74
|
try {
|
|
75
|
+
const fd = fsSync.openSync(tmp, "wx", mode);
|
|
76
|
+
try {
|
|
77
|
+
fsSync.writeFileSync(fd, data);
|
|
78
|
+
if (mode !== undefined && process.platform !== "win32") {
|
|
79
|
+
fsSync.fchmodSync(fd, mode);
|
|
80
|
+
}
|
|
81
|
+
fsSync.fsyncSync(fd);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
fsSync.closeSync(fd);
|
|
85
|
+
}
|
|
66
86
|
for (let attempt = 1;; attempt++) {
|
|
67
87
|
try {
|
|
68
88
|
fsSync.renameSync(tmp, target);
|
|
@@ -77,7 +97,13 @@ export function writeFileAtomicSync(target, data, options = {}) {
|
|
|
77
97
|
}
|
|
78
98
|
}
|
|
79
99
|
catch (error) {
|
|
80
|
-
|
|
100
|
+
// Cleanup must not replace the failure the caller needs to see.
|
|
101
|
+
try {
|
|
102
|
+
fsSync.rmSync(tmp, { force: true });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// The temp file outliving a failed write is the lesser problem.
|
|
106
|
+
}
|
|
81
107
|
throw error;
|
|
82
108
|
}
|
|
83
109
|
}
|
|
@@ -6,8 +6,16 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Deep links into the Brightspace web UI.
|
|
8
8
|
*
|
|
9
|
-
* The templates were harvested from live
|
|
10
|
-
* URLs the course pages themselves link
|
|
9
|
+
* The dropbox, quiz and gradebook templates were harvested from live
|
|
10
|
+
* Brightspace markup: they are the same URLs the course pages themselves link
|
|
11
|
+
* to, so they open the item directly.
|
|
12
|
+
*
|
|
13
|
+
* `discussionUrl` was NOT -- it was written from the shape of the others
|
|
14
|
+
* without live markup to check it against, and no one has confirmed it against
|
|
15
|
+
* a real tenant since. Treat it as unverified: if a student reports that a
|
|
16
|
+
* discussion link lands on the wrong page or a "not found", harvest the real
|
|
17
|
+
* URL from a course's own discussion list and fix the template here rather
|
|
18
|
+
* than assuming this one is right.
|
|
11
19
|
*/
|
|
12
20
|
/** Drop trailing slashes so the templates below join cleanly. */
|
|
13
21
|
function trimBaseUrl(baseUrl) {
|
|
@@ -28,7 +36,13 @@ export function quizUrl(baseUrl, courseId, quizId) {
|
|
|
28
36
|
export function gradebookUrl(baseUrl, courseId) {
|
|
29
37
|
return `${trimBaseUrl(baseUrl)}/d2l/lms/grades/my_grades/main.d2l?ou=${courseId}`;
|
|
30
38
|
}
|
|
31
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Link to the thread list of a discussion topic.
|
|
41
|
+
*
|
|
42
|
+
* Unverified against a live tenant -- see the note at the top of this file.
|
|
43
|
+
* The topic's forum id is available at every call site, so if the real
|
|
44
|
+
* template turns out to need one, it can be threaded through.
|
|
45
|
+
*/
|
|
32
46
|
export function discussionUrl(baseUrl, courseId, topicId) {
|
|
33
47
|
return `${trimBaseUrl(baseUrl)}/d2l/lms/discussions/threadlist.d2l?ou=${courseId}&tId=${topicId}`;
|
|
34
48
|
}
|
|
@@ -71,15 +71,21 @@ export async function secureDownload(options) {
|
|
|
71
71
|
throw new Error(`File size (${size} bytes) exceeds maximum allowed (${MAX_FILE_SIZE} bytes)`);
|
|
72
72
|
}
|
|
73
73
|
log("DEBUG", `secureDownload: file size ${size} bytes (within limit)`);
|
|
74
|
+
// Validate download path (prevent path traversal) and keep the name it
|
|
75
|
+
// sanitized. validateDownloadPath used to be called for its throw alone while
|
|
76
|
+
// the write below still used the raw filename, so a Brightspace-supplied
|
|
77
|
+
// "../../name.pdf" resolved cleanly through validation and was then written
|
|
78
|
+
// two directories above targetDir. Everything past this point — type
|
|
79
|
+
// detection included — uses the name the file actually gets on disk.
|
|
80
|
+
const validatedPath = validateDownloadPath(targetDir, filename);
|
|
81
|
+
const safeFilename = path.basename(validatedPath);
|
|
82
|
+
log("DEBUG", `secureDownload: path validated as ${validatedPath}`);
|
|
74
83
|
// Validate file type via magic bytes
|
|
75
84
|
// The filename decides which legacy Office format a CFB container is.
|
|
76
|
-
const { mime } = await validateFileType(data, allowedTypes,
|
|
85
|
+
const { mime } = await validateFileType(data, allowedTypes, safeFilename);
|
|
77
86
|
log("DEBUG", `secureDownload: file type validated as ${mime}`);
|
|
78
|
-
// Validate download path (prevent path traversal)
|
|
79
|
-
const validatedPath = validateDownloadPath(targetDir, filename);
|
|
80
|
-
log("DEBUG", `secureDownload: path validated as ${validatedPath}`);
|
|
81
87
|
// Resolve filename conflicts
|
|
82
|
-
const resolvedFilename = await resolveFilenameConflict(targetDir,
|
|
88
|
+
const resolvedFilename = await resolveFilenameConflict(targetDir, safeFilename);
|
|
83
89
|
const finalPath = path.join(targetDir, resolvedFilename);
|
|
84
90
|
log("DEBUG", `secureDownload: resolved filename to ${resolvedFilename}`);
|
|
85
91
|
// Write file to disk
|
|
@@ -38,6 +38,20 @@ const CFB_EXTENSION_MIMES = {
|
|
|
38
38
|
".pot": "application/vnd.ms-powerpoint",
|
|
39
39
|
".pps": "application/vnd.ms-powerpoint",
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* The same dead-allowlist-entry problem as CFB, one format over.
|
|
43
|
+
*
|
|
44
|
+
* file-type has no SVG detector: an SVG carrying the usual `<?xml ...?>`
|
|
45
|
+
* prolog is reported as application/xml, which is not in the allowlist, so
|
|
46
|
+
* every ordinary .svg download failed and the image/svg+xml entry below was
|
|
47
|
+
* unreachable. Reconcile against the declared extension exactly as CFB does --
|
|
48
|
+
* that admits only the format the allowlist already intended and still refuses
|
|
49
|
+
* every other flavour of XML.
|
|
50
|
+
*/
|
|
51
|
+
const XML_MIME = "application/xml";
|
|
52
|
+
const XML_EXTENSION_MIMES = {
|
|
53
|
+
".svg": "image/svg+xml",
|
|
54
|
+
};
|
|
41
55
|
/**
|
|
42
56
|
* Maximum file size for downloads (50 MB).
|
|
43
57
|
* Prevents memory exhaustion from malicious large file requests.
|
|
@@ -120,10 +134,26 @@ export function validateDownloadPath(baseDir, filename) {
|
|
|
120
134
|
* @throws Error if file type not allowed
|
|
121
135
|
*/
|
|
122
136
|
export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES, filename) {
|
|
137
|
+
// An empty body is not a text file. It reaches here when a fetch was
|
|
138
|
+
// truncated or the server answered with nothing, and the UTF-8 fallback
|
|
139
|
+
// below would otherwise wave it through as text/plain and write a zero-byte
|
|
140
|
+
// file to disk under whatever name the download was given.
|
|
141
|
+
if (buffer.length === 0) {
|
|
142
|
+
throw new DownloadError("undetectableType", "File is empty (0 bytes)");
|
|
143
|
+
}
|
|
123
144
|
// Try magic byte detection first
|
|
124
145
|
const fileTypeFromBuffer = await getFileTypeFromBuffer();
|
|
125
146
|
const detected = await fileTypeFromBuffer(buffer);
|
|
126
147
|
if (detected) {
|
|
148
|
+
if (detected.mime === XML_MIME) {
|
|
149
|
+
const ext = filename ? path.extname(filename).toLowerCase() : "";
|
|
150
|
+
const resolved = XML_EXTENSION_MIMES[ext];
|
|
151
|
+
if (resolved && allowedTypes.includes(resolved)) {
|
|
152
|
+
return { mime: resolved, ext: ext.slice(1) };
|
|
153
|
+
}
|
|
154
|
+
// Anything else falls through to the allowlist check, which refuses
|
|
155
|
+
// application/xml the way it always has.
|
|
156
|
+
}
|
|
127
157
|
if (detected.mime === CFB_MIME) {
|
|
128
158
|
const ext = filename ? path.extname(filename).toLowerCase() : "";
|
|
129
159
|
const resolved = CFB_EXTENSION_MIMES[ext];
|
|
@@ -150,9 +180,19 @@ export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES
|
|
|
150
180
|
if (decoded !== null) {
|
|
151
181
|
const noBom = decoded.charCodeAt(0) === 0xfeff ? decoded.slice(1) : decoded;
|
|
152
182
|
const head = noBom.trimStart().toLowerCase();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
183
|
+
let mime = "text/plain";
|
|
184
|
+
let ext = "txt";
|
|
185
|
+
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
|
|
186
|
+
mime = "text/html";
|
|
187
|
+
ext = "html";
|
|
188
|
+
}
|
|
189
|
+
else if (head.startsWith("<svg") || head.startsWith("<!doctype svg")) {
|
|
190
|
+
// An SVG without the XML prolog reaches the fallback instead of being
|
|
191
|
+
// detected. Naming it text/plain told the caller the wrong type for a
|
|
192
|
+
// file the allowlist has an entry for.
|
|
193
|
+
mime = "image/svg+xml";
|
|
194
|
+
ext = "svg";
|
|
195
|
+
}
|
|
156
196
|
if (allowedTypes.includes(mime)) {
|
|
157
197
|
return { mime, ext };
|
|
158
198
|
}
|
|
@@ -178,15 +218,41 @@ export function validateContentId(id) {
|
|
|
178
218
|
return id;
|
|
179
219
|
}
|
|
180
220
|
/**
|
|
181
|
-
* Validate URL
|
|
221
|
+
* Validate a URL belongs to the expected D2L origin.
|
|
182
222
|
* Prevents SSRF attacks via user-controlled URLs.
|
|
183
223
|
*
|
|
224
|
+
* A plain string prefix test is not enough: "https://purdue.brightspace.com"
|
|
225
|
+
* is a prefix of "https://purdue.brightspace.com.attacker.example/steal", so
|
|
226
|
+
* an attacker registering a hostname that starts with the school's own passes
|
|
227
|
+
* it. Compare parsed origins instead, and require any expected path prefix to
|
|
228
|
+
* end on a "/" so /d2lXXX cannot satisfy a prefix of /d2l.
|
|
229
|
+
*
|
|
184
230
|
* @param url - URL to validate
|
|
185
231
|
* @param expectedBaseUrl - Expected D2L base URL (e.g., "https://purdue.brightspace.com")
|
|
186
232
|
* @throws Error if URL doesn't match expected base
|
|
187
233
|
*/
|
|
188
234
|
export function validateBaseUrl(url, expectedBaseUrl) {
|
|
189
|
-
|
|
235
|
+
const reject = () => {
|
|
190
236
|
throw new Error(`URL must start with ${expectedBaseUrl}, got: ${url.substring(0, 50)}...`);
|
|
237
|
+
};
|
|
238
|
+
let target;
|
|
239
|
+
let expected;
|
|
240
|
+
try {
|
|
241
|
+
target = new URL(url);
|
|
242
|
+
expected = new URL(expectedBaseUrl);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return reject();
|
|
246
|
+
}
|
|
247
|
+
// Opaque origins serialize to "null", so two unrelated file: or data: URLs
|
|
248
|
+
// would compare equal. Only a real, comparable origin counts.
|
|
249
|
+
if (target.origin === "null" || target.origin !== expected.origin) {
|
|
250
|
+
return reject();
|
|
251
|
+
}
|
|
252
|
+
const basePath = expected.pathname.replace(/\/+$/, "");
|
|
253
|
+
if (basePath &&
|
|
254
|
+
target.pathname !== basePath &&
|
|
255
|
+
!target.pathname.startsWith(`${basePath}/`)) {
|
|
256
|
+
return reject();
|
|
191
257
|
}
|
|
192
258
|
}
|
|
@@ -15,9 +15,75 @@ function getTurndownService() {
|
|
|
15
15
|
headingStyle: "atx",
|
|
16
16
|
codeBlockStyle: "fenced",
|
|
17
17
|
});
|
|
18
|
+
// Turndown has no rule for <script>, <style> or <noscript>, so it falls
|
|
19
|
+
// back to emitting their text: a D2L description carrying an inline
|
|
20
|
+
// stylesheet or an analytics snippet put raw CSS and JavaScript source
|
|
21
|
+
// into the markdown the model reads. None of it is content.
|
|
22
|
+
turndownService.remove(["script", "style", "noscript"]);
|
|
23
|
+
// Turndown has no table rules either, and its fallback treats every cell
|
|
24
|
+
// as a standalone block -- a syllabus grading table came out as a flat
|
|
25
|
+
// run of paragraphs ("Homework", "30%", "Exams", "70%") with nothing
|
|
26
|
+
// left to say which weight belonged to which item. Emit GFM pipe tables
|
|
27
|
+
// so the pairing survives into the markdown a model is asked to read.
|
|
28
|
+
// Rules are registered once here (not at module scope) because the
|
|
29
|
+
// service itself is now lazily constructed on first use.
|
|
30
|
+
turndownService.addRule("tableCell", {
|
|
31
|
+
filter: ["th", "td"],
|
|
32
|
+
replacement: (content) => ` ${cellText(content)} |`,
|
|
33
|
+
});
|
|
34
|
+
turndownService.addRule("tableRow", {
|
|
35
|
+
filter: "tr",
|
|
36
|
+
replacement: (content, node) => {
|
|
37
|
+
const row = `|${content}`;
|
|
38
|
+
const table = closestTable(node);
|
|
39
|
+
const rows = table
|
|
40
|
+
? Array.from(table.querySelectorAll("tr")).filter((candidate) => closestTable(candidate) === table)
|
|
41
|
+
: [];
|
|
42
|
+
// GFM needs a delimiter line after the first row, header row or not:
|
|
43
|
+
// without one the whole block renders as a paragraph and the
|
|
44
|
+
// columns are lost again.
|
|
45
|
+
if (rows.length > 0 && rows[0] === node) {
|
|
46
|
+
const columns = cellsOf(node).length || 1;
|
|
47
|
+
return `\n${row}\n|${" --- |".repeat(columns)}`;
|
|
48
|
+
}
|
|
49
|
+
return `\n${row}`;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
turndownService.addRule("tableSection", {
|
|
53
|
+
filter: ["thead", "tbody", "tfoot"],
|
|
54
|
+
replacement: (content) => content,
|
|
55
|
+
});
|
|
56
|
+
turndownService.addRule("table", {
|
|
57
|
+
filter: "table",
|
|
58
|
+
replacement: (content) => {
|
|
59
|
+
const body = content.trim();
|
|
60
|
+
return body ? `\n\n${body}\n\n` : "";
|
|
61
|
+
},
|
|
62
|
+
});
|
|
18
63
|
}
|
|
19
64
|
return turndownService;
|
|
20
65
|
}
|
|
66
|
+
/** A cell's text, flattened so one table cell stays one table cell. */
|
|
67
|
+
function cellText(content) {
|
|
68
|
+
return content
|
|
69
|
+
.replace(/\|/g, "\\|")
|
|
70
|
+
.replace(/\s*\n+\s*/g, " ")
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
/** The nearest enclosing <table>, so a nested table is scoped to itself. */
|
|
74
|
+
function closestTable(node) {
|
|
75
|
+
let current = node.parentNode;
|
|
76
|
+
while (current) {
|
|
77
|
+
if (current.nodeName === "TABLE")
|
|
78
|
+
return current;
|
|
79
|
+
current = current.parentNode;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/** Direct <th>/<td> children of a row. */
|
|
84
|
+
function cellsOf(row) {
|
|
85
|
+
return Array.from(row.childNodes).filter((child) => child.nodeName === "TH" || child.nodeName === "TD");
|
|
86
|
+
}
|
|
21
87
|
/**
|
|
22
88
|
* Convert D2L HTML content to clean markdown.
|
|
23
89
|
* Returns both markdown (for LLM readability) and raw HTML (for fallback).
|
package/build/utils/logger.js
CHANGED
|
@@ -19,16 +19,32 @@ export function setLogLevel(level) {
|
|
|
19
19
|
*
|
|
20
20
|
* Deliberately no blanket uppercase or base32 rule: it would eat course codes
|
|
21
21
|
* like ECE264, which are exactly what a useful log line contains.
|
|
22
|
+
*
|
|
23
|
+
* Order matters: the JSON-shaped rules run before the free-text ones, so a
|
|
24
|
+
* serialized object is redacted by key and the looser rules never see the
|
|
25
|
+
* value they would otherwise re-match across the quoting.
|
|
22
26
|
*/
|
|
23
27
|
function redact(value) {
|
|
24
28
|
// JSON web tokens, wherever they appear
|
|
25
29
|
value = value.replace(/eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*)?/g, "eyJ...REDACTED");
|
|
26
30
|
// Redact Bearer tokens
|
|
27
31
|
value = value.replace(/Bearer\s+([A-Za-z0-9._~+/=-]{8})[A-Za-z0-9._~+/=-]*/g, "Bearer $1...REDACTED");
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
// JSON-
|
|
32
|
+
// JSON-serialized header fields: {"Authorization":"..."}, {"Cookie":"..."}.
|
|
33
|
+
// Runs before the raw-header rule below so a serialized object is redacted
|
|
34
|
+
// as JSON and the raw-header rule cannot then chew through the quoting.
|
|
31
35
|
value = value.replace(/("(?:authorization|cookie|set-cookie)"\s*:\s*")[^"]*(")/gi, "$1...REDACTED$2");
|
|
36
|
+
// JSON-serialized secret fields: {"password":"..."}, {"accessToken":"..."},
|
|
37
|
+
// {"refresh_token":"..."}, {"xsrfToken":"..."}. Only string values match, so
|
|
38
|
+
// a numeric field like "tokenExpiry": 1750000000 is left readable.
|
|
39
|
+
value = value.replace(/("[^"]*(?:password|passwd|secret|token|credential|api[_-]?key)[^"]*"\s*:\s*")[^"]*(")/gi, "$1...REDACTED$2");
|
|
40
|
+
// Raw header lines: "Cookie: d2lSessionVal=..." / "Set-Cookie: ...".
|
|
41
|
+
// The whole remainder of the line goes, because a cookie header carries
|
|
42
|
+
// every cookie for the host, not just the first pair. Case-insensitive and
|
|
43
|
+
// tolerant of the space HTTP actually puts after the colon -- without both,
|
|
44
|
+
// this rule only ever fired on a form no real header takes.
|
|
45
|
+
value = value.replace(/(^|[\s{[(,;])((?:set-)?cookie)\s*:[ \t]*([^\r\n]+)/gi, (_match, prefix, name, secret) => `${prefix}${name}: ${secret.slice(0, 8)}...REDACTED`);
|
|
46
|
+
// password=... / secret=... in a query string, form body, or free text.
|
|
47
|
+
value = value.replace(/\b(password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)\b(\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s&;,"']+)/gi, (_match, key, separator) => `${key}${separator}...REDACTED`);
|
|
32
48
|
// Credentials embedded in a URL: scheme://user:pass@host
|
|
33
49
|
value = value.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, "$1***:***@");
|
|
34
50
|
// Redact anything that looks like a long token (40+ chars of base64-like)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
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",
|