Version 2.2.0: Vorschau mit Zoom und PDF-Formularen, eine Uploadgrenze

This commit is contained in:
alientim committed 2026-09-09 21:48:05 +02:00
1 parent f16ba7d875
commit 2c3ca4acc1
67 files changed
+13051 -26

No files matched your search

+1 -1
View File
@@ -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 = [
+47 -3
View File
@@ -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("/<token>/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("/<token>/ordner")
@permission_required("fileshare.create")
def make_directory(token: str):
+37
View File
@@ -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():
-9
View File
@@ -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",
@@ -103,6 +103,8 @@
<button class="link-icon file-name" type="button"
data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{{ icon('file-text', size=15) }} {{ entry.name }}
@@ -121,6 +123,8 @@
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
@@ -201,14 +205,40 @@
Dialog für alle Dateien statt einer je Zeile. #}
{% call modal('file-preview', 'Vorschau', '', 'wide') %}
<div class="modal__caption" data-fill-target="Name"></div>
<div class="modal__body modal__body--flush">
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src"
src="about:blank" referrerpolicy="same-origin"></iframe>
{# Drei Anzeigen, eine je Art. Ein Rahmen für alles zeigte ein Foto vom
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
<div class="modal__body modal__body--flush" data-viewer
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/') }}">
<div class="viewer">
<div class="viewer__stage" data-viewer-bildbuehne hidden>
<img class="viewer__img" alt="" data-viewer-bild>
</div>
<div class="viewer__pdf" data-viewer-pdf hidden>
<div class="pdfViewer" data-viewer-pdfseiten></div>
</div>
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
<div class="viewer__bar" data-viewer-leiste hidden>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="fit">Anpassen</button>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="ein"
aria-label="Vergrössern">{{ icon('plus', size=15) }}</button>
<span class="viewer__hint fs-xs faint" data-viewer-hinweis></span>
</div>
</div>
</div>
<div class="modal__footer">
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
{{ icon('download', size=15) }} Herunterladen
</a>
{% if current_user.has('fileshare.create') %}
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern
</button>
{% endif %}
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
</div>
{% endcall %}