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>
814 lines
30 KiB
TypeScript
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;
|
|
}
|
|
}
|