The notes existed but there was nowhere to write them: a CLI command on a laptop, a tool call through Claude, or a file in a Docker volume. None of those is reachable from a phone in a lesson, which is where notes are actually taken. So: `/app`, served only when WEB_PASSWORD is set. A login, the day's notes, and a settings page for the Schulcloud token — the one surface here meant for a person rather than a program. The shape follows how the notes are written: one note per school day, one `##` heading per lesson, prose and lists and tables beneath. That turns out to be the design decision that matters, twice over. First, it is what lets WebUntis earn its keep. Opening a day with no note fills in that day's lessons — numbered, with times, teacher and room, cancellations dropped and substitutions marked. Retyping the timetable is exactly the work the second upstream exists to avoid, and "Stunden ergänzen" tops up a note started before the day ended without touching what is already written. Second, it changes how notes are indexed. A day note is indexed per lesson, not whole: search answers "my own note, Deutsch, 18.09.2026" rather than "my own note, Friday", and `list_notes subject=Deutsch` finds a day whose frontmatter names no subject at all. Indexed whole, every hit would read as a weekday and "what did we do in Deutsch" would match notes whose other five lessons were something else. `lessonHeading` and `subjectFromHeading` are a loop — the app writes the heading, the indexer reads the subject back out — and a test holds them to it. Notes taken in a lesson cannot be retaken, so the editor is built around not losing them: autosave, every keystroke mirrored to local storage, a save when the phone locks, and a fallback to the local copy when the request never arrives. A save that would overwrite a version the editor never saw is refused and the choice handed back — the notes folder is synced and open in more than one place, and a phone must not silently win over a laptop. `replaceNote` is separate from `writeNote` for that reason: never-overwrite is right for `add_note` and exactly wrong for an editor. WEB_PASSWORD is the first credential here a human types, so it is the first that can be guessed: scrypt at startup, never stored or compared in the clear, per-address rate limiting — which is not decoration, since the scrypt cost is itself a denial-of-service vector without it. The session is a signed HttpOnly SameSite=Strict cookie whose key is derived from the password, so changing it logs everyone out and there is no second secret to keep. It opens /api, because a session is the user, and never /mcp, because nothing in a browser speaks MCP. Also here, because the app made them matter: frontmatter now reads the indented `- item` list form editors write, so an Obsidian vault round-trips its tags; and a four-digit folder is a filing scheme, not a subject, so `2026/` does not file a school year under one. 357 tests; 106/107 smoke against the local instance, the one failure being the H5P service that instance does not run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
589 lines
24 KiB
TypeScript
589 lines
24 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,
|
|
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<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.
|
|
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<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);
|
|
}
|