neuro-canvas-lms 2.11.0 → 2.13.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/AGENTS.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  This repo, **Neuro-CanvasLMS** (`neuro-canvas-lms`), is a read-only Canvas LMS connector (remote MCP
4
4
  server on Cloudflare Workers) that each user deploys to their **own** Cloudflare account. It works
5
- with any Canvas school; nothing school-specific is in the code (the school comes from Worker vars).
6
- It is a community project provided as is.
5
+ with any Canvas school that lets students create access tokens; nothing school-specific is in the
6
+ code (the school comes from Worker vars). It is a community project provided as is.
7
7
 
8
8
  - To install, update, reconnect, uninstall or troubleshoot: follow
9
9
  `.claude/skills/canvas-connector-setup/SKILL.md` (Claude Code loads it as a skill; other agents
@@ -26,11 +26,16 @@ Code map:
26
26
  - Files: `file-content.mjs` (text extraction), `file-types.mjs` (file type detection shared by all
27
27
  readers), `zip-content.mjs` (server-side ZIP unpacking), `find-files.mjs` (finds files a course
28
28
  links to when its Files list is hidden), `external-links.mjs` (Google/Dropbox/GitHub share links →
29
- the file itself), `module-file.mjs` + `file-resource.mjs` (original files as MCP resources).
29
+ the file itself), `module-file.mjs` + `file-resource.mjs` (original files as MCP resources),
30
+ `download-link.mjs` (one-time, minutes-long Canvas storage link so coding agents can curl an original
31
+ file to disk).
30
32
  - `owner.mjs`: owner binding, allowed OAuth clients. `i18n.mjs` + `pages.mjs`: web pages.
33
+ `page-scene.mjs`: their one script (allowed by its hash in the CSP; pages stay off the network):
34
+ the black-hole scene and the step-by-step authorization. Everything works without it.
31
35
  - `update-check.mjs`: once a day reads the newest version number from npm; when it is newer the AI
32
36
  tells the user how to update. Also the `canvas_connector_version` tool.
33
- - `installer/`: the setup wizard. Re-running it asks once (y/n), then updates with the saved
37
+ - `installer/`: the setup wizard. On a new Cloudflare account it registers a random workers.dev
38
+ subdomain through Cloudflare's API. Re-running it asks once (y/n), then updates with the saved
34
39
  settings (without a terminal it does not ask). `installer/verify.mjs`: the post-deploy online
35
40
  check, also run alone by `npx -y neuro-canvas-lms check`.
36
41
  - `version.mjs`: shown on the home page and /healthz, compared with npm by update-check.mjs.
package/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  **English** · [简体中文](docs/README.zh-CN.md) · [Español](docs/README.es.md) · [日本語](docs/README.ja.md) · [हिन्दी](docs/README.hi.md)
4
4
 
