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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user