Luca448 Claude Sonnet 5.5 commited on
Commit
8bbfb78
·
1 Parent(s): e561127

feat(iserv): shorter output, finished tasks only with ?alle=1

Browse files

Attachment text is tidied and capped at 3000 characters per file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

iserv-filter/taskfilter.py CHANGED
@@ -1,91 +1,101 @@
1
- """filter_task: the one place that decides what leaves the server. Explicit whitelist, free text always filtered."""
2
- import io
3
- import zipfile
4
- from collections import Counter
5
-
6
- from iserv import html_text
7
- from nerfilter import filter_filename, filter_text, split_ext
8
-
9
- MAX_TEXT = 20_000 # chars per attachment; bounds CPU time on huge PDFs
10
- NOT_READABLE = "Inhalt nicht maschinell lesbar"
11
- _MEDIA = {"jpg": "bild", "jpeg": "bild", "png": "bild", "gif": "bild", "webp": "bild", "bmp": "bild", "heic": "bild",
12
- "svg": "bild", "mp3": "audio", "wav": "audio", "m4a": "audio", "ogg": "audio", "mp4": "video",
13
- "mov": "video", "avi": "video", "mkv": "video", "webm": "video"}
14
-
15
-
16
- def _decode(data):
17
- try:
18
- return data.decode("utf-8")
19
- except UnicodeDecodeError:
20
- return data.decode("cp1252", errors="replace")
21
-
22
-
23
- def _pdf(data):
24
- from pypdf import PdfReader
25
-
26
- reader = PdfReader(io.BytesIO(data))
27
- if reader.is_encrypted:
28
- return ""
29
- text = ""
30
- for page in reader.pages:
31
- text += (page.extract_text() or "") + "\n"
32
- if len(text) > MAX_TEXT:
33
- break
34
- return text
35
-
36
-
37
- def _docx(data):
38
- with zipfile.ZipFile(io.BytesIO(data)) as z:
39
- if sum(i.file_size for i in z.infolist()) > 50 * 1024 * 1024: # zip bomb
40
- return ""
41
- from docx import Document
42
-
43
- doc = Document(io.BytesIO(data))
44
- return "\n".join([p.text for p in doc.paragraphs] + [c.text for t in doc.tables for r in t.rows for c in r.cells])
45
-
46
-
47
- def _html(data):
48
- return html_text(_decode(data))
49
-
50
-
51
- _EXTRACT = {"pdf": _pdf, "docx": _docx, "txt": _decode, "md": _decode, "html": _html, "htm": _html}
52
-
53
-
54
- def describe_attachment(att, stats=None):
55
- """{name, typ, groesse, text | hinweis}. The file bytes never leave this function."""
56
- if att.get("skipped"):
57
- return {"hinweis": "weiterer Anhang nicht abgerufen"}
58
- name = att.get("name") or ""
59
- ext = split_ext(name)[1]
60
- out = {"name": filter_filename(name, stats), "typ": _MEDIA.get(ext) or ext or "unbekannt", "groesse": att.get("size")}
61
- if att.get("failed"):
62
- if stats is not None:
63
- stats["attachments_failed"] += 1
64
- return {**out, "hinweis": "Datei nicht abrufbar"}
65
- if att.get("data") is None:
66
- return {**out, "hinweis": "Datei größer als 10 MB, nicht abgerufen"}
67
- if ext not in _EXTRACT:
68
- return {**out, "hinweis": NOT_READABLE}
69
- try:
70
- text = _EXTRACT[ext](att["data"]).strip()
71
- except Exception: # corrupt/encrypted file; the message may quote its content
72
- text = ""
73
- if not text:
74
- return {**out, "hinweis": NOT_READABLE}
75
- if len(text) > MAX_TEXT: # cut at a space so no name is split in half
76
- text, out["gekuerzt"] = text[:MAX_TEXT].rsplit(None, 1)[0], True
77
- return {**out, "text": filter_text(text, stats)}
78
-
79
-
80
- def filter_task(task, stats: Counter | None = None):
81
- """Whitelist: anything not named here (creator, groups, feedback, ...) cannot appear in the output."""
82
- return {
83
- "id": int(task["id"]),
84
- "fach": filter_text(task.get("tags", ""), stats),
85
- "titel": filter_text(task.get("title", ""), stats),
86
- "beschreibung": filter_text(task.get("description", ""), stats),
87
- "start": task.get("start"),
88
- "abgabe": task.get("due"),
89
- "status": None if "done" not in task else "erledigt" if task["done"] else "offen", # None: source cannot tell
90
- "anhaenge": [describe_attachment(a, stats) for a in task.get("attachments", [])],
91
- }
 
 
 
 
 
 
 
 
 
 
 
