Index-backed search, /api surfaces, image-only PDF detection
search now queries the Postgres index and states its freshness in every result, with fresh=true bypassing it for a live crawl — the agent can always get current data rather than being quietly misled by a stale index. Adds refresh_index (per-course by default; a full crawl is ~270 requests), what_changed (generation diff — the API has no changed-since filter of any kind), and index_status. /api gives the CLI its backend behind the same bearer token as /mcp: GET /manifest (cursor + per-file status), GET /files/:id (served from the mirror with Range support, falling back to a live proxy for files too large to mirror), GET /status, POST /refresh. Bytes go over plain HTTP rather than MCP because base64 in JSON-RPC costs a third more and buffers whole files. An unresolvable manifest cursor returns 409 rather than silently meaning "everything is new", so a client cannot be tricked into a full re-download. Verified end to end against the live instance and a real Postgres: crawl -> index -> German FTS -> manifest -> ranged download, with 401 on missing token, 400 on a malformed id, and 429 on a too-soon refresh. Two findings worth recording. The build silently omitted the .sql migrations from dist, which the store's graceful degradation turned into "running without the index" rather than a crash — now copied by a build step. And 3 of 4 sampled course PDFs have no embedded fonts at all: they are scans, so extraction legitimately yields nothing. That is now detected and reported as image-only with OCR named as the missing piece, instead of an indistinguishable "0 characters". It revises the roadmap's "OCR not needed" note, which held for reading images but not for indexing them. 49 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
145
src/http/api.ts
Normal file
145
src/http/api.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import express, { type Request, type Response, type Router } from 'express';
|
||||
import { resolveWithin } from '../core/paths.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
|
||||
/**
|
||||
* The CLI's backend: file bytes, the sync manifest, and on-demand re-crawls.
|
||||
*
|
||||
* These sit behind the same bearer check as `/mcp`, on the same host. Bytes go
|
||||
* over plain HTTP rather than through MCP because base64 inside JSON-RPC costs
|
||||
* a third more bandwidth and buffers whole files in memory; ranged streaming
|
||||
* from the mirror does neither, which matters for the video files.
|
||||
*
|
||||
* Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own
|
||||
* index and mirror, and every upstream call it triggers is a GET.
|
||||
*/
|
||||
export function createApiRouter(services: Services): Router {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/manifest', async (req: Request, res: Response) => {
|
||||
if (!services.store) return res.status(503).json({ error: 'no_index' });
|
||||
try {
|
||||
const sinceParam = typeof req.query.since === 'string' ? req.query.since : undefined;
|
||||
const since = sinceParam ? await services.store.resolveCursor(sinceParam) : undefined;
|
||||
if (sinceParam && since === undefined) {
|
||||
// An unresolvable cursor must not silently mean "everything is new":
|
||||
// say so, so the client can decide to do a full sync deliberately.
|
||||
return res.status(409).json({
|
||||
error: 'cursor_unknown',
|
||||
message: `No crawl at or before "${sinceParam}". Sync without a cursor for a full manifest.`,
|
||||
});
|
||||
}
|
||||
const { crawlId, entries } = await services.store.manifest(since);
|
||||
const stats = await services.store.stats();
|
||||
return res.json({ crawlId, cursor: String(crawlId), crawledAt: stats.crawledAt, count: entries.length, entries });
|
||||
} catch (error) {
|
||||
return fail(res, error, 'manifest');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status', async (_req: Request, res: Response) => {
|
||||
if (!services.store) return res.status(503).json({ error: 'no_index' });
|
||||
try {
|
||||
const stats = await services.store.stats();
|
||||
return res.json({ ...stats, indexer: services.indexer?.status() ?? null });
|
||||
} catch (error) {
|
||||
return fail(res, error, 'status');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/refresh', express.json({ limit: '16kb' }), async (req: Request, res: Response) => {
|
||||
if (!services.indexer) return res.status(503).json({ error: 'no_index' });
|
||||
const body = (req.body ?? {}) as { courseId?: string; force?: boolean };
|
||||
try {
|
||||
const result = await services.indexer.refresh(body.courseId ?? 'full', { force: body.force === true });
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
// A rate-limit refusal is the caller's problem to act on, not a fault.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/Wait \d+s/.test(message)) return res.status(429).json({ error: 'too_soon', message });
|
||||
return fail(res, error, 'refresh');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Streams one file. Served from the local mirror when present; otherwise
|
||||
* proxied live, which is what keeps files too large to mirror reachable.
|
||||
*/
|
||||
router.get('/files/:fileId', async (req: Request, res: Response) => {
|
||||
const raw = req.params.fileId;
|
||||
const fileId = Array.isArray(raw) ? raw[0] : raw;
|
||||
// Mongo ObjectId shape. Validated before it reaches the store or a path.
|
||||
if (typeof fileId !== 'string' || !/^[0-9a-f]{24}$/i.test(fileId)) {
|
||||
return res.status(400).json({ error: 'bad_file_id' });
|
||||
}
|
||||
if (!services.store) return res.status(503).json({ error: 'no_index' });
|
||||
|
||||
try {
|
||||
const entry = await services.store.mirrorEntry(fileId);
|
||||
if (entry) {
|
||||
const absolute = resolveWithin(services.config.mirrorDir, entry.path);
|
||||
const info = await stat(absolute).catch(() => undefined);
|
||||
if (info?.isFile()) {
|
||||
res.setHeader('Content-Type', entry.mimeType);
|
||||
res.setHeader('Content-Disposition', contentDisposition(entry.name));
|
||||
// sendFile handles Range, ETag and conditional requests for us.
|
||||
return res.sendFile(absolute, { dotfiles: 'deny', acceptRanges: true }, (error) => {
|
||||
if (error && !res.headersSent) res.status(500).end();
|
||||
});
|
||||
}
|
||||
}
|
||||
return await proxyLive(services, fileId, res);
|
||||
} catch (error) {
|
||||
return fail(res, error, 'file');
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/** Falls back to Schulcloud for anything not in the mirror, streaming through. */
|
||||
async function proxyLive(services: Services, fileId: string, res: Response): Promise<void> {
|
||||
const record = await services.client.getFileRecord(fileId);
|
||||
if (record.securityCheckStatus === 'blocked') {
|
||||
res.status(403).json({ error: 'blocked', message: 'The instance virus scanner blocked this file.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const url =
|
||||
`${services.config.baseUrl}/api/v3/file/download/${encodeURIComponent(record.id)}` +
|
||||
`/${encodeURIComponent(record.name)}`;
|
||||
const upstream = await fetch(url, { headers: { Authorization: `Bearer ${services.config.jwt}` } });
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
res.status(upstream.status === 401 ? 502 : upstream.status).json({
|
||||
error: 'upstream_failed',
|
||||
message: upstream.status === 401 ? 'The Schulcloud session has expired on the server.' : `HTTP ${upstream.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', record.mimeType || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', contentDisposition(record.name));
|
||||
if (record.size) res.setHeader('Content-Length', String(record.size));
|
||||
res.setHeader('X-Schulcloud-Source', 'live');
|
||||
Readable.fromWeb(upstream.body as never).pipe(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 5987 Content-Disposition.
|
||||
*
|
||||
* Built by hand because the instance's own header is malformed — it emits
|
||||
* `attachment;; filename="…"` with the name percent-encoded inside the quotes —
|
||||
* and we should not pass that on to clients.
|
||||
*/
|
||||
function contentDisposition(name: string): string {
|
||||
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
function fail(res: Response, error: unknown, what: string): void {
|
||||
console.error(`[schulcloud-mcp] ${what} failed:`, error);
|
||||
if (!res.headersSent) res.status(500).json({ error: 'internal_error' });
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Config } from '../config.ts';
|
||||
import { createServer } from '../mcp/server.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
import { createApiRouter } from './api.ts';
|
||||
import { bearerAuth } from './auth.ts';
|
||||
|
||||
/**
|
||||
@@ -17,6 +19,8 @@ import { bearerAuth } from './auth.ts';
|
||||
*/
|
||||
|
||||
const MCP_PATH = '/mcp';
|
||||
/** The CLI's surface: file bytes, sync manifest, on-demand re-crawl. */
|
||||
const API_PATH = '/api';
|
||||
/** Sessions are dropped after this long without traffic, in case DELETE never arrives. */
|
||||
const SESSION_IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
@@ -26,7 +30,7 @@ interface Session {
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export function createHttpApp(config: Config): express.Express {
|
||||
export function createHttpApp(config: Config, services?: Services): express.Express {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
// Caddy sits in front and terminates TLS; trust its forwarding headers so
|
||||
@@ -49,11 +53,15 @@ export function createHttpApp(config: Config): express.Express {
|
||||
// Liveness probe for Docker/Caddy. Deliberately before auth and free of any
|
||||
// detail about the instance or the account.
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.json({ status: 'ok', sessions: sessions.size });
|
||||
res.json({ status: 'ok', sessions: sessions.size, index: services?.store ? 'on' : 'off' });
|
||||
});
|
||||
|
||||
// One token guards both surfaces: the MCP endpoint and the CLI's file/manifest
|
||||
// API. Splitting them was considered and rejected as unnecessary ceremony for
|
||||
// a single-user deployment.
|
||||
if (config.authToken) {
|
||||
app.use(MCP_PATH, bearerAuth(config.authToken));
|
||||
app.use(API_PATH, bearerAuth(config.authToken));
|
||||
} else {
|
||||
console.warn(
|
||||
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +
|
||||
@@ -61,6 +69,10 @@ export function createHttpApp(config: Config): express.Express {
|
||||
);
|
||||
}
|
||||
|
||||
if (services) {
|
||||
app.use(API_PATH, createApiRouter(services));
|
||||
}
|
||||
|
||||
app.use(MCP_PATH, express.json({ limit: '4mb' }));
|
||||
|
||||
app.post(MCP_PATH, async (req: Request, res: Response) => {
|
||||
@@ -83,7 +95,7 @@ export function createHttpApp(config: Config): express.Express {
|
||||
return;
|
||||
}
|
||||
|
||||
const { server } = createServer(config);
|
||||
const { server } = createServer(config, services);
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
|
||||
Reference in New Issue
Block a user