The list of notes and the note being written are one screen now, two panes: the notes on the left, the open one on the right, side by side where there is room and one at a time on a phone, where the back button returns to the list. The search box moved into the top of that list, and its results *are* the list — searching is a way of finding a note, not a separate place to be, and a tab for it was a tab too many. Emptying the box brings the whole list back. Opening a hit opens that day at the lesson that matched, rather than at the top of a day with six of them. A row has to say what the note holds, so the listing carries it: the subjects a day covers, how many lessons, and the first line actually written in it. One request for the whole list rather than one per note. `plainText` is now one rule in one place for wherever a note is shown rather than edited — the search snippet and the list row both went through their own half-copy of it, and the row's copy rendered a table as `| | |` and left `_Fazit_` wearing its markers. It strips one leading marker, not each in turn, because `## 1. Deutsch` keeps its lesson number and the list rule was eating it. Driven in Firefox at both widths: the list, the search, opening a hit, the jump to the lesson, and the phone's list-then-note. 390 unit tests, 116/117 smoke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
653 lines
27 KiB
TypeScript
653 lines
27 KiB
TypeScript
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,
|
|
plainText,
|
|
readNotes,
|
|
noteSubjects,
|
|
noteSections,
|
|
searchNotes,
|
|
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.
|
|
*/
|
|
/**
|
|
* The first words of a note, for a list row.
|
|
*
|
|
* Headings and list markers are dropped: a row that reads "## 1. Deutsch —
|
|
* 08:00" repeats what the row already says, and the point of the line is the
|
|
* first thing that was actually written down.
|
|
*/
|
|
function previewOf(text: string): string {
|
|
for (const line of text.split('\n')) {
|
|
// Headings are skipped rather than stripped: the row above already says
|
|
// which lessons the day holds, and repeating one is not a preview.
|
|
if (/^#{1,6}\s/.test(line.trim())) continue;
|
|
const plain = plainText(line);
|
|
if (plain) return plain.length > 120 ? `${plain.slice(0, 117)}…` : plain;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
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<FmFile, 'id' | 'name'> & Partial<FmFile>;
|
|
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. What replaces it
|
|
// is what a list *shows* — the lessons a day covers and a line of
|
|
// its text — so the app's note list needs one request, not one per
|
|
// note.
|
|
notes: notes.slice(0, limit).map(({ text, ...rest }) => {
|
|
const note = { ...rest, text };
|
|
const sections = noteSections(note);
|
|
return {
|
|
...rest,
|
|
subjects: noteSubjects(note),
|
|
lessons: sections.length,
|
|
preview: previewOf(note.text),
|
|
};
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });
|
|
return fail(res, error, 'notes');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Full text over the note files, for the app's search box.
|
|
*
|
|
* Reads disk rather than the index on purpose: notes reach the index only on
|
|
* a full crawl, so a lesson written this morning would not be findable, and
|
|
* "what did I write this week" is most of what anyone searches their own
|
|
* notes for. `search` in MCP is the other one — it spans Schulcloud and the
|
|
* class register too, at the cost of being as fresh as the last crawl.
|
|
*/
|
|
router.get('/notes/search', 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 query = stringParam(req.query.q) ?? '';
|
|
if (query.trim().length < 2) {
|
|
return res.status(400).json({ error: 'invalid', message: 'Mindestens zwei Zeichen suchen.' });
|
|
}
|
|
try {
|
|
const notes = filterNotes(await readNotes(root), {
|
|
...pickParam('subject', req.query.subject),
|
|
...pickParam('since', req.query.since),
|
|
...pickParam('until', req.query.until),
|
|
});
|
|
const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 50, 200);
|
|
const hits = searchNotes(notes, query, limit);
|
|
return res.json({ query, count: hits.length, hits });
|
|
} catch (error) {
|
|
return fail(res, error, 'search 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<string, unknown>;
|
|
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<typeof dayLessons> = [];
|
|
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": "<the value of the jwt cookie>"}.' });
|
|
}
|
|
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<void> {
|
|
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<void> {
|
|
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<K extends string>(key: K, value: unknown): Partial<Record<K, string>> {
|
|
const text = stringParam(value);
|
|
return text ? ({ [key]: text } as Record<K, string>) : {};
|
|
}
|
|
|
|
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<FsErrorCode, number> = {
|
|
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);
|
|
}
|