APP-Backend / src /api /iserv-filtered.js
Luca448's picture
fix: ?dateien=1 filter time (single NER pass, 420 s timeout, clearer errors)
0cc0e13
Raw History Blame Contribute Delete
11.7 kB
// 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(); // "<task id>-<file number>" -> 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 = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"><title>Dateien freigeben</title>
<style>
:root{--bg:#f4f5f7;--card:#fff;--text:#1b1f24;--muted:#667085;--line:#e1e4e8;--accent:#2f6fed;--accent-text:#fff;--ok:#117a48;--bad:#c0342c}
@media(prefers-color-scheme:dark){:root{--bg:#14161a;--card:#1e2127;--text:#e8eaed;--muted:#9aa3ad;--line:#2d323a;--accent:#5b8def;--accent-text:#0b1220;--ok:#4cc38a;--bad:#ff7b72}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.45 system-ui,sans-serif}
main{max-width:34rem;margin:0 auto;padding:1.25rem 16px 3rem}
h1{font-size:1.4rem;margin:.5rem 0 .25rem}
.sub{color:var(--muted);margin:0 0 1.25rem;font-size:.95rem}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1rem;margin-bottom:1rem}
label.f{display:block;font-weight:600;font-size:.9rem;margin-bottom:.35rem}
input[type=password]{width:100%;padding:.75rem;font-size:1rem;color:var(--text);background:var(--bg);border:1px solid var(--line);border-radius:10px}
input[type=password]:focus{outline:2px solid var(--accent);outline-offset:1px}
.row{display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:wrap;margin-top:.75rem}
.keep{display:flex;align-items:center;gap:.5rem;color:var(--muted);font-size:.9rem}
.head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem}
.head h2{font-size:1rem;margin:0}
.item{display:flex;gap:.75rem;align-items:flex-start;padding:.75rem;border:1px solid var(--line);border-radius:10px;margin-top:.5rem;cursor:pointer}
.item:has(input:checked){border-color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent)}
.item input{width:1.3rem;height:1.3rem;margin-top:.1rem;accent-color:var(--accent);flex:none}
.item b{display:block;word-break:break-word}
.item small{color:var(--muted)}
.id{font:.8rem ui-monospace,monospace;color:var(--muted)}
.empty{color:var(--muted);text-align:center;padding:1rem 0}
button{font:inherit;padding:.7rem 1.1rem;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--text);cursor:pointer}
button.main{background:var(--accent);color:var(--accent-text);border-color:var(--accent);font-weight:600;width:100%;padding:.9rem;margin-top:1rem}
button.link{border:0;background:none;color:var(--accent);padding:.25rem .5rem}
button:disabled{opacity:.45;cursor:default}
#msg{text-align:center;margin:0;font-weight:600}
#msg.ok{color:var(--ok)}#msg.bad{color:var(--bad)}
</style>
<main>
<h1>Dateien freigeben</h1>
<p class="sub">Diese Dateien wurden nicht auf Namen geprüft. Muse sieht sie nur nach deiner Freigabe: 10 Minuten, einmal.</p>
<form id="f">
<div class="card">
<label class="f" for="code">Freigabe-Code</label>
<input id="code" type="password" autocomplete="current-password" required>
<div class="row"><label class="keep"><input id="keep" type="checkbox" checked> Auf diesem Gerät merken</label>
<button type="button" class="link" id="forget">Vergessen</button></div>
</div>
<div class="card">
<div class="head"><h2 id="sum">Dateien laden …</h2><span><button type="button" class="link" id="all">Alle</button><button type="button" class="link" id="reload">Neu laden</button></span></div>
<div id="list"></div>
<button class="main" id="go" disabled>Freigeben</button>
</div>
<p id="msg"></p>
</form>
</main>
<script>
const $ = (i) => document.getElementById(i);
const KEY = 'iserv-freigabe-code';
const saved = () => { try { return localStorage.getItem(KEY) || ''; } catch { return ''; } };
$('code').value = saved();
async function call(path, body) {
const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return { ok: r.ok, data: await r.json().catch(() => ({})) };
}
function show(items) {
const list = $('list');
list.textContent = '';
for (const it of items) {
const l = document.createElement('label');
l.className = 'item';
const c = document.createElement('input');
c.type = 'checkbox'; c.value = it.id; c.onchange = count;
const t = document.createElement('div');
const n = document.createElement('b'); n.textContent = it.name;
const a = document.createElement('small'); a.textContent = it.aufgabe || '';
const i = document.createElement('div'); i.className = 'id'; i.textContent = 'ID ' + it.id;
t.append(n, a, i);
l.append(c, t);
list.append(l);
}
if (!items.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'Muse hat nach keiner Datei gefragt.'; list.append(e); }
count();
$('sum').textContent = items.length ? 'Gefragte Dateien (' + items.length + ')' : 'Keine Datei gefragt';
}
function count() {
const n = document.querySelectorAll('#list input:checked').length;
$('go').disabled = !n;
$('go').textContent = n ? n + ' freigeben' : 'Freigeben';
}
async function load() {
const code = $('code').value;
if (!code) return;
$('msg').textContent = ''; $('msg').className = '';
const { ok, data } = await call('/iserv/freigabe/liste', { code });
if (!ok) { $('sum').textContent = 'Code falsch'; $('list').textContent = ''; count(); return; }
try { $('keep').checked ? localStorage.setItem(KEY, code) : localStorage.removeItem(KEY); } catch {}
show(data.pending);
}
$('f').onsubmit = async (e) => {
e.preventDefault();
const ids = [...document.querySelectorAll('#list input:checked')].map((c) => c.value);
const { ok, data } = await call('/iserv/freigabe', { code: $('code').value, ids });
if (ok) await load();
$('msg').textContent = ok ? 'Freigegeben: ' + data.released.join(', ') + '. Muse kann sie 10 Minuten lang einmal abrufen.' : 'Nicht freigegeben.';
$('msg').className = ok ? 'ok' : 'bad';
};
$('reload').onclick = load;
$('all').onclick = () => {
const boxes = [...document.querySelectorAll('#list input')];
const on = boxes.some((c) => !c.checked);
boxes.forEach((c) => { c.checked = on; });
count();
};
$('forget').onclick = () => { try { localStorage.removeItem(KEY); } catch {} $('code').value = ''; };
$('code').onchange = load;
load();
</script>`;
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);
});
}