APP-Backend / iserv-filter /taskfilter.py
Luca448's picture
fix: ?dateien=1 filter time (single NER pass, 420 s timeout, clearer errors)
0cc0e13
Raw History Blame Contribute Delete
8.15 kB
"""filter_task: the one place that decides what leaves the server. Explicit whitelist, free text always filtered."""
import base64
import io
import mimetypes
import zipfile
from collections import Counter
from iserv import html_text
from nerfilter import filter_filename, filter_text, split_ext
READ_LIMIT = 20_000 # chars read from a file; bounds CPU time on huge PDFs
MAX_TEXT = 3_000 # chars of a file that go out: worksheets are long and Muse needs the gist
NOT_READABLE = "Inhalt nicht maschinell lesbar"
FILE_WITHHELD = "Originaldatei nicht herausgegeben (Namen enthalten oder nicht prüfbar)"
_MIME = {"pdf": "application/pdf", "txt": "text/plain", "md": "text/markdown",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
_MEDIA = {"jpg": "bild", "jpeg": "bild", "png": "bild", "gif": "bild", "webp": "bild", "bmp": "bild", "heic": "bild",
"svg": "bild", "mp3": "audio", "wav": "audio", "m4a": "audio", "ogg": "audio", "mp4": "video",
"mov": "video", "avi": "video", "mkv": "video", "webm": "video"}
def _decode(data):
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("cp1252", errors="replace")
def _pdf(data):
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(data))
if reader.is_encrypted:
return ""
text = ""
for page in reader.pages:
text += (page.extract_text() or "") + "\n"
if len(text) > READ_LIMIT:
break
return text
def _docx(data):
with zipfile.ZipFile(io.BytesIO(data)) as z:
if sum(i.file_size for i in z.infolist()) > 50 * 1024 * 1024: # zip bomb
return ""
from docx import Document
doc = Document(io.BytesIO(data))
cells = {} # a merged cell is returned once per grid column it spans: take each one only once
for t in doc.tables:
for r in t.rows:
for c in r.cells:
cells.setdefault(c._tc, c.text)
return "\n".join([p.text for p in doc.paragraphs] + list(cells.values()))
def _html(data):
return html_text(_decode(data))
_EXTRACT = {"pdf": _pdf, "docx": _docx, "txt": _decode, "md": _decode, "html": _html, "htm": _html}
def _tidy(text):
"""Drops what says nothing: blank lines, page numbers, a line identical to the one before (running headers)."""
out = []
for line in (l.strip() for l in text.splitlines()):
if line and not line.isdigit() and (not out or line != out[-1]):
out.append(line)
return "\n".join(out)
def _docx_checked(data):
"""(text of headers and footers, cleaned bytes) of a docx, or None if it has comments (author names)."""
with zipfile.ZipFile(io.BytesIO(data)) as z:
if "word/comments.xml" in z.namelist():
return None
from docx import Document
doc = Document(io.BytesIO(data))
extra = "\n".join(p.text for s in doc.sections for part in (s.header, s.footer) for p in part.paragraphs)
props = doc.core_properties
for field in ("author", "last_modified_by", "title", "subject", "comments", "keywords", "category"):
setattr(props, field, "")
out = io.BytesIO()
doc.save(out)
return extra, out.getvalue()
def _pdf_clean(data):
"""The pages only: no document info, bookmarks, forms or annotations (links, comments)."""
from pypdf import PdfReader, PdfWriter
writer = PdfWriter()
for page in PdfReader(io.BytesIO(data)).pages:
page.pop("/Annots", None)
writer.add_page(page)
out = io.BytesIO()
writer.write(out)
return out.getvalue()
def _file_out(ext, data, text, stats=None):
"""(datei, filtered): {mime, base64} of the original file if nothing in it is a name (else None), and the filtered
text, so the NER reads the file only once. Only what the NER can read is checked, so scans, images and audio never
go out; a file cut at READ_LIMIT was not read completely (filtered is None: the caller filters the part it shows)."""
if ext not in _MIME or not text or len(text) > READ_LIMIT:
return None, None
found = Counter()
filtered = filter_text(text, found)
if stats is not None:
stats["replacements"] += found["replacements"]
try:
if ext == "docx":
checked = _docx_checked(data)
if checked is None:
return None, filtered
if checked[0].strip():
filter_text(checked[0], found)
data = checked[1]
elif ext == "pdf":
data = _pdf_clean(data)
except Exception:
return None, filtered
if found["replacements"]:
return None, filtered
return {"mime": _MIME[ext], "base64": base64.b64encode(data).decode()}, filtered
def _unchecked(out, att, fid, release):
"""A file Muse may not see by itself: it gets an id to ask the user for. Only an id the user released
(via the backend's /iserv/freigabe) hands out the original, as it is: it was not checked for names."""
out["datei_hinweis"] = FILE_WITHHELD
out["datei"] = None
if fid in release:
out["datei"] = {"mime": mimetypes.guess_type(att.get("name") or "")[0] or "application/octet-stream",
"base64": base64.b64encode(att["data"]).decode()}
out["datei_hinweis"] = "Original auf Freigabe herausgegeben, nicht auf Namen geprüft"
else:
out["freigabe"] = fid
return out
def describe_attachment(att, stats=None, files=False, fid=None, release=()):
"""{name, typ, groesse, text | hinweis}; with files also datei (original file) or datei_hinweis + freigabe id.
The file bytes leave this function only through _file_out and _unchecked."""
if att.get("skipped"):
return {"hinweis": "weiterer Anhang nicht abgerufen"}
name = att.get("name") or ""
ext = split_ext(name)[1]
out = {"name": filter_filename(name, stats), "typ": _MEDIA.get(ext) or ext or "unbekannt", "groesse": att.get("size")}
if att.get("failed"):
if stats is not None:
stats["attachments_failed"] += 1
return {**out, "hinweis": "Datei nicht abrufbar"}
if att.get("data") is None:
return {**out, "hinweis": "Datei größer als 10 MB, nicht abgerufen"}
if ext not in _EXTRACT:
out["hinweis"] = NOT_READABLE
return _unchecked(out, att, fid, release) if files else out
try:
text = _tidy(_EXTRACT[ext](att["data"]))
except Exception: # corrupt/encrypted file; the message may quote its content
text = ""
if not text:
out["hinweis"] = NOT_READABLE
return _unchecked(out, att, fid, release) if files else out
filtered = None
if files:
datei, filtered = _file_out(ext, att["data"], text, stats)
if datei:
out["datei"] = datei
else:
_unchecked(out, att, fid, release)
shown = filtered if filtered is not None else text
if len(shown) > MAX_TEXT: # cut at a space so no name is split in half
shown, out["gekuerzt"] = shown[:MAX_TEXT].rsplit(None, 1)[0], True
return {**out, "text": shown if filtered is not None else filter_text(shown, stats)}
def filter_task(task, stats: Counter | None = None, files=False, release=()):
"""Whitelist: anything not named here (creator, groups, feedback, ...) cannot appear in the output."""
return {
"id": int(task["id"]),
"fach": filter_text(task.get("tags", ""), stats),
"titel": filter_text(task.get("title", ""), stats),
"beschreibung": filter_text(task.get("description", ""), stats),
"start": task.get("start"),
"abgabe": task.get("due"),
"status": None if "done" not in task else "erledigt" if task["done"] else "offen", # None: source cannot tell
"anhaenge": [describe_attachment(a, stats, files, f"{int(task['id'])}-{i}", release) for i, a in enumerate(task.get("attachments", []))],
}