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

@@ -119,8 +119,21 @@ export class SchulcloudClient {
return url;
}
private async request(url: URL, accept: string): Promise<Response> {
/**
* One upstream GET, with retries for the transient failures.
*
* `auth` picks how the session travels. The v3 API takes it as a bearer
* token; the legacy client's pages take it only as the `jwt` cookie; and a
* pre-signed storage URL must get **nothing** — it lives on another host, and
* the session token has no business leaving this instance. Anything but the
* bearer form is fetched with redirects off, so a login bounce or a hop to a
* third host is seen rather than silently followed with credentials attached.
*/
private async request(url: URL, accept: string, auth: 'bearer' | 'cookie' | 'none' = 'bearer'): Promise<Response> {
let lastError: unknown;
const headers: Record<string, string> = { Accept: accept };
if (auth === 'bearer') headers.Authorization = `Bearer ${this.config.jwt}`;
if (auth === 'cookie') headers.Cookie = `jwt=${this.config.jwt}`;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) await delay(backoffMs(attempt));
@@ -128,9 +141,9 @@ export class SchulcloudClient {
let response: Response;
try {
response = await fetch(url, {
headers: { Authorization: `Bearer ${this.config.jwt}`, Accept: accept },
headers,
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
redirect: 'follow',
redirect: auth === 'bearer' ? 'follow' : 'manual',
});
} catch (error) {
// Connection reset or timeout: worth one more try, since every call
@@ -143,7 +156,12 @@ export class SchulcloudClient {
if (response.ok) return response;
const body = await response.text().catch(() => '');
const error = new SchulcloudApiError(response.status, url.pathname + url.search, body);
// A pre-signed URL's query string is its credential, so it never goes
// into an error message; nor does the storage host's error body.
const error =
auth === 'none'
? new SchulcloudApiError(response.status, `${url.host} (pre-signed download)`, '')
: new SchulcloudApiError(response.status, url.pathname + url.search, body);
// A crawl issues hundreds of requests and the instance answers some of
// them with a 503 front-page when it decides we are going too fast.
@@ -175,6 +193,11 @@ export class SchulcloudClient {
async getBytes(path: string, fallbackName: string): Promise<DownloadedFile> {
const url = this.url(path);
const response = await this.request(url, '*/*');
return this.readCapped(response, fallbackName);
}
/** Reads a response body up to `maxDownloadBytes`, flagging anything cut off. */
private async readCapped(response: Response, fallbackName: string): Promise<DownloadedFile> {
const limit = this.config.maxDownloadBytes;
const chunks: Buffer[] = [];
@@ -550,6 +573,118 @@ export class SchulcloudClient {
const body = await this.getJson<Paginated<GroupItem>>('/api/v3/groups', { limit: MAX_PAGE_SIZE });
return body.data ?? [];
}
// --- the "Dateien" file manager ------------------------------------------
//
// Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien live in
// the legacy file system, a different store from files-storage: listing a
// course through /api/v3/file answers 0 files for a course holding dozens.
// Its Feathers service is not in the public ingress, so the only way in is
// the legacy client — HTML pages for listings, one JSON route for downloads.
// See core/legacy-files.ts for the parsing and the path model.
/**
* One file-manager page, as HTML.
*
* **Only the listing routes are reachable here, by construction.** Several of
* the legacy client's GET routes write: `GET /files/share/` mints a share
* token when the file has none, and `GET /files/file?share=…` grants the
* caller a permission on someone else's file. A GET-only client is therefore
* not read-only against this surface by itself; the allowlist is what makes
* the invariant hold.
*/
async getFileManagerPage(path: string): Promise<string> {
if (!FILE_MANAGER_PAGE.test(path)) {
throw new Error(`refusing file-manager path outside the listing routes: ${path}`);
}
const response = await this.legacyRequest(path, 'text/html');
return response.text();
}
/**
* A pre-signed download URL for one legacy file.
*
* `name` only sets the download's filename; the server checks read access on
* the id. The route is `/files/signedurl` rather than `/files/file`, which
* answers the same thing as a redirect but also accepts `share`, the
* parameter that writes.
*/
async getFileManagerSignedUrl(fileId: string, name: string): Promise<string> {
if (!/^[0-9a-f]{24}$/i.test(fileId)) throw new Error(`not a file id: ${fileId}`);
const query = new URLSearchParams({ file: fileId, name: name || fileId });
const response = await this.legacyRequest(`/files/signedurl?${query.toString()}`, 'application/json');
// The server's error path *returns* its Forbidden rather than throwing it,
// so a refused file arrives as a 200 whose body has no url.
const body = (await response.json().catch(() => ({}))) as { url?: unknown; message?: unknown };
if (typeof body.url !== 'string' || !body.url) {
throw new SchulcloudApiError(403, '/files/signedurl', typeof body.message === 'string' ? body.message : 'no download url');
}
return body.url;
}
/** Downloads one legacy file: signed URL, then the bytes, capped like every download. */
async downloadFileManagerFile(fileId: string, name: string): Promise<DownloadedFile> {
const signed = await this.getFileManagerSignedUrl(fileId, name);
const response = await this.openSignedUrl(signed);
return this.readCapped(response, name);
}
/**
* Opens a pre-signed storage URL — with no credentials at all.
*
* The URL names another host (live: an S3 endpoint at the storage provider),
* so neither the bearer nor the cookie may go with it. It must also be
* https whenever the instance is, which keeps a URL the server hands back from
* pointing this process at a plaintext service on its own network.
*/
async openSignedUrl(signedUrl: string): Promise<Response> {
const target = checkSignedUrl(signedUrl, this.config.baseUrl);
return this.request(target, '*/*', 'none');
}
private async legacyRequest(path: string, accept: string): Promise<Response> {
try {
return await this.request(this.url(path), accept, 'cookie');
} catch (error) {
// The legacy client answers a rejected cookie with a redirect to its
// login page. Report it as what it is, so tools say "token expired"
// rather than "HTTP 302".
if (error instanceof SchulcloudApiError && error.status >= 300 && error.status < 400) {
throw new SchulcloudApiError(401, path, 'redirected to login: the session is not accepted');
}
throw error;
}
}
}
/**
* The file-manager listing routes, and nothing else.
*
* Folders are addressed by id alone — `/files/courses/{course}/{folder}` holds
* one folder segment however deep the folder is — so every listing fits one of
* these shapes. `/files/my/{a}/{b}` exists too, but lists `b` exactly as
* `/files/my/{b}` does, so it is not needed.
*/
const FILE_MANAGER_PAGE =
/^\/files\/(?:(?:my|courses|teams|shared)\/|my\/[0-9a-f]{24}|(?:courses|teams)\/[0-9a-f]{24}(?:\/[0-9a-f]{24})?)$/i;
/** Validates a pre-signed URL before anything is sent to it. Exported for testing. */
export function checkSignedUrl(signedUrl: string, baseUrl: string): URL {
let target: URL;
try {
target = new URL(signedUrl);
} catch {
throw new Error('the download url the server returned is not a url');
}
const instanceIsHttps = new URL(baseUrl).protocol === 'https:';
const allowed = instanceIsHttps ? ['https:'] : ['https:', 'http:'];
if (!allowed.includes(target.protocol)) {
throw new Error(`refusing a ${target.protocol} download url from an ${instanceIsHttps ? 'https' : 'http'} instance`);
}
if (target.username || target.password) {
throw new Error('refusing a download url that carries credentials');
}
return target;
}
/**