Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb8b929c3c | ||
|
|
af5dfe30b4 |
+1
-1
@@ -1 +1 @@
|
||||
1.1.3
|
||||
1.1.5
|
||||
|
||||
+111
-4
@@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography import x509
|
||||
from datetime import datetime, timedelta
|
||||
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback
|
||||
import base64, csv, io, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
|
||||
import paramiko
|
||||
import yaml
|
||||
import ssl
|
||||
@@ -2340,6 +2340,21 @@ def fileshare_mkdir():
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
|
||||
def _fileshare_resolve_child(share, rel_path, name):
|
||||
"""Löst EINEN Kind-Eintrag (Datei oder Ordner) von rel_path auf und
|
||||
stellt zusätzlich sicher, dass er auch tatsächlich direkt DARIN liegt
|
||||
(per os.sep-Präfix-Vergleich des bereits Path-Traversal-geprüften
|
||||
_fileshare_resolve_path) -- von delete/delete-multi/download-multi
|
||||
gemeinsam genutzt. None bei jedem ungültigen Fall, wirft nie."""
|
||||
if not name:
|
||||
return None
|
||||
parent_abs = _fileshare_resolve_path(share, rel_path)
|
||||
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/"))
|
||||
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
|
||||
return None
|
||||
return target_abs
|
||||
|
||||
|
||||
@app.route("/fileshare/delete", methods=["POST"])
|
||||
@login_required
|
||||
def fileshare_delete():
|
||||
@@ -2350,9 +2365,8 @@ def fileshare_delete():
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
name = request.form.get("name", "")
|
||||
parent_abs = _fileshare_resolve_path(share, rel_path)
|
||||
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/")) if name else None
|
||||
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
|
||||
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||
if not target_abs:
|
||||
flash("Ungültiges Ziel.", "danger")
|
||||
else:
|
||||
try:
|
||||
@@ -2367,6 +2381,99 @@ def fileshare_delete():
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
|
||||
@app.route("/fileshare/delete-multi", methods=["POST"])
|
||||
@login_required
|
||||
def fileshare_delete_multi():
|
||||
"""Wie /fileshare/delete, nur für eine per Checkbox ausgewählte Menge
|
||||
an Dateien/Ordnern auf einmal (Mehrfachauswahl in der Tabelle)."""
|
||||
share = request.form.get("share", "")
|
||||
rel_path = request.form.get("path", "")
|
||||
if not current_user.has_permission("fileshare.edit"):
|
||||
flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
names = [n for n in request.form.getlist("names") if n]
|
||||
if not names:
|
||||
flash("Keine Elemente ausgewählt.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
deleted, failed = [], []
|
||||
for name in names:
|
||||
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||
if not target_abs:
|
||||
failed.append(name)
|
||||
continue
|
||||
try:
|
||||
if os.path.isdir(target_abs):
|
||||
shutil.rmtree(target_abs)
|
||||
else:
|
||||
os.remove(target_abs)
|
||||
deleted.append(name)
|
||||
except OSError:
|
||||
failed.append(name)
|
||||
|
||||
if deleted:
|
||||
log_action("fileshare.delete", share, f"{rel_path}/".strip("/") + f" ({len(deleted)} Element(e): {', '.join(deleted)})")
|
||||
flash(f"{len(deleted)} Element(e) gelöscht: {', '.join(deleted)}.", "success")
|
||||
if failed:
|
||||
flash(f"Löschen fehlgeschlagen für: {', '.join(failed)}.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
|
||||
@app.route("/fileshare/download-multi", methods=["POST"])
|
||||
@login_required
|
||||
def fileshare_download_multi():
|
||||
"""Baut eine Auswahl aus mehreren Dateien/Ordnern zu EINEM ZIP zusammen
|
||||
-- vermeidet, dass der Browser bei vielen einzelnen Downloads auf
|
||||
einmal blockiert/nachfragt, und ist die auch anderswo (Drive, Nextcloud
|
||||
etc.) übliche Erwartung bei Mehrfachauswahl. Ordner werden rekursiv mit
|
||||
aufgenommen (relativer Pfad innerhalb des Ordners als Archivpfad)."""
|
||||
share = request.form.get("share", "")
|
||||
rel_path = request.form.get("path", "")
|
||||
if not current_user.has_permission("fileshare.view"):
|
||||
return "Keine Berechtigung.", 403
|
||||
|
||||
names = [n for n in request.form.getlist("names") if n]
|
||||
if not names:
|
||||
flash("Keine Elemente ausgewählt.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
|
||||
targets = []
|
||||
skipped = []
|
||||
for name in names:
|
||||
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||||
if not target_abs or not os.path.exists(target_abs):
|
||||
skipped.append(name)
|
||||
continue
|
||||
targets.append((name, target_abs))
|
||||
if not targets:
|
||||
flash("Keines der ausgewählten Elemente konnte gefunden werden.", "danger")
|
||||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||||
if skipped:
|
||||
flash(f"Übersprungen (nicht gefunden): {', '.join(skipped)}.", "danger")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, target_abs in targets:
|
||||
if os.path.isdir(target_abs):
|
||||
for root, _dirs, files in os.walk(target_abs):
|
||||
for fname in files:
|
||||
full = os.path.join(root, fname)
|
||||
arcname = os.path.join(name, os.path.relpath(full, target_abs))
|
||||
zf.write(full, arcname)
|
||||
else:
|
||||
zf.write(target_abs, name)
|
||||
buffer.seek(0)
|
||||
|
||||
if len(targets) == 1:
|
||||
base_name, _ext = os.path.splitext(targets[0][0])
|
||||
zip_name = secure_filename(base_name if not os.path.isdir(targets[0][1]) else targets[0][0]) or "Download"
|
||||
else:
|
||||
zip_name = secure_filename(f"{share}-Auswahl") or "Download"
|
||||
log_action("fileshare.download", share, f"{len(targets)} Element(e) als ZIP: {', '.join(n for n, _ in targets)}")
|
||||
return send_file(buffer, as_attachment=True, download_name=f"{zip_name}.zip", mimetype="application/zip")
|
||||
|
||||
|
||||
@app.route("/fileshare/rename", methods=["POST"])
|
||||
@login_required
|
||||
def fileshare_rename():
|
||||
|
||||
@@ -516,6 +516,11 @@ button { font-family: inherit; }
|
||||
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
|
||||
.pill.action-pill::before { display: none; }
|
||||
.pill.action-pill svg { width: 13px; height: 13px; }
|
||||
/* Auditlog: Hinzufuegen gruen, Loeschen rot, alles andere (Bearbeiten/
|
||||
Aktivieren/Zuweisen/...) orange -- siehe activity_log.html */
|
||||
.pill.action-pill--create { background: var(--success-dim); color: var(--success); }
|
||||
.pill.action-pill--delete { background: var(--danger-dim); color: var(--danger); }
|
||||
.pill.action-pill--edit { background: var(--accent-dim); color: var(--accent-strong); }
|
||||
|
||||
.avatar-sm {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"profile.update": "Profil aktualisiert",
|
||||
"profile.password": "Passwort geändert",
|
||||
"check.run_now": "Prüfung manuell gestartet",
|
||||
"fileshare.upload": "Datei(en) hochgeladen",
|
||||
"fileshare.mkdir": "Ordner angelegt",
|
||||
"fileshare.delete": "Datei/Ordner gelöscht",
|
||||
"fileshare.rename": "Datei/Ordner umbenannt",
|
||||
"fileshare.download": "Als ZIP heruntergeladen",
|
||||
} %}
|
||||
{% set action_icons = {
|
||||
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
||||
@@ -37,6 +42,12 @@
|
||||
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
||||
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
||||
} %}
|
||||
{% set action_kind_class = {
|
||||
"create": "action-pill--create",
|
||||
"upload": "action-pill--create",
|
||||
"mkdir": "action-pill--create",
|
||||
"delete": "action-pill--delete",
|
||||
} %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
@@ -56,7 +67,7 @@
|
||||
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
||||
<td class="cell-name">{{ e['username'] }}</td>
|
||||
<td>
|
||||
<span class="pill action-pill">
|
||||
<span class="pill action-pill {{ action_kind_class.get(kind, 'action-pill--edit') }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
||||
{{ action_labels.get(e['action'], e['action']) }}
|
||||
</span>
|
||||
|
||||
@@ -66,10 +66,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bulkActionsBar" class="flex gap-2 hidden" data-share="{{ selected_share }}" data-path="{{ rel_path }}" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
|
||||
<span id="bulkSelectedCount" class="text-faint" style="font-size:12.5px; font-weight:600;"></span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkDownload()">
|
||||
<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>
|
||||
Herunterladen (ZIP)
|
||||
</button>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);" onclick="bulkDelete()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
Löschen
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if entries %}
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="fileTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th style="width:1%;"><input type="checkbox" id="selectAllFiles"></th>
|
||||
<th data-sort-key="name">Name</th>
|
||||
<th data-sort-key="size">Größe</th>
|
||||
<th data-sort-key="mtime">Geändert</th>
|
||||
@@ -78,6 +93,7 @@
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr data-sort-name="{{ e.name|lower }}" data-sort-size="{{ 0 if e.is_dir else e.size_str }}" data-sort-mtime="{{ e.mtime_str }}">
|
||||
<td><input type="checkbox" class="row-select" value="{{ e.name }}"></td>
|
||||
<td class="cell-name">
|
||||
{% if e.is_dir %}
|
||||
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||
@@ -228,6 +244,8 @@
|
||||
<script>
|
||||
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
|
||||
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
|
||||
const FILESHARE_DOWNLOAD_MULTI_URL = "{{ url_for('fileshare_download_multi') }}";
|
||||
const FILESHARE_DELETE_MULTI_URL = "{{ url_for('fileshare_delete_multi') }}";
|
||||
|
||||
function openRenameModal(name) {
|
||||
document.getElementById("renameOldName").value = name;
|
||||
@@ -243,6 +261,88 @@ function filterTable(inputId, tableId) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Mehrfachauswahl (Herunterladen als ZIP / Löschen) ---------------- */
|
||||
|
||||
function getSelectedFileNames() {
|
||||
return Array.from(document.querySelectorAll("#fileTable .row-select:checked")).map(function (cb) { return cb.value; });
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
const names = getSelectedFileNames();
|
||||
const bar = document.getElementById("bulkActionsBar");
|
||||
const countEl = document.getElementById("bulkSelectedCount");
|
||||
if (!bar) return;
|
||||
if (names.length > 0) {
|
||||
bar.classList.remove("hidden");
|
||||
countEl.textContent = names.length + " ausgewählt";
|
||||
} else {
|
||||
bar.classList.add("hidden");
|
||||
}
|
||||
const selectAll = document.getElementById("selectAllFiles");
|
||||
const allBoxes = document.querySelectorAll("#fileTable .row-select");
|
||||
if (selectAll && allBoxes.length) {
|
||||
selectAll.checked = names.length === allBoxes.length;
|
||||
selectAll.indeterminate = names.length > 0 && names.length < allBoxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function submitBulkForm(actionUrl, names) {
|
||||
// data-share/data-path statt Jinja-Werte direkt in einen JS-String-
|
||||
// Literal zu setzen -- Ordnernamen kommen von der echten Freigabe und
|
||||
// koennten Anfuehrungszeichen o.ae. enthalten, ueber HTML-Attribute
|
||||
// (von Jinja automatisch escaped) ist das unproblematisch.
|
||||
const bar = document.getElementById("bulkActionsBar");
|
||||
const form = document.createElement("form");
|
||||
form.method = "post";
|
||||
form.action = actionUrl;
|
||||
form.style.display = "none";
|
||||
[["share", bar.dataset.share], ["path", bar.dataset.path]].forEach(function (pair) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = pair[0];
|
||||
input.value = pair[1];
|
||||
form.appendChild(input);
|
||||
});
|
||||
names.forEach(function (name) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = "names";
|
||||
input.value = name;
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkDownload() {
|
||||
const names = getSelectedFileNames();
|
||||
if (!names.length) return;
|
||||
submitBulkForm(FILESHARE_DOWNLOAD_MULTI_URL, names);
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
const names = getSelectedFileNames();
|
||||
if (!names.length) return;
|
||||
window.confirmAction(
|
||||
names.length + " ausgewählte Elemente wirklich endgültig löschen?",
|
||||
function () { submitBulkForm(FILESHARE_DELETE_MULTI_URL, names); },
|
||||
"Auswahl löschen?"
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const selectAll = document.getElementById("selectAllFiles");
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", function () {
|
||||
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) { cb.checked = selectAll.checked; });
|
||||
updateBulkBar();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) {
|
||||
cb.addEventListener("change", updateBulkBar);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------------- Mehrfach-Upload (Multiauswahl + mehrmals nacheinander) ---------------- */
|
||||
/* Ein <input type=file multiple> ERSETZT bei jeder erneuten Dateiauswahl
|
||||
die vorherige -- fuer "mehrmals nacheinander hinzufuegen" wird deshalb
|
||||
|
||||
Reference in New Issue
Block a user