File size: 11,681 Bytes
c8c0d39
e561127
 
 
 
f5930da
 
 
 
 
 
e561127
f5930da
e561127
 
 
 
f5930da
 
4d4aa6f
171e99c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d4aa6f
171e99c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d4aa6f
 
 
 
 
 
 
 
 
 
 
 
 
 
171e99c
4d4aa6f
 
171e99c
 
 
 
 
 
4d4aa6f
 
171e99c
4d4aa6f
 
 
 
 
 
 
 
 
 
 
171e99c
4d4aa6f
171e99c
4d4aa6f
 
 
 
 
 
 
171e99c
 
 
4d4aa6f
 
171e99c
 
 
 
 
 
4d4aa6f
 
 
 
f5930da
 
 
 
 
 
e561127
 
 
 
 
 
 
f5930da
e561127
 
 
 
 
8bbfb78
e561127
 
 
 
f5930da
e561127
 
 
0cc0e13
e561127
0cc0e13
 
 
e561127
 
 
 
 
f5930da
4d4aa6f
 
f5930da
 
 
4d4aa6f
f5930da
4d4aa6f
 
 
 
 
 
 
 
 
 
 
 
f5930da
e561127
 
 
 
f5930da
 
 
 
 
 
8bbfb78
e561127
 
4d4aa6f
 
 
e561127
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
// 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);
  });
}