Browse the file manager ("Dateien") as a filesystem
Many teachers never use topics or boards; their material sits in the course's file area, and the tools answered "0 files" for courses holding dozens of worksheets — 21 of 26 courses on the live account. Persönliche, Kurs-, Team- and Geteilte Dateien live in the legacy file store, not in files-storage, and its service is not in the public ingress. The only way in is the legacy client: HTML listings, and GET /files/signedurl for a pre-signed download. core/legacy-files.ts turns that into one path tree — /my, /courses/<course>, /teams/<team>, /shared — resolving names that contain "/", ids anywhere in a path, and wrong or ambiguous names with a message saying what is there. A listing that does not parse throws; it never reads as an empty folder. Some of the legacy client's GET routes write (GET /files/share/ mints a share token), so getFileManagerPage allows only the listing routes, by pattern. Signed URLs are fetched with no credentials and must be https. - MCP: fs_list, fs_tree, fs_find and fs_read; get_course lists course files. - CLI: schulcloud fs ls, tree, find and get, recursive and resumable. - API: /api/fs/list, tree, find and file. - Index: the crawl walks the file manager (INDEX_FILE_MANAGER, on by default), so search covers the text inside those files and sync mirrors them under <course>/Kurs-Dateien. The local instance gains a fixture for all four areas. It needed a loopback, so signed URLs open from the host, and a pre-created bucket, since MinIO does not implement PutBucketCors. 135 tests. Smoke 55/55 live; 57/57 and 55/55 on the local instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
28
CLAUDE.md
28
CLAUDE.md
@@ -39,8 +39,8 @@ index.
|
||||
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
||||
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
|
||||
|
||||
Run smoke **both ways**: with `DATABASE_URL` set (34 checks, index-backed) and
|
||||
without (32 checks, live-only). The degradation path is a supported mode, not a
|
||||
Run smoke **both ways**: with `DATABASE_URL` set (57 checks, index-backed) and
|
||||
without (55 checks, live-only). The degradation path is a supported mode, not a
|
||||
fallback nobody exercises.
|
||||
|
||||
Store tests need a database and skip without one:
|
||||
@@ -71,6 +71,9 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
- **`crawl.ts`** — the one traversal. Search, the indexer, the what-changed
|
||||
diff and the file mirror all need it; keep it here, not in a tool.
|
||||
- `paths.ts` — the security boundary for mirrored filenames. See Invariants.
|
||||
- `legacy-files.ts` — the file manager ("Dateien": Persönliche, Kurs-, Team-,
|
||||
Geteilte Dateien) as one path tree, parsed from the legacy client's pages.
|
||||
A separate store from files-storage; the `fs_*` tools and `/api/fs` sit on it.
|
||||
- **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
|
||||
`Store.open` returns `undefined` when Postgres is down; callers degrade.
|
||||
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index.
|
||||
@@ -92,7 +95,12 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
`GET` except `extendSession` (the keepalive's `refresh-session` call, which
|
||||
touches only our own session and is not exposed as a tool, so no model-driven
|
||||
call can be a POST). `api_get` rejects non-`/api/` paths and anything carrying a
|
||||
scheme or host. `refresh_index` and `POST /api/refresh` write only to the Pi's
|
||||
scheme or host. **Against the legacy client, GET-only is not enough:**
|
||||
`GET /files/share/` mints a share token and `GET /files/file?share=` grants a
|
||||
permission, so `getFileManagerPage` allows only the listing routes, by pattern —
|
||||
widen that pattern only with a route you have read the handler of. A pre-signed
|
||||
download URL is fetched with **no** credentials: it names another host, and
|
||||
neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POST /api/refresh` write only to the Pi's
|
||||
own index and mirror — every upstream call they make is still a GET.
|
||||
|
||||
**Filenames from Schulcloud are untrusted paths.** Course titles, card titles
|
||||
@@ -217,10 +225,22 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
||||
upstream: interactive exercises are H5P elements, whose `contentId` is the
|
||||
only handle onto the content, or external (LTI) tools behind
|
||||
`contextExternalToolId`. Say that rather than looking for a quiz API.
|
||||
- **The file manager is a third store, reachable only as HTML.** Persönliche,
|
||||
Kurs-, Team- and Geteilte Dateien live in the legacy `files` collection, not in
|
||||
files-storage: `list_files` answers 0 for a course holding dozens of
|
||||
worksheets, and 21 of 26 live courses keep material there. Its Feathers service
|
||||
is not in the ingress, so listings are parsed from `/files/{my,courses,teams,shared}`
|
||||
pages and downloads go through `GET /files/signedurl` (JSON). Folders are
|
||||
addressed by id alone — `/files/courses/{course}/{folder}` at any depth.
|
||||
`permittedDirectories` returns every course with **no** folders (it queries
|
||||
`refOwnerModel: 'courses'`, records say `'course'`), and `/files/search/` 504s,
|
||||
so walk listings. Course names contain `/`; resolve by joining segments. A
|
||||
listing page that does not parse must throw, never read as an empty folder —
|
||||
"0 files" is the bug this exists to fix. `docs/API.md` has the evidence.
|
||||
- **Teams cannot be read at any version.** v3 exposes only
|
||||
`GET /team/{teamId}/news`; upstream `main`'s teams controller is write-only
|
||||
(`POST :teamId/create-room`). `/teams` is the legacy client's HTML page, not
|
||||
an API. This one is genuinely unavailable, not merely uncovered.
|
||||
an API. A team's *files* are reachable, through the file manager (`/teams/<team>`).
|
||||
- **`exp` (30 days) is not the session lifetime.** The binding limit is a Valkey
|
||||
whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at
|
||||
`GET /api/v3/config/public`) that every authenticated request re-sets.
|
||||
|
||||
Reference in New Issue
Block a user