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:
MechaCat02
2026-09-16 20:19:16 +02:00
parent 10c6544579
commit bed3923902
32 changed files with 2696 additions and 106 deletions

View File

@@ -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.