brightspace-d2l-mcp 1.0.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/LICENSE +24 -0
- package/README.md +269 -0
- package/build/api/cache.js +49 -0
- package/build/api/client.js +345 -0
- package/build/api/errors.js +48 -0
- package/build/api/index.js +12 -0
- package/build/api/paginate.js +70 -0
- package/build/api/rate-limiter.js +52 -0
- package/build/api/retry.js +39 -0
- package/build/api/types.js +11 -0
- package/build/api/version-discovery.js +43 -0
- package/build/auth/authenticator.js +146 -0
- package/build/auth/browser.js +82 -0
- package/build/auth/index.js +6 -0
- package/build/auth/login.js +227 -0
- package/build/auth/session-store.js +46 -0
- package/build/auth/token-manager.js +150 -0
- package/build/auth/token-mint.js +75 -0
- package/build/cli/clients.js +344 -0
- package/build/cli/main.js +162 -0
- package/build/cli/school.js +74 -0
- package/build/cli/setup.js +189 -0
- package/build/cli/ui.js +39 -0
- package/build/index.js +13 -0
- package/build/server.js +175 -0
- package/build/tools/download-file.js +167 -0
- package/build/tools/get-announcements.js +116 -0
- package/build/tools/get-assignment-files.js +159 -0
- package/build/tools/get-assignments.js +343 -0
- package/build/tools/get-classlist-emails.js +38 -0
- package/build/tools/get-course-content.js +175 -0
- package/build/tools/get-discussions.js +192 -0
- package/build/tools/get-my-courses.js +42 -0
- package/build/tools/get-my-grades.js +93 -0
- package/build/tools/get-roster.js +99 -0
- package/build/tools/get-syllabus.js +154 -0
- package/build/tools/get-todo.js +61 -0
- package/build/tools/get-upcoming-due-dates.js +138 -0
- package/build/tools/index.js +18 -0
- package/build/tools/schemas.js +91 -0
- package/build/tools/submission-status.js +71 -0
- package/build/tools/tool-helpers.js +62 -0
- package/build/types/index.js +2 -0
- package/build/utils/atomic-write.js +66 -0
- package/build/utils/config.js +60 -0
- package/build/utils/course-filter.js +24 -0
- package/build/utils/deep-links.js +13 -0
- package/build/utils/download-errors.js +14 -0
- package/build/utils/download-helpers.js +73 -0
- package/build/utils/errors.js +31 -0
- package/build/utils/file-validator.js +121 -0
- package/build/utils/html-converter.js +20 -0
- package/build/utils/logger.js +69 -0
- package/build/utils/paths.js +15 -0
- package/build/utils/pdf-extractor.js +18 -0
- package/build/utils/version.js +18 -0
- package/build/utils/zip-extract.js +139 -0
- package/package.json +104 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { writeFileAtomicSync } from "./atomic-write.js";
|
|
4
|
+
import { configPath } from "./paths.js";
|
|
5
|
+
export function readStoredConfig() {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(fs.readFileSync(configPath(), "utf8"));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function writeStoredConfig(update) {
|
|
14
|
+
const next = { ...readStoredConfig(), ...update };
|
|
15
|
+
fs.mkdirSync(path.dirname(configPath()), { recursive: true, mode: 0o700 });
|
|
16
|
+
writeFileAtomicSync(configPath(), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
17
|
+
return next;
|
|
18
|
+
}
|
|
19
|
+
export function normalizeSchoolUrl(input) {
|
|
20
|
+
const trimmed = input.trim().replace(/^["']|["']$/g, "");
|
|
21
|
+
if (!trimmed)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
|
|
25
|
+
if (url.username || url.password || !url.hostname.includes("."))
|
|
26
|
+
return null;
|
|
27
|
+
url.protocol = "https:";
|
|
28
|
+
return url.origin;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function parseIds(value) {
|
|
35
|
+
if (!value)
|
|
36
|
+
return undefined;
|
|
37
|
+
return value.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
|
38
|
+
}
|
|
39
|
+
export function loadConfig() {
|
|
40
|
+
const store = readStoredConfig();
|
|
41
|
+
const rawUrl = process.env.D2L_BASE_URL || store.baseUrl;
|
|
42
|
+
const baseUrl = rawUrl ? normalizeSchoolUrl(rawUrl) : null;
|
|
43
|
+
const activeOnly = process.env.D2L_ACTIVE_ONLY !== undefined
|
|
44
|
+
? process.env.D2L_ACTIVE_ONLY !== "false"
|
|
45
|
+
: store.activeOnly ?? true;
|
|
46
|
+
const autoLogin = process.env.D2L_AUTO_LOGIN !== undefined
|
|
47
|
+
? process.env.D2L_AUTO_LOGIN !== "false"
|
|
48
|
+
: store.autoLogin ?? true;
|
|
49
|
+
return {
|
|
50
|
+
baseUrl: baseUrl ?? "",
|
|
51
|
+
tokenTtl: process.env.D2L_TOKEN_TTL ? parseInt(process.env.D2L_TOKEN_TTL, 10) : 3600,
|
|
52
|
+
autoLogin,
|
|
53
|
+
courseFilter: {
|
|
54
|
+
includeCourseIds: parseIds(process.env.D2L_INCLUDE_COURSES) ?? store.includeCourses,
|
|
55
|
+
excludeCourseIds: parseIds(process.env.D2L_EXCLUDE_COURSES) ?? store.excludeCourses,
|
|
56
|
+
activeOnly,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { log } from "./logger.js";
|
|
2
|
+
export function applyCourseFilter(courses, config) {
|
|
3
|
+
let filtered = courses;
|
|
4
|
+
const originalCount = courses.length;
|
|
5
|
+
if (config.activeOnly) {
|
|
6
|
+
filtered = filtered.filter(c => c.isActive);
|
|
7
|
+
filtered = filtered.filter(c => c.canAccess !== false);
|
|
8
|
+
}
|
|
9
|
+
if (config.includeCourseIds && config.includeCourseIds.length > 0) {
|
|
10
|
+
filtered = filtered.filter(c => config.includeCourseIds.includes(c.id));
|
|
11
|
+
}
|
|
12
|
+
if (config.excludeCourseIds && config.excludeCourseIds.length > 0) {
|
|
13
|
+
filtered = filtered.filter(c => !config.excludeCourseIds.includes(c.id));
|
|
14
|
+
}
|
|
15
|
+
if (filtered.length !== originalCount) {
|
|
16
|
+
log("DEBUG", `Course filter: ${originalCount} -> ${filtered.length} courses`, {
|
|
17
|
+
activeOnly: config.activeOnly,
|
|
18
|
+
include: config.includeCourseIds,
|
|
19
|
+
exclude: config.excludeCourseIds,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return filtered;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=course-filter.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
function trimBaseUrl(baseUrl) {
|
|
2
|
+
return baseUrl.replace(/\/+$/, "");
|
|
3
|
+
}
|
|
4
|
+
export function assignmentUrl(baseUrl, courseId, folderId) {
|
|
5
|
+
return `${trimBaseUrl(baseUrl)}/d2l/lms/dropbox/user/folder_submit_files.d2l?db=${folderId}&grpid=0&ou=${courseId}`;
|
|
6
|
+
}
|
|
7
|
+
export function quizUrl(baseUrl, courseId, quizId) {
|
|
8
|
+
return `${trimBaseUrl(baseUrl)}/d2l/lms/quizzing/user/quiz_summary.d2l?qi=${quizId}&ou=${courseId}`;
|
|
9
|
+
}
|
|
10
|
+
export function gradebookUrl(baseUrl, courseId) {
|
|
11
|
+
return `${trimBaseUrl(baseUrl)}/d2l/lms/grades/my_grades/main.d2l?ou=${courseId}`;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=deep-links.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class DownloadError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
detail;
|
|
4
|
+
constructor(kind, message, detail) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.kind = kind;
|
|
7
|
+
this.detail = detail;
|
|
8
|
+
this.name = "DownloadError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function isSafeDetail(value) {
|
|
12
|
+
return value.length <= 64 && /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/.test(value);
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=download-errors.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { validateDownloadPath, validateFileType, MAX_FILE_SIZE } from "./file-validator.js";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
export async function resolveFilenameConflict(dir, filename) {
|
|
6
|
+
const fullPath = path.join(dir, filename);
|
|
7
|
+
try {
|
|
8
|
+
await fs.access(fullPath);
|
|
9
|
+
// File exists, need to resolve conflict
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// File doesn't exist, use original name
|
|
13
|
+
return filename;
|
|
14
|
+
}
|
|
15
|
+
// Parse filename into name and extension
|
|
16
|
+
const ext = path.extname(filename);
|
|
17
|
+
const basename = path.basename(filename, ext);
|
|
18
|
+
// Try filename(1), filename(2), etc.
|
|
19
|
+
for (let i = 1; i <= 100; i++) {
|
|
20
|
+
const candidate = `${basename}(${i})${ext}`;
|
|
21
|
+
const candidatePath = path.join(dir, candidate);
|
|
22
|
+
try {
|
|
23
|
+
await fs.access(candidatePath);
|
|
24
|
+
// File exists, try next
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// File doesn't exist, use this name
|
|
28
|
+
return candidate;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error("Could not resolve filename conflict after 100 attempts");
|
|
32
|
+
}
|
|
33
|
+
export async function secureDownload(options) {
|
|
34
|
+
const { targetDir, filename, data, allowedTypes } = options;
|
|
35
|
+
log("DEBUG", `secureDownload: starting download of ${filename} to ${targetDir}`);
|
|
36
|
+
// Validate target directory exists and is a directory
|
|
37
|
+
try {
|
|
38
|
+
const stats = await fs.stat(targetDir);
|
|
39
|
+
if (!stats.isDirectory()) {
|
|
40
|
+
throw new Error(`Target path is not a directory: ${targetDir}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === "ENOENT") {
|
|
45
|
+
throw new Error(`Target directory does not exist: ${targetDir}`);
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
// Validate file size
|
|
50
|
+
const size = data.byteLength;
|
|
51
|
+
if (size > MAX_FILE_SIZE) {
|
|
52
|
+
throw new Error(`File size (${size} bytes) exceeds maximum allowed (${MAX_FILE_SIZE} bytes)`);
|
|
53
|
+
}
|
|
54
|
+
log("DEBUG", `secureDownload: file size ${size} bytes (within limit)`);
|
|
55
|
+
const { mime } = await validateFileType(data, allowedTypes, filename);
|
|
56
|
+
log("DEBUG", `secureDownload: file type validated as ${mime}`);
|
|
57
|
+
// Validate download path (prevent path traversal)
|
|
58
|
+
const validatedPath = validateDownloadPath(targetDir, filename);
|
|
59
|
+
log("DEBUG", `secureDownload: path validated as ${validatedPath}`);
|
|
60
|
+
// Resolve filename conflicts
|
|
61
|
+
const resolvedFilename = await resolveFilenameConflict(targetDir, filename);
|
|
62
|
+
const finalPath = path.join(targetDir, resolvedFilename);
|
|
63
|
+
log("DEBUG", `secureDownload: resolved filename to ${resolvedFilename}`);
|
|
64
|
+
// Write file to disk
|
|
65
|
+
await fs.writeFile(finalPath, data);
|
|
66
|
+
log("INFO", `Downloaded file to ${finalPath} (${size} bytes, ${mime})`);
|
|
67
|
+
return {
|
|
68
|
+
path: finalPath,
|
|
69
|
+
size,
|
|
70
|
+
mime,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=download-helpers.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class AuthError extends Error {
|
|
2
|
+
cause;
|
|
3
|
+
constructor(message, cause) {
|
|
4
|
+
super(`[PBMCP-1001] ${message}`);
|
|
5
|
+
this.cause = cause;
|
|
6
|
+
this.name = "AuthError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class TokenExpiredError extends AuthError {
|
|
10
|
+
expiredAt;
|
|
11
|
+
constructor(expiredAt) {
|
|
12
|
+
super(`[PBMCP-1002] Token expired at ${new Date(expiredAt).toISOString()}`);
|
|
13
|
+
this.expiredAt = expiredAt;
|
|
14
|
+
this.name = "TokenExpiredError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class BrowserAuthError extends AuthError {
|
|
18
|
+
step;
|
|
19
|
+
constructor(message, step, cause) {
|
|
20
|
+
super(`[PBMCP-1003] Browser auth failed at step "${step}": ${message}`, cause);
|
|
21
|
+
this.step = step;
|
|
22
|
+
this.name = "BrowserAuthError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class SessionStoreError extends AuthError {
|
|
26
|
+
constructor(message, cause) {
|
|
27
|
+
super(`[PBMCP-1004] Session store error: ${message}`, cause);
|
|
28
|
+
this.name = "SessionStoreError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
3
|
+
import sanitizeFilename from "sanitize-filename";
|
|
4
|
+
import { DownloadError } from "./download-errors.js";
|
|
5
|
+
const CFB_MIME = "application/x-cfb";
|
|
6
|
+
const CFB_EXTENSION_MIMES = {
|
|
7
|
+
".doc": "application/msword",
|
|
8
|
+
".dot": "application/msword",
|
|
9
|
+
".xls": "application/vnd.ms-excel",
|
|
10
|
+
".xlt": "application/vnd.ms-excel",
|
|
11
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
12
|
+
".pot": "application/vnd.ms-powerpoint",
|
|
13
|
+
".pps": "application/vnd.ms-powerpoint",
|
|
14
|
+
};
|
|
15
|
+
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
16
|
+
export const ALLOWED_MIME_TYPES = [
|
|
17
|
+
// Documents
|
|
18
|
+
"application/pdf",
|
|
19
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
20
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
21
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
22
|
+
"application/msword",
|
|
23
|
+
"application/vnd.ms-powerpoint",
|
|
24
|
+
"application/vnd.ms-excel",
|
|
25
|
+
"image/png",
|
|
26
|
+
"image/jpeg",
|
|
27
|
+
"image/gif",
|
|
28
|
+
"image/webp",
|
|
29
|
+
"image/svg+xml",
|
|
30
|
+
// Text
|
|
31
|
+
"text/plain",
|
|
32
|
+
"text/csv",
|
|
33
|
+
"text/html",
|
|
34
|
+
// Data
|
|
35
|
+
"application/json",
|
|
36
|
+
// Archives
|
|
37
|
+
"application/zip",
|
|
38
|
+
"application/x-zip-compressed",
|
|
39
|
+
// Media
|
|
40
|
+
"video/mp4",
|
|
41
|
+
"audio/mpeg",
|
|
42
|
+
"audio/wav",
|
|
43
|
+
];
|
|
44
|
+
export function validateDownloadPath(baseDir, filename) {
|
|
45
|
+
let decoded;
|
|
46
|
+
try {
|
|
47
|
+
decoded = decodeURIComponent(filename);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
decoded = filename;
|
|
51
|
+
}
|
|
52
|
+
// Sanitize filename (removes path separators, null bytes, etc.)
|
|
53
|
+
const sanitized = sanitizeFilename(decoded);
|
|
54
|
+
if (!sanitized || sanitized.length === 0) {
|
|
55
|
+
throw new DownloadError("badFilename", "Invalid filename after sanitization");
|
|
56
|
+
}
|
|
57
|
+
// Resolve full path
|
|
58
|
+
const fullPath = path.resolve(baseDir, sanitized);
|
|
59
|
+
const resolvedBase = path.resolve(baseDir);
|
|
60
|
+
// Verify resolved path is within base directory
|
|
61
|
+
if (!fullPath.startsWith(resolvedBase + path.sep) &&
|
|
62
|
+
fullPath !== resolvedBase) {
|
|
63
|
+
throw new DownloadError("pathTraversal", "Path traversal detected");
|
|
64
|
+
}
|
|
65
|
+
return fullPath;
|
|
66
|
+
}
|
|
67
|
+
export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES, filename) {
|
|
68
|
+
// Try magic byte detection first
|
|
69
|
+
const detected = await fileTypeFromBuffer(buffer);
|
|
70
|
+
if (detected) {
|
|
71
|
+
if (detected.mime === CFB_MIME) {
|
|
72
|
+
const ext = filename ? path.extname(filename).toLowerCase() : "";
|
|
73
|
+
const resolved = CFB_EXTENSION_MIMES[ext];
|
|
74
|
+
if (resolved && allowedTypes.includes(resolved)) {
|
|
75
|
+
return { mime: resolved, ext: ext.slice(1) };
|
|
76
|
+
}
|
|
77
|
+
throw new DownloadError("unsupportedType", `Compound File Binary with extension '${ext || "none"}' is not an allowed Office format`, CFB_MIME);
|
|
78
|
+
}
|
|
79
|
+
if (!allowedTypes.includes(detected.mime)) {
|
|
80
|
+
throw new DownloadError("unsupportedType", `File type '${detected.mime}' not allowed`, detected.mime);
|
|
81
|
+
}
|
|
82
|
+
return { mime: detected.mime, ext: detected.ext };
|
|
83
|
+
}
|
|
84
|
+
// Fallback for text-based files with no magic-byte signature: accept if the
|
|
85
|
+
// buffer decodes as UTF-8 (rejecting binaries and NUL bytes), then sniff HTML.
|
|
86
|
+
if (!buffer.includes(0)) {
|
|
87
|
+
let decoded = null;
|
|
88
|
+
try {
|
|
89
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Invalid UTF-8, so treat as binary.
|
|
93
|
+
}
|
|
94
|
+
if (decoded !== null) {
|
|
95
|
+
const noBom = decoded.charCodeAt(0) === 0xfeff ? decoded.slice(1) : decoded;
|
|
96
|
+
const head = noBom.trimStart().toLowerCase();
|
|
97
|
+
const isHtml = head.startsWith("<!doctype html") || head.startsWith("<html");
|
|
98
|
+
const mime = isHtml ? "text/html" : "text/plain";
|
|
99
|
+
const ext = isHtml ? "html" : "txt";
|
|
100
|
+
if (allowedTypes.includes(mime)) {
|
|
101
|
+
return { mime, ext };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw new DownloadError("undetectableType", "Could not determine file type or type not allowed");
|
|
106
|
+
}
|
|
107
|
+
export function validateContentId(id) {
|
|
108
|
+
if (typeof id !== "number") {
|
|
109
|
+
throw new Error("Content ID must be a number");
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
112
|
+
throw new Error("Content ID must be a positive integer");
|
|
113
|
+
}
|
|
114
|
+
return id;
|
|
115
|
+
}
|
|
116
|
+
export function validateBaseUrl(url, expectedBaseUrl) {
|
|
117
|
+
if (!url.startsWith(expectedBaseUrl)) {
|
|
118
|
+
throw new Error(`URL must start with ${expectedBaseUrl}, got: ${url.substring(0, 50)}...`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=file-validator.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import TurndownService from "turndown";
|
|
2
|
+
const turndownService = new TurndownService({
|
|
3
|
+
headingStyle: "atx",
|
|
4
|
+
codeBlockStyle: "fenced",
|
|
5
|
+
});
|
|
6
|
+
export function convertHtmlToMarkdown(html) {
|
|
7
|
+
// Handle null/empty input
|
|
8
|
+
if (!html || html.trim().length === 0) {
|
|
9
|
+
return { markdown: "", html: "" };
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const markdown = turndownService.turndown(html);
|
|
13
|
+
return { markdown, html };
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
// If conversion fails, fallback to raw HTML
|
|
17
|
+
return { markdown: html, html };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=html-converter.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
let currentLevel = "INFO";
|
|
2
|
+
const LEVEL_ORDER = {
|
|
3
|
+
DEBUG: 0,
|
|
4
|
+
INFO: 1,
|
|
5
|
+
WARN: 2,
|
|
6
|
+
ERROR: 3,
|
|
7
|
+
};
|
|
8
|
+
export function setLogLevel(level) {
|
|
9
|
+
currentLevel = level;
|
|
10
|
+
}
|
|
11
|
+
function redact(value) {
|
|
12
|
+
// JSON web tokens, wherever they appear
|
|
13
|
+
value = value.replace(/eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*)?/g, "eyJ...REDACTED");
|
|
14
|
+
// Redact Bearer tokens
|
|
15
|
+
value = value.replace(/Bearer\s+([A-Za-z0-9._~+/=-]{8})[A-Za-z0-9._~+/=-]*/g, "Bearer $1...REDACTED");
|
|
16
|
+
// Redact cookie: prefixed tokens
|
|
17
|
+
value = value.replace(/cookie:([^\s]{8})[^\s]*/g, "cookie:$1...REDACTED");
|
|
18
|
+
// JSON-serialized header fields: {"Authorization":"..."}, {"Cookie":"..."}
|
|
19
|
+
value = value.replace(/("(?:authorization|cookie|set-cookie)"\s*:\s*")[^"]*(")/gi, "$1...REDACTED$2");
|
|
20
|
+
// Credentials embedded in a URL: scheme://user:pass@host
|
|
21
|
+
value = value.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, "$1***:***@");
|
|
22
|
+
// Redact anything that looks like a long token (40+ chars of base64-like)
|
|
23
|
+
value = value.replace(/([A-Za-z0-9._~+/=-]{40,})/g, (match) => match.substring(0, 8) + "...REDACTED");
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
const MAX_ARG_LENGTH = 2000;
|
|
27
|
+
function serializeArg(arg) {
|
|
28
|
+
if (arg instanceof Error) {
|
|
29
|
+
const frame = arg.stack?.split("\n").find((line) => line.trim().startsWith("at ")) ?? "";
|
|
30
|
+
return `${arg.name}: ${arg.message}${frame ? ` ${frame.trim()}` : ""}`;
|
|
31
|
+
}
|
|
32
|
+
if (typeof arg === "string")
|
|
33
|
+
return arg;
|
|
34
|
+
if (typeof arg === "object" && arg !== null) {
|
|
35
|
+
const seen = new WeakSet();
|
|
36
|
+
try {
|
|
37
|
+
const json = JSON.stringify(arg, (_key, value) => {
|
|
38
|
+
if (typeof value === "bigint")
|
|
39
|
+
return value.toString();
|
|
40
|
+
if (typeof value === "object" && value !== null) {
|
|
41
|
+
if (seen.has(value))
|
|
42
|
+
return "[Circular]";
|
|
43
|
+
seen.add(value);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
});
|
|
47
|
+
return json.length > MAX_ARG_LENGTH ? `${json.slice(0, MAX_ARG_LENGTH)}...` : json;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return String(arg);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return String(arg);
|
|
54
|
+
}
|
|
55
|
+
export function log(level, message, ...args) {
|
|
56
|
+
if (LEVEL_ORDER[level] < LEVEL_ORDER[currentLevel])
|
|
57
|
+
return;
|
|
58
|
+
const timestamp = new Date().toISOString();
|
|
59
|
+
const safeMessage = redact(message);
|
|
60
|
+
const safeArgs = args.map((arg) => redact(serializeArg(arg)));
|
|
61
|
+
console.error(`[${timestamp}] [${level}] ${safeMessage}`, ...safeArgs);
|
|
62
|
+
}
|
|
63
|
+
// Override console.log in production to prevent accidental stdout writes
|
|
64
|
+
export function enableStdoutGuard() {
|
|
65
|
+
console.log = (...args) => {
|
|
66
|
+
console.error("[WARN] console.log intercepted (would corrupt stdio):", ...args);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
export const PACKAGE_NAME = "brightspace-d2l-mcp";
|
|
4
|
+
export const SERVER_NAME = "brightspace";
|
|
5
|
+
export const SETUP_COMMAND = `npx -y ${PACKAGE_NAME}@latest setup`;
|
|
6
|
+
export const LOGIN_COMMAND = `npx -y ${PACKAGE_NAME}@latest login`;
|
|
7
|
+
export function dataDir() {
|
|
8
|
+
return process.env.BRIGHTSPACE_MCP_HOME || path.join(os.homedir(), ".brightspace-d2l-mcp");
|
|
9
|
+
}
|
|
10
|
+
export const configPath = () => path.join(dataDir(), "config.json");
|
|
11
|
+
export const sessionPath = () => path.join(dataDir(), "session.json");
|
|
12
|
+
export const browserProfileDir = () => path.join(dataDir(), "browser-profile");
|
|
13
|
+
export const savedCookiesPath = () => path.join(dataDir(), "browser-cookies.json");
|
|
14
|
+
export const loginLockPath = () => path.join(dataDir(), "login.lock");
|
|
15
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { extractText } from "unpdf";
|
|
2
|
+
import { log } from "./logger.js";
|
|
3
|
+
export async function extractPdfText(buffer) {
|
|
4
|
+
try {
|
|
5
|
+
const result = await extractText(new Uint8Array(buffer), {
|
|
6
|
+
mergePages: true,
|
|
7
|
+
});
|
|
8
|
+
return {
|
|
9
|
+
text: result.text,
|
|
10
|
+
totalPages: result.totalPages,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
log("ERROR", "Failed to extract text from PDF", error);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=pdf-extractor.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
let cached = null;
|
|
5
|
+
export function packageVersion() {
|
|
6
|
+
if (cached)
|
|
7
|
+
return cached;
|
|
8
|
+
try {
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
// build/utils/version.js -> package.json two levels up
|
|
11
|
+
cached = JSON.parse(readFileSync(resolve(here, "..", "..", "package.json"), "utf8")).version ?? "0.0.0";
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
cached = "0.0.0";
|
|
15
|
+
}
|
|
16
|
+
return cached;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=version.js.map
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { inflateRawSync } from "node:zlib";
|
|
2
|
+
const EOCD_SIGNATURE = 0x06054b50;
|
|
3
|
+
const CENTRAL_FILE_SIGNATURE = 0x02014b50;
|
|
4
|
+
const EOCD_MIN_SIZE = 22;
|
|
5
|
+
const EOCD_MAX_COMMENT = 0xffff;
|
|
6
|
+
const ZIP64_MARKER = 0xffffffff;
|
|
7
|
+
const METHOD_STORED = 0;
|
|
8
|
+
const METHOD_DEFLATE = 8;
|
|
9
|
+
function findEocd(buffer) {
|
|
10
|
+
const earliest = Math.max(0, buffer.length - EOCD_MIN_SIZE - EOCD_MAX_COMMENT);
|
|
11
|
+
for (let i = buffer.length - EOCD_MIN_SIZE; i >= earliest; i--) {
|
|
12
|
+
if (buffer.readUInt32LE(i) === EOCD_SIGNATURE)
|
|
13
|
+
return i;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function readCentralDirectory(buffer) {
|
|
18
|
+
const eocd = findEocd(buffer);
|
|
19
|
+
if (eocd === null)
|
|
20
|
+
return null;
|
|
21
|
+
const count = buffer.readUInt16LE(eocd + 10);
|
|
22
|
+
let offset = buffer.readUInt32LE(eocd + 16);
|
|
23
|
+
if (offset === ZIP64_MARKER)
|
|
24
|
+
return null; // ZIP64 is out of scope.
|
|
25
|
+
const entries = [];
|
|
26
|
+
for (let i = 0; i < count; i++) {
|
|
27
|
+
if (offset + 46 > buffer.length)
|
|
28
|
+
return null;
|
|
29
|
+
if (buffer.readUInt32LE(offset) !== CENTRAL_FILE_SIGNATURE)
|
|
30
|
+
return null;
|
|
31
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
32
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
33
|
+
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
|
34
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
35
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
36
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
37
|
+
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
|
38
|
+
const nameStart = offset + 46;
|
|
39
|
+
if (nameStart + nameLength > buffer.length)
|
|
40
|
+
return null;
|
|
41
|
+
entries.push({
|
|
42
|
+
name: buffer.subarray(nameStart, nameStart + nameLength).toString("utf-8"),
|
|
43
|
+
method,
|
|
44
|
+
compressedSize,
|
|
45
|
+
uncompressedSize,
|
|
46
|
+
localHeaderOffset,
|
|
47
|
+
});
|
|
48
|
+
offset = nameStart + nameLength + extraLength + commentLength;
|
|
49
|
+
}
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
function readEntryData(buffer, entry) {
|
|
53
|
+
const header = entry.localHeaderOffset;
|
|
54
|
+
if (header + 30 > buffer.length)
|
|
55
|
+
return null;
|
|
56
|
+
// The local header repeats the name and extra lengths, and its extra field
|
|
57
|
+
// can differ in length from the central one, so read them from here.
|
|
58
|
+
const nameLength = buffer.readUInt16LE(header + 26);
|
|
59
|
+
const extraLength = buffer.readUInt16LE(header + 28);
|
|
60
|
+
const start = header + 30 + nameLength + extraLength;
|
|
61
|
+
const end = start + entry.compressedSize;
|
|
62
|
+
if (end > buffer.length)
|
|
63
|
+
return null;
|
|
64
|
+
const raw = buffer.subarray(start, end);
|
|
65
|
+
if (entry.method === METHOD_STORED)
|
|
66
|
+
return Buffer.from(raw);
|
|
67
|
+
if (entry.method !== METHOD_DEFLATE)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
return inflateRawSync(raw);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function extractZipEntry(buffer, entryName) {
|
|
77
|
+
try {
|
|
78
|
+
const entries = readCentralDirectory(buffer);
|
|
79
|
+
if (!entries)
|
|
80
|
+
return null;
|
|
81
|
+
const entry = entries.find((e) => e.name === entryName);
|
|
82
|
+
if (!entry)
|
|
83
|
+
return null;
|
|
84
|
+
return readEntryData(buffer, entry);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function listZipEntries(buffer) {
|
|
91
|
+
try {
|
|
92
|
+
return readCentralDirectory(buffer)?.map((e) => e.name) ?? null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const XML_ENTITIES = {
|
|
99
|
+
"&": "&",
|
|
100
|
+
"<": "<",
|
|
101
|
+
">": ">",
|
|
102
|
+
""": '"',
|
|
103
|
+
"'": "'",
|
|
104
|
+
"'": "'",
|
|
105
|
+
};
|
|
106
|
+
function xmlToText(xml) {
|
|
107
|
+
return xml
|
|
108
|
+
.replace(/<[^>]+>/g, " ")
|
|
109
|
+
.replace(/&(?:amp|lt|gt|quot|apos|#39);/g, (m) => XML_ENTITIES[m] ?? m)
|
|
110
|
+
.replace(/\s+/g, " ")
|
|
111
|
+
.trim();
|
|
112
|
+
}
|
|
113
|
+
export function officeDocumentText(buffer) {
|
|
114
|
+
const names = listZipEntries(buffer);
|
|
115
|
+
if (!names)
|
|
116
|
+
return null;
|
|
117
|
+
const read = (name) => xmlToText(extractZipEntry(buffer, name)?.toString("utf-8") ?? "");
|
|
118
|
+
let text = "";
|
|
119
|
+
if (names.includes("word/document.xml")) {
|
|
120
|
+
text = read("word/document.xml");
|
|
121
|
+
}
|
|
122
|
+
else if (names.includes("xl/sharedStrings.xml")) {
|
|
123
|
+
text = read("xl/sharedStrings.xml");
|
|
124
|
+
}
|
|
125
|
+
else if (names.some((n) => n.startsWith("ppt/slides/slide"))) {
|
|
126
|
+
const slides = names
|
|
127
|
+
.filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))
|
|
128
|
+
.sort((a, b) => slideNumber(a) - slideNumber(b));
|
|
129
|
+
text = slides.map(read).filter(Boolean).join("\n\n");
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return text.length > 0 ? text : null;
|
|
135
|
+
}
|
|
136
|
+
function slideNumber(name) {
|
|
137
|
+
return Number(/slide(\d+)\.xml$/.exec(name)?.[1] ?? 0);
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=zip-extract.js.map
|