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:
201
src/http/api.ts
201
src/http/api.ts
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user