Browse the file manager ("Dateien") as a filesystem

Many teachers never use topics or boards; their material sits in the
course's file area, and the tools answered "0 files" for courses holding
dozens of worksheets — 21 of 26 courses on the live account. Persönliche,
Kurs-, Team- and Geteilte Dateien live in the legacy file store, not in
files-storage, and its service is not in the public ingress. The only way in
is the legacy client: HTML listings, and GET /files/signedurl for a
pre-signed download.

core/legacy-files.ts turns that into one path tree — /my, /courses/<course>,
/teams/<team>, /shared — resolving names that contain "/", ids anywhere in a
path, and wrong or ambiguous names with a message saying what is there. A
listing that does not parse throws; it never reads as an empty folder.

Some of the legacy client's GET routes write (GET /files/share/ mints a
share token), so getFileManagerPage allows only the listing routes, by
pattern. Signed URLs are fetched with no credentials and must be https.

- MCP: fs_list, fs_tree, fs_find and fs_read; get_course lists course files.
- CLI: schulcloud fs ls, tree, find and get, recursive and resumable.
- API: /api/fs/list, tree, find and file.
- Index: the crawl walks the file manager (INDEX_FILE_MANAGER, on by
  default), so search covers the text inside those files and sync mirrors
  them under <course>/Kurs-Dateien.

The local instance gains a fixture for all four areas. It needed a loopback,
so signed URLs open from the host, and a pre-created bucket, since MinIO
does not implement PutBucketCors.

135 tests. Smoke 55/55 live; 57/57 and 55/55 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 20:19:16 +02:00
parent 10c6544579
commit bed3923902
32 changed files with 2696 additions and 106 deletions

View File

@@ -2,6 +2,16 @@ 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 { resolveWithin } from '../core/paths.ts';
import type { Services } from '../services.ts';
@@ -64,6 +74,113 @@ export function createApiRouter(services: Services): Router {
}
});
// --- 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.
@@ -91,6 +208,8 @@ export function createApiRouter(services: Services): Router {
});
}
}
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');
@@ -101,6 +220,27 @@ export function createApiRouter(services: Services): Router {
}
/** 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') {
@@ -143,3 +283,64 @@ 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;
}
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);
}