Store: crawl generations as the sync cursor. Diffs compare generations
on entity identity plus a content digest, never on upstream timestamps —
GET /course-rooms/{id}/board returns request time as updatedAt for most
elements, so a timestamp cursor would report every board as changed on
every crawl. Identity diffing also yields deletions, which no timestamp
scheme can. A per-course crawl carries the other courses' rows forward
so every completed generation is a complete picture and any two diff
directly; without that a partial crawl reads as a mass deletion.
FTS uses the german dictionary with weighted title/body, plus a pg_trgm
arm because stemming will not match "Datenschutz" inside
"Datenschutzgrundverordnung" and German compounds make that the common
case. file_texts is keyed by file record id and deliberately outlives
generations: records are immutable upstream, so text extracted once is
valid forever and a re-crawl of unchanged content costs nothing.
Store.open returns undefined instead of throwing when Postgres is
unreachable — the index is an accelerator, and a Pi that loses its
database should get slower, not broken.
core/paths.ts is the security boundary for the mirror. Course titles,
card titles and filenames are all user-supplied upstream, so this is
where a hostile name stops being text and becomes a path. Two bugs found
by its own tests: "///" produced "---" instead of falling back, and dot
runs survived mid-component. Now no ".." can survive anywhere, which
makes the invariant checkable rather than a claim about ordering.
Indexer coalesces concurrent refreshes onto one run and enforces a
minimum interval, since a full crawl is ~270 requests from an account
that looks like a student.
9 store tests against a real Postgres (mocks would test nothing here)
and 13 path tests; 47 total.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
8.4 KiB
TypeScript
241 lines
8.4 KiB
TypeScript
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
import type { Config } from '../config.ts';
|
|
import type { SchulcloudClient } from '../core/client.ts';
|
|
import { crawl, forEachLimited, type Snapshot } from '../core/crawl.ts';
|
|
import { extractContent, formatBytes } from '../core/extract.ts';
|
|
import { mirrorPath, resolveWithin } from '../core/paths.ts';
|
|
import type { Store } from '../store/store.ts';
|
|
|
|
/**
|
|
* Crawls Schulcloud, persists a generation, mirrors file bytes and indexes
|
|
* their text.
|
|
*
|
|
* Two properties shape this. First, file records are immutable — editing a file
|
|
* upstream produces a new record — so a file only ever needs downloading and
|
|
* extracting once, and `file_texts` deliberately outlives the generation that
|
|
* discovered it. Second, a crawl is ~270 upstream requests from an account that
|
|
* looks like a student, so concurrent requests coalesce onto one run and a
|
|
* minimum interval keeps a misbehaving caller from hammering the instance.
|
|
*/
|
|
|
|
export interface IndexResult {
|
|
crawlId: number;
|
|
scope: string;
|
|
courses: number;
|
|
files: number;
|
|
mirrored: number;
|
|
extracted: number;
|
|
skipped: number;
|
|
failures: { courseId: string; reason: string }[];
|
|
durationMs: number;
|
|
/** Set when the caller joined a run already in progress. */
|
|
joined?: boolean;
|
|
}
|
|
|
|
export interface IndexerStatus {
|
|
running: boolean;
|
|
scope?: string;
|
|
startedAt?: string;
|
|
lastResult?: IndexResult;
|
|
lastError?: string;
|
|
}
|
|
|
|
export class Indexer {
|
|
private readonly client: SchulcloudClient;
|
|
private readonly store: Store;
|
|
private readonly config: Config;
|
|
private readonly minIntervalMs: number;
|
|
|
|
private inFlight = new Map<string, Promise<IndexResult>>();
|
|
private startedAt: Date | undefined;
|
|
private runningScope: string | undefined;
|
|
private lastFinishedAt = new Map<string, number>();
|
|
private lastResult: IndexResult | undefined;
|
|
private lastError: string | undefined;
|
|
|
|
constructor(client: SchulcloudClient, store: Store, config: Config, minIntervalMs = 60_000) {
|
|
this.client = client;
|
|
this.store = store;
|
|
this.config = config;
|
|
this.minIntervalMs = minIntervalMs;
|
|
}
|
|
|
|
status(): IndexerStatus {
|
|
return {
|
|
running: this.inFlight.size > 0,
|
|
scope: this.runningScope,
|
|
startedAt: this.startedAt?.toISOString(),
|
|
lastResult: this.lastResult,
|
|
lastError: this.lastError,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Re-crawls and re-indexes. `scope` is 'full' or a single course id.
|
|
*
|
|
* A per-course refresh is ~3-10 requests against ~270 for a full one, so it
|
|
* is the right default for "I just uploaded something". Callers arriving
|
|
* while a run is in progress join it rather than starting a second.
|
|
*/
|
|
async refresh(scope: string, options: { force?: boolean } = {}): Promise<IndexResult> {
|
|
// A full crawl covers every course, so a per-course request can ride along.
|
|
const existing = this.inFlight.get('full') ?? this.inFlight.get(scope);
|
|
if (existing) return existing.then((result) => ({ ...result, joined: true }));
|
|
|
|
const since = Date.now() - (this.lastFinishedAt.get(scope) ?? 0);
|
|
if (!options.force && since < this.minIntervalMs) {
|
|
const wait = Math.ceil((this.minIntervalMs - since) / 1000);
|
|
throw new Error(
|
|
`${scope === 'full' ? 'A full re-crawl' : `Course ${scope}`} was refreshed ${Math.round(since / 1000)}s ago. ` +
|
|
`Wait ${wait}s, or pass force to override — a full crawl is ~270 requests against Schulcloud.`,
|
|
);
|
|
}
|
|
|
|
const run = this.run(scope).finally(() => {
|
|
this.inFlight.delete(scope);
|
|
this.lastFinishedAt.set(scope, Date.now());
|
|
this.runningScope = undefined;
|
|
this.startedAt = undefined;
|
|
});
|
|
this.inFlight.set(scope, run);
|
|
this.runningScope = scope;
|
|
this.startedAt = new Date();
|
|
return run;
|
|
}
|
|
|
|
private async run(scope: string): Promise<IndexResult> {
|
|
const began = Date.now();
|
|
try {
|
|
const schoolId = (await this.client.me()).school.id;
|
|
const snapshot: Snapshot = await crawl(this.client, {
|
|
schoolId,
|
|
courseIds: scope === 'full' ? undefined : [scope],
|
|
includeLessonContents: true,
|
|
includeFiles: true,
|
|
});
|
|
|
|
const crawlId = await this.store.saveSnapshot(snapshot, scope);
|
|
const { mirrored, extracted, skipped } = await this.ingestFiles(snapshot);
|
|
|
|
const result: IndexResult = {
|
|
crawlId,
|
|
scope,
|
|
courses: snapshot.courses.length,
|
|
files: snapshot.files.length,
|
|
mirrored,
|
|
extracted,
|
|
skipped,
|
|
failures: snapshot.failures,
|
|
durationMs: Date.now() - began,
|
|
};
|
|
this.lastResult = result;
|
|
this.lastError = undefined;
|
|
return result;
|
|
} catch (error) {
|
|
this.lastError = error instanceof Error ? error.message : String(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads, mirrors and extracts every file the index has no text for.
|
|
*
|
|
* Only files new to the store are touched, so a re-crawl of unchanged
|
|
* content costs nothing here — which is what makes a 6-hourly crawl cheap
|
|
* enough to run unattended.
|
|
*/
|
|
private async ingestFiles(snapshot: Snapshot): Promise<{ mirrored: number; extracted: number; skipped: number }> {
|
|
const pending = await this.store.filesNeedingText();
|
|
if (pending.length === 0) return { mirrored: 0, extracted: 0, skipped: 0 };
|
|
|
|
const byId = new Map(snapshot.files.map((file) => [file.record.id, file]));
|
|
// Same path function the store recorded, so mirror and index agree.
|
|
const paths = new Map(
|
|
snapshot.files.map((file) => [file.record.id, mirrorPath(file.at, file.record.name, file.record.id)]),
|
|
);
|
|
|
|
let mirrored = 0;
|
|
let extracted = 0;
|
|
let skipped = 0;
|
|
|
|
await forEachLimited(pending, 3, async (entry) => {
|
|
const file = byId.get(entry.fileId);
|
|
if (!file) return;
|
|
|
|
// The instance scans uploads; a file it rejected must not be mirrored.
|
|
if (file.record.securityCheckStatus === 'blocked') {
|
|
await this.store.recordFileText({
|
|
fileId: entry.fileId, name: entry.name, mimeType: entry.mimeType, size: entry.size,
|
|
content: null, note: 'blocked by the instance virus scanner; not downloaded',
|
|
mirrorPath: null, mirrorSize: null,
|
|
});
|
|
skipped++;
|
|
return;
|
|
}
|
|
|
|
if (entry.size > this.config.mirrorMaxBytes) {
|
|
await this.store.recordFileText({
|
|
fileId: entry.fileId, name: entry.name, mimeType: entry.mimeType, size: entry.size,
|
|
content: null,
|
|
note: `too large to mirror (${formatBytes(entry.size)} > ${formatBytes(this.config.mirrorMaxBytes)}); indexed as metadata only`,
|
|
mirrorPath: null, mirrorSize: null,
|
|
});
|
|
skipped++;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const downloaded = await this.client.downloadFile(file.record);
|
|
const relative = paths.get(entry.fileId);
|
|
if (!relative) return;
|
|
const absolute = resolveWithin(this.config.mirrorDir, relative);
|
|
await mkdir(dirname(absolute), { recursive: true });
|
|
await writeFile(absolute, downloaded.bytes);
|
|
mirrored++;
|
|
|
|
const extraction = await extractContent(
|
|
downloaded.bytes,
|
|
downloaded.mimeType || entry.mimeType,
|
|
entry.name,
|
|
this.config.maxExtractedChars,
|
|
);
|
|
const content = extraction.kind === 'text' ? (extraction.text ?? '') : null;
|
|
if (content) extracted++;
|
|
|
|
await this.store.recordFileText({
|
|
fileId: entry.fileId, name: entry.name, mimeType: entry.mimeType, size: entry.size,
|
|
content, note: extraction.note, mirrorPath: relative, mirrorSize: downloaded.bytes.length,
|
|
});
|
|
} catch (error) {
|
|
// One unreadable file must not abort the crawl; record and move on.
|
|
await this.store.recordFileText({
|
|
fileId: entry.fileId, name: entry.name, mimeType: entry.mimeType, size: entry.size,
|
|
content: null,
|
|
note: `download or extraction failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
mirrorPath: null, mirrorSize: null,
|
|
});
|
|
skipped++;
|
|
}
|
|
});
|
|
|
|
return { mirrored, extracted, skipped };
|
|
}
|
|
|
|
/** Verifies a mirrored file is present and the expected size. */
|
|
async verifyMirror(fileId: string): Promise<{ ok: boolean; path?: string; reason?: string }> {
|
|
const entry = await this.store.mirrorEntry(fileId);
|
|
if (!entry) return { ok: false, reason: 'not mirrored' };
|
|
try {
|
|
const absolute = resolveWithin(this.config.mirrorDir, entry.path);
|
|
const info = await stat(absolute);
|
|
// Size only: the API exposes no ETag or checksum, and hashing would
|
|
// mean re-downloading every file to learn what it already told us.
|
|
if (info.size !== entry.size) return { ok: false, path: entry.path, reason: `size ${info.size} != ${entry.size}` };
|
|
return { ok: true, path: entry.path };
|
|
} catch (error) {
|
|
return { ok: false, path: entry.path, reason: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
}
|