diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 82a9bfc..997ac34 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.1.2" +version = "2.2.0" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/fileshare.py b/apps/tesm/src/tesm/blueprints/fileshare.py index 076da6e..4d86727 100644 --- a/apps/tesm/src/tesm/blueprints/fileshare.py +++ b/apps/tesm/src/tesm/blueprints/fileshare.py @@ -18,6 +18,7 @@ from flask import ( from tesm_core import audit from tesm_core.auth.models import current_user, permission_required from tesm_core.auth.service import client_ip +from tesm_core import uploads from tesm_core.extension import core, db, transaction from ..services import fileshare as service @@ -75,7 +76,10 @@ def browse(): token=token, listing=listing, error=error, - max_upload_mb=int(core().settings.get(conn, "fileshare_upload_max_mb") or 512), + # Dieselbe Zahl, die auch die Anfrage begrenzt und in nginx steht -- + # vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen + # wurde. + max_upload_mb=uploads.limit_mb(conn), ) @@ -95,7 +99,12 @@ def download(token: str): path, as_attachment=True, download_name=path.name, - mimetype=service.download_mime(path.name), + # Bewusst der neutrale Typ und nicht der erkannte: iOS Safari + # entscheidet nach dem MIME-Typ und zeigt eine "application/pdf" im + # Browser an, auch wenn sie als Anhang ausgeliefert wird. Wer hier + # klickt, will die Datei aber ablegen -- angesehen wird sie in der + # Vorschau. Fuer alle anderen Browser aendert sich damit nichts. + mimetype="application/octet-stream", ) @@ -148,7 +157,7 @@ def upload(token: str): flash("Keine Datei ausgewaehlt.", "danger") return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) - limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024 + limit = uploads.limit_bytes(db()) stored = 0 try: target_dir = service.resolve_path(mount, relative) @@ -172,6 +181,41 @@ def upload(token: str): return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) +@bp.post("//formular") +@permission_required("fileshare.create") +def save_form(token: str): + """Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab. + + Als **neue** Datei: auf einer Freigabe liegt in der Regel die leere + Vorlage, und die soll leer bleiben. Wer das Original ersetzen will, kann + die alte Datei danach loeschen -- das ist eine bewusste Handlung. + """ + mount = _mount_or_404(token) + relative = request.form.get("pfad") or "" + upload_file = request.files.get("datei") + if upload_file is None or not upload_file.filename: + return {"error": "Es kam keine Datei an."}, 400 + + grenze = uploads.limit_bytes(db()) + daten = upload_file.stream.read(grenze + 1) + if len(daten) > grenze: + return {"error": f"Zu gross (Grenze {grenze // 1024 // 1024} MB)."}, 413 + if not daten.startswith(b"%PDF-"): + # Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt + # waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen. + return {"error": "Das ist keine PDF-Datei."}, 415 + + try: + original = service.resolve_path(mount, relative) + ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf") + ziel.write_bytes(daten) + except service.FileshareError as exc: + return {"error": str(exc)}, 400 + + _audit("fileshare.form_saved", f"{mount.label}:{relative}", ziel.name, severity="notice") + return {"message": f"Als {ziel.name} gespeichert."} + + @bp.post("//ordner") @permission_required("fileshare.create") def make_directory(token: str): diff --git a/apps/tesm/src/tesm/services/fileshare.py b/apps/tesm/src/tesm/services/fileshare.py index 79b1834..ff0ca43 100644 --- a/apps/tesm/src/tesm/services/fileshare.py +++ b/apps/tesm/src/tesm/services/fileshare.py @@ -57,6 +57,10 @@ PREVIEW_MIME: dict[str, str] = { ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", + # Das Format, in dem ein iPad fotografiert. Safari zeigt es an, andere + # Browser nicht -- die Vorschau sagt es dann und bietet den Download an. + ".heic": "image/heic", + ".heif": "image/heif", ".txt": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8", ".log": "text/plain; charset=utf-8", @@ -68,6 +72,21 @@ PREVIEW_MIME: dict[str, str] = { #: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich. +def preview_kind(name: str) -> str: + """Welche Anzeige passt: ``bild``, ``pdf`` oder ``text``? + + Die Unterscheidung gehoert hierher und nicht in die Vorlage: die + Zuordnung Endung -> MIME-Typ steht schon hier, und eine zweite Liste in + Jinja liefe frueher oder spaeter auseinander. + """ + mime = PREVIEW_MIME.get(Path(name).suffix.lower(), "") + if mime.startswith("image/"): + return "bild" + if mime == "application/pdf": + return "pdf" + return "text" + + class FileshareError(Exception): """Fehler mit Klartextmeldung fuer die Oberflaeche.""" @@ -528,6 +547,7 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]: directories.append(record) else: record["previewable"] = entry.suffix.lower() in PREVIEW_MIME + record["preview_kind"] = preview_kind(entry.name) files.append(record) except OSError as exc: raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc @@ -568,6 +588,23 @@ def safe_filename(name: str) -> str: return cleaned +def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path: + """Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``. + + Nichts ueberschreiben: auf einer Freigabe liegt meist die leere Vorlage, + und zwei Leute fuellen sie nacheinander aus. Ab dem zweiten Mal wird + durchnummeriert. + """ + kandidat = parent / f"{stem}{zusatz}{suffix}" + zaehler = 2 + while kandidat.exists(): + kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}" + zaehler += 1 + if zaehler > 200: + raise FileshareError("Zu viele Dateien dieses Namens -- bitte aufraeumen.") + return kandidat + + def delete_entry(mount: Mount, relative: str) -> str: path = resolve_path(mount, relative) if path == mount.path.resolve(): diff --git a/apps/tesm/src/tesm/settings_spec.py b/apps/tesm/src/tesm/settings_spec.py index 28983a3..61b244f 100644 --- a/apps/tesm/src/tesm/settings_spec.py +++ b/apps/tesm/src/tesm/settings_spec.py @@ -79,15 +79,6 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( minimum=1, maximum=168, ), - SettingSpec( - "fileshare_upload_max_mb", - 512, - "int", - "Maximale Dateigroesse beim Hochladen (MB)", - section="fileshare", - minimum=1, - maximum=8192, - ), # -- Wartung ------------------------------------------------------------ # SettingSpec( "maintenance_command_timeout", diff --git a/apps/tesm/src/tesm/templates/tesm/fileshare.html b/apps/tesm/src/tesm/templates/tesm/fileshare.html index faf9463..da851e1 100644 --- a/apps/tesm/src/tesm/templates/tesm/fileshare.html +++ b/apps/tesm/src/tesm/templates/tesm/fileshare.html @@ -103,6 +103,8 @@ @@ -201,14 +205,40 @@ Dialog für alle Dateien statt einer je Zeile. #} {% call modal('file-preview', 'Vorschau', '', 'wide') %} -