Files
Schulcloud-MCP/src/http/api.ts
MechaCat02 48c6cf39d5 Lay the notes screen out like a notes app
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>
2026-09-21 18:22:47 +02:00

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);
}