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>
501 lines
19 KiB
TypeScript
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'));
|
|
}
|