Files
Schulcloud-MCP/src/core/client.ts
MechaCat02 a0cef532c6 Read the quizzes behind H5P elements
A quiz in Schulcloud is an H5P element, and a board hands over nothing but a
contentId — so a teacher's exercise was until now a line saying one exists.
The player shows a single question at a time, which makes it look like
something to step through or scrape. It is not:
`GET /api/v3/h5p-editor/params/{contentId}` returns the JSON the player is fed,
so one request holds every question, every option and which of them are
correct. (`play/{id}` is the same content plus the player's script lists: 74 kB
against 51 kB for the live quiz. Neither docs-json describes the service.)

get_h5p prints the exercise, and solutions=false keeps the options while
dropping the answers, so it can be used to ask the questions instead of
answering them. get_board names the exercise — title, question count, kinds —
rather than printing a bare id, and the crawl indexes its text, so a phrase
that exists only inside a quiz is now findable. That is the treatment pads
already get, for the same reason: it is course material and nothing else
surfaces it.

What varies is the shape inside `params`, which belongs to whichever H5P
library the teacher used. Modelled: MultiChoice, whose `behaviour.singleAnswer`
is the only honest source for "tick exactly one"; TrueFalse, whose `correct` is
the string "true"; the cloze libraries, which mark solutions inline as
`*answer:tip*`; SingleChoiceSet and Summary, which put the correct option first
and let the player shuffle; and Column. Anything else has its text harvested
and labelled unmodelled — an exercise reported as "0 questions" would be worse
than a clumsy rendering of one. The harvest skips the UI and l10n subtrees, or
a quiz reads as "Überprüfen, Wiederholen, Absenden".

Verified against this account's quiz, an H5P.QuestionSet of 20 MultiChoice
questions on a room's board: 239 tests, smoke 91/91 live-only and 93/93 with
the index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:39:19 +02:00

814 lines
30 KiB
TypeScript