1
+ """filter_task: the one place that decides what leaves the server. Explicit whitelist, free text always filtered."""
2
+ import io
3
+ import zipfile
4
+ from collections import Counter
5
+
6
+ from iserv import html_text
7
+ from nerfilter import filter_filename, filter_text, split_ext
8
+
9
+ READ_LIMIT = 20_000 # chars read from a file; bounds CPU time on huge PDFs
10
+ MAX_TEXT = 3_000 # chars of a file that go out: worksheets are long and Muse needs the gist
11
+ NOT_READABLE = "Inhalt nicht maschinell lesbar"
12
+ _MEDIA = {"jpg": "bild", "jpeg": "bild", "png": "bild", "gif": "bild", "webp": "bild", "bmp": "bild", "heic": "bild",
13
+ "svg": "bild", "mp3": "audio", "wav": "audio", "m4a": "audio", "ogg": "audio", "mp4": "video",
14
+ "mov": "video", "avi": "video", "mkv": "video", "webm": "video"}
15
+
16
+
17
+ def _decode(data):
18
+ try:
19
+ return data.decode("utf-8")
20
+ except UnicodeDecodeError:
21
+ return data.decode("cp1252", errors="replace")
22
+
23
+
24
+ def _pdf(data):
25
+ from pypdf import PdfReader
26
+
27
+ reader = PdfReader(io.BytesIO(data))
28
+ if reader.is_encrypted:
29
+ return ""
30
+ text = ""
31
+ for page in reader.pages:
32
+ text += (page.extract_text() or "") + "\n"
33
+ if len(text) > READ_LIMIT:
34
+ break
35
+ return text
36
+
37
+
38
+ def _docx(data):
39
+ with zipfile.ZipFile(io.BytesIO(data)) as z:
40
+ if sum(i.file_size for i in z.infolist()) > 50 * 1024 * 1024: # zip bomb
41
+ return ""
42
+ from docx import Document
43
+
44
+ doc = Document(io.BytesIO(data))
45
+ return "\n".join([p.text for p in doc.paragraphs] + [c.text for t in doc.tables for r in t.rows for c in r.cells])
46
+
47
+
48
+ def _html(data):
49
+ return html_text(_decode(data))
50
+
51
+
52
+ _EXTRACT = {"pdf": _pdf, "docx": _docx, "txt": _decode, "md": _decode, "html": _html, "htm": _html}
53
+
54
+
55
+ def _tidy(text):
56
+ """Drops what says nothing: blank lines, page numbers, a line identical to the one before (running headers)."""
57
+ out = []
58
+ for line in (l.strip() for l in text.splitlines()):
59
+ if line and not line.isdigit() and (not out or line != out[-1]):
60
+ out.append(line)
61
+ return "\n".join(out)
62
+
63
+
64
+ def describe_attachment(att, stats=None):
65
+ """{name, typ, groesse, text | hinweis}. The file bytes never leave this function."""
66
+ if att.get("skipped"):
67
+ return {"hinweis": "weiterer Anhang nicht abgerufen"}
68
+ name = att.get("name") or ""
69
+ ext = split_ext(name)[1]
70
+ out = {"name": filter_filename(name, stats), "typ": _MEDIA.get(ext) or ext or "unbekannt", "groesse": att.get("size")}
71
+ if att.get("failed"):
72
+ if stats is not None:
73
+ stats["attachments_failed"] += 1
74
+ return {**out, "hinweis": "Datei nicht abrufbar"}
75
+ if att.get("data") is None:
76
+ return {**out, "hinweis": "Datei größer als 10 MB, nicht abgerufen"}
77
+ if ext not in _EXTRACT:
78
+ return {**out, "hinweis": NOT_READABLE}
79
+ try:
80
+ text = _tidy(_EXTRACT[ext](att["data"]))
81
+ except Exception: # corrupt/encrypted file; the message may quote its content
82
+ text = ""
83
+ if not text:
84
+ return {**out, "hinweis": NOT_READABLE}
85
+ if len(text) > MAX_TEXT: # cut at a space so no name is split in half
86
+ text, out["gekuerzt"] = text[:MAX_TEXT].rsplit(None, 1)[0], True
87
+ return {**out, "text": filter_text(text, stats)}
88
+
89
+
90
+ def filter_task(task, stats: Counter | None = None):
91
+ """Whitelist: anything not named here (creator, groups, feedback, ...) cannot appear in the output."""
92
+ return {
93
+ "id": int(task["id"]),
94
+ "fach": filter_text(task.get("tags", ""), stats),
95
+ "titel": filter_text(task.get("title", ""), stats),
96
+ "beschreibung": filter_text(task.get("description", ""), stats),
97
+ "start": task.get("start"),
98
+ "abgabe": task.get("due"),
99
+ "status": None if "done" not in task else "erledigt" if task["done"] else "offen", # None: source cannot tell
100
+ "anhaenge": [describe_attachment(a, stats) for a in task.get("attachments", [])],
101
+ }
src/api/iserv-filtered.js CHANGED
@@ -1,4 +1,4 @@
1
- // GET /iserv/tasks: the open IServ tasks with person, place and school names removed, for the assistant "Muse".
2
  // IServ credentials come from the Space secrets (never from the request). The raw tasks stay in memory: the fetcher