5
- **Wire your Canvas LMS straight into Claude and ChatGPT.** Your courses, deadlines, grades and lecture files become part of what your AI knows, in any chat and on any device. Works with any school that uses Canvas.
5
+ ![Neuro-CanvasLMS answering "What's due this week?" in Claude](https://raw.githubusercontent.com/BloodyReginaa/Neuro-CanvasLMS/main/docs/images/banner.png)
6
6
 
7
- > "What's due this week?" · "Summarize Lecture 5 in MATH 132." · "What feedback did I get on HW 3?" · "What's in the code ZIP for Lecture 12?"
7
+ **Wire your Canvas LMS straight into Claude and ChatGPT.** Your courses, deadlines, grades and lecture files become part of what your AI knows, in any chat and on any device. It is a read-only remote MCP server that one command deploys to your own free Cloudflare account. Works at any Canvas school that lets students create access tokens ([30-second check](#can-you-use-it)).
8
+
9
+ > "What's due this week?" · "Summarize Lecture 5 in MATH 132." · "What feedback did I get on HW 3?" · "What's in the code ZIP for Lecture 12?" · "Set up the PA2 starter code in this folder."
8
10
 
9
11
  ## Why Neuro-CanvasLMS
10
12
 
@@ -15,28 +17,33 @@
15
17
  | 💸 **Free** | It runs on Cloudflare's free plan, which is far more than one student needs. |
16
18
  | 🧠 **Your files live in the conversation** | Lecture notes and PDFs the AI reads stay with that chat on your AI provider's servers. Nothing to download. |
17
19
 
18
- Most Canvas integrations for AI are **local** tools: they only work while your own computer runs them, and only inside desktop or coding apps. Neuro-CanvasLMS is a **remote connector** that runs in your own Cloudflare account, so it also works on your phone and in ChatGPT.
20
+ Local Canvas tools only work while your own computer runs them. Hosted services keep your Canvas token, or a copy of your courses, on their servers. Neuro-CanvasLMS runs in **your own** Cloudflare account: always on, usable from your phone and in ChatGPT, and your token goes only to your own Worker and your school's Canvas.
19
21
 
20
22
  ## What you get
21
23
 
22
- - **63 read-only tools:** courses, modules, pages, files, assignments, submissions, grades and feedback, rubrics, announcements, discussions, inbox, calendar, to-dos, groups, quiz history and profile settings.
24
+ - **64 read-only tools:** courses, modules, pages, files, assignments, submissions, grades and feedback, rubrics, announcements, discussions, inbox, calendar, to-dos, groups, quiz history and profile settings.
23
25
  - **Real file reading:** text from PDF, Word, PowerPoint and Excel, about 90 kinds of code and text files, and Jupyter notebooks (cells and outputs); ZIP archives unpacked on the server (listing plus every code/text file); scanned or handwritten PDFs are recognised and their original pages handed to the AI to look at.
24
26
  - **Linked files too:** course files that a module links on Google Docs/Slides/Sheets, Google Drive, Dropbox (files and folders) and GitHub (files and repositories) are read as text, as long as they are shared publicly ("Anyone with the link").
25
27
  - **Finds files the course hides:** many courses hide their Files list from students. The connector searches the front page, syllabus, modules, assignments, announcements and the pages they link to, so the AI can still find "the Lecture 12 slides" by name.
28
+ - **Starter code onto your computer:** in Claude Code, Codex and other coding agents, ask "set up PA2 from Canvas". The agent gets a one-time download link that expires in minutes, saves the assignment's starter files in your folder and unpacks them.
26
29
  - **Read-only by design:** only fixed `GET` requests. It never submits work, sends messages, starts quizzes or changes anything in Canvas.
27
30
  - **Yours only:** deployed in your own Cloudflare account and locked to your own Canvas account. Nobody else, including the authors, ever sees your Canvas token.
28
31
 
32
+ ## Can you use it?
33
+
34
+ Check in 30 seconds: in Canvas, open **Account → Settings** and scroll to **Approved Integrations**. If you see a **+ New Access Token** button, you're set. If it is missing, or creating a token is refused, your school doesn't let students make personal tokens, and this connector can't work there yet.
35
+
29
36
  ## Install (about 5 minutes)
30
37
 
31
- You need [Node.js 22+](https://nodejs.org), a free [Cloudflare account](https://dash.cloudflare.com/sign-up) and your Canvas login. In a terminal, run:
38
+ You need [Node.js 22+](https://nodejs.org), a free [Cloudflare account](https://dash.cloudflare.com/sign-up) (verify its email address) and your Canvas login. In a terminal, run:
32
39
 
33
40
  ```bash
34
41
  npx -y neuro-canvas-lms
35
42
  ```
36
43
 
37
- The installer signs you in to Cloudflare (a browser page opens; click **Authorize**), asks for your school's Canvas address, deploys the connector to your own account and checks that it works. Then:
44
+ The installer signs you in to Cloudflare (a browser page opens; click **Authorize**), asks for your school's Canvas address, sets up your free workers.dev address if the account is new, deploys the connector to your own account and checks that it works. A new address can take a few minutes to come online; meanwhile the installer opens Canvas so you can create your token. Then:
38
45
 
39
- 1. **Create a Canvas token:** Canvas → Account → Settings → **+ New Access Token**. Your school may cap its lifetime (30 days at UMass Amherst). Copy it right away; Canvas shows it only once.
46
+ 1. **Create a Canvas token** (if you haven't yet): Canvas → Account → Settings → **+ New Access Token**. Your school may cap its lifetime (30 days at UMass Amherst). Copy it right away; Canvas shows it only once.
40
47
  2. **Claude:** Settings → Connectors → **Add custom connector** → paste the URL the installer printed (it is already copied) → **Connect** → paste the Canvas token → Authorize. It then works everywhere you use Claude, including the mobile app and Claude Code.
41
48
  3. **ChatGPT:** add a custom connector (developer mode) with the same URL.
42
49
  4. Start a new chat and ask: "What assignments do I have this week?"
package/canvas.mjs CHANGED
@@ -229,27 +229,55 @@ export async function callCanvas(name, args, token, fetcher = fetch, opts = {})
229
229
  return out;
230
230
  }
231
231
 
232
+ // Canvas could not be reached at all: say so (and that the connection is fine), so the AI retries
233
+ // instead of changing its arguments. Our own transport messages already start with "Canvas".
234
+ export function unreachable(e) {
235
+ if (/^Canvas/.test(e?.message ?? '')) return e;
236
+ const timeout = e?.name === 'TimeoutError' || e?.name === 'AbortError';
237
+ return Object.assign(
238
+ new Error(
239
+ timeout
240
+ ? 'Canvas did not answer within 20 seconds; it may be slow or down right now. Try again in a minute. The token and the connection are fine: do not reconnect.'
241
+ : `Canvas could not be reached (network error: ${e?.name ?? 'Error'}). Try again in a minute. The token and the connection are fine: do not reconnect.`,
242
+ ),
243
+ { unreachable: true, cause: e },
244
+ );
245
+ }
246
+
232
247
  // One GET to the Canvas API: fixed headers, no redirects, Canvas errors turned into safe messages
233
248
  // (never the upstream body). `e.status` is set when Canvas refused this item (not a token problem).
234
249
  export async function canvasGet(u, token, fetcher = fetch) {
235
- const r = await fetcher(u, {
236
- method: 'GET',
237
- headers: {
238
- Authorization: `Bearer ${token}`,
239
- Accept: 'application/json',
240
- 'User-Agent': 'Neuro-CanvasLMS/2.0 (read-only personal integration)',
241
- },
242
- redirect: 'manual',
243
- signal: AbortSignal.timeout(20000),
244
- });
250
+ let r;
251
+ try {
252
+ r = await fetcher(u, {
253
+ method: 'GET',
254
+ headers: {
255
+ Authorization: `Bearer ${token}`,
256
+ Accept: 'application/json',
257
+ 'User-Agent': 'Neuro-CanvasLMS/2.0 (read-only personal integration)',
258
+ },
259
+ redirect: 'manual',
260
+ signal: AbortSignal.timeout(20000),
261
+ });
262
+ } catch (e) {
263
+ throw unreachable(e);
264
+ }
245
265
  if (
246
266
  r.status === 406 ||
247
267
  (r.status >= 500 && !(r.headers.get('content-type') ?? '').includes('json'))
248
268
  ) {
249
- // Canvas answers its API with JSON. An HTML error page means a firewall in front of Canvas
250
- // rejected the request, or Canvas failed on a malformed token (it returns an HTML 500 for that).
251
- throw new Error(
252
- `Canvas returned an HTML error page (HTTP ${r.status}) instead of JSON: a firewall in front of Canvas rejected the request, or the token is malformed. If it persists, reconnect with a freshly created token.`,
269
+ // Canvas answers its API with JSON. An HTML error page means Canvas is down or in maintenance
270
+ // (502/503/504 from the load balancer), a firewall in front of Canvas rejected the request, or
271
+ // Canvas failed on a malformed token (an HTML 500; only possible on the authorization page,
272
+ // since a connected token already passed). The phrase "HTML error page" stops canvas_find_files.
273
+ await r.body?.cancel().catch(() => {});
274
+ throw Object.assign(
275
+ new Error(
276
+ [502, 503, 504].includes(r.status)
277
+ ? `Canvas is temporarily unavailable (HTTP ${r.status}, an HTML error page instead of JSON). Try again in a few minutes. The token and the connection are fine: do not reconnect.`
278
+ : `Canvas returned an HTML error page (HTTP ${r.status}) instead of JSON: usually a temporary Canvas problem or a firewall in front of Canvas. Try again in a few minutes; reconnecting does not help.`,
279
+ ),
280
+ { htmlError: r.status },
253
281
  );
254
282
  }
255
283
  if (r.status === 401) {
@@ -276,7 +304,14 @@ export async function canvasGet(u, token, fetcher = fetch) {
276
304
  ),
277
305
  { status: r.status },
278
306
  );
279
- const data = await r.json();
307
+ let data;
308
+ try {
309
+ data = await r.json();
310
+ } catch {
311
+ throw new Error(
312
+ `Canvas answered HTTP ${r.status} but not with JSON (a maintenance or login page in between?). Try again in a few minutes; the token and the connection are fine.`,
313
+ );
314
+ }
280
315
  const next = nextPage(r.headers.get('link'));
281
316
  // api_url is the API request itself (it shows JSON); links for the user are the html_url fields.
282
317
  return { data, has_more: next !== null, next_page: next, api_url: u.href };
@@ -286,20 +321,47 @@ export async function canvasGet(u, token, fetcher = fetch) {
286
321
  // anyone holding the link can download the file without logging in. The connector needs it for
287
322
  // its own downloads, but it must not end up in conversations, so it is stripped from everything
288
323
  // returned to the AI. The links keep working for the user, who is logged in to Canvas.
289
- // Private calendar/announcement feed links (/feeds/…) work the same way and are removed whole.
324
+ // Other passes are removed whole, in compact and raw results alike (raw skips slim.mjs):
325
+ // - private calendar/announcement feed links (/feeds/…);
326
+ // - signed links: document preview sessions (canvadoc/crocodoc), storage links signed with
327
+ // sf_verifier, hmac, S3/CloudFront signatures or a JWT `token`. They open a file, or carry the
328
+ // user's id, without logging in.
329
+ // Ordinary links stay: Canvas pages (including a submission's preview page), Dropbox/Google share
330
+ // links and meeting links a teacher posted, and this connector's own /dl/ download links.
331
+ // The connector's own requests use Canvas data directly and never pass through this filter.
290
332
  const VERIFIER = /(&amp;|[?&])verifier=[^&#"'\s<>()]*(&amp;|&)?/gi;
291
333
  const FEED = /https?:\/\/[^\s"'<>()]*\/feeds\/[^\s"'<>()]+/gi;
334
+ const LINK = /(?:https?:\/\/|\/api\/v1\/(?:canvadoc|crocodoc)_session)[^\s"'<>()]*/gi;
335
+ const SIGNED =
336
+ /(?:canvadoc|crocodoc)_session|[?&](?:amp;)?(?:sf_verifier|hmac|x-amz-signature|signature)=|[?&](?:amp;)?token=[\w-]+\.[\w-]+\.[\w-]+/i;
337
+ // A file's `uuid` is the verifier itself (Canvas accepts ?verifier=<uuid>) and its thumbnail_url
338
+ // carries it in the path; a course's uuid is the key of its calendar feed. Raw results lose them.
339
+ const SIGNED_KEYS = new Set([
340
+ 'canvadoc_session_url',
341
+ 'crocodoc_session_url',
342
+ 'uuid',
343
+ 'thumbnail_url',
344
+ ]);
345
+ // The same pass URL-encoded inside another link (e.g. a login page's return_to=).
346
+ const ENCODED_VERIFIER = /verifier%3D[^&#"'\s<>()%]*/gi;
292
347
  export function withoutVerifiers(value) {
293
348
  if (typeof value === 'string') {
294
349
  let v = value;
295
350
  if (v.includes('verifier='))
296
351
  v = v.replace(VERIFIER, (m, before, after) => (after ? before : ''));
352
+ if (/verifier%3D/i.test(v)) v = v.replace(ENCODED_VERIFIER, 'verifier%3D');
297
353
  if (v.includes('/feeds/')) v = v.replace(FEED, '[private feed link removed]');
354
+ if (/[?]|_session/.test(v))
355
+ v = v.replace(LINK, link => (SIGNED.test(link) ? '[signed link removed]' : link));
298
356
  return v;
299
357
  }
300
358
  if (Array.isArray(value)) return value.map(withoutVerifiers);
301
359
  if (value && typeof value === 'object')
302
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, withoutVerifiers(v)]));
360
+ return Object.fromEntries(
361
+ Object.entries(value)
362
+ .filter(([k]) => !SIGNED_KEYS.has(k))
363
+ .map(([k, v]) => [k, withoutVerifiers(v)]),
364
+ );
303
365
  return value;
304
366
  }
305
367
 
@@ -0,0 +1,203 @@
1
+ // canvas_file_download_link: put an original Canvas file on the user's own computer from a coding
2
+ // agent with a shell (Claude Code, Codex, Cursor). Those agents get MCP resources only as links they
3
+ // cannot save to disk; cloud chats (which can) don't need this.
4
+ // The Worker asks Canvas for the file's download address once and takes where Canvas redirects it
5
+ // (Canvas's file domain), minus the permanent `verifier` pass. It never downloads the bytes and never
6
+ // requests that address itself. Measured on UMass Amherst, 2026-11-06: curl follows that address
7
+ // (302 → 302 → 200) to the complete file once, a second use answers 401 — but its "5-minute"
8
+ // signature is NOT enforced: an unused address still worked after 60 minutes. So the address is
9
+ // never shown to the AI. The AI gets a link to this Worker instead (/dl/<random id>), which
10
+ // - works for 5 minutes and once: the Canvas address waits in this deployment's own KV with a
11
+ // 5-minute expiry and is deleted on use;
12
+ // - is only honoured with the X-Canvas-Download header from the command we return, so a browser
13
+ // or a link preview that opens it gets a 403 and does not use it up;
14
+ // - answers with a redirect: the file still comes straight from Canvas, with no size limit.
15
+ import { callCanvas } from './canvas.mjs';
16
+ import { CANVAS_HOST } from './config.mjs';
17
+ import { DOWNLOAD_UA, loginRedirect, allowedDownload } from './file-content.mjs';
18
+ import { safeURL } from './module-file.mjs';
19
+
20
+ export const fileDownloadLinkTool = {
21
+ name: 'canvas_file_download_link',
22
+ description:
23
+ "Put an ORIGINAL Canvas file (e.g. an assignment's starter-code ZIP) on the user's computer from a coding agent with a shell, such as Claude Code, Codex or Cursor: returns the exact curl command (a link that works ONCE, for 5 minutes, and only with the header in that command) and the expected size; the file itself comes straight from Canvas. Run the command right away; if it fails or expires, call this again. Cloud chats cannot reach it: there, use canvas_file_original (the app attaches the file) or canvas_file_content. Takes a Canvas file_id only (from canvas_find_files, canvas_files or a /files/<id> link).",
24
+ inputSchema: {
25
+ type: 'object',
26
+ properties: { file_id: { type: 'integer', minimum: 1 } },
27
+ required: ['file_id'],
28
+ additionalProperties: false,
29
+ },
30
+ annotations: {
31
+ readOnlyHint: true,
32
+ destructiveHint: false,
33
+ idempotentHint: false,
34
+ openWorldHint: false,
35
+ },
36
+ };
37
+
38
+ const cloudFetch = (input, init) => fetch(input, init);
39
+ const REDIRECT = new Set([301, 302, 303, 307, 308]);
40
+ export const DOWNLOAD_TTL = 5 * 60; // seconds a /dl link works
41
+ export const DOWNLOAD_HEADER = 'X-Canvas-Download';
42
+ const ID = /^[A-Za-z0-9_-]{43}$/;
43
+ const onCanvas = u => u.hostname === CANVAS_HOST || u.hostname.endsWith('.instructure.com');
44
+
45
+ // When a signed storage link stops working (epoch seconds), or null if it carries no expiry we
46
+ // recognise. A link without one is never handed out: it could be permanent.
47
+ export function linkExpiry(u) {
48
+ const q = u.searchParams;
49
+ for (const [, v] of q) {
50
+ if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(v)) continue;
51
+ try {
52
+ const exp = JSON.parse(atob(v.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))).exp;
53
+ if (Number.isFinite(exp)) return exp;
54
+ } catch {}
55
+ }
56
+ const amzDate = q.get('X-Amz-Date');
57
+ const amzExpires = Number(q.get('X-Amz-Expires'));
58
+ if (amzDate && amzExpires > 0) {
59
+ const m = amzDate.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
60
+ if (m) return Date.UTC(m[1], m[2] - 1, m[3], m[4], m[5], m[6]) / 1000 + amzExpires;
61
+ }
62
+ const expires = Number(q.get('Expires'));
63
+ return Number.isFinite(expires) && expires > 1e9 ? expires : null;
64
+ }
65
+
66
+ // A file name that is safe inside quotes on macOS, Linux and Windows and stays in the folder.
67
+ export function safeFileName(name) {
68
+ const base = String(name ?? '')
69
+ .split(/[\\/]/)
70
+ .pop()
71
+ .replace(/[\x00-\x1f"*:<>?|$`]/g, '_')
72
+ .replace(/^\.+/, '')
73
+ .trim();
74
+ return base.slice(0, 180) || 'canvas-file';
75
+ }
76
+
77
+ export async function fileDownloadLink(
78
+ args,
79
+ token,
80
+ apiFetch,
81
+ { fetcher = cloudFetch, issue } = {},
82
+ ) {
83
+ if (typeof issue !== 'function')
84
+ throw new Error('Canvas file: download links are not available.');
85
+ if (
86
+ Object.keys(args).some(k => k !== 'file_id') ||
87
+ !Number.isSafeInteger(args.file_id) ||
88
+ args.file_id < 1
89
+ )
90
+ throw new Error('Canvas file: only a positive Canvas file_id is accepted.');
91
+ const meta = (await callCanvas('canvas_file', { file_id: args.file_id }, token, apiFetch)).data;
92
+ // `hidden` files are only hidden from the Files list ("only available with link"), which is how
93
+ // many courses share files; students may open them. Only a lock stops a download.
94
+ if (meta.locked_for_user) throw new Error('Canvas file: file is locked.');
95
+ if (!meta.url)
96
+ throw new Error('Canvas file: Canvas did not provide a download link for this file.');
97
+ let url = safeURL(meta.url);
98
+ if (!onCanvas(url)) throw new Error('Canvas file: unexpected download address.');
99
+ for (let hop = 0; hop < 4; hop++) {
100
+ // Canvas credentials are never attached: the download address carries its own authorization.
101
+ const r = await fetcher(url.href, {
102
+ method: 'GET',
103
+ redirect: 'manual',
104
+ headers: { Accept: '*/*', 'User-Agent': DOWNLOAD_UA },
105
+ signal: AbortSignal.timeout(15000),
106
+ });
107
+ await r.body?.cancel();
108
+ if (!REDIRECT.has(r.status)) {
109
+ if (r.ok)
110
+ throw new Error(
111
+ 'Canvas file: this Canvas sends the file without a short-lived link, so no one-time link can be made. Use canvas_file_original, or give the user the html_url to download it in their browser.',
112
+ );
113
+ throw new Error(`Canvas file: the download address answered HTTP ${r.status}.`);
114
+ }
115
+ const location = r.headers.get('location');
116
+ if (!location) throw new Error('Canvas file: download redirect without a destination.');
117
+ const next = safeURL(new URL(location, url).href);
118
+ if (loginRedirect(next.href))
119
+ throw new Error('Canvas file: download was redirected to the Canvas login page.');
120
+ if (onCanvas(next)) {
121
+ url = next; // a redirect inside Canvas: not the storage link yet
122
+ continue;
123
+ }
124
+ if (!allowedDownload(next.href))
125
+ throw new Error('Canvas file: download redirected to an unexpected host.');
126
+ next.searchParams.delete('verifier'); // the permanent pass; the signed one stays
127
+ // Without a signature of its own, the address would not work once the pass is removed.
128
+ if (!linkExpiry(next))
129
+ throw new Error(
130
+ 'Canvas file: this Canvas did not give a signed download address. Use canvas_file_original, or give the user the html_url to download it in their browser.',
131
+ );
132
+ const name = safeFileName(meta.display_name ?? meta.filename);
133
+ const link = await issue(next.href);
134
+ const header = `-H "${DOWNLOAD_HEADER}: 1"`;
135
+ return {
136
+ file_id: args.file_id,
137
+ name,
138
+ size: meta.size,
139
+ content_type: meta['content-type'],
140
+ html_url: `https://${CANVAS_HOST}/files/${args.file_id}`,
141
+ download_url: link,
142
+ expires_in_seconds: DOWNLOAD_TTL,
143
+ single_use: true,
144
+ command: `curl -L --fail ${header} -o "${name}" "${link}"`,
145
+ command_windows: `curl.exe -L --fail ${header} -o "${name}" "${link}"`,
146
+ check: `The file must be ${meta.size} bytes${/\.zip$/i.test(name) ? '; then test the archive with `unzip -t`' : ''}.`,
147
+ note: 'Run the command exactly as given, right away: the link works once, for 5 minutes, and only with that header. If it fails, call canvas_file_download_link again.',
148
+ };
149
+ }
150
+ throw new Error('Canvas file: too many redirects inside Canvas.');
151
+ }
152
+
153
+ // --- the /dl/<id> links ------------------------------------------------------------------------
154
+
155
+ export function newDownloadId() {
156
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
157
+ return btoa(String.fromCharCode(...bytes))
158
+ .replace(/\+/g, '-')
159
+ .replace(/\//g, '_')
160
+ .replace(/=+$/, '');
161
+ }
162
+
163
+ // Store the Canvas address for 5 minutes and return the Worker link that leads to it.
164
+ export async function issueDownload(kv, origin, target, now = Date.now) {
165
+ const id = newDownloadId();
166
+ await kv.put('dl:' + id, JSON.stringify({ target, exp: now() + DOWNLOAD_TTL * 1000 }), {
167
+ expirationTtl: DOWNLOAD_TTL,
168
+ });
169
+ return `${origin}/dl/${id}`;
170
+ }
171
+
172
+ const plain = {
173
+ 'Cache-Control': 'no-store',
174
+ 'Referrer-Policy': 'no-referrer',
175
+ 'X-Content-Type-Options': 'nosniff',
176
+ 'Content-Type': 'text/plain; charset=utf-8',
177
+ };
178
+ // GET /dl/<id> with the header: redirect to the Canvas address once, within 5 minutes.
179
+ export async function redeemDownload(request, kv, id, now = Date.now) {
180
+ if (!ID.test(id)) return new Response('Not found.\n', { status: 404, headers: plain });
181
+ if (request.method !== 'GET')
182
+ return new Response('Method not allowed.\n', { status: 405, headers: plain });
183
+ if (request.headers.get(DOWNLOAD_HEADER) !== '1')
184
+ return new Response(
185
+ 'This one-time link only works with the curl command the connector gave (it sends the ' +
186
+ DOWNLOAD_HEADER +
187
+ ' header). Opening it anywhere else does not use it up.\n',
188
+ { status: 403, headers: plain },
189
+ );
190
+ const key = 'dl:' + id;
191
+ let entry = null;
192
+ try {
193
+ entry = JSON.parse((await kv.get(key)) ?? 'null');
194
+ } catch {}
195
+ if (entry) await kv.delete(key);
196
+ // KV expiry can lag a little: the stored deadline is checked too.
197
+ if (!entry?.target || !(now() < entry.exp))
198
+ return new Response(
199
+ 'This download link has expired or was already used. Ask the AI for a new one.\n',
200
+ { status: 410, headers: plain },
201
+ );
202
+ return new Response(null, { status: 302, headers: { ...plain, Location: entry.target } });
203
+ }
package/file-content.mjs CHANGED
@@ -226,9 +226,24 @@ export async function extractBytes(bytes, shown, mime = '', opts = {}) {
226
226
  'This format is not supported for text extraction. Call canvas_file_original with this file_id to get the original file.',
227
227
  };
228
228
  }
229
+ // extractBytes, but a parser failing on the file itself (damaged, password-protected, or not really
230
+ // a PDF/Office file) becomes a note with a way on instead of an error the AI reads as "check your
231
+ // arguments". Our own messages (limits, a wrong `entry`) start with "Canvas" and go through.
232
+ export async function extractOrExplain(bytes, name, mime, opts, wayOn) {
233
+ try {
234
+ return await extractBytes(bytes, name, mime, opts);
235
+ } catch (e) {
236
+ if (/^Canvas/.test(e?.message ?? '')) throw e;
237
+ return {
238
+ text: null,
239
+ extraction_note: `Could not read the text of this file (${e?.name ?? 'Error'}): it may be damaged, password-protected, or not really the type its name says. ${wayOn}`,
240
+ };
241
+ }
242
+ }
229
243
  export async function readFileContent(meta, args, fetcher = fetch) {
230
- if (meta.locked_for_user || meta.hidden_for_user)
231
- throw new Error('Canvas file is locked or hidden for this user.');
244
+ // `hidden` files are only hidden from the Files list ("only available with link"), which is how
245
+ // many courses share files; students may open them. Only a lock stops a download.
246
+ if (meta.locked_for_user) throw new Error('Canvas file is locked for this user.');
232
247
  const base = {
233
248
  file_id: meta.id,
234
249
  name: meta.display_name ?? meta.filename,
@@ -236,7 +251,14 @@ export async function readFileContent(meta, args, fetcher = fetch) {
236
251
  html_url: `https://${CANVAS_HOST}/files/${meta.id}`,
237
252
  mime_type: meta['content-type'],
238
253
  };
239
- if (!meta.url || !allowedDownload(meta.url))
254
+ if (!meta.url)
255
+ return {
256
+ ...base,
257
+ text: null,
258
+ extraction_note:
259
+ 'Canvas gives no download address for this file (it may only be viewable inside Canvas); open html_url.',
260
+ };
261
+ if (!allowedDownload(meta.url))
240
262
  return {
241
263
  ...base,
242
264
  text: null,
@@ -261,14 +283,22 @@ export async function readFileContent(meta, args, fetcher = fetch) {
261
283
  throw new Error(
262
284
  'Canvas file redirect host is not allowed: ' + [...hops, where(url)].join(' → '),
263
285
  );
264
- response = await fetcher(url, {
265
- method: 'GET',
266
- redirect: 'manual',
267
- headers: { Accept: '*/*', 'User-Agent': DOWNLOAD_UA },
268
- signal: AbortSignal.timeout(20000),
269
- });
286
+ try {
287
+ response = await fetcher(url, {
288
+ method: 'GET',
289
+ redirect: 'manual',
290
+ headers: { Accept: '*/*', 'User-Agent': DOWNLOAD_UA },
291
+ signal: AbortSignal.timeout(20000),
292
+ });
293
+ } catch (e) {
294
+ if (/^Canvas/.test(e?.message ?? '')) throw e;
295
+ throw new Error(
296
+ `Canvas file download did not finish (${e?.name === 'TimeoutError' || e?.name === 'AbortError' ? 'no answer within 20 seconds' : 'network error: ' + (e?.name ?? 'Error')}; ${where(url)}). Try again in a minute; the connection is fine.`,
297
+ );
298
+ }
270
299
  hops.push(response.status + ' ' + where(url));
271
300
  if (response.status >= 300 && response.status < 400) {
301
+ await response.body?.cancel().catch(() => {});
272
302
  const location = response.headers.get('location');
273
303
  if (!location) throw new Error('Canvas download redirect is missing its destination.');
274
304
  url = new URL(location, url).href;
@@ -292,11 +322,12 @@ export async function readFileContent(meta, args, fetcher = fetch) {
292
322
  (response.headers.get('content-type') ?? 'unknown').split(';')[0] +
293
323
  ').',
294
324
  );
295
- const extracted = await extractBytes(
325
+ const extracted = await extractOrExplain(
296
326
  await boundedBytes(response),
297
327
  base.name ?? '',
298
328
  base.mime_type ?? response.headers.get('content-type') ?? '',
299
329
  { entry: args.entry, storedName: meta.filename },
330
+ 'Call canvas_file_original with this file_id to get the file itself, or open html_url.',
300
331
  );
301
332
  return paginated(base, extracted, args);
302
333
  }
package/find-files.mjs CHANGED
@@ -180,14 +180,24 @@ export async function findFiles(args, token, fetcher) {
180
180
  return canvasGet(u, token, fetcher);
181
181
  };
182
182
  const searched = {};
183
- // One source failing (hidden, not enabled) is normal; an expired token stops everything.
183
+ // A source being hidden or not enabled (401/403/404) is normal. A source that failed (timeout,
184
+ // network, rate limit, Canvas error) was not searched: the result is then incomplete, and if
185
+ // nothing could be searched at all the error is passed on instead of an empty "complete" list.
186
+ // An expired token or an HTML error page (Canvas down, a firewall) stops everything.
187
+ const failures = [];
188
+ const hidden = e => [401, 403, 404].includes(e.status);
184
189
  const attempt = async (source, run) => {
185
190
  try {
186
191
  const n = await run();
187
192
  searched[source] = n === undefined ? 'ok' : `ok (${n})`;
188
193
  } catch (e) {
189
194
  if (/authorization expired|HTML error page/.test(e.message ?? '')) throw e;
190
- searched[source] = e.status ? `not available (HTTP ${e.status})` : 'failed';
195
+ if (hidden(e)) searched[source] = `not available (HTTP ${e.status})`;
196
+ else {
197
+ failures.push(e);
198
+ complete = false;
199
+ searched[source] = e.status ? `failed (HTTP ${e.status})` : 'failed';
200
+ }
191
201
  }
192
202
  };
193
203
  const pagesOf = async (path, query, maxPages, each) => {
@@ -337,7 +347,8 @@ export async function findFiles(args, token, fetcher) {
337
347
 
338
348
  // Pages linked from everything above, breadth first, never outside this course.
339
349
  let pagesRead = 0,
340
- pagesUnavailable = 0;
350
+ pagesUnavailable = 0,
351
+ pagesFailed = 0;
341
352
  while (queue.length) {
342
353
  const batch = queue.splice(0, PAGE_BATCH).filter(slug => !read.has(slug));
343
354
  await Promise.all(
@@ -355,7 +366,12 @@ export async function findFiles(args, token, fetcher) {
355
366
  });
356
367
  } catch (e) {
357
368
  if (/authorization expired|HTML error page/.test(e.message ?? '')) throw e;
358
- pagesUnavailable++;
369
+ if (hidden(e)) pagesUnavailable++;
370
+ else {
371
+ failures.push(e);
372
+ complete = false;
373
+ pagesFailed++;
374
+ }
359
375
  }
360
376
  }),
361
377
  );
@@ -365,7 +381,20 @@ export async function findFiles(args, token, fetcher) {
365
381
  }
366
382
  }
367
383
  searched.linked_pages =
368
- `${pagesRead} read` + (pagesUnavailable ? `, ${pagesUnavailable} not available` : '');
384
+ `${pagesRead} read` +
385
+ (pagesUnavailable ? `, ${pagesUnavailable} not available` : '') +
386
+ (pagesFailed ? `, ${pagesFailed} failed` : '');
387
+ const sources = Object.entries(searched).filter(([k]) => k !== 'linked_pages');
388
+ // Nothing could be searched: say why instead of "no files, complete".
389
+ if (failures.length && !pagesRead && !sources.some(([, v]) => v.startsWith('ok'))) {
390
+ const e = failures.find(x => /^Canvas/.test(x.message ?? '')) ?? failures[0];
391
+ throw /^Canvas/.test(e.message ?? '')
392
+ ? e
393
+ : new Error(`Canvas could not be searched (${e.name}). Try again in a minute.`);
394
+ }
395
+ // Every source 404: most likely not a course this user is in.
396
+ const wrongCourse =
397
+ !files.size && sources.length && sources.every(([, v]) => v === 'not available (HTTP 404)');
369
398
 
370
399
  let list = [...files.values()];
371
400
  let matchedOn = null;
@@ -393,11 +422,21 @@ export async function findFiles(args, token, fetcher) {
393
422
  external_links: ext,
394
423
  searched,
395
424
  complete,
396
- ...(complete
397
- ? {}
398
- : {
399
- note: `Stopped after ${requests} Canvas requests; some linked pages or lists were not searched. Open more pages with canvas_context_page or canvas_module_items if the file is not listed.`,
400
- }),
425
+ ...(failures.length
426
+ ? {
427
+ note: `Some places could not be searched (they failed, see searched: ${failures.length} failed request${failures.length > 1 ? 's' : ''}), so a file missing here may still exist. Try again in a minute before telling the user it is not there.`,
428
+ }
429
+ : complete
430
+ ? {}
431
+ : {
432
+ note: `Stopped after ${requests} Canvas requests; some linked pages or lists were not searched. Open more pages with canvas_context_page or canvas_module_items if the file is not listed.`,
433
+ }),
434
+ ...(wrongCourse
435
+ ? {
436
+ course_note:
437
+ 'Every part of this course answered 404: check the course_id (canvas_courses lists the user’s courses).',
438
+ }
439
+ : {}),
401
440
  how_to_read:
402
441
  'Read a file_id with canvas_file_content (text from PDF/Office/code, ZIP listing and contents) or canvas_file_original (original bytes, e.g. scanned PDFs). An external link with read_with (a module item): call canvas_module_file_content with those IDs for its text (Google Docs/Slides/Sheets/Drive, Dropbox, GitHub), or canvas_module_file for the original file. Other external links cannot be read by this connector: give the user the link.',
403
442
  };