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

291
test/legacy-files.test.ts Normal file
View File

@@ -0,0 +1,291 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { checkSignedUrl, type SchulcloudClient } from '../src/core/client.ts';
import {
childRef,
FileManager,
FileManagerMarkupError,
FsError,
pageFor,
parseFileListing,
splitPath,
} from '../src/core/legacy-files.ts';
/**
* Fixtures follow the legacy client's templates as served (files/files.hbs and
* files/files-grid.hbs, client 33.40): a folder is an `openfolder` button whose
* name sits unescaped after an icon; a file is a `card file` div carrying its
* name, size and id as data attributes, with the viewer type further inside.
*/
const folder = (id: string, name: string) => `
<div class="col-xs-12">
<button class="card card-block folder openfolder" data-testid="folder-open-button" data-folder-id="${id}" aria-label="
files.label.clickToOpenDir">
<strong class="card-title-directory">
<i class="fa fa-folder" data-testid="folder-icon" aria-hidden="true"></i> ${name}
</strong>
</button>
</div>`;
const file = (id: string, name: string, size: number, type = 'application/pdf', blocked = false) => `
<div class="col-sm-12" data-testid="files-section">
<div class="card file ${blocked ? 'btn-file-danger' : ''}" data-href="/files/file?file=${name}" data-file-name="${name}" data-file-size="${size}" data-file-id="${id}">
<div class="openFile">
<div class="card-block
${blocked ? '' : ' fileviewer'}"
${blocked ? '' : ` data-file-viewer-type="${type}"`}
${blocked ? '' : ` data-file-viewer-id="${id}"`}
tabindex="0" role="button">
<a class="col-sm-10 title" data-testid="file-title">${name}</a>
</div>
</div>
</div>
</div>`;
const page = (folders: string[], files: string[]) => `
<html><body><main id="main-content" class="container-fluid">
<div class="route-files">
${folders.length ? `<section class="directories"><div class="row">${folders.join('')}</div></section>` : ''}
<section class="files"><div class="row">${files.join('')}</div></section>
</div></main></body></html>`;
const id = (n: number) => n.toString(16).padStart(24, '0');
describe('parseFileListing', () => {
it('reads folders and files with their ids, sizes and types', () => {
const listing = parseFileListing(
page([folder(id(1), 'Handlungssituation')], [file(id(2), '13_AB_Wareneingang.pdf', 171941)]),
);
assert.deepEqual(listing.directories, [{ id: id(1), name: 'Handlungssituation' }]);
assert.deepEqual(listing.files, [
{ id: id(2), name: '13_AB_Wareneingang.pdf', size: 171941, mimeType: 'application/pdf', blocked: false },
]);
});
it('decodes entities in both folder and file names', () => {
const listing = parseFileListing(
page([folder(id(1), 'Lager &amp; Logistik')], [file(id(2), 'A&amp;B &quot;final&quot;.pdf', 10)]),
);
assert.equal(listing.directories[0]?.name, 'Lager & Logistik');
assert.equal(listing.files[0]?.name, 'A&B "final".pdf');
});
it('marks a file the virus scanner blocked, which also has no viewer type', () => {
const listing = parseFileListing(page([], [file(id(3), 'bad.exe', 5, undefined, true), file(id(4), 'ok.png', 7, 'image/png')]));
assert.equal(listing.files[0]?.blocked, true);
assert.equal(listing.files[0]?.mimeType, undefined);
// The next card's type must not leak into the blocked one.
assert.equal(listing.files[1]?.mimeType, 'image/png');
});
it('returns an empty listing for an empty folder', () => {
assert.deepEqual(parseFileListing(page([], [])), { directories: [], files: [] });
});
it('refuses a page that is not a file-manager listing rather than reporting 0 files', () => {
assert.throws(() => parseFileListing('<html><body>Anmelden</body></html>'), FileManagerMarkupError);
});
});
describe('pageFor and childRef', () => {
it('builds each listing route', () => {
assert.equal(pageFor({}), undefined);
assert.equal(pageFor({ area: 'my' }), '/files/my/');
assert.equal(pageFor({ area: 'my', folderId: id(1) }), `/files/my/${id(1)}`);
assert.equal(pageFor({ area: 'courses' }), '/files/courses/');
assert.equal(pageFor({ area: 'courses', ownerId: id(2) }), `/files/courses/${id(2)}`);
assert.equal(pageFor({ area: 'teams', ownerId: id(2), folderId: id(3) }), `/files/teams/${id(2)}/${id(3)}`);
assert.equal(pageFor({ area: 'shared' }), '/files/shared/');
});
it('addresses a nested folder by its own id, whatever its depth', () => {
const course = childRef({ area: 'courses' }, { id: id(2), name: 'Kurs' });
const top = childRef(course, { id: id(3), name: 'A' });
const deep = childRef(top, { id: id(4), name: 'B' });
assert.deepEqual(deep, { area: 'courses', ownerId: id(2), folderId: id(4) });
});
it('refuses to open a shared folder, which the file manager cannot do either', () => {
assert.throws(
() => childRef({ area: 'shared' }, { id: id(5), name: 'Ordner' }),
(error: unknown) => error instanceof FsError && error.code === 'not_navigable',
);
});
});
describe('splitPath', () => {
it('drops empty segments and surrounding spaces', () => {
assert.deepEqual(splitPath('//courses/ Kurs A /x.pdf/'), ['courses', 'Kurs A', 'x.pdf']);
assert.deepEqual(splitPath('/'), []);
});
});
/** A client that serves canned listing pages and records what was asked for. */
function fakeClient(pages: Record<string, string>) {
const requested: string[] = [];
const client = {
getFileManagerPage: async (path: string) => {
requested.push(path);
const html = pages[path];
if (html === undefined) throw new Error(`no fixture for ${path}`);
return html;
},
} as unknown as SchulcloudClient;
return { client, requested };
}
describe('FileManager.resolve', () => {
const course = id(10);
const slashCourse = id(11);
const sub = id(12);
const pdf = id(13);
const pages = {
'/files/courses/': page([folder(course, 'FIA24B - LF2 (Rh)'), folder(slashCourse, 'LF07 - FIA24A/B - Sb/Ha')], []),
[`/files/courses/${course}`]: page([folder(sub, 'Handlungssituation')], [file(pdf, 'Mahnwesen.pdf', 100)]),
[`/files/courses/${course}/${sub}`]: page([], [file(id(14), 'Lager.pdf', 50)]),
[`/files/courses/${slashCourse}`]: page([], [file(id(15), 'MQTT.docx', 70, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')]),
'/files/my/': page([], []),
};
it('resolves the root and each area without a request for the root', async () => {
const { client, requested } = fakeClient(pages);
const fs = new FileManager(client);
assert.deepEqual(await fs.resolve('/'), { kind: 'directory', path: '/', ref: {}, name: '/' });
const area = await fs.resolve('/Kurs-Dateien');
assert.equal(area.kind, 'directory');
assert.deepEqual(area.kind === 'directory' && area.ref, { area: 'courses' });
assert.deepEqual(requested, []);
});
it('walks names down to a file in a nested folder', async () => {
const { client } = fakeClient(pages);
const node = await new FileManager(client).resolve('/courses/FIA24B - LF2 (Rh)/Handlungssituation/Lager.pdf');
assert.equal(node.kind, 'file');
assert.equal(node.kind === 'file' && node.file.id, id(14));
assert.equal(node.path, '/courses/FIA24B - LF2 (Rh)/Handlungssituation/Lager.pdf');
});
it('resolves a course whose name contains slashes, typed plainly', async () => {
const { client } = fakeClient(pages);
const node = await new FileManager(client).resolve('/courses/LF07 - FIA24A/B - Sb/Ha/MQTT.docx');
assert.equal(node.kind === 'file' && node.file.id, id(15));
});
it('accepts ids as path segments', async () => {
const { client } = fakeClient(pages);
const node = await new FileManager(client).resolve(`/courses/${course}/${pdf}`);
assert.equal(node.kind === 'file' && node.file.name, 'Mahnwesen.pdf');
});
it('falls back to a case-insensitive match', async () => {
const { client } = fakeClient(pages);
const node = await new FileManager(client).resolve('/courses/fia24b - lf2 (rh)/handlungssituation');
assert.equal(node.kind, 'directory');
});
it('says what is there when a name is not found', async () => {
const { client } = fakeClient(pages);
await assert.rejects(
new FileManager(client).resolve('/courses/FIA24B - LF2 (Rh)/Handlung'),
(error: unknown) => error instanceof FsError && error.code === 'not_found' && /Handlungssituation/.test(error.message),
);
});
it('refuses to treat a file as a folder', async () => {
const { client } = fakeClient(pages);
await assert.rejects(
new FileManager(client).resolve('/courses/FIA24B - LF2 (Rh)/Mahnwesen.pdf/more'),
(error: unknown) => error instanceof FsError && error.code === 'not_a_directory',
);
});
it('reports an ambiguous name with the ids to use instead', async () => {
const twins = {
'/files/my/': page([folder(id(20), 'Kopie'), folder(id(21), 'Kopie')], []),
};
const { client } = fakeClient(twins);
await assert.rejects(
new FileManager(client).resolve('/my/Kopie'),
(error: unknown) =>
error instanceof FsError && error.code === 'ambiguous' && error.message.includes(id(20)) && error.message.includes(id(21)),
);
});
it('rejects an unknown area and names the real ones', async () => {
const { client } = fakeClient(pages);
await assert.rejects(
new FileManager(client).resolve('/Downloads/x'),
(error: unknown) => error instanceof FsError && /\/courses/.test(error.message),
);
});
it('caches a listing, so ls followed by read costs no second request', async () => {
const { client, requested } = fakeClient(pages);
const fs = new FileManager(client);
await fs.resolve('/courses/FIA24B - LF2 (Rh)/Mahnwesen.pdf');
await fs.resolve('/courses/FIA24B - LF2 (Rh)/Mahnwesen.pdf');
assert.equal(requested.filter((path) => path === '/files/courses/').length, 1);
});
});
describe('FileManager.walk', () => {
it('stays within its folder budget and says so', async () => {
const course = id(30);
const pages = {
'/files/courses/': page([folder(course, 'Kurs')], []),
[`/files/courses/${course}`]: page([folder(id(31), 'A'), folder(id(32), 'B')], []),
[`/files/courses/${course}/${id(31)}`]: page([], [file(id(33), 'a.pdf', 1)]),
[`/files/courses/${course}/${id(32)}`]: page([], [file(id(34), 'b.pdf', 1)]),
};
const { client } = fakeClient(pages);
const result = await new FileManager(client).walk(
{ path: '/courses', ref: { area: 'courses' } },
{ maxDepth: 5, maxDirectories: 2 },
);
assert.equal(result.visited, 2);
assert.equal(result.truncated, true);
});
it('records a folder it could not read instead of dropping it', async () => {
const course = id(40);
const pages = { '/files/courses/': page([folder(course, 'Kurs')], []) };
const { client } = fakeClient(pages);
const result = await new FileManager(client).walk(
{ path: '/courses', ref: { area: 'courses' } },
{ maxDepth: 3, maxDirectories: 10 },
);
assert.equal(result.failures.length, 1);
assert.equal(result.failures[0]?.path, '/courses/Kurs');
});
});
describe('the file-manager allowlist', () => {
// Some legacy GET routes write. The client must refuse them before any request.
const client = {
config: { baseUrl: 'https://example.org', jwt: 'x', requestTimeoutMs: 1000, maxDownloadBytes: 10 },
};
it('refuses the routes that write despite being GETs', async () => {
const { SchulcloudClient } = await import('../src/core/client.ts');
const real = new SchulcloudClient(client.config as never);
for (const path of ['/files/share/?file=' + id(1), `/files/file?file=${id(1)}&share=abc`, `/files/fileModel/${id(1)}/proxy`, '/files/search/?q=x', '/files/permittedDirectories/']) {
await assert.rejects(real.getFileManagerPage(path), /refusing file-manager path/, path);
}
});
});
describe('checkSignedUrl', () => {
it('accepts an https storage url from an https instance', () => {
assert.equal(checkSignedUrl('https://s3.example.com/bucket/key?X-Amz-Signature=1', 'https://schulcloud.example').host, 's3.example.com');
});
it('refuses plaintext from an https instance, and credentials in the url', () => {
assert.throws(() => checkSignedUrl('http://10.0.0.1/x', 'https://schulcloud.example'), /refusing a http:/);
assert.throws(() => checkSignedUrl('https://user:pw@s3.example.com/x', 'https://schulcloud.example'), /credentials/);
assert.throws(() => checkSignedUrl('file:///etc/passwd', 'http://localhost:4400'), /refusing a file:/);
});
it('allows http when the instance itself is http, as the local one is', () => {
assert.equal(checkSignedUrl('http://localhost:9900/x', 'http://localhost:4400').protocol, 'http:');
});
});