Fileshare: Baum-Navigation, Datei-Vorschau, 15MB-Uploads (v1.1.1)
- Freigaben-Auswahl von einem Dropdown auf eine Baum-Navigation (links) umgestellt: alle gemounteten Freigaben als Wurzelknoten, Ordner beliebig aufklappbar. Der Pfad von der Wurzel bis zur aktuellen Position wird serverseitig schon aufgeklappt gerendert (_fileshare_tree_ancestors), alles andere laedt der Client per Klick lazy nach (neue Route /fileshare/subfolders) -- kein Durchlaufen der kompletten Freigabe auf einmal noetig. Dateien stehen bewusst nicht im Baum, nur in der bestehenden Tabelle rechts. - Inline-Vorschau fuer PDF (iframe), Bilder, TXT/CSV/LOG/MD/JSON (per Range- Request in Haeppchen bis 512KB, mit Hinweis bei Abschneiden -- Erkennung ueber den Content-Range-Header, nicht nur den 206-Status, der bei JEDEM Range-Request auftritt) sowie Word/Excel rein clientseitig per mammoth.js bzw. SheetJS (beide lokal vendored, kein Cloud-Viewer -- interne Dokumente verlassen das Netz nicht). Excel-Zellen werden bewusst per textContent statt ueber SheetJS' eigene HTML-Ausgabe gesetzt, um unabhaengig von deren Escaping-Verhalten sicher zu sein. Neue Route /fileshare/view mit fester Endungs-Positivliste, nosniff + restriktiver CSP. - Uploads bis 15MB erlaubt (MAX_CONTENT_LENGTH in app.py) -- dafuer musste auch nginx' client_max_body_size (Standard 1MB) auf 16MB angehoben werden, sowohl im Vorlagen-Code (_NGINX_PROXY_LOCATIONS/_render_nginx_config, fuer die naechste Anwendung ueber Einstellungen->NGINX) als auch in der Fresh-Install-Vorlage. Neuer 413-Errorhandler zeigt eine Flash-Meldung statt Werkzeugs nackter Fehlerseite. - install.sh: known_hosts (liegt unter /srv/tesm) von rsync --delete ausgenommen -- fehlte bisher in der Exclude-Liste (anders als sqlite.db/ fernet.key/secret.key) und waere bei jedem Update verlorengegangen. Live auf POETEST getestet: Baum-Vorbefuellung, Lazy-Load von Geschwisterordnern, alle 5 Vorschautypen (PDF-Route direkt verifiziert, Bild/Text/Word/Excel zusaetzlich visuell), Abschneide-Erkennung sowohl bei kleiner als auch bei >512KB-Datei. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,11 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
||||
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
||||
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
||||
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
||||
client_max_body_size 16m;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -134,6 +134,7 @@ sudo chmod 755 /var/log/tesm
|
||||
step "Deploying application to /srv/tesm"
|
||||
sudo mkdir -p /srv/tesm
|
||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||
--exclude 'known_hosts' \
|
||||
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
||||
print_status "Application files copied"
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.0
|
||||
1.1.1
|
||||
|
||||
+120
@@ -164,6 +164,14 @@ DEVICE_MAINTENANCE_CATEGORY = "linux"
|
||||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||||
|
||||
app = Flask(__name__)
|
||||
# Fileshare-Uploads: bislang gab es keine Grenze (nginx' eigenes Limit vor
|
||||
# app.py griff mangels client_max_body_size faktisch bei 1MB, siehe
|
||||
# etc/nginx/sites-available/tesm bzw. _NGINX_PROXY_LOCATIONS -- beides jetzt
|
||||
# passend auf 15/16MB angehoben). Ohne dieses Flask-seitige Limit würde ein
|
||||
# zu großer Upload erst ganz am Ende, nach vollständigem Empfang, an
|
||||
# secure_filename()/os.path-Prüfungen scheitern -- mit MAX_CONTENT_LENGTH
|
||||
# bricht Werkzeug den Request sofort ab (413), siehe Fehlerbehandlung unten.
|
||||
app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024
|
||||
|
||||
try:
|
||||
_app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8")
|
||||
@@ -175,6 +183,18 @@ except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@app.errorhandler(413)
|
||||
def _handle_request_too_large(_e):
|
||||
"""Greift z.B. beim Fileshare-Upload (siehe MAX_CONTENT_LENGTH oben) --
|
||||
ohne diesen Handler würde Werkzeug eine nackte 413-Fehlerseite ohne
|
||||
App-Look ausliefern. request.referrer statt einer festen Route, damit
|
||||
das auch für andere, spätere Uploads (nicht nur Fileshare) die richtige
|
||||
Seite trifft."""
|
||||
max_mb = (app.config.get("MAX_CONTENT_LENGTH") or 0) // (1024 * 1024)
|
||||
flash(f"Die Datei ist zu groß (Limit: {max_mb} MB).", "danger")
|
||||
return redirect(request.referrer or url_for("index"))
|
||||
|
||||
|
||||
def _load_or_create_secret() -> str:
|
||||
env_secret = os.environ.get("TESM_SECRET_KEY")
|
||||
if env_secret:
|
||||
@@ -1833,6 +1853,35 @@ FILESHARE_MOUNT_ROOT = os.environ.get("TESM_FILESHARE_MOUNT_ROOT", "/mnt/tesm-sh
|
||||
FILESHARE_MAX_AGE_SECONDS = 12 * 3600
|
||||
FILESHARE_SWEEP_INTERVAL_SECONDS = 1800
|
||||
|
||||
# Inline-Vorschau (/fileshare/view, siehe unten): pro Endung, welche Art von
|
||||
# Vorschau der Client bauen soll (steuert nur die UI/JS-Verzweigung) und mit
|
||||
# welchem Content-Type die Datei ausgeliefert wird. Bewusst eine feste
|
||||
# Positivliste -- alles andere bekommt gar keinen Vorschau-Button und die
|
||||
# View-Route liefert für unbekannte Endungen 415 statt "irgendwas" mit vom
|
||||
# Dateinamen geratenem Content-Type auszuliefern. .doc (altes Word-Binär-
|
||||
# format) ist bewusst NICHT dabei -- mammoth.js kann nur .docx (OOXML)
|
||||
# zuverlässig konvertieren; SheetJS dagegen liest sowohl alte .xls- als auch
|
||||
# .xlsx-Dateien ordentlich, deshalb dort beide.
|
||||
_FILESHARE_PREVIEW_KINDS = {
|
||||
".pdf": "pdf",
|
||||
".jpg": "image", ".jpeg": "image", ".png": "image", ".gif": "image",
|
||||
".webp": "image", ".bmp": "image", ".svg": "image",
|
||||
".txt": "text", ".csv": "text", ".log": "text", ".md": "text", ".json": "text",
|
||||
".docx": "docx",
|
||||
".xlsx": "xlsx", ".xls": "xlsx",
|
||||
}
|
||||
_FILESHARE_PREVIEW_MIME = {
|
||||
".pdf": "application/pdf",
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
|
||||
".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml",
|
||||
".txt": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
|
||||
".log": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
|
||||
".json": "text/plain; charset=utf-8",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
}
|
||||
|
||||
_active_fileshare_mounts = {}
|
||||
|
||||
|
||||
@@ -2099,11 +2148,13 @@ def _fileshare_list_dir(abs_path):
|
||||
except OSError:
|
||||
continue
|
||||
is_dir = entry.is_dir(follow_symlinks=False)
|
||||
ext = os.path.splitext(entry.name)[1].lower()
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"is_dir": is_dir,
|
||||
"size_str": "" if is_dir else _format_log_size(st.st_size),
|
||||
"mtime_str": datetime.fromtimestamp(st.st_mtime).strftime("%d.%m.%Y %H:%M"),
|
||||
"preview_kind": None if is_dir else _FILESHARE_PREVIEW_KINDS.get(ext),
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
@@ -2111,6 +2162,29 @@ def _fileshare_list_dir(abs_path):
|
||||
return entries
|
||||
|
||||
|
||||
def _fileshare_tree_ancestors(label, rel_path):
|
||||
"""Für die Baumansicht: liefert für JEDE Ebene von der Freigabe-Wurzel
|
||||
bis zum aktuellen Pfad die dortigen Unterordner (nur Ordner, keine
|
||||
Dateien) -- damit der Baum serverseitig schon bis zur aktuellen
|
||||
Position aufgeklappt gerendert werden kann. Alles darüber hinaus
|
||||
(Geschwister-Ordner, die der Nutzer selbst aufklappt) lädt der Client
|
||||
bei Bedarf über /fileshare/subfolders nach. Schlüssel ist der jeweilige
|
||||
Teilpfad ("" für die Freigabe-Wurzel selbst)."""
|
||||
segments = [p for p in rel_path.split("/") if p]
|
||||
expanded = {}
|
||||
acc = []
|
||||
for depth in range(len(segments) + 1):
|
||||
current_rel = "/".join(acc)
|
||||
abs_path = _fileshare_resolve_path(label, current_rel)
|
||||
if not abs_path or not os.path.isdir(abs_path):
|
||||
break
|
||||
folders = [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]
|
||||
expanded[current_rel] = folders
|
||||
if depth < len(segments):
|
||||
acc.append(segments[depth])
|
||||
return expanded
|
||||
|
||||
|
||||
@app.route("/fileshare")
|
||||
@login_required
|
||||
def fileshare():
|
||||
@@ -2150,9 +2224,50 @@ def fileshare():
|
||||
entries=_fileshare_list_dir(abs_path) if abs_path else [],
|
||||
can_create=current_user.has_permission("fileshare.create"),
|
||||
can_edit=current_user.has_permission("fileshare.edit"),
|
||||
tree_expanded=_fileshare_tree_ancestors(selected_share, rel_path) if abs_path else {"": []},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/fileshare/subfolders")
|
||||
@login_required
|
||||
def fileshare_subfolders():
|
||||
"""Lazy-Nachladen EINER Baumebene (nur Unterordner) für die Baum-
|
||||
Navigation der Fileshare-Seite -- die Wurzel-bis-aktuell-Kette liefert
|
||||
die Hauptroute bereits serverseitig mit (siehe tree_expanded), alles
|
||||
andere (vom Nutzer aufgeklappte Geschwisterordner) holt der Client
|
||||
gezielt über diese Route nach, ohne die Freigabe komplett zu durchlaufen."""
|
||||
if not current_user.has_permission("fileshare.view"):
|
||||
return jsonify({"error": "Keine Berechtigung."}), 403
|
||||
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
|
||||
if not abs_path or not os.path.isdir(abs_path):
|
||||
return jsonify({"error": "Ungültiger Pfad."}), 404
|
||||
return jsonify({"folders": [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]})
|
||||
|
||||
|
||||
@app.route("/fileshare/view")
|
||||
@login_required
|
||||
def fileshare_view():
|
||||
"""Inline-Vorschau (Content-Disposition NICHT 'attachment', anders als
|
||||
/fileshare/download) für eine feste Positivliste von Dateitypen (siehe
|
||||
_FILESHARE_PREVIEW_MIME) -- alles andere liefert bewusst 415 statt mit
|
||||
geratenem Content-Type etwas potenziell Falsches inline auszuliefern.
|
||||
nosniff + eine restriktive CSP zusätzlich als Tiefenverteidigung, falls
|
||||
diese URL direkt (statt über das Vorschau-Modal) aufgerufen wird."""
|
||||
if not current_user.has_permission("fileshare.view"):
|
||||
return "Keine Berechtigung.", 403
|
||||
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
|
||||
if not abs_path or not os.path.isfile(abs_path):
|
||||
return "Datei nicht gefunden.", 404
|
||||
ext = os.path.splitext(abs_path)[1].lower()
|
||||
mimetype = _FILESHARE_PREVIEW_MIME.get(ext)
|
||||
if not mimetype:
|
||||
return "Vorschau für diesen Dateityp nicht verfügbar.", 415
|
||||
resp = send_file(abs_path, as_attachment=False, mimetype=mimetype, conditional=True)
|
||||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||||
resp.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/fileshare/download")
|
||||
@login_required
|
||||
def fileshare_download():
|
||||
@@ -3143,6 +3258,11 @@ _NGINX_PROXY_LOCATIONS = """ location /ws/ {
|
||||
}
|
||||
|
||||
location / {
|
||||
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
||||
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
||||
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
||||
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
||||
client_max_body_size 16m;
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -1292,6 +1292,70 @@ select {
|
||||
}
|
||||
.xterm-container .xterm { height: 100%; }
|
||||
|
||||
/* ==========================================================================
|
||||
Fileshare (Baum-Navigation + Vorschau)
|
||||
========================================================================== */
|
||||
|
||||
.fileshare-layout { display: flex; align-items: flex-start; gap: 16px; }
|
||||
.fileshare-main { flex: 1; min-width: 0; }
|
||||
|
||||
.fileshare-tree {
|
||||
flex: 0 0 260px;
|
||||
max-width: 260px;
|
||||
padding: 14px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.fileshare-tree-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
font-weight: 650;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tree-root, .tree-children { list-style: none; margin: 0; padding: 0; }
|
||||
.tree-children { padding-left: 16px; }
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.tree-row:hover { background: var(--bg-card-hover); }
|
||||
.tree-row.active { background: var(--bg-card-hover); color: var(--accent); font-weight: 600; }
|
||||
|
||||
.tree-toggle {
|
||||
width: 18px; height: 18px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: none; background: transparent; color: var(--text-faint);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.tree-toggle:hover { color: var(--text); }
|
||||
.tree-toggle:disabled { visibility: hidden; }
|
||||
|
||||
.tree-label {
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Vorschau: von mammoth.js/SheetJS erzeugter bzw. selbst gebauter Inhalt */
|
||||
.docx-preview { font-size: 14px; line-height: 1.65; }
|
||||
.docx-preview table { border-collapse: collapse; margin: 10px 0; }
|
||||
.docx-preview table td, .docx-preview table th { border: 1px solid var(--border); padding: 6px 10px; }
|
||||
.docx-preview img { max-width: 100%; }
|
||||
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
|
||||
.xlsx-preview-sheet-title:first-child { margin-top: 0; }
|
||||
|
||||
/* ==========================================================================
|
||||
Utilities
|
||||
========================================================================== */
|
||||
|
||||
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
@@ -5,32 +5,56 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% macro render_tree_node(share, path, name, expanded_map, active_path) %}
|
||||
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
|
||||
<div class="tree-row{{ ' active' if path == active_path else '' }}">
|
||||
{% if path in expanded_map %}
|
||||
<button type="button" class="tree-toggle" aria-expanded="true">▼</button>
|
||||
{% else %}
|
||||
<button type="button" class="tree-toggle" aria-expanded="false">▶</button>
|
||||
{% endif %}
|
||||
<span class="tree-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px; height:14px; margin-right:5px; vertical-align:-2px; color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
{{ name }}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="tree-children{{ '' if path in expanded_map else ' hidden' }}"{% if path in expanded_map %} data-loaded="1"{% endif %}>
|
||||
{% if path in expanded_map %}
|
||||
{% for child in expanded_map[path] %}
|
||||
{{ render_tree_node(share, (path ~ '/' ~ child) if path else child, child, expanded_map, active_path) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Dateifreigaben</h2>
|
||||
<div class="hint">Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.</div>
|
||||
</div>
|
||||
{% if shares|length > 1 %}
|
||||
<div class="flex gap-2" style="align-items:center;">
|
||||
<label for="shareSelect" class="text-faint" style="font-size:12.5px; font-weight:600;">Freigabe:</label>
|
||||
<select id="shareSelect" onchange="window.location.href='{{ url_for('fileshare') }}?share=' + encodeURIComponent(this.value);">
|
||||
{% for s in shares %}
|
||||
<option value="{{ s }}" {% if s == selected_share %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2" style="align-items:center; margin-bottom:14px; flex-wrap:wrap;">
|
||||
<div class="fileshare-layout">
|
||||
<div class="fileshare-tree card">
|
||||
<div class="fileshare-tree-title">Freigaben</div>
|
||||
<ul class="tree-root" id="fileshareTree">
|
||||
{% for s in shares %}
|
||||
{{ render_tree_node(s, '', s, tree_expanded if s == selected_share else {}, rel_path if s == selected_share else None) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="fileshare-main">
|
||||
<div class="flex gap-2" style="align-items:center; margin-bottom:14px; flex-wrap:wrap;">
|
||||
<a href="{{ url_for('fileshare', share=selected_share) }}" class="mono" style="font-size:13px; font-weight:600;">{{ selected_share }}</a>
|
||||
{% for b in breadcrumbs %}
|
||||
<span class="text-faint">/</span>
|
||||
<a href="{{ url_for('fileshare', share=selected_share, path=b.path) }}" class="mono" style="font-size:13px;">{{ b.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" style="margin-bottom:16px;">
|
||||
<div class="table-wrap" style="margin-bottom:16px;">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
@@ -77,6 +101,14 @@
|
||||
<td class="text-faint">{{ e.mtime_str }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if e.preview_kind %}
|
||||
<button type="button" class="icon-btn" title="Vorschau"
|
||||
data-preview-kind="{{ e.preview_kind }}"
|
||||
data-preview-name="{{ e.name }}"
|
||||
data-preview-url="{{ url_for('fileshare_view', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if not e.is_dir %}
|
||||
<a class="icon-btn" title="Herunterladen" href="{{ url_for('fileshare_download', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||
@@ -105,6 +137,8 @@
|
||||
{% else %}
|
||||
<div style="padding:40px 16px; text-align:center; color:var(--text-faint);">Dieser Ordner ist leer.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_create %}
|
||||
@@ -121,6 +155,7 @@
|
||||
<div class="field">
|
||||
<label>Datei</label>
|
||||
<input type="file" name="file" required>
|
||||
<div class="field-hint">Maximal 15 MB pro Datei.</div>
|
||||
</div>
|
||||
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
|
||||
</div>
|
||||
@@ -182,10 +217,25 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="modal-overlay" id="previewModal">
|
||||
<div class="modal" style="max-width:900px; width:90vw;">
|
||||
<div class="modal-header">
|
||||
<h3 id="previewTitle">Vorschau</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="previewBody" style="max-height:75vh; overflow:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/vendor/mammoth.browser.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vendor/xlsx.full.min.js') }}"></script>
|
||||
<script>
|
||||
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
|
||||
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
|
||||
|
||||
function openRenameModal(name) {
|
||||
document.getElementById("renameOldName").value = name;
|
||||
document.getElementById("renameNewName").value = name;
|
||||
@@ -199,5 +249,212 @@ function filterTable(inputId, tableId) {
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Baum-Navigation (Freigaben links) ---------------- */
|
||||
|
||||
function buildTreeNode(share, path, name) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "tree-node";
|
||||
li.dataset.share = share;
|
||||
li.dataset.path = path;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "tree-row";
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "tree-toggle";
|
||||
toggle.textContent = "▶";
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "tree-label";
|
||||
label.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:5px;vertical-align:-2px;color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>';
|
||||
label.append(document.createTextNode(name));
|
||||
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(label);
|
||||
|
||||
const childUl = document.createElement("ul");
|
||||
childUl.className = "tree-children hidden";
|
||||
|
||||
li.appendChild(row);
|
||||
li.appendChild(childUl);
|
||||
return li;
|
||||
}
|
||||
|
||||
function toggleTreeNode(btn) {
|
||||
const li = btn.closest(".tree-node");
|
||||
const childUl = li.querySelector(":scope > .tree-children");
|
||||
if (childUl.classList.contains("hidden")) {
|
||||
if (childUl.dataset.loaded === "1") {
|
||||
childUl.classList.remove("hidden");
|
||||
btn.textContent = "▼";
|
||||
btn.setAttribute("aria-expanded", "true");
|
||||
} else {
|
||||
loadTreeChildren(li, childUl, btn);
|
||||
}
|
||||
} else {
|
||||
childUl.classList.add("hidden");
|
||||
btn.textContent = "▶";
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
|
||||
function loadTreeChildren(li, childUl, btn) {
|
||||
const share = li.dataset.share;
|
||||
const path = li.dataset.path;
|
||||
const prevLabel = btn.textContent;
|
||||
btn.textContent = "…";
|
||||
fetch(FILESHARE_SUBFOLDERS_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
childUl.innerHTML = "";
|
||||
(data.folders || []).forEach(name => {
|
||||
const childPath = path ? path + "/" + name : name;
|
||||
childUl.appendChild(buildTreeNode(share, childPath, name));
|
||||
});
|
||||
childUl.dataset.loaded = "1";
|
||||
childUl.classList.remove("hidden");
|
||||
btn.textContent = "▼";
|
||||
btn.setAttribute("aria-expanded", "true");
|
||||
})
|
||||
.catch(() => { btn.textContent = prevLabel; });
|
||||
}
|
||||
|
||||
function navigateTree(share, path) {
|
||||
window.location.href = FILESHARE_BASE_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path);
|
||||
}
|
||||
|
||||
document.getElementById("fileshareTree").addEventListener("click", function (e) {
|
||||
const toggle = e.target.closest(".tree-toggle");
|
||||
if (toggle) { toggleTreeNode(toggle); return; }
|
||||
const label = e.target.closest(".tree-label");
|
||||
if (label) {
|
||||
const li = label.closest(".tree-node");
|
||||
navigateTree(li.dataset.share, li.dataset.path);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------- Datei-Vorschau ---------------- */
|
||||
|
||||
const fileTableEl = document.getElementById("fileTable");
|
||||
if (fileTableEl) {
|
||||
fileTableEl.addEventListener("click", function (e) {
|
||||
const btn = e.target.closest("[data-preview-kind]");
|
||||
if (!btn) return;
|
||||
openPreview(btn.dataset.previewKind, btn.dataset.previewName, btn.dataset.previewUrl);
|
||||
});
|
||||
}
|
||||
|
||||
function openPreview(kind, name, url) {
|
||||
document.getElementById("previewTitle").textContent = name;
|
||||
const body = document.getElementById("previewBody");
|
||||
body.innerHTML = "";
|
||||
PoeUI.openModal("previewModal");
|
||||
|
||||
if (kind === "pdf") {
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.src = url;
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.height = "70vh";
|
||||
iframe.style.border = "0";
|
||||
body.appendChild(iframe);
|
||||
} else if (kind === "image") {
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = name;
|
||||
img.style.maxWidth = "100%";
|
||||
img.style.display = "block";
|
||||
img.style.margin = "0 auto";
|
||||
body.appendChild(img);
|
||||
} else if (kind === "text") {
|
||||
body.textContent = "Lade …";
|
||||
// Nur die ersten 512KB anfordern -- bei sehr großen Textdateien
|
||||
// (Logs etc.) reicht das für eine Vorschau, ohne alles auf einmal
|
||||
// laden zu müssen. Server unterstützt Range ueber send_file(conditional=True).
|
||||
fetch(url, { headers: { "Range": "bytes=0-524287" } })
|
||||
.then(r => {
|
||||
// r.status ist bei einem Range-Request praktisch immer 206,
|
||||
// auch wenn die Datei kleiner als die angefragten 512KB ist
|
||||
// (der Server liefert dann trotzdem "206" mit der kompletten
|
||||
// Datei) -- ob wirklich abgeschnitten wurde, steht nur im
|
||||
// Content-Range-Header ("bytes 0-524287/<Gesamtgroesse>").
|
||||
const contentRange = r.headers.get("Content-Range") || "";
|
||||
const m = contentRange.match(/\/(\d+)$/);
|
||||
const truncated = !!m && parseInt(m[1], 10) > 524288;
|
||||
return r.text().then(text => ({ text: text, truncated: truncated }));
|
||||
})
|
||||
.then(({ text, truncated }) => {
|
||||
body.innerHTML = "";
|
||||
const pre = document.createElement("pre");
|
||||
pre.style.whiteSpace = "pre-wrap";
|
||||
pre.style.wordBreak = "break-word";
|
||||
pre.style.fontSize = "12.5px";
|
||||
pre.textContent = text;
|
||||
body.appendChild(pre);
|
||||
if (truncated) {
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "text-faint";
|
||||
hint.style.marginTop = "10px";
|
||||
hint.style.fontSize = "12px";
|
||||
hint.textContent = "Nur die ersten 512 KB angezeigt — bitte herunterladen für die komplette Datei.";
|
||||
body.appendChild(hint);
|
||||
}
|
||||
})
|
||||
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||
} else if (kind === "docx") {
|
||||
body.textContent = "Lade …";
|
||||
fetch(url).then(r => r.arrayBuffer())
|
||||
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
|
||||
.then(result => {
|
||||
body.innerHTML = "";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "docx-preview";
|
||||
// mammoth erzeugt kontrolliertes HTML aus Words eigenem,
|
||||
// begrenztem Formatierungsmodell (Absätze/Tabellen/Listen/
|
||||
// Bilder) -- kein beliebiges, aus der Datei uebernommenes
|
||||
// Skript kann darin stecken, anders als bei generischem
|
||||
// "fremdes HTML direkt einbetten".
|
||||
wrap.innerHTML = result.value;
|
||||
body.appendChild(wrap);
|
||||
})
|
||||
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden (Format evtl. nicht unterstützt)."; });
|
||||
} else if (kind === "xlsx") {
|
||||
body.textContent = "Lade …";
|
||||
fetch(url).then(r => r.arrayBuffer())
|
||||
.then(buf => {
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
body.innerHTML = "";
|
||||
wb.SheetNames.forEach(function (sheetName, idx) {
|
||||
const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], { header: 1, defval: "" });
|
||||
const h4 = document.createElement("div");
|
||||
h4.className = "xlsx-preview-sheet-title";
|
||||
h4.textContent = sheetName;
|
||||
body.appendChild(h4);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.overflowX = "auto";
|
||||
const table = document.createElement("table");
|
||||
table.className = "data-table";
|
||||
// Zellenwerte bewusst per textContent statt ueber die
|
||||
// eingebaute HTML-Ausgabe von SheetJS gesetzt -- so ist
|
||||
// die Vorschau unabhaengig von deren Escaping-Verhalten
|
||||
// garantiert sicher gegen Inhalte in den Zellen.
|
||||
rows.forEach(function (row) {
|
||||
const tr = document.createElement("tr");
|
||||
row.forEach(function (cell) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = (cell === null || cell === undefined) ? "" : String(cell);
|
||||
tr.appendChild(td);
|
||||
});
|
||||
table.appendChild(tr);
|
||||
});
|
||||
wrap.appendChild(table);
|
||||
body.appendChild(wrap);
|
||||
});
|
||||
})
|
||||
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user