Compare commits
12 Commits
0770259d3d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5713d6dbc9 | |||
| 82782c4f92 | |||
| b2d6c547a9 | |||
| fd5e917249 | |||
| d4c1d1f4bf | |||
| 386855d76a | |||
| 2b1417ccf3 | |||
| 20e61aa61c | |||
| d476784c06 | |||
| 182ef04cc5 | |||
| 3f86fca578 | |||
| 4567e93aa2 |
@@ -1,3 +1,12 @@
|
|||||||
# Ports, auf denen die Services vom Host erreichbar sind.
|
# Ports, auf denen die Services vom Host erreichbar sind.
|
||||||
APP_PORT=3500
|
APP_PORT=3500
|
||||||
WEBDAV_PORT=1900
|
WEBDAV_PORT=1900
|
||||||
|
|
||||||
|
# Auf "true" setzen, wenn die App hinter einem Reverse-Proxy (nginx/Traefik/Caddy)
|
||||||
|
# läuft, der X-Forwarded-For/-Proto setzt. Sonst bitte aus lassen, sonst können
|
||||||
|
# Angreifer per gespooften X-Forwarded-For die Rate-Limits umgehen.
|
||||||
|
# TRUST_PROXY=true
|
||||||
|
|
||||||
|
# Erzwingt das "Secure"-Flag auf Session-Cookies (für HTTPS-only Setups).
|
||||||
|
# Wenn TRUST_PROXY=true gesetzt ist, wird das automatisch erkannt.
|
||||||
|
# SECURE_COOKIES=true
|
||||||
|
|||||||
+6
-2
@@ -3,7 +3,7 @@ FROM node:20-bookworm-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 make g++ \
|
python3 make g++ gosu \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
@@ -13,7 +13,7 @@ COPY src ./src
|
|||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
# Prepare mount points with UID 1000 ownership so named volumes inherit it.
|
# Prepare mount points with UID 1000 ownership so named volumes inherit it.
|
||||||
RUN mkdir -p /data/db /data/uploads /webdav-config \
|
RUN mkdir -p /data/db /data/uploads /data/logo /webdav-config \
|
||||||
&& chown -R 1000:1000 /data /webdav-config /app
|
&& chown -R 1000:1000 /data /webdav-config /app
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
@@ -22,6 +22,10 @@ ENV NODE_ENV=production \
|
|||||||
DB_PATH=/data/db/app.db \
|
DB_PATH=/data/db/app.db \
|
||||||
WEBDAV_CONFIG_DIR=/webdav-config
|
WEBDAV_CONFIG_DIR=/webdav-config
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["node", "src/server.js"]
|
CMD ["node", "src/server.js"]
|
||||||
|
|||||||
@@ -41,11 +41,10 @@ docker compose up -d --build
|
|||||||
- `./data/uploads/` → ein Unterordner pro Kunde (Slug)
|
- `./data/uploads/` → ein Unterordner pro Kunde (Slug)
|
||||||
- Named Volume `webdav-config` → dynamisch generierte Apache-Config
|
- Named Volume `webdav-config` → dynamisch generierte Apache-Config
|
||||||
|
|
||||||
Beide Container laufen als UID `1000:1000`. Falls vorhandene Daten root gehören:
|
Beide Container laufen als UID `1000:1000`. Der App-Container startet kurz
|
||||||
|
als root, korrigiert die Eigentümer der `./data/`-Mountpunkte auf `1000:1000`
|
||||||
```bash
|
und droppt dann via `gosu` die Privilegien — fresh deploys laufen also ohne
|
||||||
sudo chown -R 1000:1000 data/
|
manuellen `chown` durch.
|
||||||
```
|
|
||||||
|
|
||||||
## Wie die WebDAV-ACLs funktionieren
|
## Wie die WebDAV-ACLs funktionieren
|
||||||
|
|
||||||
@@ -66,8 +65,59 @@ Der WebDAV-Container (Debian Apache) beobachtet das Verzeichnis via `inotifywait
|
|||||||
|
|
||||||
## WebDAV-Zugriff
|
## WebDAV-Zugriff
|
||||||
|
|
||||||
- macOS Finder: `Gehe zu → Mit Server verbinden → http://HOST:1900/`
|
- **macOS Finder**: `Gehe zu → Mit Server verbinden → http://HOST:1900/`
|
||||||
- Windows: Netzlaufwerk hinzufügen → `http://HOST:1900/`
|
- **Linux / KDE Dolphin**: `webdav://<user>@HOST:1900/`
|
||||||
- Linux / KDE Dolphin: `webdav://<user>@HOST:1900/`
|
- **Windows**: siehe Abschnitt unten (einmalige Einrichtung nötig)
|
||||||
- Write-Rechte umfassen: `PUT`, `DELETE`, `MKCOL`, `MOVE`, `COPY`, `PROPPATCH`, `LOCK`, `UNLOCK`.
|
|
||||||
- In Dolphin löscht **Shift+Entf** direkt (umgeht den nicht existierenden WebDAV-Papierkorb).
|
Write-Rechte umfassen: `PUT`, `DELETE`, `MKCOL`, `MOVE`, `COPY`, `PROPPATCH`, `LOCK`, `UNLOCK`.
|
||||||
|
In Dolphin löscht **Shift+Entf** direkt (umgeht den nicht existierenden WebDAV-Papierkorb).
|
||||||
|
|
||||||
|
### Windows als Netzlaufwerk verbinden
|
||||||
|
|
||||||
|
Windows verweigert standardmäßig WebDAV via Basic Auth über HTTP und limitiert
|
||||||
|
Dateien auf 50 MB. Beides per Registry erlauben:
|
||||||
|
|
||||||
|
1. [windows/enable-webdav-basicauth.reg](windows/enable-webdav-basicauth.reg)
|
||||||
|
per Rechtsklick → **Zusammenführen** (als Administrator) ausführen.
|
||||||
|
2. WebClient-Dienst (neu) starten:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
sc config WebClient start=auto
|
||||||
|
net stop WebClient
|
||||||
|
net start WebClient
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verbinden — **HTTP-URL ist die saubere Variante** (eindeutig WebDAV, keine
|
||||||
|
SMB-Verwechslung). Beispiel mit `karlheinz`-Credentials, gemountet als `Z:`:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
net use Z: http://HOST:1900/frank-meier /user:karlheinz DEINPW /persistent:yes
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Explorer **„Netzlaufwerk verbinden"** → Adresse:
|
||||||
|
`http://HOST:1900/` bzw. `http://HOST:1900/frank-meier/`.
|
||||||
|
|
||||||
|
Alternativ mit UNC-Syntax (intern dasselbe, etwas hakeliger):
|
||||||
|
`\\HOST@1900\DavWWWRoot\frank-meier\` — `@1900` und `\DavWWWRoot\` sind
|
||||||
|
dabei Pflicht, sonst landest du bei SMB Port 445.
|
||||||
|
|
||||||
|
Wenn weiterhin „Ordner nicht gefunden" → meist ist der WebClient-Dienst nicht
|
||||||
|
gestartet oder das Registry-Merge wurde nicht als Administrator ausgeführt.
|
||||||
|
|
||||||
|
#### Bekannte Windows-Eigenheiten bei Read-Only-Zugriff
|
||||||
|
|
||||||
|
Hat ein Sachbearbeiter nur **Leserechte**, zeigt der Windows-Explorer
|
||||||
|
gelegentlich seltsames Verhalten:
|
||||||
|
|
||||||
|
- **Entf-Taste „löscht" die Datei optisch** — der Server lehnt das `DELETE`
|
||||||
|
korrekt mit `403` ab, aber der Explorer entfernt das Icon optimistisch.
|
||||||
|
`F5` stellt die echte Sicht wieder her.
|
||||||
|
- **Beim Doppelklick: „Datei existiert nicht, neu erstellen?"** — Office &
|
||||||
|
Editoren senden vor dem Öffnen ein `LOCK`. Bei Read-Only schlägt das mit
|
||||||
|
`403` fehl, das Programm denkt die Datei sei weg. Nach 30–60 Sek. (WebClient
|
||||||
|
Negative-Cache läuft aus) klappt es dann meist direkt.
|
||||||
|
|
||||||
|
Wenn dich das stört: [windows/disable-webdav-locking.reg](windows/disable-webdav-locking.reg)
|
||||||
|
ausführen — schaltet WebDAV-Locking systemweit ab. Read-Only-Viewing läuft
|
||||||
|
dann sofort und ohne Wartezeit. Trade-off: gemeinsames Bearbeiten ohne
|
||||||
|
Konflikte kann nicht mehr über WebDAV-Locks koordiniert werden.
|
||||||
|
|||||||
+3
-1
@@ -3,7 +3,8 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: simple-file-upload
|
container_name: simple-file-upload
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "1000:1000"
|
# No `user:` here — entrypoint.sh starts as root, chowns the bind mounts
|
||||||
|
# to UID 1000, then drops privileges via gosu before running node.
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-3500}:3000" # Web (Upload + Admin)
|
- "${APP_PORT:-3500}:3000" # Web (Upload + Admin)
|
||||||
environment:
|
environment:
|
||||||
@@ -14,6 +15,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data/db:/data/db # SQLite DB liegt hier neben docker-compose.yml
|
- ./data/db:/data/db # SQLite DB liegt hier neben docker-compose.yml
|
||||||
- ./data/uploads:/data/uploads # Kunden-Uploads
|
- ./data/uploads:/data/uploads # Kunden-Uploads
|
||||||
|
- ./data/logo:/data/logo # Admin-Logo
|
||||||
- webdav-config:/webdav-config # dynamisch generierte htpasswd + access.conf
|
- webdav-config:/webdav-config # dynamisch generierte htpasswd + access.conf
|
||||||
|
|
||||||
webdav:
|
webdav:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Wenn die Bind-Mounts (data/db, data/uploads, data/logo) auf dem Host noch
|
||||||
|
# nicht existieren, legt Docker sie als root an — der App-Prozess (UID 1000)
|
||||||
|
# könnte dann nicht reinschreiben ("readonly database"). Das fangen wir hier ab.
|
||||||
|
mkdir -p /data/db /data/uploads /data/logo /webdav-config
|
||||||
|
chown -R 1000:1000 /data /webdav-config 2>/dev/null || true
|
||||||
|
|
||||||
|
# Privilegien fallen lassen und das eigentliche Kommando als UID 1000 ausführen.
|
||||||
|
exec gosu 1000:1000 "$@"
|
||||||
+6
-2
@@ -11,7 +11,11 @@
|
|||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"archiver": "^7.0.1",
|
||||||
"nanoid": "^3.3.7"
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"multer": "^2.0.0",
|
||||||
|
"nanoid": "^3.3.7",
|
||||||
|
"nodemailer": "^6.9.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+820
-256
File diff suppressed because it is too large
Load Diff
+439
-76
@@ -5,53 +5,200 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Datei-Upload</title>
|
<title>Datei-Upload</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light dark; }
|
:root {
|
||||||
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
|
--bg: #0b0f1a;
|
||||||
h1 { margin-bottom: .25rem; }
|
--bg-raise: #111827;
|
||||||
.muted { color: #888; font-size: .9rem; }
|
--bg-card: #151d2e;
|
||||||
|
--border: #1f2937;
|
||||||
|
--border-strong: #374151;
|
||||||
|
--text: #e5e7eb;
|
||||||
|
--text-muted: #9ca3af;
|
||||||
|
--text-dim: #6b7280;
|
||||||
|
--primary: #6366f1;
|
||||||
|
--primary-hover: #818cf8;
|
||||||
|
--primary-fg: #ffffff;
|
||||||
|
--success: #10b981;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--shadow: 0 8px 32px rgba(0,0,0,.35);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f8fafc; --bg-raise: #fff; --bg-card: #fff;
|
||||||
|
--border: #e2e8f0; --border-strong: #cbd5e1;
|
||||||
|
--text: #0f172a; --text-muted: #475569; --text-dim: #94a3b8;
|
||||||
|
--shadow: 0 4px 24px rgba(15,23,42,.08);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: radial-gradient(ellipse at top, color-mix(in srgb, var(--primary) 8%, var(--bg)), var(--bg));
|
||||||
|
color: var(--text); line-height: 1.5; min-height: 100vh;
|
||||||
|
display: flex; flex-direction: column; align-items: center; padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
header { width: 100%; max-width: 720px; margin-bottom: 2rem; text-align: left; }
|
||||||
|
header img { max-height: 56px; display: block; }
|
||||||
|
.card {
|
||||||
|
width: 100%; max-width: 720px; background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
padding: 2rem; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
h1 { margin: 0 0 .25rem; font-size: 1.6rem; }
|
||||||
|
.subtitle { color: var(--text-muted); margin: 0 0 1.5rem; font-size: .95rem; }
|
||||||
|
|
||||||
.drop {
|
.drop {
|
||||||
margin-top: 1.5rem; border: 2px dashed #888; border-radius: 12px;
|
border: 2px dashed var(--border-strong); border-radius: var(--radius);
|
||||||
padding: 3rem 1rem; text-align: center; transition: background .15s, border-color .15s;
|
padding: 3rem 1rem; text-align: center;
|
||||||
|
transition: background .15s, border-color .15s, transform .15s;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.drop.drag { background: rgba(0,120,255,.08); border-color: #0078ff; }
|
.drop:hover { border-color: color-mix(in srgb, var(--primary) 70%, var(--border-strong)); }
|
||||||
.buttons { margin-top: 1rem; display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
|
.drop.drag {
|
||||||
button, label.btn {
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
padding: .6rem 1rem; border-radius: 8px; border: 1px solid #888;
|
border-color: var(--primary); transform: scale(1.01);
|
||||||
cursor: pointer; background: transparent; font: inherit;
|
|
||||||
}
|
}
|
||||||
label.btn input { display: none; }
|
.drop .icon {
|
||||||
#list { margin-top: 1.5rem; }
|
width: 56px; height: 56px; margin: 0 auto 1rem;
|
||||||
.file { display: flex; justify-content: space-between; gap: 1rem; padding: .4rem 0; border-bottom: 1px solid #333; font-size: .9rem; }
|
border-radius: 50%; background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
.file .status { font-variant-numeric: tabular-nums; }
|
display: grid; place-items: center; color: var(--primary);
|
||||||
.ok { color: #2ecc71; }
|
}
|
||||||
.err { color: #e74c3c; }
|
.drop .icon svg { width: 28px; height: 28px; }
|
||||||
.gate { margin-top: 1rem; display: none; }
|
.drop .title { font-size: 1.05rem; font-weight: 500; margin-bottom: .25rem; }
|
||||||
.gate input { padding: .5rem; border-radius: 6px; border: 1px solid #888; background: transparent; color: inherit; }
|
.drop .hint { color: var(--text-muted); font-size: .9rem; margin-bottom: 1rem; }
|
||||||
progress { width: 100%; height: 8px; }
|
.drop .buttons { display: flex; gap: .5rem; justify-content: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: .4rem;
|
||||||
|
padding: .55rem 1rem; border: 1px solid var(--border-strong);
|
||||||
|
background: transparent; color: var(--text);
|
||||||
|
border-radius: var(--radius-sm); font: inherit; cursor: pointer;
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--bg); border-color: var(--primary); }
|
||||||
|
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
|
||||||
|
.btn.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||||
|
.btn input { display: none; }
|
||||||
|
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%; padding: .6rem .8rem; font: inherit;
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
input:focus { outline: none; border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent); }
|
||||||
|
|
||||||
|
#list { margin-top: 1.5rem; display: grid; gap: .5rem; }
|
||||||
|
.file-row {
|
||||||
|
display: grid; grid-template-columns: auto 1fr auto;
|
||||||
|
gap: .75rem; align-items: center;
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
padding: .65rem .85rem; border-radius: var(--radius-sm);
|
||||||
|
font-size: .88rem;
|
||||||
|
}
|
||||||
|
.file-row .name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.file-row .meta { color: var(--text-muted); font-size: .8rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.file-row .status { width: 4.5em; text-align: right; font-variant-numeric: tabular-nums; font-size: .85rem; }
|
||||||
|
.file-row progress { grid-column: 1 / -1; width: 100%; height: 4px; border: 0;
|
||||||
|
border-radius: 2px; background: var(--border); overflow: hidden; }
|
||||||
|
.file-row progress::-webkit-progress-bar { background: var(--border); }
|
||||||
|
.file-row progress::-webkit-progress-value { background: var(--primary); transition: width .2s; }
|
||||||
|
.file-row progress::-moz-progress-bar { background: var(--primary); }
|
||||||
|
.file-row.done .status { color: var(--success); }
|
||||||
|
.file-row.err .status { color: var(--danger); }
|
||||||
|
.file-row.err progress::-webkit-progress-value { background: var(--danger); }
|
||||||
|
|
||||||
|
.gate { display: none; padding: 1rem; background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm); }
|
||||||
|
.gate p { margin: 0 0 .75rem; color: var(--text-muted); font-size: .9rem; }
|
||||||
|
.gate .row { display: flex; gap: .5rem; }
|
||||||
|
.err-line { color: var(--danger); font-size: .85rem; margin-top: .5rem; }
|
||||||
|
|
||||||
|
.expires { display: inline-flex; align-items: center; gap: .35rem;
|
||||||
|
padding: .15rem .55rem; border-radius: 999px; font-size: .75rem;
|
||||||
|
color: var(--text-muted); border: 1px solid var(--border-strong); }
|
||||||
|
|
||||||
|
.browser { margin-top: 2rem; }
|
||||||
|
.browser h2 { font-size: 1rem; margin: 0 0 .5rem; display: flex; align-items: center; gap: .5rem; }
|
||||||
|
.browser .count { color: var(--text-muted); font-size: .85rem; font-weight: normal; }
|
||||||
|
.browser .empty { color: var(--text-muted); font-size: .9rem; padding: 1rem; text-align: center;
|
||||||
|
border: 1px dashed var(--border); border-radius: var(--radius-sm); }
|
||||||
|
.browser .row {
|
||||||
|
display: grid; grid-template-columns: auto 1fr auto auto; gap: .75rem;
|
||||||
|
align-items: center; padding: .55rem .75rem; border: 1px solid var(--border);
|
||||||
|
background: var(--bg); border-radius: var(--radius-sm); margin-bottom: .35rem;
|
||||||
|
font-size: .88rem;
|
||||||
|
}
|
||||||
|
.browser .row .icon { font-size: 1rem; opacity: .7; }
|
||||||
|
.browser .row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.browser .row .meta { color: var(--text-muted); font-size: .8rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.browser .row .dl { padding: .3rem .65rem; font-size: .8rem; }
|
||||||
|
.browser .row.dir .name { cursor: pointer; color: var(--primary); }
|
||||||
|
.browser .row.dir .name:hover { text-decoration: underline; }
|
||||||
|
.browser .crumbs {
|
||||||
|
display: flex; flex-wrap: wrap; gap: .25rem; align-items: center;
|
||||||
|
margin-bottom: .5rem; font-size: .9rem;
|
||||||
|
}
|
||||||
|
.browser .crumbs button {
|
||||||
|
background: transparent; border: none; color: var(--primary);
|
||||||
|
font: inherit; cursor: pointer; padding: .15rem .35rem; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.browser .crumbs button:hover { background: color-mix(in srgb, var(--primary) 12%, transparent); }
|
||||||
|
.browser .crumbs span { color: var(--text-dim); }
|
||||||
|
.browser .toolbar { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; }
|
||||||
|
|
||||||
|
footer { margin-top: 2rem; color: var(--text-dim); font-size: .8rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<header><img id="logo" alt="" style="display:none" /></header>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
<h1 id="title">Datei-Upload</h1>
|
<h1 id="title">Datei-Upload</h1>
|
||||||
<div class="muted" id="info"></div>
|
<p class="subtitle" id="info"></p>
|
||||||
|
|
||||||
<div class="gate" id="gate">
|
<div class="gate" id="gate">
|
||||||
<p>Dieser Link ist passwortgeschützt.</p>
|
<p>Dieser Link ist passwortgeschützt. Bitte gib das Passwort ein:</p>
|
||||||
<input type="password" id="pw" placeholder="Passwort" />
|
<div class="row">
|
||||||
<button id="pwBtn">Entsperren</button>
|
<input type="password" id="pw" placeholder="Passwort" autocomplete="off" />
|
||||||
<div id="pwErr" class="err" style="display:none;margin-top:.5rem">Passwort falsch.</div>
|
<button class="btn primary" id="pwBtn">Entsperren</button>
|
||||||
|
</div>
|
||||||
|
<div class="err-line" id="pwErr" style="display:none">Passwort falsch.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="main" style="display:none">
|
<div id="main" style="display:none">
|
||||||
<div class="drop" id="drop">
|
<div class="drop" id="drop">
|
||||||
<div>Dateien oder Ordner hier hineinziehen</div>
|
<div class="icon">
|
||||||
<div class="muted">oder</div>
|
<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 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="title">Dateien oder Ordner hier hineinziehen</div>
|
||||||
|
<div class="hint">oder auswählen</div>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<label class="btn">Dateien wählen<input type="file" id="fileInput" multiple /></label>
|
<label class="btn"><input type="file" id="fileInput" multiple />📄 Dateien</label>
|
||||||
<label class="btn">Ordner wählen<input type="file" id="dirInput" webkitdirectory multiple /></label>
|
<label class="btn"><input type="file" id="dirInput" webkitdirectory multiple />📁 Ordner</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="list"></div>
|
<div id="list"></div>
|
||||||
|
|
||||||
|
<div class="browser">
|
||||||
|
<h2>Bisher hochgeladene und/oder empfangene Dateien <span class="count" id="fileCount"></span></h2>
|
||||||
|
<div class="crumbs" id="crumbs"></div>
|
||||||
|
<div class="toolbar"><button type="button" class="btn dl" id="zipCurrent">⬇ Aktuellen Ordner als ZIP</button></div>
|
||||||
|
<div id="fileBrowser"><div class="empty">– wird geladen –</div></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer id="footer"></footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const token = location.pathname.split('/').filter(Boolean)[1];
|
const token = location.pathname.split('/').filter(Boolean)[1];
|
||||||
@@ -63,18 +210,242 @@ const main = document.getElementById('main');
|
|||||||
const drop = document.getElementById('drop');
|
const drop = document.getElementById('drop');
|
||||||
const list = document.getElementById('list');
|
const list = document.getElementById('list');
|
||||||
|
|
||||||
async function init() {
|
async function applyBranding() {
|
||||||
const r = await fetch(`/u/${token}/info`);
|
try {
|
||||||
if (!r.ok) { document.body.innerHTML = '<h1>Link ungültig oder abgelaufen.</h1>'; return; }
|
const r = await fetch('/admin/api/branding');
|
||||||
const data = await r.json();
|
const b = await r.json();
|
||||||
document.getElementById('title').textContent = `Upload für ${data.name}`;
|
if (b.logo_filename) {
|
||||||
if (data.expires_at) {
|
const logo = document.getElementById('logo');
|
||||||
info.textContent = `Gültig bis: ${new Date(data.expires_at).toLocaleString()}`;
|
logo.src = '/logo?t=' + Date.now();
|
||||||
}
|
if (b.logo_width_px > 0) logo.style.width = b.logo_width_px + 'px';
|
||||||
if (data.has_password) gate.style.display = 'block';
|
if (b.logo_height_px > 0) logo.style.height = b.logo_height_px + 'px';
|
||||||
else main.style.display = 'block';
|
if (!b.logo_width_px && !b.logo_height_px) logo.style.maxHeight = '56px';
|
||||||
|
logo.style.display = '';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyInfo(data) {
|
||||||
|
window._uploadInfo = data;
|
||||||
|
document.getElementById('title').textContent = `Upload für ${data.name}`;
|
||||||
|
if (data.expired) {
|
||||||
|
info.innerHTML = `<span class="expires" style="color:#f59e0b; border-color:#f59e0b">⚠ Link ist abgelaufen — Uploads sind weiterhin möglich, der Datei-Browser ist deaktiviert.</span>`;
|
||||||
|
} else if (data.expires_at) {
|
||||||
|
info.innerHTML = `<span class="expires">⏳ gültig bis ${new Date(data.expires_at).toLocaleString()}</span>`;
|
||||||
|
} else {
|
||||||
|
info.textContent = 'Lade Dateien oder ganze Ordner hoch — die Ordnerstruktur bleibt erhalten.';
|
||||||
|
}
|
||||||
|
// Browser visibility synced to expiry
|
||||||
|
const browser = document.querySelector('.browser');
|
||||||
|
if (browser) browser.style.display = data.expired ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLinkGone(reason) {
|
||||||
|
stopPolling();
|
||||||
|
document.querySelector('.card').innerHTML =
|
||||||
|
`<h1>Link nicht mehr verfügbar</h1><p class="subtitle">${reason}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await applyBranding();
|
||||||
|
const r = await fetch(`/u/${token}/info`);
|
||||||
|
if (!r.ok) {
|
||||||
|
showLinkGone('Bitte wende dich an deinen Ansprechpartner für einen neuen Link.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
applyInfo(data);
|
||||||
|
if (data.has_password) gate.style.display = 'block';
|
||||||
|
else { main.style.display = 'block'; loadFiles(); }
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto-refresh ---
|
||||||
|
const POLL_INTERVAL_MS = 20000;
|
||||||
|
let pollTimer = null;
|
||||||
|
let pollBusy = false;
|
||||||
|
let lastInfoSig = '';
|
||||||
|
let lastFilesSig = '';
|
||||||
|
|
||||||
|
async function pollState() {
|
||||||
|
if (pollBusy) return;
|
||||||
|
pollBusy = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/u/${token}/info`);
|
||||||
|
if (r.status === 404) {
|
||||||
|
showLinkGone('Der Upload-Link wurde deaktiviert oder gelöscht.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
const sig = JSON.stringify(data);
|
||||||
|
if (sig !== lastInfoSig) {
|
||||||
|
lastInfoSig = sig;
|
||||||
|
applyInfo(data);
|
||||||
|
// Newly expired or unexpired → reload list
|
||||||
|
if (!data.expired && main.style.display !== 'none') loadFiles();
|
||||||
|
}
|
||||||
|
if (!data.expired && main.style.display !== 'none') await refreshFilesIfChanged();
|
||||||
|
} catch {}
|
||||||
|
finally { pollBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshFilesIfChanged() {
|
||||||
|
try {
|
||||||
|
const url = `/u/${token}/files${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`;
|
||||||
|
const r = await fetch(url, { headers: authHeaders() });
|
||||||
|
if (r.status === 410) { applyInfo({ ...(window._uploadInfo || {}), expired: true }); return; }
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
const sig = JSON.stringify(data);
|
||||||
|
if (sig === lastFilesSig) return;
|
||||||
|
lastFilesSig = sig;
|
||||||
|
renderFiles(data);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollTimer) return;
|
||||||
|
pollTimer = setInterval(pollState, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) stopPolling();
|
||||||
|
else if (window._uploadInfo) startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
return password ? { 'X-Upload-Password': password } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDir = '';
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCrumbs() {
|
||||||
|
const cr = document.getElementById('crumbs');
|
||||||
|
const segs = currentDir ? currentDir.split('/') : [];
|
||||||
|
let acc = '';
|
||||||
|
let html = `<button data-go="">🏠 Hauptordner</button>`;
|
||||||
|
for (const s of segs) {
|
||||||
|
acc = acc ? acc + '/' + s : s;
|
||||||
|
html += `<span>›</span><button data-go="${esc(acc)}">${esc(s)}</button>`;
|
||||||
|
}
|
||||||
|
cr.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(data) {
|
||||||
|
const browser = document.getElementById('fileBrowser');
|
||||||
|
const count = document.getElementById('fileCount');
|
||||||
|
const entries = (data && data.entries) || [];
|
||||||
|
count.textContent = entries.length ? `(${entries.length})` : '';
|
||||||
|
if (!entries.length) {
|
||||||
|
browser.innerHTML = '<div class="empty">Dieser Ordner ist leer.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
browser.innerHTML = entries.map(e => {
|
||||||
|
const name = esc(e.name);
|
||||||
|
const date = new Date(e.mtime).toLocaleString();
|
||||||
|
const fullPath = currentDir ? currentDir + '/' + e.name : e.name;
|
||||||
|
const escPath = esc(fullPath);
|
||||||
|
if (e.type === 'dir') {
|
||||||
|
return `
|
||||||
|
<div class="row dir">
|
||||||
|
<div class="icon">📁</div>
|
||||||
|
<div>
|
||||||
|
<div class="name" title="${name}" data-open="${escPath}">${name}/</div>
|
||||||
|
<div class="meta">${date}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">Ordner</div>
|
||||||
|
<button type="button" class="btn dl" data-zip="${escPath}">⬇ ZIP</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="row">
|
||||||
|
<div class="icon">📄</div>
|
||||||
|
<div>
|
||||||
|
<div class="name" title="${name}">${name}</div>
|
||||||
|
<div class="meta">${date}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta">${fmtSize(e.size)}</div>
|
||||||
|
<button type="button" class="btn dl" data-path="${escPath}">⬇ Download</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const browser = document.getElementById('fileBrowser');
|
||||||
|
if (window._uploadInfo && window._uploadInfo.expired) {
|
||||||
|
document.querySelector('.browser').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderCrumbs();
|
||||||
|
try {
|
||||||
|
const url = `/u/${token}/files${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`;
|
||||||
|
const r = await fetch(url, { headers: authHeaders() });
|
||||||
|
if (r.status === 410) { document.querySelector('.browser').style.display = 'none'; return; }
|
||||||
|
if (!r.ok) { browser.innerHTML = '<div class="empty">Konnte Dateien nicht laden.</div>'; return; }
|
||||||
|
const data = await r.json();
|
||||||
|
lastFilesSig = JSON.stringify(data);
|
||||||
|
renderFiles(data);
|
||||||
|
} catch {
|
||||||
|
browser.innerHTML = '<div class="empty">Fehler beim Laden.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamDownload(url, suggestedName, btn) {
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.disabled = true; btn.textContent = '… lädt';
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { headers: authHeaders() });
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
const blob = await r.blob();
|
||||||
|
const objUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = objUrl; a.download = suggestedName;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objUrl), 1000);
|
||||||
|
} catch (ex) {
|
||||||
|
alert('Download fehlgeschlagen: ' + ex.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = orig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('crumbs').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button[data-go]');
|
||||||
|
if (!btn) return;
|
||||||
|
currentDir = btn.dataset.go;
|
||||||
|
loadFiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('zipCurrent').addEventListener('click', (e) => {
|
||||||
|
const url = `/u/${token}/zip${currentDir ? '?dir=' + encodeURIComponent(currentDir) : ''}`;
|
||||||
|
const name = (currentDir ? currentDir.split('/').pop() : 'alle-dateien') + '.zip';
|
||||||
|
streamDownload(url, name, e.currentTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('fileBrowser').addEventListener('click', async (e) => {
|
||||||
|
const open = e.target.closest('[data-open]');
|
||||||
|
if (open) { currentDir = open.dataset.open; loadFiles(); return; }
|
||||||
|
const fileBtn = e.target.closest('button[data-path]');
|
||||||
|
if (fileBtn) {
|
||||||
|
const p = fileBtn.dataset.path;
|
||||||
|
streamDownload(`/u/${token}/file?path=${encodeURIComponent(p)}`, p.split('/').pop(), fileBtn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const zipBtn = e.target.closest('button[data-zip]');
|
||||||
|
if (zipBtn) {
|
||||||
|
const p = zipBtn.dataset.zip;
|
||||||
|
streamDownload(`/u/${token}/zip?dir=${encodeURIComponent(p)}`, p.split('/').pop() + '.zip', zipBtn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('pwBtn').onclick = async () => {
|
document.getElementById('pwBtn').onclick = async () => {
|
||||||
const pw = document.getElementById('pw').value;
|
const pw = document.getElementById('pw').value;
|
||||||
const r = await fetch(`/u/${token}/auth`, {
|
const r = await fetch(`/u/${token}/auth`, {
|
||||||
@@ -82,7 +453,7 @@ document.getElementById('pwBtn').onclick = async () => {
|
|||||||
body: JSON.stringify({ password: pw }),
|
body: JSON.stringify({ password: pw }),
|
||||||
});
|
});
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.ok) { password = pw; gate.style.display='none'; main.style.display='block'; }
|
if (j.ok) { password = pw; gate.style.display='none'; main.style.display='block'; loadFiles(); startPolling(); }
|
||||||
else document.getElementById('pwErr').style.display='block';
|
else document.getElementById('pwErr').style.display='block';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,18 +466,24 @@ function fmtSize(n) {
|
|||||||
|
|
||||||
function addRow(name, size) {
|
function addRow(name, size) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'file';
|
row.className = 'file-row';
|
||||||
row.innerHTML = `<div>${name}</div><div class="status">${fmtSize(size)} – <span>wartet</span><progress max="100" value="0"></progress></div>`;
|
row.innerHTML = `
|
||||||
|
<div style="font-size:1.1rem">📄</div>
|
||||||
|
<div>
|
||||||
|
<div class="name">${name.replace(/</g,'<')}</div>
|
||||||
|
<div class="meta">${fmtSize(size)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="status">0 %</div>
|
||||||
|
<progress max="100" value="0"></progress>`;
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadOne(file, relPath) {
|
async function uploadOne(file, relPath) {
|
||||||
const row = addRow(relPath, file.size);
|
const row = addRow(relPath, file.size);
|
||||||
const status = row.querySelector('.status span');
|
const status = row.querySelector('.status');
|
||||||
const bar = row.querySelector('progress');
|
const bar = row.querySelector('progress');
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
// path first, so multer has it available when processing the file
|
|
||||||
fd.append('path', relPath);
|
fd.append('path', relPath);
|
||||||
fd.append('file', file, file.name);
|
fd.append('file', file, file.name);
|
||||||
|
|
||||||
@@ -115,40 +492,36 @@ async function uploadOne(file, relPath) {
|
|||||||
xhr.open('POST', `/u/${token}/upload`);
|
xhr.open('POST', `/u/${token}/upload`);
|
||||||
if (password) xhr.setRequestHeader('X-Upload-Password', password);
|
if (password) xhr.setRequestHeader('X-Upload-Password', password);
|
||||||
xhr.upload.onprogress = (e) => {
|
xhr.upload.onprogress = (e) => {
|
||||||
if (e.lengthComputable) bar.value = (e.loaded / e.total) * 100;
|
if (e.lengthComputable) {
|
||||||
|
const pct = (e.loaded / e.total) * 100;
|
||||||
|
bar.value = pct; status.textContent = Math.round(pct) + ' %';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
status.textContent = 'fertig';
|
status.textContent = '✓'; bar.value = 100; row.classList.add('done');
|
||||||
status.className = 'ok';
|
} else { status.textContent = '✗'; row.classList.add('err'); }
|
||||||
bar.value = 100;
|
|
||||||
} else {
|
|
||||||
status.textContent = 'Fehler';
|
|
||||||
status.className = 'err';
|
|
||||||
}
|
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
xhr.onerror = () => { status.textContent='Fehler'; status.className='err'; resolve(); };
|
xhr.onerror = () => { status.textContent = '✗'; row.classList.add('err'); resolve(); };
|
||||||
xhr.send(fd);
|
xhr.send(fd);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFiles(items) {
|
async function uploadFiles(items) {
|
||||||
for (const { file, path } of items) {
|
for (const { file, path } of items) await uploadOne(file, path);
|
||||||
await uploadOne(file, path);
|
loadFiles();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('fileInput').onchange = (e) => {
|
document.getElementById('fileInput').onchange = (e) => {
|
||||||
const items = [...e.target.files].map(f => ({ file: f, path: f.name }));
|
uploadFiles([...e.target.files].map(f => ({ file: f, path: f.name })));
|
||||||
uploadFiles(items);
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
document.getElementById('dirInput').onchange = (e) => {
|
document.getElementById('dirInput').onchange = (e) => {
|
||||||
const items = [...e.target.files].map(f => ({ file: f, path: f.webkitRelativePath || f.name }));
|
uploadFiles([...e.target.files].map(f => ({ file: f, path: f.webkitRelativePath || f.name })));
|
||||||
uploadFiles(items);
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drag & drop with directory support
|
|
||||||
async function traverse(entry, prefix='') {
|
async function traverse(entry, prefix='') {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (entry.isFile) {
|
if (entry.isFile) {
|
||||||
@@ -157,10 +530,7 @@ async function traverse(entry, prefix='') {
|
|||||||
} else if (entry.isDirectory) {
|
} else if (entry.isDirectory) {
|
||||||
const reader = entry.createReader();
|
const reader = entry.createReader();
|
||||||
const entries = await new Promise(r => reader.readEntries(r));
|
const entries = await new Promise(r => reader.readEntries(r));
|
||||||
for (const e of entries) {
|
for (const e of entries) out.push(...await traverse(e, prefix + entry.name + '/'));
|
||||||
const sub = await traverse(e, prefix + entry.name + '/');
|
|
||||||
out.push(...sub);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -168,19 +538,12 @@ async function traverse(entry, prefix='') {
|
|||||||
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('drag'); });
|
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('drag'); });
|
||||||
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
|
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
|
||||||
drop.addEventListener('drop', async (e) => {
|
drop.addEventListener('drop', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault(); drop.classList.remove('drag');
|
||||||
drop.classList.remove('drag');
|
|
||||||
const items = [...e.dataTransfer.items];
|
|
||||||
const all = [];
|
const all = [];
|
||||||
for (const it of items) {
|
for (const it of e.dataTransfer.items) {
|
||||||
const entry = it.webkitGetAsEntry && it.webkitGetAsEntry();
|
const entry = it.webkitGetAsEntry && it.webkitGetAsEntry();
|
||||||
if (entry) {
|
if (entry) all.push(...await traverse(entry));
|
||||||
const sub = await traverse(entry);
|
else if (it.kind === 'file') { const f = it.getAsFile(); all.push({ file: f, path: f.name }); }
|
||||||
all.push(...sub);
|
|
||||||
} else if (it.kind === 'file') {
|
|
||||||
const f = it.getAsFile();
|
|
||||||
all.push({ file: f, path: f.name });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
uploadFiles(all);
|
uploadFiles(all);
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-4
@@ -5,6 +5,11 @@ const db = require('./db');
|
|||||||
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
|
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
|
||||||
const COOKIE_NAME = 'sfu_session';
|
const COOKIE_NAME = 'sfu_session';
|
||||||
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/i;
|
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/i;
|
||||||
|
const MIN_PASSWORD = 8;
|
||||||
|
// Pre-computed bcrypt hash of a random password — used to keep login response
|
||||||
|
// time roughly constant when the username does not exist (prevents timing
|
||||||
|
// attacks that enumerate usernames).
|
||||||
|
const DUMMY_HASH = bcrypt.hashSync('not-a-real-password-' + nanoid(16), 10);
|
||||||
|
|
||||||
function parseCookies(req) {
|
function parseCookies(req) {
|
||||||
const raw = req.headers.cookie || '';
|
const raw = req.headers.cookie || '';
|
||||||
@@ -30,7 +35,7 @@ function validateUsername(u) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validatePassword(p) {
|
function validatePassword(p) {
|
||||||
return typeof p === 'string' && p.length >= 6;
|
return typeof p === 'string' && p.length >= MIN_PASSWORD;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUser(username, password, role = 'staff') {
|
async function createUser(username, password, role = 'staff') {
|
||||||
@@ -52,9 +57,10 @@ async function setUserPassword(id, password) {
|
|||||||
|
|
||||||
async function verifyCredentials(username, password) {
|
async function verifyCredentials(username, password) {
|
||||||
const u = db.prepare('SELECT * FROM users WHERE username = ?').get((username || '').toLowerCase());
|
const u = db.prepare('SELECT * FROM users WHERE username = ?').get((username || '').toLowerCase());
|
||||||
if (!u) return null;
|
// Always run bcrypt so response time does not reveal whether the user exists.
|
||||||
const ok = await bcrypt.compare(password || '', u.password_hash);
|
const hash = u ? u.password_hash : DUMMY_HASH;
|
||||||
return ok ? u : null;
|
const ok = await bcrypt.compare(password || '', hash);
|
||||||
|
return (u && ok) ? u : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(user_id) {
|
function createSession(user_id) {
|
||||||
@@ -82,9 +88,14 @@ function deleteSession(token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setSessionCookie(res, token) {
|
function setSessionCookie(res, token) {
|
||||||
|
// Mark cookie secure when the request actually came over HTTPS (incl.
|
||||||
|
// X-Forwarded-Proto from a TLS-terminating proxy when "trust proxy" is set).
|
||||||
|
const secure = process.env.SECURE_COOKIES === 'true' ||
|
||||||
|
(res.req && res.req.protocol === 'https');
|
||||||
res.cookie(COOKIE_NAME, token, {
|
res.cookie(COOKIE_NAME, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
|
secure,
|
||||||
maxAge: SESSION_TTL_MS,
|
maxAge: SESSION_TTL_MS,
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
@@ -116,6 +127,7 @@ function cleanupExpiredSessions() {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
COOKIE_NAME,
|
COOKIE_NAME,
|
||||||
SESSION_TTL_MS,
|
SESSION_TTL_MS,
|
||||||
|
MIN_PASSWORD,
|
||||||
parseCookies,
|
parseCookies,
|
||||||
hasAnyUser,
|
hasAnyUser,
|
||||||
validateUsername,
|
validateUsername,
|
||||||
|
|||||||
@@ -62,4 +62,15 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Additive migrations for existing DBs
|
||||||
|
function addColumnIfMissing(table, column, ddl) {
|
||||||
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||||
|
if (!cols.some(c => c.name === column)) {
|
||||||
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addColumnIfMissing('users', 'email', 'TEXT');
|
||||||
|
addColumnIfMissing('customers', 'email', 'TEXT');
|
||||||
|
addColumnIfMissing('customers', 'archived_at', 'INTEGER');
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const settings = require('./settings');
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
return {
|
||||||
|
host: settings.get('smtp_host', ''),
|
||||||
|
port: parseInt(settings.get('smtp_port', '587'), 10),
|
||||||
|
secure: settings.get('smtp_secure', 'false') === 'true',
|
||||||
|
user: settings.get('smtp_user', ''),
|
||||||
|
pass: settings.get('smtp_pass', ''),
|
||||||
|
from: settings.get('smtp_from', ''),
|
||||||
|
notify_admin: settings.get('smtp_notify_admin', 'false') === 'true',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigured() {
|
||||||
|
const c = getConfig();
|
||||||
|
return !!(c.host && c.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTransport() {
|
||||||
|
const c = getConfig();
|
||||||
|
const opts = {
|
||||||
|
host: c.host,
|
||||||
|
port: c.port,
|
||||||
|
secure: c.secure,
|
||||||
|
};
|
||||||
|
if (c.user) opts.auth = { user: c.user, pass: c.pass };
|
||||||
|
return nodemailer.createTransport(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send({ to, subject, text, html }) {
|
||||||
|
if (!isConfigured()) throw new Error('SMTP nicht konfiguriert');
|
||||||
|
const c = getConfig();
|
||||||
|
const t = buildTransport();
|
||||||
|
return t.sendMail({ from: c.from, to, subject, text, html });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verify() {
|
||||||
|
if (!isConfigured()) throw new Error('SMTP nicht konfiguriert');
|
||||||
|
const t = buildTransport();
|
||||||
|
return t.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { send, verify, isConfigured, getConfig };
|
||||||
+391
-31
@@ -1,26 +1,79 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
|
const archiver = require('archiver');
|
||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const auth = require('./auth');
|
const auth = require('./auth');
|
||||||
const webdavConfig = require('./webdav-config');
|
const webdavConfig = require('./webdav-config');
|
||||||
const settings = require('./settings');
|
const settings = require('./settings');
|
||||||
const janitor = require('./janitor');
|
const janitor = require('./janitor');
|
||||||
|
const mailer = require('./mailer');
|
||||||
|
const uploadNotifier = require('./upload-notifier');
|
||||||
|
|
||||||
|
function validEmail(v) {
|
||||||
|
if (!v) return true; // optional
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v).trim());
|
||||||
|
}
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||||
const UPLOAD_ROOT = process.env.UPLOAD_ROOT || '/data/uploads';
|
const UPLOAD_ROOT = process.env.UPLOAD_ROOT || '/data/uploads';
|
||||||
|
const LOGO_DIR = process.env.LOGO_DIR || '/data/logo';
|
||||||
|
|
||||||
|
fs.mkdirSync(LOGO_DIR, { recursive: true });
|
||||||
settings.seedFromEnv();
|
settings.seedFromEnv();
|
||||||
|
|
||||||
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
|
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
// Only trust X-Forwarded-* when explicitly told there's a proxy in front.
|
||||||
|
// Otherwise an attacker hitting the app directly could spoof the header
|
||||||
|
// and bypass IP-based rate limits.
|
||||||
|
if (process.env.TRUST_PROXY === 'true') {
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security headers. CSP is disabled because the admin and upload pages use
|
||||||
|
// inline <script>/<style>; XSS protection comes from explicit escaping
|
||||||
|
// (esc()/escapeHtml()) and from rejecting dangerous file types at upload time.
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
crossOriginResourcePolicy: { policy: 'same-origin' },
|
||||||
|
}));
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Brute-force protection on auth-relevant endpoints
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'too many attempts, try again later' },
|
||||||
|
});
|
||||||
|
const customerAuthLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'too many attempts, try again later' },
|
||||||
|
});
|
||||||
|
// Polling endpoints — generous but not unlimited, mainly to cap abuse from
|
||||||
|
// leaked tokens spamming the cheap read endpoints.
|
||||||
|
const customerPollLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 60,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'rate limited' },
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Helpers ----------
|
// ---------- Helpers ----------
|
||||||
function slugify(name) {
|
function slugify(name) {
|
||||||
return name
|
return name
|
||||||
@@ -70,6 +123,8 @@ function isExpired(customer) {
|
|||||||
return customer.expires_at && customer.expires_at < Date.now();
|
return customer.expires_at && customer.expires_at < Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isArchived(customer) { return !!customer.archived_at; }
|
||||||
|
|
||||||
function canAccessCustomer(user, customer, needWrite = false) {
|
function canAccessCustomer(user, customer, needWrite = false) {
|
||||||
if (user.role === 'admin') return true;
|
if (user.role === 'admin') return true;
|
||||||
const row = db.prepare(
|
const row = db.prepare(
|
||||||
@@ -83,16 +138,27 @@ function canAccessCustomer(user, customer, needWrite = false) {
|
|||||||
// ---------- Setup & Auth API ----------
|
// ---------- Setup & Auth API ----------
|
||||||
const publicApi = express.Router();
|
const publicApi = express.Router();
|
||||||
|
|
||||||
publicApi.get('/status', (req, res) => {
|
publicApi.get('/branding', (req, res) => {
|
||||||
const u = auth.getSessionUser(req);
|
|
||||||
res.json({
|
res.json({
|
||||||
setup_required: !auth.hasAnyUser(),
|
logo_filename: settings.get('logo_filename', ''),
|
||||||
authenticated: !!u,
|
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
|
||||||
user: u ? { id: u.id, username: u.username, role: u.role } : null,
|
logo_height_px: parseInt(settings.get('logo_height_px', '0'), 10),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
publicApi.post('/setup', async (req, res) => {
|
publicApi.get('/status', (req, res) => {
|
||||||
|
const u = auth.getSessionUser(req);
|
||||||
|
const payload = {
|
||||||
|
setup_required: !auth.hasAnyUser(),
|
||||||
|
authenticated: !!u,
|
||||||
|
user: u ? { id: u.id, username: u.username, role: u.role } : null,
|
||||||
|
};
|
||||||
|
// WebDAV URL is internal infra info — only expose it to authenticated users.
|
||||||
|
if (u) payload.webdav_url = settings.get('webdav_url', '');
|
||||||
|
res.json(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
publicApi.post('/setup', loginLimiter, async (req, res) => {
|
||||||
if (auth.hasAnyUser()) return res.status(409).json({ error: 'already configured' });
|
if (auth.hasAnyUser()) return res.status(409).json({ error: 'already configured' });
|
||||||
const { username, password } = req.body || {};
|
const { username, password } = req.body || {};
|
||||||
if (!auth.validateUsername(username)) return res.status(400).json({ error: 'invalid username' });
|
if (!auth.validateUsername(username)) return res.status(400).json({ error: 'invalid username' });
|
||||||
@@ -108,7 +174,7 @@ publicApi.post('/setup', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
publicApi.post('/login', async (req, res) => {
|
publicApi.post('/login', loginLimiter, async (req, res) => {
|
||||||
const { username, password } = req.body || {};
|
const { username, password } = req.body || {};
|
||||||
const u = await auth.verifyCredentials(username, password);
|
const u = await auth.verifyCredentials(username, password);
|
||||||
if (!u) return res.status(401).json({ error: 'invalid credentials' });
|
if (!u) return res.status(401).json({ error: 'invalid credentials' });
|
||||||
@@ -134,14 +200,16 @@ const api = express.Router();
|
|||||||
|
|
||||||
// --- Users (admin only) ---
|
// --- Users (admin only) ---
|
||||||
api.get('/users', auth.requireAdmin, (req, res) => {
|
api.get('/users', auth.requireAdmin, (req, res) => {
|
||||||
const rows = db.prepare('SELECT id, username, role, created_at FROM users ORDER BY username').all();
|
const rows = db.prepare('SELECT id, username, email, role, created_at FROM users ORDER BY username').all();
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/users', auth.requireAdmin, async (req, res) => {
|
api.post('/users', auth.requireAdmin, async (req, res) => {
|
||||||
const { username, password, role } = req.body || {};
|
const { username, password, role, email } = req.body || {};
|
||||||
|
if (!validEmail(email)) return res.status(400).json({ error: 'invalid email' });
|
||||||
try {
|
try {
|
||||||
const id = await auth.createUser(username, password, role || 'staff');
|
const id = await auth.createUser(username, password, role || 'staff');
|
||||||
|
if (email) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(String(email).trim(), id);
|
||||||
webdavConfig.sync();
|
webdavConfig.sync();
|
||||||
res.json({ id });
|
res.json({ id });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -154,8 +222,9 @@ api.patch('/users/:id', auth.requireAdmin, async (req, res) => {
|
|||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
const u = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
const u = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||||
if (!u) return res.status(404).json({ error: 'not found' });
|
if (!u) return res.status(404).json({ error: 'not found' });
|
||||||
const { password, role } = req.body || {};
|
const { password, role, email } = req.body || {};
|
||||||
if (role && role !== 'admin' && role !== 'staff') return res.status(400).json({ error: 'invalid role' });
|
if (role && role !== 'admin' && role !== 'staff') return res.status(400).json({ error: 'invalid role' });
|
||||||
|
if (email !== undefined && !validEmail(email)) return res.status(400).json({ error: 'invalid email' });
|
||||||
// Don't let admin demote themselves to the last non-admin
|
// Don't let admin demote themselves to the last non-admin
|
||||||
if (role && role !== 'admin' && u.id === req.user.id) {
|
if (role && role !== 'admin' && u.id === req.user.id) {
|
||||||
const otherAdmins = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND id != ?").get(id).n;
|
const otherAdmins = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND id != ?").get(id).n;
|
||||||
@@ -164,6 +233,7 @@ api.patch('/users/:id', auth.requireAdmin, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
if (password) await auth.setUserPassword(id, password);
|
if (password) await auth.setUserPassword(id, password);
|
||||||
if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
if (role) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||||||
|
if (email !== undefined) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email ? String(email).trim() : null, id);
|
||||||
webdavConfig.sync();
|
webdavConfig.sync();
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -184,25 +254,126 @@ api.delete('/users/:id', auth.requireAdmin, (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Self-service: any logged-in user can change their own password
|
||||||
|
api.post('/me/password', loginLimiter, auth.requireAuth, async (req, res) => {
|
||||||
|
const { old_password, new_password } = req.body || {};
|
||||||
|
if (!auth.validatePassword(new_password)) {
|
||||||
|
return res.status(400).json({ error: `Passwort zu kurz (min. ${auth.MIN_PASSWORD} Zeichen)` });
|
||||||
|
}
|
||||||
|
const u = await auth.verifyCredentials(req.user.username, old_password);
|
||||||
|
if (!u) return res.status(401).json({ error: 'aktuelles Passwort falsch' });
|
||||||
|
await auth.setUserPassword(req.user.id, new_password);
|
||||||
|
// Invalidate all other sessions of this user
|
||||||
|
const currentToken = auth.parseCookies(req)[auth.COOKIE_NAME];
|
||||||
|
db.prepare('DELETE FROM sessions WHERE user_id = ? AND token != ?').run(req.user.id, currentToken);
|
||||||
|
webdavConfig.sync(); // password hash changed → htpasswd needs refresh
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// --- Settings (admin only) ---
|
// --- Settings (admin only) ---
|
||||||
api.get('/settings', auth.requireAdmin, (req, res) => {
|
api.get('/settings', auth.requireAdmin, (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
public_base_url: settings.get('public_base_url', ''),
|
public_base_url: settings.get('public_base_url', ''),
|
||||||
|
webdav_url: settings.get('webdav_url', ''),
|
||||||
janitor_interval_minutes: parseInt(settings.get('janitor_interval_minutes', '30'), 10),
|
janitor_interval_minutes: parseInt(settings.get('janitor_interval_minutes', '30'), 10),
|
||||||
|
logo_filename: settings.get('logo_filename', ''),
|
||||||
|
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
|
||||||
|
logo_height_px: parseInt(settings.get('logo_height_px', '0'), 10),
|
||||||
|
smtp_host: settings.get('smtp_host', ''),
|
||||||
|
smtp_port: parseInt(settings.get('smtp_port', '587'), 10),
|
||||||
|
smtp_secure: settings.get('smtp_secure', 'false') === 'true',
|
||||||
|
smtp_user: settings.get('smtp_user', ''),
|
||||||
|
smtp_pass_set: !!settings.get('smtp_pass', ''),
|
||||||
|
smtp_from: settings.get('smtp_from', ''),
|
||||||
|
smtp_notify_admin: settings.get('smtp_notify_admin', 'false') === 'true',
|
||||||
|
smtp_notify_customer: settings.get('smtp_notify_customer', 'true') === 'true',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
api.put('/settings', auth.requireAdmin, (req, res) => {
|
api.put('/settings', auth.requireAdmin, (req, res) => {
|
||||||
const { public_base_url, janitor_interval_minutes } = req.body || {};
|
const b = req.body || {};
|
||||||
if (public_base_url !== undefined) {
|
if (b.public_base_url !== undefined) {
|
||||||
const v = String(public_base_url || '').trim().replace(/\/+$/, '');
|
settings.set('public_base_url', String(b.public_base_url || '').trim().replace(/\/+$/, ''));
|
||||||
settings.set('public_base_url', v);
|
|
||||||
}
|
}
|
||||||
if (janitor_interval_minutes !== undefined) {
|
if (b.webdav_url !== undefined) {
|
||||||
const n = Math.max(1, parseInt(janitor_interval_minutes, 10) || 30);
|
settings.set('webdav_url', String(b.webdav_url || '').trim().replace(/\/+$/, ''));
|
||||||
|
}
|
||||||
|
if (b.janitor_interval_minutes !== undefined) {
|
||||||
|
const n = Math.max(1, parseInt(b.janitor_interval_minutes, 10) || 30);
|
||||||
settings.set('janitor_interval_minutes', String(n));
|
settings.set('janitor_interval_minutes', String(n));
|
||||||
janitor.restart(n * 60 * 1000);
|
janitor.restart(n * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
if (b.logo_width_px !== undefined) {
|
||||||
|
settings.set('logo_width_px', String(Math.max(0, Math.min(800, parseInt(b.logo_width_px, 10) || 0))));
|
||||||
|
}
|
||||||
|
if (b.logo_height_px !== undefined) {
|
||||||
|
settings.set('logo_height_px', String(Math.max(0, Math.min(600, parseInt(b.logo_height_px, 10) || 0))));
|
||||||
|
}
|
||||||
|
if (b.smtp_host !== undefined) settings.set('smtp_host', String(b.smtp_host || '').trim());
|
||||||
|
if (b.smtp_port !== undefined) settings.set('smtp_port', String(parseInt(b.smtp_port, 10) || 587));
|
||||||
|
if (b.smtp_secure !== undefined) settings.set('smtp_secure', b.smtp_secure ? 'true' : 'false');
|
||||||
|
if (b.smtp_user !== undefined) settings.set('smtp_user', String(b.smtp_user || '').trim());
|
||||||
|
if (b.smtp_pass !== undefined && b.smtp_pass !== '')
|
||||||
|
settings.set('smtp_pass', String(b.smtp_pass));
|
||||||
|
if (b.smtp_pass_clear) settings.set('smtp_pass', '');
|
||||||
|
if (b.smtp_from !== undefined) settings.set('smtp_from', String(b.smtp_from || '').trim());
|
||||||
|
if (b.smtp_notify_admin !== undefined)
|
||||||
|
settings.set('smtp_notify_admin', b.smtp_notify_admin ? 'true' : 'false');
|
||||||
|
if (b.smtp_notify_customer !== undefined)
|
||||||
|
settings.set('smtp_notify_customer', b.smtp_notify_customer ? 'true' : 'false');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/settings/test-mail', auth.requireAdmin, async (req, res) => {
|
||||||
|
const to = (req.body && req.body.to) || req.user.email || '';
|
||||||
|
if (!to || !validEmail(to)) return res.status(400).json({ error: 'gültige Empfänger-Adresse erforderlich' });
|
||||||
|
try {
|
||||||
|
await mailer.verify();
|
||||||
|
await mailer.send({
|
||||||
|
to,
|
||||||
|
subject: 'Test-Mail vom Upload-Portal',
|
||||||
|
text: 'Wenn du diese Mail liest, funktioniert die SMTP-Konfiguration.',
|
||||||
|
html: '<p>Wenn du diese Mail liest, funktioniert die SMTP-Konfiguration. ✅</p>',
|
||||||
|
});
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Logo (admin manages, public serves) ---
|
||||||
|
// SVG is intentionally NOT accepted: rendered SVGs can execute embedded scripts
|
||||||
|
// when the user navigates directly to /logo, allowing same-origin XSS.
|
||||||
|
const ALLOWED_LOGO_MIMES = {
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
};
|
||||||
|
const logoUpload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const ok = !!ALLOWED_LOGO_MIMES[file.mimetype];
|
||||||
|
cb(ok ? null : new Error('invalid image type'), ok);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/logo', auth.requireAdmin, logoUpload.single('logo'), (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'no file' });
|
||||||
|
const ext = ALLOWED_LOGO_MIMES[req.file.mimetype] || '';
|
||||||
|
const oldFn = settings.get('logo_filename', '');
|
||||||
|
if (oldFn) { try { fs.unlinkSync(path.join(LOGO_DIR, oldFn)); } catch {} }
|
||||||
|
const fn = `logo${ext}`;
|
||||||
|
fs.writeFileSync(path.join(LOGO_DIR, fn), req.file.buffer);
|
||||||
|
settings.set('logo_filename', fn);
|
||||||
|
res.json({ ok: true, filename: fn });
|
||||||
|
});
|
||||||
|
|
||||||
|
api.delete('/logo', auth.requireAdmin, (req, res) => {
|
||||||
|
const fn = settings.get('logo_filename', '');
|
||||||
|
if (fn) { try { fs.unlinkSync(path.join(LOGO_DIR, fn)); } catch {} }
|
||||||
|
settings.set('logo_filename', '');
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,20 +411,24 @@ api.get('/customers', auth.requireAuth, (req, res) => {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
slug: r.slug,
|
slug: r.slug,
|
||||||
|
email: r.email,
|
||||||
token: isAdmin ? r.token : undefined,
|
token: isAdmin ? r.token : undefined,
|
||||||
has_password: !!r.password_hash,
|
has_password: !!r.password_hash,
|
||||||
expires_at: r.expires_at,
|
expires_at: r.expires_at,
|
||||||
|
archived_at: r.archived_at,
|
||||||
|
archived: !!r.archived_at,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
upload_count: r.upload_count,
|
upload_count: r.upload_count,
|
||||||
total_size: r.total_size,
|
total_size: r.total_size,
|
||||||
my_access: isAdmin ? 'admin' : r.my_access,
|
my_access: isAdmin ? 'admin' : r.my_access,
|
||||||
upload_url: isAdmin ? `${baseUrl}/u/${r.token}` : undefined,
|
upload_url: isAdmin && !r.archived_at ? `${baseUrl}/u/${r.token}` : undefined,
|
||||||
})));
|
})));
|
||||||
});
|
});
|
||||||
|
|
||||||
api.post('/customers', auth.requireAdmin, async (req, res) => {
|
api.post('/customers', auth.requireAdmin, async (req, res) => {
|
||||||
const { name, password, expires_at } = req.body || {};
|
const { name, password, expires_at, email } = req.body || {};
|
||||||
if (!name || !String(name).trim()) return res.status(400).json({ error: 'name required' });
|
if (!name || !String(name).trim()) return res.status(400).json({ error: 'name required' });
|
||||||
|
if (!validEmail(email)) return res.status(400).json({ error: 'invalid email' });
|
||||||
const base = slugify(name);
|
const base = slugify(name);
|
||||||
const slug = ensureUniqueSlug(base);
|
const slug = ensureUniqueSlug(base);
|
||||||
const token = nanoid(24);
|
const token = nanoid(24);
|
||||||
@@ -261,9 +436,9 @@ api.post('/customers', auth.requireAdmin, async (req, res) => {
|
|||||||
const exp = expires_at ? parseInt(expires_at, 10) : null;
|
const exp = expires_at ? parseInt(expires_at, 10) : null;
|
||||||
const created_at = Date.now();
|
const created_at = Date.now();
|
||||||
const info = db.prepare(`
|
const info = db.prepare(`
|
||||||
INSERT INTO customers (name, slug, token, password_hash, expires_at, created_at)
|
INSERT INTO customers (name, slug, token, password_hash, expires_at, created_at, email)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(String(name).trim(), slug, token, password_hash, exp, created_at);
|
`).run(String(name).trim(), slug, token, password_hash, exp, created_at, email ? String(email).trim() : null);
|
||||||
customerDir(slug);
|
customerDir(slug);
|
||||||
webdavConfig.sync();
|
webdavConfig.sync();
|
||||||
res.json({
|
res.json({
|
||||||
@@ -278,14 +453,17 @@ api.patch('/customers/:id', auth.requireAdmin, async (req, res) => {
|
|||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
||||||
if (!c) return res.status(404).json({ error: 'not found' });
|
if (!c) return res.status(404).json({ error: 'not found' });
|
||||||
const { password, expires_at, clear_password } = req.body || {};
|
const { password, expires_at, clear_password, email } = req.body || {};
|
||||||
|
if (email !== undefined && !validEmail(email)) return res.status(400).json({ error: 'invalid email' });
|
||||||
let pwHash = c.password_hash;
|
let pwHash = c.password_hash;
|
||||||
if (clear_password) pwHash = null;
|
if (clear_password) pwHash = null;
|
||||||
else if (password) pwHash = await bcrypt.hash(password, 10);
|
else if (password) pwHash = await bcrypt.hash(password, 10);
|
||||||
let exp = c.expires_at;
|
let exp = c.expires_at;
|
||||||
if (expires_at === null) exp = null;
|
if (expires_at === null) exp = null;
|
||||||
else if (expires_at) exp = parseInt(expires_at, 10);
|
else if (expires_at) exp = parseInt(expires_at, 10);
|
||||||
db.prepare('UPDATE customers SET password_hash = ?, expires_at = ? WHERE id = ?').run(pwHash, exp, id);
|
let mail = c.email;
|
||||||
|
if (email !== undefined) mail = email ? String(email).trim() : null;
|
||||||
|
db.prepare('UPDATE customers SET password_hash = ?, expires_at = ?, email = ? WHERE id = ?').run(pwHash, exp, mail, id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,12 +475,35 @@ api.post('/customers/:id/regenerate-token', auth.requireAdmin, (req, res) => {
|
|||||||
res.json({ token, upload_url: `${settings.getPublicBaseUrl(req)}/u/${token}` });
|
res.json({ token, upload_url: `${settings.getPublicBaseUrl(req)}/u/${token}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Archive = disable link, keep entry + files. Reversible.
|
||||||
|
api.post('/customers/:id/archive', auth.requireAdmin, (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
||||||
|
if (!c) return res.status(404).json({ error: 'not found' });
|
||||||
|
db.prepare('UPDATE customers SET archived_at = ? WHERE id = ?').run(Date.now(), id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
api.post('/customers/:id/unarchive', auth.requireAdmin, (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
|
||||||
|
if (!c) return res.status(404).json({ error: 'not found' });
|
||||||
|
db.prepare('UPDATE customers SET archived_at = NULL WHERE id = ?').run(id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Purge = remove files + DB row. Irreversible.
|
||||||
api.delete('/customers/:id', auth.requireAdmin, (req, res) => {
|
api.delete('/customers/:id', auth.requireAdmin, (req, res) => {
|
||||||
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
|
const c = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
|
||||||
if (!c) return res.status(404).json({ error: 'not found' });
|
if (!c) return res.status(404).json({ error: 'not found' });
|
||||||
|
try {
|
||||||
|
const dir = path.join(UPLOAD_ROOT, c.slug);
|
||||||
|
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[purge] failed to remove files for', c.slug, e.message);
|
||||||
|
}
|
||||||
db.prepare('DELETE FROM customers WHERE id = ?').run(c.id);
|
db.prepare('DELETE FROM customers WHERE id = ?').run(c.id);
|
||||||
webdavConfig.sync();
|
webdavConfig.sync();
|
||||||
// Files are kept on disk; admin can remove via WebDAV.
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,26 +546,29 @@ app.use('/admin/api', api);
|
|||||||
// ---------- Customer Upload Portal ----------
|
// ---------- Customer Upload Portal ----------
|
||||||
app.get('/u/:token', (req, res) => {
|
app.get('/u/:token', (req, res) => {
|
||||||
const c = getCustomerByToken(req.params.token);
|
const c = getCustomerByToken(req.params.token);
|
||||||
if (!c) return res.status(404).send('Link nicht gefunden.');
|
// Expiry no longer blocks the page — only archive/missing token does.
|
||||||
if (isExpired(c)) return res.status(410).send('Link ist abgelaufen.');
|
// Uploads stay open; the file browser is gated separately below.
|
||||||
|
if (!c || isArchived(c)) return res.status(404).send('Link nicht gefunden.');
|
||||||
res.sendFile(path.join(__dirname, '..', 'public', 'upload.html'));
|
res.sendFile(path.join(__dirname, '..', 'public', 'upload.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/u/:token/auth', async (req, res) => {
|
app.post('/u/:token/auth', customerAuthLimiter, async (req, res) => {
|
||||||
const c = getCustomerByToken(req.params.token);
|
const c = getCustomerByToken(req.params.token);
|
||||||
if (!c || isExpired(c)) return res.status(404).json({ error: 'invalid' });
|
if (!c || isArchived(c)) return res.status(404).json({ error: 'invalid' });
|
||||||
if (!c.password_hash) return res.json({ ok: true });
|
if (!c.password_hash) return res.json({ ok: true });
|
||||||
const ok = await bcrypt.compare(req.body.password || '', c.password_hash);
|
const ok = await bcrypt.compare(req.body.password || '', c.password_hash);
|
||||||
res.json({ ok });
|
res.json({ ok });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/u/:token/info', (req, res) => {
|
app.get('/u/:token/info', customerPollLimiter, (req, res) => {
|
||||||
const c = getCustomerByToken(req.params.token);
|
const c = getCustomerByToken(req.params.token);
|
||||||
if (!c || isExpired(c)) return res.status(404).json({ error: 'invalid' });
|
if (!c || isArchived(c)) return res.status(404).json({ error: 'invalid' });
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.json({
|
res.json({
|
||||||
name: c.name,
|
name: c.name,
|
||||||
has_password: !!c.password_hash,
|
has_password: !!c.password_hash,
|
||||||
expires_at: c.expires_at,
|
expires_at: c.expires_at,
|
||||||
|
expired: isExpired(c),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -391,9 +595,12 @@ const upload = multer({
|
|||||||
limits: { fileSize: 10 * 1024 * 1024 * 1024 },
|
limits: { fileSize: 10 * 1024 * 1024 * 1024 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Customer-side auth: archive/token check + optional password.
|
||||||
|
// Expiry intentionally does NOT block here; uploads stay possible after
|
||||||
|
// the link expired. Use requireNotExpired below to gate read-only actions.
|
||||||
function uploadAuth(req, res, next) {
|
function uploadAuth(req, res, next) {
|
||||||
const c = getCustomerByToken(req.params.token);
|
const c = getCustomerByToken(req.params.token);
|
||||||
if (!c || isExpired(c)) return res.status(404).json({ error: 'invalid' });
|
if (!c || isArchived(c)) return res.status(404).json({ error: 'invalid' });
|
||||||
if (c.password_hash) {
|
if (c.password_hash) {
|
||||||
const provided = req.headers['x-upload-password'] || '';
|
const provided = req.headers['x-upload-password'] || '';
|
||||||
bcrypt.compare(provided, c.password_hash).then(ok => {
|
bcrypt.compare(provided, c.password_hash).then(ok => {
|
||||||
@@ -407,6 +614,141 @@ function uploadAuth(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_WALK_DEPTH = 20;
|
||||||
|
|
||||||
|
// List a single directory level (non-recursive). Symlinks are skipped, and
|
||||||
|
// the requested sub-path is resolved via realpath to guarantee it has not
|
||||||
|
// been redirected out of the customer folder by an existing symlink.
|
||||||
|
function listCustomerDir(baseDir, sub) {
|
||||||
|
const dirAbs = sub ? safeJoin(baseDir, sub) : baseDir;
|
||||||
|
let real, baseReal;
|
||||||
|
try { real = fs.realpathSync(dirAbs); baseReal = fs.realpathSync(baseDir); }
|
||||||
|
catch { return null; }
|
||||||
|
if (real !== baseReal && !real.startsWith(baseReal + path.sep)) return null;
|
||||||
|
let entries;
|
||||||
|
try { entries = fs.readdirSync(real, { withFileTypes: true }); }
|
||||||
|
catch { return null; }
|
||||||
|
const out = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isSymbolicLink()) continue;
|
||||||
|
const abs = path.join(real, e.name);
|
||||||
|
try {
|
||||||
|
const st = fs.lstatSync(abs);
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
out.push({ name: e.name, type: 'dir', mtime: st.mtimeMs });
|
||||||
|
} else if (st.isFile()) {
|
||||||
|
out.push({ name: e.name, type: 'file', size: st.size, mtime: st.mtimeMs });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// Folders first, then files, each sorted newest-first
|
||||||
|
return out.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||||
|
return b.mtime - a.mtime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterative, depth-limited, symlink-skipping walker — used for ZIP packing.
|
||||||
|
function* walkSafe(rootAbs, maxDepth = MAX_WALK_DEPTH) {
|
||||||
|
const queue = [{ dir: rootAbs, depth: 0 }];
|
||||||
|
while (queue.length) {
|
||||||
|
const { dir, depth } = queue.shift();
|
||||||
|
if (depth > maxDepth) continue;
|
||||||
|
let entries;
|
||||||
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isSymbolicLink()) continue;
|
||||||
|
const abs = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
queue.push({ dir: abs, depth: depth + 1 });
|
||||||
|
} else if (e.isFile()) {
|
||||||
|
try {
|
||||||
|
const st = fs.lstatSync(abs);
|
||||||
|
if (st.isFile()) yield { abs, rel: path.relative(rootAbs, abs).split(path.sep).join('/') };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip control chars / CRLF / quotes for Content-Disposition.
|
||||||
|
// Anything non-ASCII-safe is replaced with "_"; the original is offered
|
||||||
|
// via RFC 5987 filename* so international names still work in modern clients.
|
||||||
|
function cdFilename(name) {
|
||||||
|
const safe = String(name).replace(/[\x00-\x1f\x7f"\\]+/g, '_').slice(0, 200) || 'download';
|
||||||
|
const encoded = encodeURIComponent(name);
|
||||||
|
return `attachment; filename="${safe}"; filename*=UTF-8''${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse-only guard (after uploadAuth). Uploads are unaffected.
|
||||||
|
function requireNotExpired(req, res, next) {
|
||||||
|
if (isExpired(req._customer)) return res.status(410).json({ error: 'expired' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/u/:token/files', customerPollLimiter, uploadAuth, requireNotExpired, (req, res) => {
|
||||||
|
const c = req._customer;
|
||||||
|
const base = customerDir(c.slug);
|
||||||
|
const sub = sanitizeRelPath(req.query.dir || '');
|
||||||
|
let entries;
|
||||||
|
try { entries = listCustomerDir(base, sub); }
|
||||||
|
catch { return res.status(400).json({ error: 'invalid path' }); }
|
||||||
|
if (entries === null) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.json({ dir: sub, entries });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/u/:token/file', uploadAuth, requireNotExpired, (req, res) => {
|
||||||
|
const c = req._customer;
|
||||||
|
const base = customerDir(c.slug);
|
||||||
|
const rel = sanitizeRelPath(req.query.path || '');
|
||||||
|
if (!rel) return res.status(400).end();
|
||||||
|
let abs;
|
||||||
|
try { abs = safeJoin(base, rel); } catch { return res.status(400).end(); }
|
||||||
|
// Defeat symlinks: resolve the real path and re-check containment.
|
||||||
|
let real;
|
||||||
|
try { real = fs.realpathSync(abs); } catch { return res.status(404).end(); }
|
||||||
|
const baseReal = fs.realpathSync(base);
|
||||||
|
if (real !== baseReal && !real.startsWith(baseReal + path.sep)) {
|
||||||
|
return res.status(404).end();
|
||||||
|
}
|
||||||
|
let st;
|
||||||
|
try { st = fs.lstatSync(real); } catch { return res.status(404).end(); }
|
||||||
|
if (!st.isFile()) return res.status(404).end();
|
||||||
|
res.setHeader('Content-Disposition', cdFilename(path.basename(real)));
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.sendFile(real);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream a ZIP of a folder (or the whole customer dir when dir param is empty).
|
||||||
|
app.get('/u/:token/zip', uploadAuth, requireNotExpired, (req, res) => {
|
||||||
|
const c = req._customer;
|
||||||
|
const base = customerDir(c.slug);
|
||||||
|
const sub = sanitizeRelPath(req.query.dir || '');
|
||||||
|
const dirAbs = sub ? safeJoin(base, sub) : base;
|
||||||
|
// Containment check after symlink resolution
|
||||||
|
let real;
|
||||||
|
try { real = fs.realpathSync(dirAbs); } catch { return res.status(404).end(); }
|
||||||
|
const baseReal = fs.realpathSync(base);
|
||||||
|
if (real !== baseReal && !real.startsWith(baseReal + path.sep)) {
|
||||||
|
return res.status(404).end();
|
||||||
|
}
|
||||||
|
if (!fs.lstatSync(real).isDirectory()) return res.status(404).end();
|
||||||
|
|
||||||
|
const zipName = (sub ? path.basename(sub) : c.slug) + '.zip';
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', cdFilename(zipName));
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||||
|
archive.on('error', err => { console.error('[zip]', err.message); try { res.end(); } catch {} });
|
||||||
|
archive.pipe(res);
|
||||||
|
for (const { abs, rel } of walkSafe(real)) {
|
||||||
|
archive.file(abs, { name: rel });
|
||||||
|
}
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => {
|
app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => {
|
||||||
const c = req._customer;
|
const c = req._customer;
|
||||||
const f = req.file;
|
const f = req.file;
|
||||||
@@ -417,9 +759,27 @@ app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => {
|
|||||||
INSERT INTO uploads (customer_id, filename, relative_path, size, uploaded_at)
|
INSERT INTO uploads (customer_id, filename, relative_path, size, uploaded_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`).run(c.id, f.filename, rel, f.size, Date.now());
|
`).run(c.id, f.filename, rel, f.size, Date.now());
|
||||||
|
uploadNotifier.queue(c.id, { name: f.filename, path: rel, size: f.size });
|
||||||
res.json({ ok: true, file: { name: f.filename, path: rel, size: f.size } });
|
res.json({ ok: true, file: { name: f.filename, path: rel, size: f.size } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public logo (no auth) — used by admin & upload page
|
||||||
|
app.get('/logo', (req, res) => {
|
||||||
|
const fn = settings.get('logo_filename', '');
|
||||||
|
if (!fn) return res.status(404).end();
|
||||||
|
// Defense-in-depth path traversal check (current upload code only writes
|
||||||
|
// logo.<ext>, but the DB value is treated as untrusted on read).
|
||||||
|
const baseResolved = path.resolve(LOGO_DIR);
|
||||||
|
const fp = path.resolve(LOGO_DIR, fn);
|
||||||
|
if (fp !== baseResolved && !fp.startsWith(baseResolved + path.sep)) {
|
||||||
|
return res.status(404).end();
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(fp)) return res.status(404).end();
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=60');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.sendFile(fp);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Root ----------
|
// ---------- Root ----------
|
||||||
app.get('/', (req, res) => res.redirect('/admin/'));
|
app.get('/', (req, res) => res.redirect('/admin/'));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const db = require('./db');
|
||||||
|
const mailer = require('./mailer');
|
||||||
|
const settings = require('./settings');
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 30_000; // collect uploads then send one summary email
|
||||||
|
const pending = new Map(); // customer_id → { timer, items: [{name, size}] }
|
||||||
|
|
||||||
|
function fmtSize(n) {
|
||||||
|
if (!n) return '0 B';
|
||||||
|
if (n < 1024) return n + ' B';
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||||
|
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipientsFor(customer) {
|
||||||
|
const out = [];
|
||||||
|
const notifyCustomer = settings.get('smtp_notify_customer', 'true') === 'true';
|
||||||
|
if (notifyCustomer && customer.email) out.push({ kind: 'customer', to: customer.email });
|
||||||
|
|
||||||
|
// Staff assigned to this customer (with email)
|
||||||
|
const staff = db.prepare(`
|
||||||
|
SELECT u.email FROM customer_access ca
|
||||||
|
JOIN users u ON u.id = ca.user_id
|
||||||
|
WHERE ca.customer_id = ? AND u.email IS NOT NULL AND u.email != ''
|
||||||
|
`).all(customer.id);
|
||||||
|
for (const s of staff) out.push({ kind: 'staff', to: s.email });
|
||||||
|
|
||||||
|
// All admins with email, if global setting enabled
|
||||||
|
if (settings.get('smtp_notify_admin', 'false') === 'true') {
|
||||||
|
const admins = db.prepare(`
|
||||||
|
SELECT email FROM users WHERE role = 'admin' AND email IS NOT NULL AND email != ''
|
||||||
|
`).all();
|
||||||
|
for (const a of admins) out.push({ kind: 'admin', to: a.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe by email
|
||||||
|
const seen = new Set();
|
||||||
|
return out.filter(r => (seen.has(r.to) ? false : (seen.add(r.to), true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBodies(customer, items, kind) {
|
||||||
|
const total = items.reduce((a, b) => a + b.size, 0);
|
||||||
|
const list = items.map(i => `- ${i.name} (${fmtSize(i.size)})`).join('\n');
|
||||||
|
const listHtml = items.map(i =>
|
||||||
|
`<tr><td style="padding:2px 10px 2px 0">${escapeHtml(i.name)}</td>` +
|
||||||
|
`<td style="padding:2px 0;color:#666;text-align:right">${fmtSize(i.size)}</td></tr>`
|
||||||
|
).join('');
|
||||||
|
const subject = kind === 'customer'
|
||||||
|
? `Upload-Bestätigung – ${items.length} Datei(en)`
|
||||||
|
: `Neuer Upload von ${customer.name} – ${items.length} Datei(en)`;
|
||||||
|
const intro = kind === 'customer'
|
||||||
|
? `Vielen Dank! Ihr Upload war erfolgreich. Es wurden ${items.length} Datei(en) (${fmtSize(total)}) übertragen.`
|
||||||
|
: `Für Kunde "${customer.name}" wurden soeben ${items.length} Datei(en) (${fmtSize(total)}) hochgeladen.`;
|
||||||
|
const text = `${intro}\n\n${list}\n`;
|
||||||
|
const html = `
|
||||||
|
<div style="font-family:system-ui,sans-serif;max-width:560px;color:#111">
|
||||||
|
<h2 style="margin:0 0 .5rem">${kind === 'customer' ? 'Upload-Bestätigung' : 'Neuer Upload'}</h2>
|
||||||
|
<p>${escapeHtml(intro)}</p>
|
||||||
|
<table style="border-collapse:collapse;font-size:.9rem">${listHtml}</table>
|
||||||
|
</div>`;
|
||||||
|
return { subject, text, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(customerId) {
|
||||||
|
const entry = pending.get(customerId);
|
||||||
|
if (!entry) return;
|
||||||
|
pending.delete(customerId);
|
||||||
|
if (!entry.items.length) return;
|
||||||
|
if (!mailer.isConfigured()) return;
|
||||||
|
|
||||||
|
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(customerId);
|
||||||
|
if (!customer) return;
|
||||||
|
|
||||||
|
const recipients = recipientsFor(customer);
|
||||||
|
for (const r of recipients) {
|
||||||
|
try {
|
||||||
|
const { subject, text, html } = buildBodies(customer, entry.items, r.kind);
|
||||||
|
await mailer.send({ to: r.to, subject, text, html });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[notifier] send to ${r.to} failed:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queue(customerId, file) {
|
||||||
|
let entry = pending.get(customerId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { timer: null, items: [] };
|
||||||
|
pending.set(customerId, entry);
|
||||||
|
}
|
||||||
|
entry.items.push({ name: file.path || file.name, size: file.size });
|
||||||
|
if (entry.timer) clearTimeout(entry.timer);
|
||||||
|
entry.timer = setTimeout(() => flush(customerId), DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { queue, flush };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
Windows Registry Editor Version 5.00
|
||||||
|
|
||||||
|
; Disables WebDAV file locking globally. Useful when staff users have
|
||||||
|
; only "read" rights on customer folders: Office/editors stop trying
|
||||||
|
; to LOCK the file before opening (which would 403 and trigger the
|
||||||
|
; "file does not exist, create new?" dialog). Read-only viewing then
|
||||||
|
; works without the artificial wait.
|
||||||
|
;
|
||||||
|
; Trade-off: collaborative editing scenarios cannot rely on WebDAV
|
||||||
|
; locks anymore. For pure download/view workflows this is fine.
|
||||||
|
;
|
||||||
|
; Rechtsklick -> "Zusammenführen" (als Administrator), dann
|
||||||
|
; den WebClient-Dienst neu starten:
|
||||||
|
; net stop WebClient && net start WebClient
|
||||||
|
|
||||||
|
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters]
|
||||||
|
"SupportLocking"=dword:00000000
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Windows Registry Editor Version 5.00
|
||||||
|
|
||||||
|
; Enables WebDAV via Basic Auth over plain HTTP and lifts the 50 MB
|
||||||
|
; file size limit so large uploads/downloads work over a mapped drive.
|
||||||
|
;
|
||||||
|
; Rechtsklick -> "Zusammenführen" (als Administrator), dann
|
||||||
|
; den WebClient-Dienst neu starten:
|
||||||
|
; net stop WebClient && net start WebClient
|
||||||
|
|
||||||
|
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters]
|
||||||
|
"BasicAuthLevel"=dword:00000002
|
||||||
|
"FileSizeLimitInBytes"=dword:ffffffff
|
||||||
Reference in New Issue
Block a user