3
  // (iserv.js) reads them, the filter service (iserv-filter/, Python, 127.0.0.1 only, own unix user without the IServ
4
  // secrets) cleans them, only its answer leaves. Nothing is stored, cached or logged.
@@ -16,12 +16,13 @@ function tokenOk(req) {
16
  return offered.some((o) => o && Buffer.byteLength(o) === expected.length && timingSafeEqual(Buffer.from(o), expected));
17
  }
18
 
19
- async function cleanTasks() {
20
  const { ISERV_BASE_URL: url, ISERV_USERNAME: user, ISERV_PASSWORD: pass } = process.env;
21
  if (!url || !user || !pass || !url.startsWith('https://')) return { status: 503, body: { error: 'config' } };
22
  let raw;
23
  try {
24
  raw = await fetchIServTasks(url, user, pass);
 
25
  } catch {
26
  return { status: 502, body: { error: 'unreachable' } }; // the message may quote the school domain
27
  }
@@ -44,7 +45,8 @@ export function registerIServFiltered(app) {
44
  res.set('Cache-Control', 'no-store');
45
  if ((process.env.API_BEARER_TOKEN || '').length < 16) return res.status(503).json({ error: 'config' }); // fail closed
46
  if (!tokenOk(req)) return res.status(401).json({ error: 'unauthorized' });
47
- const job = running.then(cleanTasks, cleanTasks);
 
48
  running = job.catch(() => {});
49
  const { status, body } = await job;
50
  res.status(status).json(body);
 
1
+ // GET /iserv/tasks (?alle=1 adds finished tasks): the open IServ tasks with person, place and school names removed, for the assistant "Muse".
2
  // IServ credentials come from the Space secrets (never from the request). The raw tasks stay in memory: the fetcher
3
  // (iserv.js) reads them, the filter service (iserv-filter/, Python, 127.0.0.1 only, own unix user without the IServ
4
  // secrets) cleans them, only its answer leaves. Nothing is stored, cached or logged.
 
16
  return offered.some((o) => o && Buffer.byteLength(o) === expected.length && timingSafeEqual(Buffer.from(o), expected));
17
  }
18
 
19
+ async function cleanTasks(includeDone) {
20
  const { ISERV_BASE_URL: url, ISERV_USERNAME: user, ISERV_PASSWORD: pass } = process.env;
21
  if (!url || !user || !pass || !url.startsWith('https://')) return { status: 503, body: { error: 'config' } };
22
  let raw;
23
  try {
24
  raw = await fetchIServTasks(url, user, pass);
25
+ if (!includeDone) raw = raw.filter((t) => t.done !== true); // finished tasks only with ?alle=1
26
  } catch {
27
  return { status: 502, body: { error: 'unreachable' } }; // the message may quote the school domain
28
  }
 
45
  res.set('Cache-Control', 'no-store');
46
  if ((process.env.API_BEARER_TOKEN || '').length < 16) return res.status(503).json({ error: 'config' }); // fail closed
47
  if (!tokenOk(req)) return res.status(401).json({ error: 'unauthorized' });
48
+ const run = () => cleanTasks(req.query.alle === '1');
49
+ const job = running.then(run, run);
50
  running = job.catch(() => {});
51
  const { status, body } = await job;
52
  res.status(status).json(body);