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:
291
test/legacy-files.test.ts
Normal file
291
test/legacy-files.test.ts
Normal 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 & Logistik')], [file(id(2), 'A&B "final".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:');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user