import type { Config } from '../config.ts';
import type {
BoardContext,
BoardSkeleton,
CardResponse,
ClassItem,
CourseBoardResponse,
CourseMetadata,
DashboardResponse,
FileParentType,
FileRecord,
GroupItem,
LegacyCourse,
LegacyUser,
LessonResponse,
MeResponse,
NewsResponse,
Paginated,
ParentFileStats,
PreviewWidth,
SubmissionStatus,
LessonLinkedTask,
RoomApplicant,
RoomBoardItem,
RoomDetails,
RoomInvitationLink,
RoomItem,
RoomMember,
TaskContent,
} from './types.ts';
/**
* Maximum repeated query parameters the API's parser will still treat as an
* array — the `qs` default. See `getCards` for what happens above it.
*/
export const MAX_IDS_PER_QUERY = 20;
/** The only output format the preview endpoint accepts; anything else is a 400. */
const PREVIEW_OUTPUT_FORMAT = 'image/webp';
/** Statuses worth retrying: transient by definition, and every call here is a GET. */
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const MAX_RETRIES = 3;
/** Exponential backoff with jitter, so parallel workers do not retry in lockstep. */
function backoffMs(attempt: number): number {
const base = 400 * 2 ** (attempt - 1);
return Math.round(base + Math.random() * base * 0.5);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** An API response outside the 2xx range, carrying the status for callers to branch on. */
export class SchulcloudApiError extends Error {
readonly status: number;
readonly path: string;
readonly body: string;
constructor(status: number, path: string, body: string) {
super(`Schulcloud API ${status} for ${path}${body ? `: ${truncate(body, 400)}` : ''}`);
this.name = 'SchulcloudApiError';
this.status = status;
this.path = path;
this.body = body;
}
/** True when the instance rejected our JWT — the one error the user must act on. */
get isAuthFailure(): boolean {
return this.status === 401;
}
}
function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max)}` : value;
}
export interface DownloadedFile {
bytes: Buffer;
mimeType: string;
fileName: string;
/** True when the file was longer than `maxDownloadBytes` and got cut short. */
truncated: boolean;
}
/**
* HTTP client for a Schulcloud instance. Read-only apart from `extendSession`,
* which touches only the caller's own session — see its doc comment.
*
* Two services sit behind the same origin and both accept the same bearer
* token: the main server under `/api/v3/*`, and the files-storage service
* under `/api/v3/file/*`. The JWT from the browser's `jwt` cookie works
* verbatim as `Authorization: Bearer` — no cookie jar or session refresh is
* involved, and the token is valid for 30 days (see docs/AUTH.md).
*
* Every method that touches user data is a GET. Keeping the client incapable of
* writing is the main safety property of this server: whoever reaches the MCP
* endpoint can read this account's data but cannot act as the user inside
* Schulcloud. `extendSession` is the single exception and is not exposed as a
* tool, so no model-driven call can ever be a POST.
*/
export class SchulcloudClient {
private readonly config: Config;
constructor(config: Config) {
this.config = config;
}
// --- transport -------------------------------------------------------
private url(path: string, query?: Record<string, string | number | string[] | undefined>): URL {
const url = new URL(`${this.config.baseUrl}${path}`);
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined) continue;
if (Array.isArray(value)) for (const v of value) url.searchParams.append(key, v);
else url.searchParams.set(key, String(value));
}
return url;
}
/**
* 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',
options: { idleTimeout?: boolean; token?: string } = {},
): Promise<Response> {
let lastError: unknown;
const headers: Record<string, string> = { Accept: accept };
// Read at the moment of use, never earlier: the token can be replaced
// while the server runs (core/session-token.ts).
const jwt = options.token ?? this.config.jwt;
if (auth === 'bearer') headers.Authorization = `Bearer ${jwt}`;
if (auth === 'cookie') headers.Cookie = `jwt=${jwt}`;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) await delay(backoffMs(attempt));
let response: Response;
const deadline = options.idleTimeout ? idleDeadline(this.config.requestTimeoutMs) : undefined;
try {
response = await fetch(url, {
headers,
signal: deadline?.signal ?? AbortSignal.timeout(this.config.requestTimeoutMs),
redirect: auth === 'bearer' ? 'follow' : 'manual',
});
} catch (error) {
deadline?.stop();
// Connection reset or timeout: worth one more try, since every call
// here is an idempotent GET.
lastError = error;
if (attempt === MAX_RETRIES) throw error;
continue;
}
if (response.ok) return deadline ? deadline.watch(response) : response;
deadline?.stop();
const body = await response.text().catch(() => '');
// 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.
// Observed live: 4 of 26 course pages failed that way in one crawl, and
// all of them succeeded on a retry. 429 and the other gateway errors
// are the same kind of "come back shortly".
if (!RETRYABLE.has(response.status) || attempt === MAX_RETRIES) throw error;
const retryAfter = Number(response.headers.get('retry-after'));
if (Number.isFinite(retryAfter) && retryAfter > 0) await delay(retryAfter * 1000);
lastError = error;
}
throw lastError instanceof Error ? lastError : new Error('request failed');
}
/** Authenticated GET returning JSON. `path` is absolute, e.g. `/api/v3/courses`. */
async getJson<T>(path: string, query?: Record<string, string | number | string[] | undefined>): Promise<T> {
const response = await this.request(this.url(path, query), 'application/json');
return (await response.json()) as T;
}
/**
* Authenticated GET returning bytes, capped at `maxDownloadBytes`.
*
* The cap is enforced while streaming rather than via Content-Length, so a
* mis-declared or chunked response still can't exhaust memory.
*/
async getBytes(path: string, fallbackName: string): Promise<DownloadedFile> {
const url = this.url(path);
const response = await this.request(url, '*/*', 'bearer', { idleTimeout: true });
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[] = [];
let total = 0;
let truncated = false;
if (response.body) {
const reader = response.body.getReader();
try {
while (total < limit) {
const { done, value } = await reader.read();
if (done) break;
const chunk = Buffer.from(value);
const room = limit - total;
if (chunk.length > room) {
chunks.push(chunk.subarray(0, room));
total = limit;
truncated = true;
break;
}
chunks.push(chunk);
total += chunk.length;
}
if (total >= limit) {
// Anything still queued is beyond the cap; drop the rest.
const { done } = await reader.read();
if (!done) truncated = true;
}
} finally {
await reader.cancel().catch(() => {});
}
}
return {
bytes: Buffer.concat(chunks),
mimeType: response.headers.get('content-type')?.split(';')[0]?.trim() || 'application/octet-stream',
fileName: filenameFromDisposition(response.headers.get('content-disposition')) ?? fallbackName,
truncated,
};
}
// --- identity --------------------------------------------------------
me(): Promise<MeResponse> {
return this.getJson<MeResponse>('/api/v3/me');
}
/**
* `/me` as another token sees it, leaving the token in use untouched — how a
* replacement is checked before it is swapped in.
*/
async meAs(token: string): Promise<MeResponse> {
const response = await this.request(this.url('/api/v3/me'), 'application/json', 'bearer', { token });
return (await response.json()) as MeResponse;
}
// --- session ---------------------------------------------------------
/**
* Extends the current session and reports its remaining budget.
*
* **The only non-GET request in this server, and deliberately so.** It is
* what the web UI's "Sitzung verlängern" button calls. It takes no body,
* touches nothing but the caller's own session, and cannot read or change
* any user data — so it does not weaken the property that matters: nobody
* reaching this server can act as the user inside Schulcloud. No MCP tool
* exposes it, so Claude can never cause a POST; only the keepalive calls it.
*
* Using a plain GET here was tried and does not work — see docs/AUTH.md for
* the endurance test that ruled it out.
*/
async extendSession(): Promise<{ expiresInSeconds: number }> {
const url = this.url('/api/v3/authentication/refresh-session');
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.jwt}`,
Accept: 'application/json',
'Content-Length': '0',
},
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new SchulcloudApiError(response.status, url.pathname, body);
}
return (await response.json()) as { expiresInSeconds: number };
}
// --- courses and the classic course board ----------------------------
listCourses(params: { skip?: number; limit?: number } = {}): Promise<Paginated<CourseMetadata>> {
return this.getJson<Paginated<CourseMetadata>>('/api/v3/courses', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
/** Every course the account can see, paging past the API's per-page ceiling. */
listAllCourses(max = 500): Promise<CourseMetadata[]> {
return collectPages((skip, limit) => this.listCourses({ skip, limit }), max);
}
/**
* The contents of one course, as the course page shows them: lessons,
* tasks and column boards interleaved. The route is `course-rooms`, and
* its `:roomId` is the *course* id.
*/
getCourseBoard(courseId: string): Promise<CourseBoardResponse> {
return this.getJson<CourseBoardResponse>(`/api/v3/course-rooms/${encodeURIComponent(courseId)}/board`);
}
getDashboard(): Promise<DashboardResponse> {
return this.getJson<DashboardResponse>('/api/v3/dashboard');
}
// --- tasks -----------------------------------------------------------
listTasks(params: { skip?: number; limit?: number } = {}): Promise<Paginated<TaskContent>> {
return this.getJson<Paginated<TaskContent>>('/api/v3/tasks', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
listFinishedTasks(params: { skip?: number; limit?: number } = {}): Promise<Paginated<TaskContent>> {
return this.getJson<Paginated<TaskContent>>('/api/v3/tasks/finished', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
// --- submissions -----------------------------------------------------
/**
* Submission statuses for one task.
*
* The only way to obtain a submission id: there is no `GET /submissions` and
* no `GET /submissions/{id}`. What comes back depends on the account — a
* student sees their own submission, a teacher sees the whole class's.
*/
async listSubmissionStatuses(taskId: string): Promise<SubmissionStatus[]> {
const page = await this.getJson<{ data: SubmissionStatus[] }>(
`/api/v3/submissions/status/task/${encodeURIComponent(taskId)}`,
);
return page.data ?? [];
}
// --- lessons ---------------------------------------------------------
getLesson(lessonId: string): Promise<LessonResponse> {
return this.getJson<LessonResponse>(`/api/v3/lessons/${encodeURIComponent(lessonId)}`);
}
/**
* A lesson's tasks.
*
* Returns a bare array, not the `{data, total}` envelope every other list
* endpoint uses — checked against both the live instance and a local 33.40.
* Typing it as `Paginated` made `.data` undefined, which silently dropped
* every task attached to a topic: they vanished from get_lesson, get_task
* reported them as non-existent, and their submissions — grades included —
* could not be reached at all. The envelope branch is kept in case the
* endpoint is ever normalised to match its siblings.
*/
async getLessonTasks(lessonId: string): Promise<LessonLinkedTask[]> {
const body = await this.getJson<LessonLinkedTask[] | Paginated<LessonLinkedTask>>(
`/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`,
);
return Array.isArray(body) ? body : (body.data ?? []);
}
// --- rooms ------------------------------------------------------------
/**
* The rooms this account belongs to.
*
* Returns `{data}` with no `total` — not the usual paginated envelope. The
* server derives the list from actual room memberships, so an empty result
* means exactly that, and a room a teacher revoked access to simply stops
* appearing.
*/
async listRooms(): Promise<RoomItem[]> {
const body = await this.getJson<{ data?: RoomItem[] }>('/api/v3/rooms');
return body.data ?? [];
}
getRoom(roomId: string): Promise<RoomDetails> {
return this.getJson<RoomDetails>(`/api/v3/rooms/${encodeURIComponent(roomId)}`);
}
async listRoomBoards(roomId: string): Promise<RoomBoardItem[]> {
const body = await this.getJson<Paginated<RoomBoardItem>>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/boards`,
);
return body.data ?? [];
}
/**
* A room's members. Needs no special permission for a member to see who else
* is in the room, but it can still be refused — callers treat that as "not
* available" rather than as an error worth surfacing.
*/
async listRoomMembers(roomId: string): Promise<RoomMember[]> {
const body = await this.getJson<{ data?: RoomMember[] }>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/members`,
);
return body.data ?? [];
}
/**
* People waiting to be let into a room, and the room's invitation links.
*
* Both are room-admin surface: a viewer gets 403, which is ordinary rather
* than exceptional. `allowedOperations` on the room says which of these the
* account may ask for, so callers can skip the ones that would be refused.
*/
async listRoomApplicants(roomId: string): Promise<RoomApplicant[]> {
const body = await this.getJson<{ data?: RoomApplicant[] }>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/applicants`,
);
return body.data ?? [];
}
async listRoomInvitationLinks(roomId: string): Promise<RoomInvitationLink[]> {
const body = await this.getJson<{ data?: RoomInvitationLink[] }>(
`/api/v3/rooms/${encodeURIComponent(roomId)}/room-invitation-links`,
);
return body.data ?? [];
}
// --- column boards ---------------------------------------------------
getBoardSkeleton(boardId: string): Promise<BoardSkeleton> {
return this.getJson<BoardSkeleton>(`/api/v3/boards/${encodeURIComponent(boardId)}`);
}
getBoardContext(boardId: string): Promise<BoardContext> {
return this.getJson<BoardContext>(`/api/v3/boards/${encodeURIComponent(boardId)}/context`);
}
/**
* The content behind an H5P element: a quiz with all of its questions.
*
* `params` is what the player loads before it renders anything, so one GET
* returns the whole exercise — every question, every answer option and which
* of them is correct — even though the player then shows one question at a
* time. There is nothing to step through and no page to scrape.
*
* The shape belongs to whichever H5P library the content uses, so it stays
* `unknown` here and `core/h5p.ts` interprets it.
*/
getH5pParams(contentId: string): Promise<unknown> {
return this.getJson<unknown>(`/api/v3/h5p-editor/params/${encodeURIComponent(contentId)}`);
}
/**
* Card bodies for the given ids.
*
* **Never request more than 20 at once.** Express/NestJS parse the query
* string with `qs`, whose default `arrayLimit` is 20: past that, repeated
* `ids=` params stop becoming an array and become an object keyed `"0"`,
* `"1"`, … `@IsMongoId({ each: true })` then iterates something that is not
* an array and rejects every value, so the API answers
* `"each value in ids must be a mongodb id"` — blaming the ids when the real
* problem is how many there are. Verified against the live instance: 20 ids
* return 200, 21 return 400, with identical ids.
*
* Nothing in the controller or its DTO says this; the limit lives in the
* query parser underneath them.
*/
async getCards(cardIds: string[]): Promise<CardResponse[]> {
const CHUNK = MAX_IDS_PER_QUERY;
const out: CardResponse[] = [];
for (let i = 0; i < cardIds.length; i += CHUNK) {
const chunk = cardIds.slice(i, i + CHUNK);
const page = await this.getJson<{ data: CardResponse[] }>('/api/v3/cards', { ids: chunk });
out.push(...page.data);
}
return out;
}
// --- files -----------------------------------------------------------
/**
* Files attached to one parent entity.
*
* `storageLocationId` is the school id for `storageLocation: 'school'`,
* which is what every parent type in normal use resolves to. Board file
* elements are addressed with `parentType: 'boardnodes'` and the *element*
* id as `parentId`.
*/
listFiles(args: {
storageLocationId: string;
parentType: FileParentType;
parentId: string;
storageLocation?: 'school' | 'instance';
}): Promise<Paginated<FileRecord>> {
const location = args.storageLocation ?? 'school';
const path =
`/api/v3/file/list/${location}/${encodeURIComponent(args.storageLocationId)}` +
`/${args.parentType}/${encodeURIComponent(args.parentId)}`;
return this.getJson<Paginated<FileRecord>>(path);
}
getFileRecord(fileRecordId: string): Promise<FileRecord> {
return this.getJson<FileRecord>(`/api/v3/file/${encodeURIComponent(fileRecordId)}`);
}
downloadFile(record: Pick<FileRecord, 'id' | 'name'>): Promise<DownloadedFile> {
const path = `/api/v3/file/download/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}`;
return this.getBytes(path, record.name);
}
// --- misc ------------------------------------------------------------
listNews(params: { skip?: number; limit?: number } = {}): Promise<Paginated<NewsResponse>> {
return this.getJson<Paginated<NewsResponse>>('/api/v3/news', {
skip: params.skip,
limit: clampPageSize(params.limit),
});
}
/** File count and total bytes under one parent, without listing the records. */
getParentFileStats(parentType: FileParentType, parentId: string): Promise<ParentFileStats> {
return this.getJson<ParentFileStats>(
`/api/v3/file/stats/${parentType}/${encodeURIComponent(parentId)}`,
);
}
/**
* A rasterised preview of one file.
*
* The reason this exists: many course PDFs are image-only scans, so text
* extraction legitimately yields nothing and their contents are otherwise
* unreadable. A preview is a picture of the page, which Claude can read
* directly. Only meaningful when the record's `previewStatus` is
* `preview_possible`; anything else 404s or returns the placeholder.
*/
async getFilePreview(
record: Pick<FileRecord, 'id' | 'name'>,
width?: PreviewWidth,
): Promise<DownloadedFile> {
// Two traps here, both of which answer with a 400 that names the value but
// not the permitted set:
// - `width` is an enum (50 | 150 | 500), not a free number;
// - `outputFormat` accepts only `image/webp`. Omitting it is worse than
// wrong: the preview is then rendered in the *source* format, so a PDF
// comes back as a PDF and the whole point — a picture of the page — is
// lost.
const query = new URLSearchParams({ outputFormat: PREVIEW_OUTPUT_FORMAT });
if (width) query.set('width', String(width));
const path =
`/api/v3/file/preview/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}` +
`?${query.toString()}`;
const file = await this.getBytes(path, record.name);
// The response labels itself `webp` rather than `image/webp`, which no
// image consumer would accept. We asked for the format, so we know it.
return file.mimeType.startsWith('image/') ? file : { ...file, mimeType: PREVIEW_OUTPUT_FORMAT };
}
// --- legacy /api/v1 ---------------------------------------------------
//
// Exactly three legacy routes survive in the deployment's ingress table
// (dof_app_deploy .../all/x_ingress.yml): courses, users and classes. They
// are production surface, not a leftover — the table even notes why each
// one is still needed. Everything else under /api/v1 is unrouted and 404s,
// so do not reach for it.
/** One course with the fields v3 drops: description, members, timetable. */
getLegacyCourse(courseId: string): Promise<LegacyCourse> {
return this.getJson<LegacyCourse>(`/api/v1/courses/${encodeURIComponent(courseId)}`);
}
/**
* One user's name.
*
* The only id-to-name mapping available: submission `submitters`, file
* `creatorId` and course `teacherIds` are all bare ids, and no v3 route
* resolves them for a non-admin.
*/
getLegacyUser(userId: string): Promise<LegacyUser> {
return this.getJson<LegacyUser>(`/api/v1/users/${encodeURIComponent(userId)}`);
}
// --- groups and classes ------------------------------------------------
/** Classes ("Klassen") this account belongs to, with teacher names. */
async listClasses(): Promise<ClassItem[]> {
const body = await this.getJson<Paginated<ClassItem>>('/api/v3/groups/class', { limit: MAX_PAGE_SIZE });
return body.data ?? [];
}
/** Groups this account belongs to — room membership groups, classes, courses. */
async listGroups(): Promise<GroupItem[]> {
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', { idleTimeout: true });
}
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;
}
}
}
/**
* A timeout that measures silence rather than total time, for downloads.
*
* The request timeout is right for an API call and wrong for a file: it bounds
* the whole transfer, so an 11 MB scan from a slow storage host was cut off at
* 30 seconds while its bytes were still arriving — four files on the live
* account, recorded as failures. Here the clock starts over with every chunk,
* so only a transfer that stalls is abandoned.
*/
function idleDeadline(ms: number) {
const controller = new AbortController();
const expire = () => controller.abort(new Error(`no data received for ${Math.round(ms / 1000)}s`));
let timer = setTimeout(expire, ms);
// Never the reason a process stays alive: a caller that stops reading early
// (the download cap) leaves the last timer behind.
timer.unref();
const rearm = () => {
clearTimeout(timer);
timer = setTimeout(expire, ms);
timer.unref();
};
const stop = () => clearTimeout(timer);
const watch = (response: Response): Response => {
if (!response.body) {
stop();
return response;
}
rearm();
const body = response.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, output) {
rearm();
output.enqueue(chunk);
},
flush() {
stop();
},
}),
);
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
};
return { signal: controller.signal, stop, watch };
}
/**
* 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;
}
/**
* The list endpoints reject `limit` above 100 and document a maximum of 99, so
* page at 99 and let `collectPages` stitch the results back together.
*/
export const MAX_PAGE_SIZE = 99;
function clampPageSize(limit: number | undefined): number | undefined {
if (limit === undefined) return undefined;
return Math.min(Math.max(1, Math.trunc(limit)), MAX_PAGE_SIZE);
}
/** Follows `skip`/`limit` paging until `max` items or the server runs out. */
async function collectPages<T>(
fetchPage: (skip: number, limit: number) => Promise<Paginated<T>>,
max: number,
): Promise<T[]> {
const items: T[] = [];
let skip = 0;
while (items.length < max) {
const page = await fetchPage(skip, Math.min(MAX_PAGE_SIZE, max - items.length));
items.push(...page.data);
skip += page.data.length;
// Stop on an empty page too, so a server that ignores `skip` can't loop forever.
if (page.data.length === 0 || skip >= page.total) break;
}
return items.slice(0, max);
}
function filenameFromDisposition(header: string | null): string | undefined {
if (!header) return undefined;
// Prefer RFC 5987 `filename*`, which carries the encoding explicitly.
const extended = /filename\*=(?:UTF-8|utf-8)''([^;]+)/.exec(header);
if (extended?.[1]) return safeDecode(extended[1].trim());
const plain = /filename="?([^";]+)"?/.exec(header);
if (plain?.[1]) return safeDecode(plain[1].trim());
return undefined;
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}