Compare commits
9 Commits
182ef04cc5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5713d6dbc9 | |||
| 82782c4f92 | |||
| b2d6c547a9 | |||
| fd5e917249 | |||
| d4c1d1f4bf | |||
| 386855d76a | |||
| 2b1417ccf3 | |||
| 20e61aa61c | |||
| d476784c06 |
+5
-1
@@ -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 ./
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -104,3 +103,21 @@ Dateien auf 50 MB. Beides per Registry erlauben:
|
|||||||
|
|
||||||
Wenn weiterhin „Ordner nicht gefunden" → meist ist der WebClient-Dienst nicht
|
Wenn weiterhin „Ordner nicht gefunden" → meist ist der WebClient-Dienst nicht
|
||||||
gestartet oder das Registry-Merge wurde nicht als Administrator ausgeführt.
|
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.
|
||||||
|
|||||||
+2
-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:
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"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",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"express-rate-limit": "^7.4.0",
|
"express-rate-limit": "^7.4.0",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
|
|||||||
+58
-7
@@ -166,7 +166,9 @@
|
|||||||
.muted { color: var(--text-muted); }
|
.muted { color: var(--text-muted); }
|
||||||
|
|
||||||
/* Login/Setup center view */
|
/* Login/Setup center view */
|
||||||
.center { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
|
.center { min-height: 100vh; display: flex; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; padding: 1.5rem; gap: 1.5rem; }
|
||||||
|
.auth-logo { max-width: 240px; max-height: 80px; object-fit: contain; display: block; }
|
||||||
.auth-card { width: 100%; max-width: 420px; }
|
.auth-card { width: 100%; max-width: 420px; }
|
||||||
.auth-card h2 { margin: 0 0 .25rem; font-size: 1.4rem; }
|
.auth-card h2 { margin: 0 0 .25rem; font-size: 1.4rem; }
|
||||||
.auth-card p.small { margin: 0 0 1.25rem; }
|
.auth-card p.small { margin: 0 0 1.25rem; }
|
||||||
@@ -215,6 +217,7 @@
|
|||||||
|
|
||||||
<!-- SETUP VIEW -->
|
<!-- SETUP VIEW -->
|
||||||
<div id="view-setup" class="center" style="display:none">
|
<div id="view-setup" class="center" style="display:none">
|
||||||
|
<img id="setupLogo" class="auth-logo" alt="" style="display:none" />
|
||||||
<div class="card auth-card">
|
<div class="card auth-card">
|
||||||
<h2>Willkommen 👋</h2>
|
<h2>Willkommen 👋</h2>
|
||||||
<p class="small">Lege den ersten Admin-Account an. Dieser kann später weitere Benutzer verwalten.</p>
|
<p class="small">Lege den ersten Admin-Account an. Dieser kann später weitere Benutzer verwalten.</p>
|
||||||
@@ -231,6 +234,7 @@
|
|||||||
|
|
||||||
<!-- LOGIN VIEW -->
|
<!-- LOGIN VIEW -->
|
||||||
<div id="view-login" class="center" style="display:none">
|
<div id="view-login" class="center" style="display:none">
|
||||||
|
<img id="loginLogo" class="auth-logo" alt="" style="display:none" />
|
||||||
<div class="card auth-card">
|
<div class="card auth-card">
|
||||||
<h2>Login</h2>
|
<h2>Login</h2>
|
||||||
<p class="small">Melde dich mit deinem Account an.</p>
|
<p class="small">Melde dich mit deinem Account an.</p>
|
||||||
@@ -292,7 +296,11 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="small" style="margin-top:.75rem">WebDAV-Server: <code id="webdavUrl"></code> — Login mit deinem eigenen Benutzer.</p>
|
<p class="small" style="margin-top:.75rem">
|
||||||
|
WebDAV-Server: <code id="webdavUrl"></code><br>
|
||||||
|
Windows-UNC: <code id="webdavUnc"></code>
|
||||||
|
— Login mit deinem eigenen Benutzer.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- USERS TAB -->
|
<!-- USERS TAB -->
|
||||||
@@ -379,6 +387,12 @@
|
|||||||
<div><label>Absender (From)</label><input name="smtp_from" placeholder="noreply@firma.de" /></div>
|
<div><label>Absender (From)</label><input name="smtp_from" placeholder="noreply@firma.de" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field" style="margin-top:.75rem">
|
<div class="field" style="margin-top:.75rem">
|
||||||
|
<label style="display:flex; align-items:center; gap:.4rem">
|
||||||
|
<input type="checkbox" name="smtp_notify_customer" style="width:auto" />
|
||||||
|
Kunden nach Upload eine Bestätigungsmail schicken (nur wenn E-Mail beim Kunden hinterlegt)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-top:.5rem">
|
||||||
<label style="display:flex; align-items:center; gap:.4rem">
|
<label style="display:flex; align-items:center; gap:.4rem">
|
||||||
<input type="checkbox" name="smtp_notify_admin" style="width:auto" />
|
<input type="checkbox" name="smtp_notify_admin" style="width:auto" />
|
||||||
Admins bei jedem Upload benachrichtigen (alle Admins mit E-Mail-Adresse)
|
Admins bei jedem Upload benachrichtigen (alle Admins mit E-Mail-Adresse)
|
||||||
@@ -398,7 +412,11 @@
|
|||||||
<form id="settingsForm">
|
<form id="settingsForm">
|
||||||
<div class="field"><label>Öffentliche Basis-URL</label>
|
<div class="field"><label>Öffentliche Basis-URL</label>
|
||||||
<input name="public_base_url" placeholder="z. B. https://upload.example.com" />
|
<input name="public_base_url" placeholder="z. B. https://upload.example.com" />
|
||||||
<p class="small" style="margin:.35rem 0 0">Leer lassen, um aus jedem Request die aktuelle URL zu nutzen.</p>
|
<p class="small" style="margin:.35rem 0 0">Wird in den Kunden-Upload-Links eingesetzt. Leer lassen = aus dem Request abgeleitet.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>Private WebDAV-URL</label>
|
||||||
|
<input name="webdav_url" placeholder="z. B. webdav://upload.example.com:1900/" />
|
||||||
|
<p class="small" style="margin:.35rem 0 0">Wird im Adminportal unter der Kundenliste angezeigt. Leer lassen = <code>webdav://<host>:1900/</code>.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Cron-Intervall (Minuten)</label>
|
<div class="field"><label>Cron-Intervall (Minuten)</label>
|
||||||
<input name="janitor_interval_minutes" type="number" min="1" style="max-width: 8rem" />
|
<input name="janitor_interval_minutes" type="number" min="1" style="max-width: 8rem" />
|
||||||
@@ -490,6 +508,24 @@ function show(view) {
|
|||||||
document.getElementById(id).style.display = id === view ? '' : 'none';
|
document.getElementById(id).style.display = id === view ? '' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function deriveUnc(webdavUrl) {
|
||||||
|
try {
|
||||||
|
let s = String(webdavUrl || '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (!/^[a-z]+:\/\//i.test(s)) s = 'http://' + s;
|
||||||
|
const u = new URL(s);
|
||||||
|
const host = u.hostname;
|
||||||
|
const port = u.port || '1900';
|
||||||
|
if (!host) return null;
|
||||||
|
return `\\\\${host}@${port}\\DavWWWRoot\\`;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
function setWebdavDisplay(url) {
|
||||||
|
document.getElementById('webdavUrl').textContent = url;
|
||||||
|
const unc = deriveUnc(url);
|
||||||
|
document.getElementById('webdavUnc').textContent = unc || '–';
|
||||||
|
}
|
||||||
|
|
||||||
function fmtSize(n) {
|
function fmtSize(n) {
|
||||||
if (!n) return '0 B';
|
if (!n) return '0 B';
|
||||||
if (n < 1024) return n + ' B';
|
if (n < 1024) return n + ' B';
|
||||||
@@ -510,11 +546,20 @@ function applySizeStyle(el, w, h) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyLogo(width, height) {
|
async function applyLogo(width, height) {
|
||||||
const img = document.getElementById('navLogo');
|
const navImg = document.getElementById('navLogo');
|
||||||
|
const setupImg = document.getElementById('setupLogo');
|
||||||
|
const loginImg = document.getElementById('loginLogo');
|
||||||
const src = '/logo?t=' + Date.now();
|
const src = '/logo?t=' + Date.now();
|
||||||
const r = await fetch(src, { method: 'HEAD' });
|
const r = await fetch(src, { method: 'HEAD' });
|
||||||
if (r.ok) { img.src = src; img.style.display = ''; applySizeStyle(img, width, height); }
|
if (r.ok) {
|
||||||
else img.style.display = 'none';
|
navImg.src = src; navImg.style.display = ''; applySizeStyle(navImg, width, height);
|
||||||
|
if (setupImg) { setupImg.src = src; setupImg.style.display = ''; }
|
||||||
|
if (loginImg) { loginImg.src = src; loginImg.style.display = ''; }
|
||||||
|
} else {
|
||||||
|
navImg.style.display = 'none';
|
||||||
|
if (setupImg) setupImg.style.display = 'none';
|
||||||
|
if (loginImg) loginImg.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function measureNatural() {
|
function measureNatural() {
|
||||||
@@ -541,7 +586,7 @@ async function bootstrap() {
|
|||||||
} else {
|
} else {
|
||||||
document.getElementById('createCustomerCard').style.display = '';
|
document.getElementById('createCustomerCard').style.display = '';
|
||||||
}
|
}
|
||||||
document.getElementById('webdavUrl').textContent = `webdav://${location.hostname}:1900/`;
|
setWebdavDisplay((status.webdav_url || '').trim() || `webdav://${location.hostname}:1900/`);
|
||||||
show('view-app');
|
show('view-app');
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
}
|
}
|
||||||
@@ -748,6 +793,7 @@ async function loadSettings() {
|
|||||||
const s = await api.get('/settings');
|
const s = await api.get('/settings');
|
||||||
const form = document.getElementById('settingsForm');
|
const form = document.getElementById('settingsForm');
|
||||||
form.public_base_url.value = s.public_base_url || '';
|
form.public_base_url.value = s.public_base_url || '';
|
||||||
|
form.webdav_url.value = s.webdav_url || '';
|
||||||
form.janitor_interval_minutes.value = s.janitor_interval_minutes || 30;
|
form.janitor_interval_minutes.value = s.janitor_interval_minutes || 30;
|
||||||
setSlider('logoWidth', s.logo_width_px || 0);
|
setSlider('logoWidth', s.logo_width_px || 0);
|
||||||
setSlider('logoHeight', s.logo_height_px || 0);
|
setSlider('logoHeight', s.logo_height_px || 0);
|
||||||
@@ -765,6 +811,7 @@ async function loadSettings() {
|
|||||||
? 'gesetzt — leer = unverändert lassen'
|
? 'gesetzt — leer = unverändert lassen'
|
||||||
: 'nicht gesetzt';
|
: 'nicht gesetzt';
|
||||||
sf.smtp_notify_admin.checked = !!s.smtp_notify_admin;
|
sf.smtp_notify_admin.checked = !!s.smtp_notify_admin;
|
||||||
|
sf.smtp_notify_customer.checked = s.smtp_notify_customer !== false;
|
||||||
}
|
}
|
||||||
function setSlider(id, val) {
|
function setSlider(id, val) {
|
||||||
document.getElementById(id).value = val;
|
document.getElementById(id).value = val;
|
||||||
@@ -799,10 +846,13 @@ document.getElementById('settingsForm').addEventListener('submit', async (e) =>
|
|||||||
try {
|
try {
|
||||||
await api.send('PUT', '/settings', {
|
await api.send('PUT', '/settings', {
|
||||||
public_base_url: fd.get('public_base_url') || '',
|
public_base_url: fd.get('public_base_url') || '',
|
||||||
|
webdav_url: fd.get('webdav_url') || '',
|
||||||
janitor_interval_minutes: parseInt(fd.get('janitor_interval_minutes') || '30', 10),
|
janitor_interval_minutes: parseInt(fd.get('janitor_interval_minutes') || '30', 10),
|
||||||
logo_width_px: getSlider('logoWidth'),
|
logo_width_px: getSlider('logoWidth'),
|
||||||
logo_height_px: getSlider('logoHeight'),
|
logo_height_px: getSlider('logoHeight'),
|
||||||
});
|
});
|
||||||
|
// Refresh main view in case the WebDAV-URL display needs an update.
|
||||||
|
setWebdavDisplay((fd.get('webdav_url') || '').trim() || `webdav://${location.hostname}:1900/`);
|
||||||
const msg = document.getElementById('settingsMsg');
|
const msg = document.getElementById('settingsMsg');
|
||||||
msg.textContent = '✓ Gespeichert';
|
msg.textContent = '✓ Gespeichert';
|
||||||
setTimeout(() => msg.textContent = '', 2000);
|
setTimeout(() => msg.textContent = '', 2000);
|
||||||
@@ -819,6 +869,7 @@ document.getElementById('smtpForm').addEventListener('submit', async (e) => {
|
|||||||
smtp_user: fd.get('smtp_user') || '',
|
smtp_user: fd.get('smtp_user') || '',
|
||||||
smtp_from: fd.get('smtp_from') || '',
|
smtp_from: fd.get('smtp_from') || '',
|
||||||
smtp_notify_admin: !!fd.get('smtp_notify_admin'),
|
smtp_notify_admin: !!fd.get('smtp_notify_admin'),
|
||||||
|
smtp_notify_customer: !!fd.get('smtp_notify_customer'),
|
||||||
};
|
};
|
||||||
const pw = fd.get('smtp_pass');
|
const pw = fd.get('smtp_pass');
|
||||||
if (pw) body.smtp_pass = pw;
|
if (pw) body.smtp_pass = pw;
|
||||||
|
|||||||
+252
-13
@@ -122,6 +122,35 @@
|
|||||||
padding: .15rem .55rem; border-radius: 999px; font-size: .75rem;
|
padding: .15rem .55rem; border-radius: 999px; font-size: .75rem;
|
||||||
color: var(--text-muted); border: 1px solid var(--border-strong); }
|
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; }
|
footer { margin-top: 2rem; color: var(--text-dim); font-size: .8rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -159,6 +188,13 @@
|
|||||||
</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>
|
</div>
|
||||||
|
|
||||||
@@ -189,25 +225,227 @@ async function applyBranding() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
function applyInfo(data) {
|
||||||
await applyBranding();
|
window._uploadInfo = data;
|
||||||
const r = await fetch(`/u/${token}/info`);
|
|
||||||
if (!r.ok) {
|
|
||||||
document.querySelector('.card').innerHTML =
|
|
||||||
'<h1>Link ungültig oder abgelaufen</h1><p class="subtitle">Bitte wende dich an deinen Ansprechpartner für einen neuen Link.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await r.json();
|
|
||||||
document.getElementById('title').textContent = `Upload für ${data.name}`;
|
document.getElementById('title').textContent = `Upload für ${data.name}`;
|
||||||
if (data.expires_at) {
|
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>`;
|
info.innerHTML = `<span class="expires">⏳ gültig bis ${new Date(data.expires_at).toLocaleString()}</span>`;
|
||||||
} else {
|
} else {
|
||||||
info.textContent = 'Lade Dateien oder ganze Ordner hoch — die Ordnerstruktur bleibt erhalten.';
|
info.textContent = 'Lade Dateien oder ganze Ordner hoch — die Ordnerstruktur bleibt erhalten.';
|
||||||
}
|
}
|
||||||
if (data.has_password) gate.style.display = 'block';
|
// Browser visibility synced to expiry
|
||||||
else main.style.display = 'block';
|
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`, {
|
||||||
@@ -215,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';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -272,6 +510,7 @@ async function uploadOne(file, relPath) {
|
|||||||
|
|
||||||
async function uploadFiles(items) {
|
async function uploadFiles(items) {
|
||||||
for (const { file, path } of items) await uploadOne(file, path);
|
for (const { file, path } of items) await uploadOne(file, path);
|
||||||
|
loadFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('fileInput').onchange = (e) => {
|
document.getElementById('fileInput').onchange = (e) => {
|
||||||
|
|||||||
+168
-7
@@ -5,6 +5,7 @@ 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');
|
||||||
@@ -63,6 +64,15 @@ const customerAuthLimiter = rateLimit({
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: { error: 'too many attempts, try again later' },
|
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) {
|
||||||
@@ -138,11 +148,14 @@ publicApi.get('/branding', (req, res) => {
|
|||||||
|
|
||||||
publicApi.get('/status', (req, res) => {
|
publicApi.get('/status', (req, res) => {
|
||||||
const u = auth.getSessionUser(req);
|
const u = auth.getSessionUser(req);
|
||||||
res.json({
|
const payload = {
|
||||||
setup_required: !auth.hasAnyUser(),
|
setup_required: !auth.hasAnyUser(),
|
||||||
authenticated: !!u,
|
authenticated: !!u,
|
||||||
user: u ? { id: u.id, username: u.username, role: u.role } : null,
|
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) => {
|
publicApi.post('/setup', loginLimiter, async (req, res) => {
|
||||||
@@ -261,6 +274,7 @@ api.post('/me/password', loginLimiter, auth.requireAuth, async (req, res) => {
|
|||||||
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_filename: settings.get('logo_filename', ''),
|
||||||
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
|
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
|
||||||
@@ -272,6 +286,7 @@ api.get('/settings', auth.requireAdmin, (req, res) => {
|
|||||||
smtp_pass_set: !!settings.get('smtp_pass', ''),
|
smtp_pass_set: !!settings.get('smtp_pass', ''),
|
||||||
smtp_from: settings.get('smtp_from', ''),
|
smtp_from: settings.get('smtp_from', ''),
|
||||||
smtp_notify_admin: settings.get('smtp_notify_admin', 'false') === 'true',
|
smtp_notify_admin: settings.get('smtp_notify_admin', 'false') === 'true',
|
||||||
|
smtp_notify_customer: settings.get('smtp_notify_customer', 'true') === 'true',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,6 +295,9 @@ api.put('/settings', auth.requireAdmin, (req, res) => {
|
|||||||
if (b.public_base_url !== undefined) {
|
if (b.public_base_url !== undefined) {
|
||||||
settings.set('public_base_url', String(b.public_base_url || '').trim().replace(/\/+$/, ''));
|
settings.set('public_base_url', String(b.public_base_url || '').trim().replace(/\/+$/, ''));
|
||||||
}
|
}
|
||||||
|
if (b.webdav_url !== undefined) {
|
||||||
|
settings.set('webdav_url', String(b.webdav_url || '').trim().replace(/\/+$/, ''));
|
||||||
|
}
|
||||||
if (b.janitor_interval_minutes !== undefined) {
|
if (b.janitor_interval_minutes !== undefined) {
|
||||||
const n = Math.max(1, parseInt(b.janitor_interval_minutes, 10) || 30);
|
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));
|
||||||
@@ -301,6 +319,8 @@ api.put('/settings', auth.requireAdmin, (req, res) => {
|
|||||||
if (b.smtp_from !== undefined) settings.set('smtp_from', String(b.smtp_from || '').trim());
|
if (b.smtp_from !== undefined) settings.set('smtp_from', String(b.smtp_from || '').trim());
|
||||||
if (b.smtp_notify_admin !== undefined)
|
if (b.smtp_notify_admin !== undefined)
|
||||||
settings.set('smtp_notify_admin', b.smtp_notify_admin ? 'true' : 'false');
|
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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -526,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);
|
||||||
|
// Expiry no longer blocks the page — only archive/missing token does.
|
||||||
|
// Uploads stay open; the file browser is gated separately below.
|
||||||
if (!c || isArchived(c)) return res.status(404).send('Link nicht gefunden.');
|
if (!c || isArchived(c)) return res.status(404).send('Link nicht gefunden.');
|
||||||
if (isExpired(c)) return res.status(410).send('Link ist abgelaufen.');
|
|
||||||
res.sendFile(path.join(__dirname, '..', 'public', 'upload.html'));
|
res.sendFile(path.join(__dirname, '..', 'public', 'upload.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/u/:token/auth', customerAuthLimiter, 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) || isArchived(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) || isArchived(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),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -572,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) || isArchived(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 => {
|
||||||
@@ -588,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;
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ function fmtSize(n) {
|
|||||||
|
|
||||||
function recipientsFor(customer) {
|
function recipientsFor(customer) {
|
||||||
const out = [];
|
const out = [];
|
||||||
if (customer.email) out.push({ kind: 'customer', to: customer.email });
|
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)
|
// Staff assigned to this customer (with email)
|
||||||
const staff = db.prepare(`
|
const staff = db.prepare(`
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user