import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { Readable } from 'node:stream'; import express, { type Request, type Response, type Router } from 'express'; import { SchulcloudApiError } from '../core/client.ts'; import { compareNames, FileManagerMarkupError, FsError, nameMatcher, type FmFile, type FsErrorCode, type WalkEntry, } from '../core/legacy-files.ts'; import { dayLessons, dayNoteSkeleton, dayNoteTitle, missingHeadings } from '../core/day-note.ts'; import { isCalendarDate, schoolToday } from '../core/dates.ts'; import { dayNotePath, NoteConflict, NoteNotFound, filterNotes, readNoteAt, readNotes, replaceNote, writeNote, } from '../core/notes.ts'; import { resolveWithin } from '../core/paths.ts'; import { TokenRejected } from '../core/session-token.ts'; import type { Services } from '../services.ts'; /** * The CLI's backend: file bytes, the sync manifest, and on-demand re-crawls. * * These sit behind the same bearer check as `/mcp`, on the same host. Bytes go * over plain HTTP rather than through MCP because base64 inside JSON-RPC costs * a third more bandwidth and buffers whole files in memory; ranged streaming * from the mirror does neither, which matters for the video files. * * Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own * index and mirror, `/token` only to the server's own token, and every upstream * call either triggers is a GET. */ const NO_NOTES_DIR = 'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.'; export function createApiRouter(services: Services): Router { const router = express.Router(); router.get('/manifest', async (req: Request, res: Response) => { if (!services.store) return res.status(503).json({ error: 'no_index' }); try { const sinceParam = typeof req.query.since === 'string' ? req.query.since : undefined; const since = sinceParam ? await services.store.resolveCursor(sinceParam) : undefined; if (sinceParam && since === undefined) { // An unresolvable cursor must not silently mean "everything is new": // say so, so the client can decide to do a full sync deliberately. return res.status(409).json({ error: 'cursor_unknown', message: `No crawl at or before "${sinceParam}". Sync without a cursor for a full manifest.`, }); } const { crawlId, entries } = await services.store.manifest(since); const stats = await services.store.stats(); return res.json({ crawlId, cursor: String(crawlId), crawledAt: stats.crawledAt, count: entries.length, entries }); } catch (error) { return fail(res, error, 'manifest'); } }); router.get('/status', async (_req: Request, res: Response) => { if (!services.store) return res.status(503).json({ error: 'no_index' }); try { const stats = await services.store.stats(); return res.json({ ...stats, indexer: services.indexer?.status() ?? null }); } catch (error) { return fail(res, error, 'status'); } }); router.post('/refresh', express.json({ limit: '16kb' }), async (req: Request, res: Response) => { if (!services.indexer) return res.status(503).json({ error: 'no_index' }); const body = (req.body ?? {}) as { courseId?: string; force?: boolean; wait?: boolean }; try { const scope = body.courseId ?? 'full'; // `wait: false` answers at once and leaves the caller to poll /status. // Waiting for the result in this request is kept for older clients, but // it cannot outlast a long crawl: Node's fetch abandons a response whose // headers have not arrived within five minutes. if (body.wait === false) { const { run, joined } = services.indexer.start(scope, { force: body.force === true }); // The outcome is recorded by the indexer (status().lastResult/lastError); // this only keeps an unawaited failure from becoming an unhandled one. run.catch(() => {}); const status = services.indexer.status(); return res.status(202).json({ started: !joined, joined, scope, startedAt: status.startedAt ?? null }); } const result = await services.indexer.refresh(scope, { force: body.force === true }); return res.json(result); } catch (error) { // A rate-limit refusal is the caller's problem to act on, not a fault. const message = error instanceof Error ? error.message : String(error); if (/Wait \d+s/.test(message)) return res.status(429).json({ error: 'too_soon', message }); return fail(res, error, 'refresh'); } }); // --- the file manager ("Dateien"), as a filesystem ---------------------- // // Live, not from the index: these answer what the file manager holds now, and // need no database. Paths are the same ones the MCP fs_* tools print. router.get('/fs/list', async (req: Request, res: Response) => { try { const node = await services.files.resolve(stringParam(req.query.path) ?? '/'); if (node.kind === 'file') return res.json({ path: node.path, kind: 'file', file: node.file }); const listing = await services.files.list(node.ref); return res.json({ path: node.path, kind: 'directory', area: node.ref.area ?? null, directories: listing.directories.map((entry) => ({ ...entry, path: childPath(node.path, entry.name) })), files: listing.files.map((entry) => ({ ...entry, path: childPath(node.path, entry.name) })), }); } catch (error) { return fsFail(res, error, 'fs list'); } }); router.get('/fs/tree', async (req: Request, res: Response) => { try { const node = await services.files.resolve(stringParam(req.query.path) ?? '/'); if (node.kind === 'file') return res.json({ path: node.path, kind: 'file', file: node.file }); const result = await services.files.walk(node, { maxDepth: boundedInt(req.query.depth, 3, 1, 12), maxDirectories: boundedInt(req.query.maxFolders, 200, 1, 1000), }); return res.json({ path: node.path, kind: 'directory', entries: result.entries.map(treeEntry), visited: result.visited, truncated: result.truncated, failures: result.failures, }); } catch (error) { return fsFail(res, error, 'fs tree'); } }); router.get('/fs/find', async (req: Request, res: Response) => { const name = stringParam(req.query.name); if (!name) return res.status(400).json({ error: 'bad_request', message: 'Give name.' }); try { const node = await services.files.resolve(stringParam(req.query.path) ?? '/'); if (node.kind === 'file') return res.json({ path: node.path, kind: 'file', matches: [] }); const type = stringParam(req.query.type) ?? 'any'; const matches = nameMatcher(name); const result = await services.files.walk(node, { maxDepth: 12, maxDirectories: boundedInt(req.query.maxFolders, 400, 1, 1000), }); return res.json({ path: node.path, kind: 'directory', matches: result.entries .filter((entry) => (type === 'file' ? entry.file : type === 'folder' ? entry.directory : true)) .filter((entry) => matches((entry.file ?? entry.directory)?.name ?? '')) .sort((a, b) => compareNames(a.path, b.path)) .map(treeEntry), visited: result.visited, truncated: result.truncated, failures: result.failures, }); } catch (error) { return fsFail(res, error, 'fs find'); } }); router.get('/fs/file', async (req: Request, res: Response) => { try { const path = stringParam(req.query.path); const id = stringParam(req.query.id); let file: Pick & Partial; if (path) { const node = await services.files.resolve(path); if (node.kind !== 'file') return res.status(400).json({ error: 'not_a_file', message: `${node.path} is a folder.` }); file = node.file; } else if (id && /^[0-9a-f]{24}$/i.test(id)) { file = { id, name: stringParam(req.query.name) ?? id }; } else { return res.status(400).json({ error: 'bad_request', message: 'Give path, or id (and name).' }); } if (file.blocked) { return res.status(403).json({ error: 'blocked', message: 'The instance virus scanner blocked this file.' }); } // Streamed straight through rather than buffered: the CLI uses this for // whole folders, and videos routinely exceed any sensible in-memory cap. const signed = await services.client.getFileManagerSignedUrl(file.id, file.name); const upstream = await services.client.openSignedUrl(signed); if (!upstream.body) return res.status(502).json({ error: 'upstream_failed', message: 'empty response' }); res.setHeader('Content-Type', file.mimeType || upstream.headers.get('content-type') || 'application/octet-stream'); res.setHeader('Content-Disposition', contentDisposition(file.name)); const length = upstream.headers.get('content-length'); if (length) res.setHeader('Content-Length', length); res.setHeader('X-Schulcloud-Source', 'file-manager'); Readable.fromWeb(upstream.body as never).pipe(res); } catch (error) { return fsFail(res, error, 'fs file'); } }); /** * Streams one file. Served from the local mirror when present; otherwise * proxied live, which is what keeps files too large to mirror reachable. */ router.get('/files/:fileId', async (req: Request, res: Response) => { const raw = req.params.fileId; const fileId = Array.isArray(raw) ? raw[0] : raw; // Mongo ObjectId shape. Validated before it reaches the store or a path. if (typeof fileId !== 'string' || !/^[0-9a-f]{24}$/i.test(fileId)) { return res.status(400).json({ error: 'bad_file_id' }); } if (!services.store) return res.status(503).json({ error: 'no_index' }); try { const entry = await services.store.mirrorEntry(fileId); if (entry) { const absolute = resolveWithin(services.config.mirrorDir, entry.path); const info = await stat(absolute).catch(() => undefined); if (info?.isFile()) { res.setHeader('Content-Type', entry.mimeType); res.setHeader('Content-Disposition', contentDisposition(entry.name)); // sendFile handles Range, ETag and conditional requests for us. return res.sendFile(absolute, { dotfiles: 'deny', acceptRanges: true }, (error) => { if (error && !res.headersSent) res.status(500).end(); }); } } const known = await services.store.fileSource(fileId); if (known?.source === 'file-manager') return await proxyFileManager(services, fileId, known, res); return await proxyLive(services, fileId, res); } catch (error) { return fail(res, error, 'file'); } }); // --- the Schulcloud session token ------------------------------------------- // // A write, but to this server's own state: the token it reads Schulcloud with. // The only upstream call is the GET /me a replacement must pass first. Works // without an index, since a server without one still needs a token. // --- the user's own lesson notes ---------------------------------------- // // Read off disk, like the fs_* routes read Schulcloud: no index involved, so // these answer before the first crawl and while Postgres is down. The POST is // the only write in this server that is not the index or its own token, and // it can reach nothing but the notes directory — `writeNote` builds every // path component with `safeComponent` and checks the result with // `resolveWithin`. router.get('/notes', async (req: Request, res: Response) => { const root = services.config.notesDir; if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR }); try { const path = stringParam(req.query.path); if (path) return res.json(await readNoteAt(root, path)); const notes = filterNotes(await readNotes(root), { ...pickParam('subject', req.query.subject), ...pickParam('since', req.query.since), ...pickParam('until', req.query.until), ...pickParam('courseId', req.query.courseId), }); const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 500, 2000); return res.json({ root, writable: services.config.notesWritable, count: notes.length, // The body is dropped from a listing: a term of notes is megabytes, // and the CLI asks for the ones it wants by path. notes: notes.slice(0, limit).map(({ text, ...rest }) => rest), }); } catch (error) { if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message }); return fail(res, error, 'notes'); } }); router.post('/notes', express.json({ limit: '1mb' }), async (req: Request, res: Response) => { const root = services.config.notesDir; if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR }); if (!services.config.notesWritable) { return res.status(403).json({ error: 'notes_readonly', message: 'This server was started with NOTES_READONLY.' }); } const body = (req.body ?? {}) as Record; const title = typeof body.title === 'string' ? body.title.trim() : ''; const noteText = typeof body.text === 'string' ? body.text : ''; if (!title || !noteText.trim()) { return res.status(400).json({ error: 'invalid', message: 'A note needs a title and some text.' }); } try { const { note, appended } = await writeNote(root, { title, text: noteText, ...pickParam('date', body.date), ...pickParam('subject', body.subject), ...pickParam('courseId', body.courseId), ...pickParam('path', body.path), ...pickParam('source', body.source), ...(Array.isArray(body.tags) ? { tags: body.tags.filter((tag): tag is string => typeof tag === 'string') } : {}), append: body.append === true, }); return res.status(appended ? 200 : 201).json({ ...note, appended }); } catch (error) { return fail(res, error, 'save a note'); } }); // --- one school day, as the notes page edits it ------------------------- // // The page is a Markdown editor for a single file, so these two are `GET the // day` and `PUT the day`. What makes them worth their own routes rather than // the generic ones above is the skeleton: WebUntis is the only thing that // knows which lessons a day held, and handing someone their day already laid // out is the difference between a note per day and an empty box. router.get('/notes/day', async (req: Request, res: Response) => { const root = services.config.notesDir; if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR }); const date = stringParam(req.query.date) ?? schoolToday(); if (!isCalendarDate(date)) { return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date}.` }); } try { const path = dayNotePath(date); const note = await readNoteAt(root, path).catch((error: unknown) => { if (error instanceof NoteNotFound) return undefined; throw error; }); // Never fatal, and reported rather than hidden: without a key, or with // WebUntis down, the page still has to open — it just cannot offer the // lessons, and saying so beats an empty skeleton that looks like a day // with no school. let lessons: ReturnType = []; let timetable: 'ok' | 'off' | 'unavailable' = services.untis ? 'ok' : 'off'; if (services.untis) { try { lessons = dayLessons(await services.untis.timetable(date, date), date); } catch { timetable = 'unavailable'; } } return res.json({ date, path, title: dayNoteTitle(date), exists: Boolean(note), text: note?.text ?? '', modifiedAt: note?.modifiedAt ?? null, timetable, lessons, skeleton: dayNoteSkeleton(lessons), // What the page would add to a note already started, so "top up the // day" never rewrites what is there. missing: note ? dayNoteSkeleton(missingHeadings(note.text, lessons)) : '', }); } catch (error) { return fail(res, error, 'read a day note'); } }); router.put('/notes/day', express.json({ limit: '2mb' }), async (req: Request, res: Response) => { const root = services.config.notesDir; if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR }); if (!services.config.notesWritable) { return res.status(403).json({ error: 'notes_readonly', message: 'This server was started with NOTES_READONLY.' }); } const body = (req.body ?? {}) as { date?: unknown; text?: unknown; expectedModifiedAt?: unknown }; const date = typeof body.date === 'string' ? body.date : ''; if (!isCalendarDate(date)) { return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date || '(none)'}.` }); } if (typeof body.text !== 'string') { return res.status(400).json({ error: 'invalid', message: 'A day note needs its text.' }); } try { const note = await replaceNote( root, dayNotePath(date), { title: dayNoteTitle(date), text: body.text, date, source: 'notes-page' }, typeof body.expectedModifiedAt === 'string' ? { expectedModifiedAt: body.expectedModifiedAt } : {}, ); return res.json({ path: note.path, modifiedAt: note.modifiedAt, bytes: note.bytes }); } catch (error) { // A clash is the caller's to resolve, not a fault: the page shows both // and lets the person decide, which is the only safe answer when the // notes folder is synced and open in two places. if (error instanceof NoteConflict) { return res.status(409).json({ error: 'conflict', message: error.message, modifiedAt: error.modifiedAt }); } return fail(res, error, 'save a day note'); } }); router.get('/token', (_req: Request, res: Response) => { res.json(tokenStatus(services)); }); router.put('/token', express.json({ limit: '16kb' }), async (req: Request, res: Response) => { const jwt = (req.body as { jwt?: unknown } | undefined)?.jwt; if (typeof jwt !== 'string' || !jwt.trim()) { return res.status(400).json({ error: 'missing_jwt', message: 'Send {"jwt": ""}.' }); } try { const { changed, persisted } = await services.session.replace(jwt); if (changed) console.log('[schulcloud-mcp] session token replaced at runtime'); return res.json({ changed, persisted, ...tokenStatus(services) }); } catch (error) { if (error instanceof TokenRejected) return res.status(422).json({ error: error.problem, message: error.message }); // Anything else is the instance failing to answer the check. The error // cannot contain the token — SchulcloudApiError carries only a path — but // the response still says no more than that. const detail = error instanceof SchulcloudApiError ? `HTTP ${error.status}` : error instanceof Error ? error.name : 'error'; console.error(`[schulcloud-mcp] token check failed: ${detail}`); return res.status(502).json({ error: 'check_failed', message: `Schulcloud did not answer the check (${detail}); the token in use is unchanged. Try again shortly.`, }); } }); // A body that is not JSON would otherwise reach Express's default handler, // which logs it — and here the body is a credential. router.use((error: unknown, _req: Request, res: Response, next: (error?: unknown) => void) => { const type = (error as { type?: string } | undefined)?.type; if (type === 'entity.parse.failed' || type === 'entity.too.large') { res.status(400).json({ error: 'bad_request' }); return; } next(error); }); return router; } function tokenStatus(services: Services) { return { ...services.session.status(), keepalive: services.keepalive?.state() ?? null }; } /** Falls back to Schulcloud for anything not in the mirror, streaming through. */ /** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */ async function proxyFileManager( services: Services, fileId: string, known: { name: string; mimeType: string; size: number }, res: Response, ): Promise { const signed = await services.client.getFileManagerSignedUrl(fileId, known.name); const upstream = await services.client.openSignedUrl(signed); if (!upstream.body) { res.status(502).json({ error: 'upstream_failed', message: 'empty response' }); return; } res.setHeader('Content-Type', known.mimeType || 'application/octet-stream'); res.setHeader('Content-Disposition', contentDisposition(known.name)); const length = upstream.headers.get('content-length'); if (length) res.setHeader('Content-Length', length); res.setHeader('X-Schulcloud-Source', 'live'); Readable.fromWeb(upstream.body as never).pipe(res); } async function proxyLive(services: Services, fileId: string, res: Response): Promise { const record = await services.client.getFileRecord(fileId); if (record.securityCheckStatus === 'blocked') { res.status(403).json({ error: 'blocked', message: 'The instance virus scanner blocked this file.' }); return; } const url = `${services.config.baseUrl}/api/v3/file/download/${encodeURIComponent(record.id)}` + `/${encodeURIComponent(record.name)}`; const upstream = await fetch(url, { headers: { Authorization: `Bearer ${services.config.jwt}` } }); if (!upstream.ok || !upstream.body) { res.status(upstream.status === 401 ? 502 : upstream.status).json({ error: 'upstream_failed', message: upstream.status === 401 ? 'The Schulcloud session has expired on the server.' : `HTTP ${upstream.status}`, }); return; } res.setHeader('Content-Type', record.mimeType || 'application/octet-stream'); res.setHeader('Content-Disposition', contentDisposition(record.name)); if (record.size) res.setHeader('Content-Length', String(record.size)); res.setHeader('X-Schulcloud-Source', 'live'); Readable.fromWeb(upstream.body as never).pipe(res); } /** * RFC 5987 Content-Disposition. * * Built by hand because the instance's own header is malformed — it emits * `attachment;; filename="…"` with the name percent-encoded inside the quotes — * and we should not pass that on to clients. */ function contentDisposition(name: string): string { const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; } function fail(res: Response, error: unknown, what: string): void { console.error(`[schulcloud-mcp] ${what} failed:`, error); if (!res.headersSent) res.status(500).json({ error: 'internal_error' }); } function stringParam(value: unknown): string | undefined { const first = Array.isArray(value) ? value[0] : value; return typeof first === 'string' && first.length > 0 ? first : undefined; } /** * A query or body value as an optional field, so callers can spread it into an * options object without turning "not given" into `undefined` the way an * exactOptionalPropertyTypes build rejects. */ function pickParam(key: K, value: unknown): Partial> { const text = stringParam(value); return text ? ({ [key]: text } as Record) : {}; } function boundedInt(value: unknown, fallback: number, min: number, max: number): number { const parsed = Number.parseInt(stringParam(value) ?? '', 10); return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback; } function childPath(parent: string, name: string): string { return `${parent === '/' ? '' : parent}/${name}`; } /** A walk entry as JSON; `name` travels separately because names may contain "/". */ function treeEntry(entry: WalkEntry) { if (entry.directory) { return { type: 'directory', path: entry.path, parentPath: entry.parentPath, depth: entry.depth, id: entry.directory.id, name: entry.directory.name }; } const file = entry.file as FmFile; return { type: 'file', path: entry.path, parentPath: entry.parentPath, depth: entry.depth, id: file.id, name: file.name, size: file.size, mimeType: file.mimeType ?? null, blocked: file.blocked, }; } const FS_STATUS: Record = { not_found: 404, ambiguous: 409, not_a_directory: 400, not_a_file: 400, not_navigable: 422, }; function fsFail(res: Response, error: unknown, what: string): void { if (res.headersSent) return; if (error instanceof FsError) { res.status(FS_STATUS[error.code]).json({ error: error.code, message: error.message }); return; } if (error instanceof FileManagerMarkupError) { res.status(502).json({ error: 'markup_changed', message: error.message }); return; } if (error instanceof SchulcloudApiError) { // 401 upstream is the Pi's session, not the caller's token: say which. const status = error.status === 401 ? 502 : error.status === 403 || error.status === 404 ? error.status : 502; const message = error.status === 401 ? 'The Schulcloud session has expired on the server.' : error.message; res.status(status).json({ error: 'upstream_failed', message }); return; } fail(res, error, what); }