Files
Schulcloud-MCP/src/core/legacy-files.ts
MechaCat02 bed3923902 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>
2026-09-16 20:19:16 +02:00

501 lines
19 KiB
TypeScript

import type { DownloadedFile, SchulcloudClient } from './client.ts';
import { decodeEntities } from './text.ts';
/**
* The "Dateien" file manager — Persönliche Dateien, Kurs-Dateien, Team-Dateien
* and Geteilte Dateien — as one read-only filesystem.
*
* This is the legacy file system, a different store from files-storage
* (`/api/v3/file`), which holds board, topic and task attachments. The two do
* not overlap: asking files-storage for a course's files answers 0 for a course
* whose file manager holds dozens, and on the account this was built for 21 of
* 26 courses keep material here — some teachers use nothing else.
*
* Its Feathers service is not in the public ingress, so it is reached through
* the legacy client: server-rendered listing pages, parsed here, and one JSON
* route for pre-signed downloads (see the client). Two server quirks shape the
* design:
*
* - `GET /files/permittedDirectories/` looks like the obvious JSON source for
* the directory tree, but its query matches course folders on
* `refOwnerModel: 'courses'` while the records say `'course'`. It lists every
* course with **no** folders in any of them. Listings are the only complete
* view, which is also exactly what the file manager itself shows.
* - `GET /files/search/` runs an unindexed regex over every file record and
* times out (504) on the live instance, so finding is done by walking.
*/
export type FileArea = 'my' | 'courses' | 'teams' | 'shared';
interface AreaInfo {
area: FileArea;
label: string;
/** Accepted spellings of the first path segment, compared lower-cased. */
aliases: string[];
summary: string;
}
export const FILE_AREAS: AreaInfo[] = [
{
area: 'my',
label: 'Persönliche Dateien',
aliases: ['my', 'persönliche dateien', 'persoenliche dateien', 'personal', 'meine dateien'],
summary: 'your own files',
},
{
area: 'courses',
label: 'Kurs-Dateien',
aliases: ['courses', 'kurs-dateien', 'meine kurs-dateien', 'kursdateien', 'kurse'],
summary: 'one folder per course, holding what its teachers uploaded',
},
{
area: 'teams',
label: 'Team-Dateien',
aliases: ['teams', 'team-dateien', 'meine team-dateien', 'teamdateien'],
summary: 'one folder per team',
},
{
area: 'shared',
label: 'Geteilte Dateien',
aliases: ['shared', 'geteilte dateien', 'mit mir geteilt'],
summary: 'files other people shared with you, read-only and flat',
},
];
export function areaInfo(area: FileArea): AreaInfo {
return FILE_AREAS.find((entry) => entry.area === area) as AreaInfo;
}
export interface FmDirectory {
id: string;
name: string;
}
export interface FmFile {
id: string;
name: string;
/** Bytes, as the listing reports it. */
size: number;
/** Absent for a blocked file: the page withholds its viewer attributes. */
mimeType?: string;
/** Rejected by the instance's virus scanner; it cannot be downloaded. */
blocked: boolean;
}
export interface FmListing {
directories: FmDirectory[];
files: FmFile[];
}
/** A listing page's markup changed shape; never report that as an empty folder. */
export class FileManagerMarkupError extends Error {}
/**
* Parses one file-manager page. Exported for testing.
*
* Anchored on what the templates (`files/files.hbs`, `files/files-grid.hbs`)
* emit for the page's own scripts — `data-folder-id`, `data-file-id`,
* `data-file-name`, `data-file-size` — rather than on layout classes.
*
* Throws rather than returning an empty listing when the page is not a file
* manager page at all: "0 files" is precisely the wrong answer this module
* exists to fix, and a markup change must not quietly reproduce it.
*/
export function parseFileListing(html: string): FmListing {
if (!/class="route-files"/.test(html)) {
throw new FileManagerMarkupError('the page is not a file-manager listing (markup changed, or not logged in)');
}
const directories: FmDirectory[] = [];
const folderPattern = /<button\b([^>]*\bopenfolder\b[^>]*)>([\s\S]*?)<\/button>/g;
for (const match of html.matchAll(folderPattern)) {
const id = /data-folder-id="([0-9a-f]{24})"/i.exec(match[1] ?? '')?.[1];
if (!id) continue;
// The name is emitted unescaped (`{{{stripOnlyScript name}}}`) inside the
// title element, after an icon; strip the tags, then decode what is left.
const title = /<strong\b[^>]*card-title-directory[^>]*>([\s\S]*?)<\/strong>/.exec(match[2] ?? '')?.[1] ?? '';
const name = decodeEntities(title.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
directories.push({ id, name: name || id });
}
const files: FmFile[] = [];
const cardPattern = /<div\b[^>]*\bclass="card file\b([^"]*)"([^>]*)>/g;
const cards = [...html.matchAll(cardPattern)];
cards.forEach((match, index) => {
const attributes = match[2] ?? '';
const id = /data-file-id="([0-9a-f]{24})"/i.exec(attributes)?.[1];
if (!id) return;
const name = decodeEntities(/data-file-name="([^"]*)"/.exec(attributes)?.[1] ?? '');
const size = Number(/data-file-size="(\d*)"/.exec(attributes)?.[1] ?? '');
// The viewer attributes sit further inside this card; look no further
// than the next card, so one file can never borrow another's type.
const start = (match.index ?? 0) + match[0].length;
const end = cards[index + 1]?.index ?? html.length;
const mimeType = /data-file-viewer-type="([^"]*)"/.exec(html.slice(start, end))?.[1];
files.push({
id,
name: name || id,
size: Number.isFinite(size) ? size : 0,
mimeType: mimeType ? decodeEntities(mimeType) : undefined,
blocked: /\bbtn-file-danger\b/.test(match[1] ?? ''),
});
});
return { directories, files };
}
/**
* Where a directory is. `area` absent is the root; `ownerId` is the course or
* team (absent for `my` and `shared`); `folderId` absent is the owner's top.
*/
export interface DirectoryRef {
area?: FileArea;
ownerId?: string;
folderId?: string;
}
export type FsNode =
| { kind: 'directory'; path: string; ref: DirectoryRef; name: string }
| { kind: 'file'; path: string; parent: DirectoryRef; file: FmFile };
export type FsErrorCode = 'not_found' | 'ambiguous' | 'not_a_directory' | 'not_a_file' | 'not_navigable';
export class FsError extends Error {
readonly code: FsErrorCode;
constructor(code: FsErrorCode, message: string) {
super(message);
this.code = code;
}
}
/** The legacy page that lists a directory. */
export function pageFor(ref: DirectoryRef): string | undefined {
if (!ref.area) return undefined;
if (ref.area === 'shared') return '/files/shared/';
if (ref.area === 'my') return ref.folderId ? `/files/my/${ref.folderId}` : '/files/my/';
if (!ref.ownerId) return `/files/${ref.area}/`;
return ref.folderId ? `/files/${ref.area}/${ref.ownerId}/${ref.folderId}` : `/files/${ref.area}/${ref.ownerId}`;
}
/** The reference for a subdirectory found in `parent`'s listing. */
export function childRef(parent: DirectoryRef, directory: FmDirectory): DirectoryRef {
if (!parent.area) return { area: directory.id as FileArea };
if (parent.area === 'my') return { area: 'my', folderId: directory.id };
if (parent.area === 'shared') {
// The file manager has no route that opens a shared folder — its owner is
// someone else, and every listing route is scoped to an owner. The UI's
// own link to one is a 404.
throw new FsError(
'not_navigable',
`"${directory.name}" is a folder someone shared with you. The file manager cannot open shared folders ` +
'(not even in the browser); ask for the files themselves to be shared, or find them in the owning course.',
);
}
if (!parent.ownerId) return { area: parent.area, ownerId: directory.id };
return { area: parent.area, ownerId: parent.ownerId, folderId: directory.id };
}
/** Splits a path into raw segments. Empty segments (`//`, trailing `/`) drop out. */
export function splitPath(path: string): string[] {
return path
.split('/')
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
}
/** Joins names into a display path. Names keep any `/` they contain; see `resolve`. */
export function joinPath(parent: string, name: string): string {
return `${parent === '/' ? '' : parent}/${name}`;
}
function normalise(value: string): string {
return value.normalize('NFC').replace(/\s+/g, ' ').trim().toLocaleLowerCase('de');
}
export interface WalkEntry {
path: string;
/** The listed directory this entry came from; renderers group on it. */
parentPath: string;
depth: number;
parent: DirectoryRef;
directory?: { ref: DirectoryRef; name: string; id: string };
file?: FmFile;
}
export interface WalkResult {
entries: WalkEntry[];
/** Directories that were listed. */
visited: number;
/** Set when the budget ran out before the walk finished. */
truncated: boolean;
failures: { path: string; reason: string }[];
}
interface CachedListing {
at: number;
listing: Promise<FmListing>;
}
/** A listing is reused this long: long enough for ls→read, short enough to stay live. */
const LISTING_TTL_MS = 60_000;
/**
* The file manager as a tree of paths:
*
* / the four areas
* /my/… Persönliche Dateien
* /courses/<course>/… Kurs-Dateien
* /teams/<team>/… Team-Dateien
* /shared/… Geteilte Dateien (flat)
*
* Names are the file manager's own. Because course names contain `/` in real
* data ("LF07 - FIA24A/B - Sb/Ha"), a path is not split naively: resolution
* tries joining consecutive segments into one name and backtracks when a
* shorter reading leads nowhere. Any segment may also be an id instead of a
* name, which is always unambiguous and is what the listings print alongside.
*/
export class FileManager {
private readonly client: SchulcloudClient;
private readonly cache = new Map<string, CachedListing>();
constructor(client: SchulcloudClient) {
this.client = client;
}
/** Lists one directory. The root is synthetic and costs nothing. */
async list(ref: DirectoryRef): Promise<FmListing> {
const page = pageFor(ref);
if (!page) {
return { directories: FILE_AREAS.map((entry) => ({ id: entry.area, name: entry.area })), files: [] };
}
const cached = this.cache.get(page);
if (cached && Date.now() - cached.at < LISTING_TTL_MS) return cached.listing;
const listing = this.client.getFileManagerPage(page).then(parseFileListing);
this.cache.set(page, { at: Date.now(), listing });
// A failure must not be served from the cache for the next minute.
listing.catch(() => this.cache.delete(page));
return listing;
}
/** Resolves a path to a directory or a file. */
async resolve(path: string): Promise<FsNode> {
const segments = splitPath(path);
if (segments.length === 0) return { kind: 'directory', path: '/', ref: {}, name: '/' };
const first = segments[0] as string;
const area = FILE_AREAS.find((entry) => entry.aliases.includes(normalise(first)) || entry.area === first);
if (!area) {
throw new FsError(
'not_found',
`"${first}" is not a file area. The root holds ${FILE_AREAS.map((entry) => `/${entry.area} (${entry.label})`).join(', ')}.`,
);
}
const ref: DirectoryRef = { area: area.area };
const found = await this.descend(ref, `/${area.area}`, segments.slice(1));
return found;
}
private async descend(ref: DirectoryRef, path: string, rest: string[]): Promise<FsNode> {
if (rest.length === 0) return { kind: 'directory', path, ref, name: path.split('/').pop() || '/' };
const listing = await this.list(ref);
const readings = candidateReadings(listing, rest);
if (readings.length === 0) throw notFound(listing, rest[0] as string, path);
let lastError: FsError | undefined;
for (const reading of readings) {
if (reading.matches.length > 1) {
const options = reading.matches.map((match) => `"${match.entry.name}" (\`${match.entry.id}\`)`).join(', ');
throw new FsError(
'ambiguous',
`${path} holds more than one entry named "${reading.name}": ${options}. Use the id as that path segment instead.`,
);
}
const match = reading.matches[0] as { kind: 'directory'; entry: FmDirectory } | { kind: 'file'; entry: FmFile };
const remaining = rest.slice(reading.consumed);
const nextPath = joinPath(path, match.entry.name);
if (match.kind === 'file') {
if (remaining.length === 0) return { kind: 'file', path: nextPath, parent: ref, file: match.entry };
lastError = new FsError('not_a_directory', `${nextPath} is a file, not a folder.`);
continue;
}
try {
return await this.descend(childRef(ref, match.entry), nextPath, remaining);
} catch (error) {
// A shorter reading of a name containing "/" can lead nowhere while a
// longer one resolves; only give up once every reading has failed.
if (error instanceof FsError && error.code !== 'ambiguous' && error.code !== 'not_navigable') {
lastError = error;
continue;
}
throw error;
}
}
throw lastError ?? notFound(listing, rest[0] as string, path);
}
/**
* Walks a directory breadth-first, listing at most `maxDirectories` of them.
*
* Every listing is one page fetch, so the budget is the cost. A folder that
* cannot be read is recorded and skipped, never silently dropped.
*/
async walk(
start: { path: string; ref: DirectoryRef },
options: { maxDepth: number; maxDirectories: number; concurrency?: number },
): Promise<WalkResult> {
const entries: WalkEntry[] = [];
const failures: { path: string; reason: string }[] = [];
let visited = 0;
let truncated = false;
let frontier: { path: string; ref: DirectoryRef; depth: number }[] = [{ ...start, depth: 0 }];
while (frontier.length > 0) {
const next: typeof frontier = [];
const batch = frontier;
frontier = [];
const concurrency = Math.max(1, options.concurrency ?? 3);
for (let i = 0; i < batch.length; i += concurrency) {
const slice = batch.slice(i, i + concurrency);
await Promise.all(
slice.map(async (node) => {
// The root is synthetic and costs no request, so it does not count.
if (pageFor(node.ref)) {
if (visited >= options.maxDirectories) {
truncated = true;
return;
}
visited++;
}
let listing: FmListing;
try {
listing = await this.list(node.ref);
} catch (error) {
failures.push({ path: node.path, reason: error instanceof Error ? error.message : String(error) });
return;
}
// The root's areas keep their defined order; real folders sort by name.
const directories = pageFor(node.ref) ? sortByName(listing.directories) : listing.directories;
for (const directory of directories) {
const path = joinPath(node.path, directory.name);
let ref: DirectoryRef | undefined;
try {
ref = childRef(node.ref, directory);
} catch {
ref = undefined; // shared folders: listed, never opened
}
entries.push({
path,
parentPath: node.path,
depth: node.depth + 1,
parent: node.ref,
directory: { ref: ref ?? node.ref, name: directory.name, id: directory.id },
});
if (ref && node.depth + 1 < options.maxDepth) next.push({ path, ref, depth: node.depth + 1 });
}
for (const file of sortByName(listing.files)) {
entries.push({
path: joinPath(node.path, file.name),
parentPath: node.path,
depth: node.depth + 1,
parent: node.ref,
file,
});
}
}),
);
}
frontier = next;
}
// Unsorted across directories on purpose: sorting whole path strings
// interleaves a folder's children with a sibling that shares its prefix
// ("Sub/…" against "Sub - Kopie"). Renderers group on `parentPath`.
return { entries, visited, truncated, failures };
}
download(file: Pick<FmFile, 'id' | 'name'>): Promise<DownloadedFile> {
return this.client.downloadFileManagerFile(file.id, file.name);
}
}
type Match = { kind: 'directory'; entry: FmDirectory } | { kind: 'file'; entry: FmFile };
/**
* Every way the next path segments can name an entry, shortest first.
*
* `rest[0]`, then `rest[0]/rest[1]`, and so on — so a course called
* "LF07 - FIA24A/B - Sb/Ha" resolves even when typed plainly. An id segment
* matches too. Exact names are preferred over case-insensitive ones.
*/
function candidateReadings(listing: FmListing, rest: string[]): { name: string; consumed: number; matches: Match[] }[] {
const all: Match[] = [
...listing.directories.map((entry) => ({ kind: 'directory' as const, entry })),
...listing.files.map((entry) => ({ kind: 'file' as const, entry })),
];
const first = rest[0] as string;
if (/^[0-9a-f]{24}$/i.test(first)) {
const byId = all.filter((match) => match.entry.id.toLowerCase() === first.toLowerCase());
if (byId.length > 0) return [{ name: first, consumed: 1, matches: byId }];
}
const readings: { name: string; consumed: number; matches: Match[] }[] = [];
for (const strict of [true, false]) {
for (let take = 1; take <= rest.length; take++) {
const name = rest.slice(0, take).join('/');
const matches = all.filter((match) =>
strict ? match.entry.name.trim() === name : normalise(match.entry.name) === normalise(name),
);
if (matches.length > 0 && !readings.some((reading) => reading.consumed === take)) {
readings.push({ name, consumed: take, matches });
}
}
if (readings.length > 0) break;
}
return readings;
}
function notFound(listing: FmListing, segment: string, path: string): FsError {
const names = [...listing.directories.map((entry) => `${entry.name}/`), ...listing.files.map((entry) => entry.name)];
const needle = normalise(segment);
const close = names.filter((name) => normalise(name).includes(needle) || needle.includes(normalise(name).replace(/\/$/, '')));
const hint =
close.length > 0
? ` Did you mean: ${close.slice(0, 5).map((name) => `"${name}"`).join(', ')}?`
: names.length > 0
? ` It holds ${names.length} entr${names.length === 1 ? 'y' : 'ies'}; list it with fs_list.`
: ' It is empty.';
return new FsError('not_found', `No "${segment}" in ${path}.${hint}`);
}
function sortByName<T extends { name: string }>(items: T[]): T[] {
return [...items].sort((a, b) => compareNames(a.name, b.name));
}
/** Name order as a person expects it: German collation, "Blatt 2" before "Blatt 10". */
export function compareNames(a: string, b: string): number {
return a.localeCompare(b, 'de', { numeric: true, sensitivity: 'base' });
}
/**
* Case-insensitive substring; or, when the pattern uses * or ?, a glob that
* must match the whole name, as `find -name` does. Exported for testing.
*/
export function nameMatcher(pattern: string): (name: string) => boolean {
const needle = pattern.normalize('NFC').toLocaleLowerCase('de');
if (!/[*?]/.test(needle)) return (name) => name.normalize('NFC').toLocaleLowerCase('de').includes(needle);
const source = needle
.split('')
.map((char) => (char === '*' ? '.*' : char === '?' ? '.' : char.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
.join('');
const regex = new RegExp(`^${source}$`, 'i');
return (name) => regex.test(name.normalize('NFC'));
}