// GET /iserv/tasks (?alle=1 adds finished tasks, ?dateien=1 adds the original files that contain no name, as base64): the open IServ tasks with person, place and school names removed, for the assistant "Muse". // IServ credentials come from the Space secrets (never from the request). The raw tasks stay in memory: the fetcher // (iserv.js) reads them, the filter service (iserv-filter/, Python, 127.0.0.1 only, own unix user without the IServ // secrets) cleans them, only its answer leaves. Nothing is stored, cached or logged. // // Files that could not be checked for names (scans, images, audio, files with names) come out only after the user // released them on /iserv/freigabe with APPROVE_TOKEN, which Muse does not have. A release is kept in memory for // 10 minutes and used once. // // Secrets: ISERV_BASE_URL (https://host), ISERV_USERNAME, ISERV_PASSWORD, API_BEARER_TOKEN, APPROVE_TOKEN (each at // least 16 characters, the last two different). import { timingSafeEqual } from 'node:crypto'; import express from 'express'; import { fetchIServTasks } from './iserv.js'; const FILTER_URL = process.env.ISERV_FILTER_URL || 'http://127.0.0.1:7862/filter'; let running = Promise.resolve(); // one IServ session at a time const RELEASE_MS = 10 * 60_000; const releases = new Map(); // "-" -> expiry time; memory only const pending = new Map(); // id -> {name, aufgabe, until}: files Muse was told to ask about; memory only, filtered names const FORM = ` Dateien freigeben

Dateien freigeben

Diese Dateien wurden nicht auf Namen geprüft. Muse sieht sie nur nach deiner Freigabe: 10 Minuten, einmal.

Dateien laden …

`; function sameSecret(offered, expected) { const a = Buffer.from(String(offered || '')); const b = Buffer.from(expected || ''); return b.length >= 16 && a.length === b.length && timingSafeEqual(a, b); } function tokenOk(req) { const expected = Buffer.from(process.env.API_BEARER_TOKEN || ''); const offered = [req.headers['x-app-token'], (req.headers.authorization || '').replace(/^Bearer\s+/i, '')]; return offered.some((o) => o && Buffer.byteLength(o) === expected.length && timingSafeEqual(Buffer.from(o), expected)); } async function cleanTasks(includeDone, withFiles, released) { const { ISERV_BASE_URL: url, ISERV_USERNAME: user, ISERV_PASSWORD: pass } = process.env; if (!url || !user || !pass || !url.startsWith('https://')) return { status: 503, body: { error: 'config' } }; let raw; try { raw = await fetchIServTasks(url, user, pass); if (!includeDone) raw = raw.filter((t) => t.done !== true); // finished tasks only with ?alle=1 } catch { return { status: 502, body: { error: 'unreachable' } }; // the message may quote the school domain } try { const res = await fetch(withFiles ? `${FILTER_URL}?dateien=1&freigabe=${released.join(',')}` : FILTER_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-App-Token': process.env.API_BEARER_TOKEN }, body: JSON.stringify(raw), signal: AbortSignal.timeout(420_000), // with ?dateien=1 the filter reads every file completely }); return { status: res.ok ? 200 : 502, body: res.ok ? await res.json() : { error: 'filter', status: res.status } }; } catch (e) { return { status: 502, body: { error: e.name === 'TimeoutError' ? 'filter_timeout' : 'filter' } }; } } export function registerIServFiltered(app) { app.get('/iserv/health', (req, res) => res.json({ status: 'ok' })); app.get('/iserv/freigabe', (req, res) => res.set('Cache-Control', 'no-store').type('html').send(FORM)); const approver = async (req, res, next) => { res.set('Cache-Control', 'no-store'); const approve = process.env.APPROVE_TOKEN; if (approve === process.env.API_BEARER_TOKEN || !sameSecret(req.body?.code, approve)) { await new Promise((r) => setTimeout(r, 1000)); // slows guessing return res.status(401).json({ error: 'unauthorized' }); } next(); }; const json = express.json({ limit: '4kb' }); app.post('/iserv/freigabe/liste', json, approver, (req, res) => { const now = Date.now(); const items = [...pending].filter(([, v]) => v.until > now).map(([id, v]) => ({ id, name: v.name, aufgabe: v.aufgabe })); res.json({ pending: items }); }); app.post('/iserv/freigabe', json, approver, (req, res) => { const ids = (Array.isArray(req.body.ids) ? req.body.ids : []).filter((i) => typeof i === 'string' && /^\d{1,12}-\d{1,3}$/.test(i)); ids.forEach((id) => { releases.set(id, Date.now() + RELEASE_MS); pending.delete(id); }); res.json({ released: ids }); }); app.get('/iserv/tasks', async (req, res) => { res.set('Cache-Control', 'no-store'); if ((process.env.API_BEARER_TOKEN || '').length < 16) return res.status(503).json({ error: 'config' }); // fail closed if (!tokenOk(req)) return res.status(401).json({ error: 'unauthorized' }); const run = () => { const now = Date.now(); const released = [...releases].filter(([, until]) => until > now).map(([id]) => id); releases.clear(); // used once, whether or not the request succeeds return cleanTasks(req.query.alle === '1', req.query.dateien === '1', released); }; const job = running.then(run, run); running = job.catch(() => {}); const { status, body } = await job; for (const t of body.tasks || []) { // remember what Muse has to ask the user about for (const a of t.anhaenge || []) if (a.freigabe) pending.set(a.freigabe, { name: a.name, aufgabe: t.titel, until: Date.now() + 60 * 60_000 }); } res.status(status).json(body); }); }