Spaces:
Paused
Paused
Download src/api/iserv-filtered.js from Luca448/APP-Backend: direct link, hf CLI and curl.
- Browser
- Download file 11.7 kB
-
https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/src/api/iserv-filtered.js
- Command line
-
hf download hf://spaces/Luca448/APP-Backend/src/api/iserv-filtered.js
-
curl -L -o iserv-filtered.js https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/src/api/iserv-filtered.js
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); | |
| }); | |
| } | |