File size: 8,149 Bytes
8bbfb78
c8c0d39
8bbfb78
f5930da
8bbfb78
 
 
 
 
 
 
 
 
c8c0d39
 
 
8bbfb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ccc0d70
 
 
 
 
 
8bbfb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8c0d39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0cc0e13
 
 
 
c8c0d39
0cc0e13
 
 
 
 
c8c0d39
 
 
 
0cc0e13
 
 
 
c8c0d39
 
 
0cc0e13
 
 
 
c8c0d39
 
f5930da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8bbfb78
 
 
 
 
 
 
 
 
 
 
 
f5930da
 
8bbfb78
 
 
 
 
f5930da
 
0cc0e13
c8c0d39
0cc0e13
f5930da
 
 
 
0cc0e13
 
 
 
8bbfb78
 
f5930da
8bbfb78
 
 
 
 
 
 
 
 
f5930da
8bbfb78
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
"""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", []))],
    }