Upload-Freigabe: öffentlicher Upload-Link mit Passwort, Ablaufzeit, max. Größe; Admin-Verwaltung

This commit is contained in:
2026-06-05 20:01:52 +02:00
parent 8a5e8cc003
commit dd9a91b5d7
6 changed files with 647 additions and 12 deletions

View File

@@ -521,4 +521,46 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cal
`);
}
// ── Upload-Freigaben (externer Upload-Link) ───────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='upload_shares'").get()) {
db.exec(`
CREATE TABLE upload_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
expires_at DATETIME NOT NULL,
max_size_mb INTEGER NOT NULL DEFAULT 10,
folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL,
is_active INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT NULL
)
`);
}
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='upload_share_logs'").get()) {
db.exec(`
CREATE TABLE upload_share_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
share_id INTEGER NOT NULL REFERENCES upload_shares(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
originalname TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
ip_address TEXT NOT NULL DEFAULT '',
uploaded_at DATETIME DEFAULT NULL
)
`);
}
// allow_upload_share Recht pro User
{
const userCols = db.pragma('table_info(users)').map(c => c.name);
if (!userCols.includes('allow_upload_share'))
db.exec('ALTER TABLE users ADD COLUMN allow_upload_share INTEGER DEFAULT 0');
}
// Upload-Ordner Flag auf Folders-Tabelle
{
const folderCols = db.pragma('table_info(folders)').map(c => c.name);
if (!folderCols.includes('is_upload_folder'))
db.exec('ALTER TABLE folders ADD COLUMN is_upload_folder INTEGER DEFAULT 0');
}
module.exports = db;

View File

@@ -68,6 +68,7 @@ fs.readdirSync(toolsDir, { withFileTypes: true })
app.use('/api/tools/snippets', require('./tools/snippets/routes'));
app.use('/api/tools/qrcodes', require('./tools/qrcodes/routes'));
app.use('/api/upload-shares', require('./tools/dateien/upload-share'));
app.use('/api/search', require('./routes/search'));
const PUBLIC = path.join(__dirname, '../public');
@@ -84,6 +85,13 @@ app.get('/sw.js', (_req, res) => {
res.sendFile(path.join(PUBLIC, 'sw.js'));
});
// Öffentliche Upload-Seite (kein Login erforderlich)
const UPLOAD_HTML = path.join(__dirname, '../upload-page.html');
app.get('/u/:token', (_req, res) => {
if (require('fs').existsSync(UPLOAD_HTML)) res.sendFile(UPLOAD_HTML);
else res.status(404).send('Upload-Seite nicht gefunden');
});
app.use(express.static(PUBLIC, {
setHeaders: (res, filePath) => {
// JS/CSS Assets haben Hash im Namen → lang cachen

View File

@@ -0,0 +1,183 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const db = require('../../db');
const { authenticate, requireAdmin } = require('../../middleware/auth');
const router = express.Router();
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
function generateToken() {
return crypto.randomBytes(20).toString('base64url');
}
function getOrCreateUploadFolder(userId) {
let folder = db.prepare(
"SELECT * FROM folders WHERE user_id=? AND is_upload_folder=1 LIMIT 1"
).get(userId);
if (!folder) {
const r = db.prepare(
"INSERT INTO folders (user_id, name, is_upload_folder, created_at) VALUES (?,?,1,datetime('now','localtime'))"
).run(userId, 'Upload-Ordner');
folder = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
}
return folder;
}
function canUseUploadShare(userId) {
const user = db.prepare('SELECT role, allow_upload_share FROM users WHERE id=?').get(userId);
return user && (user.role === 'admin' || !!user.allow_upload_share);
}
// ── Eigene Shares verwalten ───────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error: 'Keine Berechtigung' });
const folder = getOrCreateUploadFolder(req.user.id);
const shares = db.prepare(`
SELECT s.*, COUNT(l.id) as upload_count
FROM upload_shares s
LEFT JOIN upload_share_logs l ON l.share_id=s.id
WHERE s.user_id=?
GROUP BY s.id
ORDER BY s.created_at DESC
`).all(req.user.id);
res.json({ shares, folder });
});
router.post('/', authenticate, (req, res) => {
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error: 'Keine Berechtigung' });
const { password, expires_hours=24, max_size_mb=10 } = req.body;
if (!password?.trim()) return res.status(400).json({ error: 'Passwort erforderlich' });
const folder = getOrCreateUploadFolder(req.user.id);
const token = generateToken();
const hash = bcrypt.hashSync(password.trim(), 10);
const expires = new Date(Date.now() + Number(expires_hours) * 3600000).toISOString();
const r = db.prepare(`
INSERT INTO upload_shares (user_id, token, password_hash, expires_at, max_size_mb, folder_id, created_at)
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
`).run(req.user.id, token, hash, expires, Number(max_size_mb), folder.id);
res.json(db.prepare('SELECT * FROM upload_shares WHERE id=?').get(r.lastInsertRowid));
});
router.delete('/:id', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('UPDATE upload_shares SET is_active=0 WHERE id=?').run(s.id);
res.json({ ok: true });
});
router.get('/logs', authenticate, (req, res) => {
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error: 'Keine Berechtigung' });
const logs = db.prepare(`
SELECT l.*, s.expires_at, s.max_size_mb
FROM upload_share_logs l
JOIN upload_shares s ON s.id=l.share_id
WHERE l.user_id=?
ORDER BY l.uploaded_at DESC
LIMIT 100
`).all(req.user.id);
res.json(logs);
});
// ── Admin: alle Logs sehen ────────────────────────────────────────────────────
router.get('/admin/logs', authenticate, requireAdmin, (req, res) => {
const logs = db.prepare(`
SELECT l.*, s.expires_at, s.max_size_mb, u.username
FROM upload_share_logs l
JOIN upload_shares s ON s.id=l.share_id
JOIN users u ON u.id=l.user_id
ORDER BY l.uploaded_at DESC
LIMIT 500
`).all();
res.json(logs);
});
router.get('/admin/shares', authenticate, requireAdmin, (req, res) => {
const shares = db.prepare(`
SELECT s.*, u.username, COUNT(l.id) as upload_count
FROM upload_shares s
JOIN users u ON u.id=s.user_id
LEFT JOIN upload_share_logs l ON l.share_id=s.id
GROUP BY s.id
ORDER BY s.created_at DESC
`).all();
res.json(shares);
});
// ── Admin: Berechtigung pro User setzen ──────────────────────────────────────
router.post('/admin/permission', authenticate, requireAdmin, (req, res) => {
const { user_id, allow } = req.body;
db.prepare('UPDATE users SET allow_upload_share=? WHERE id=?').run(allow?1:0, user_id);
res.json({ ok: true });
});
// ── Öffentlicher Endpunkt: Infos zum Share ────────────────────────────────────
router.get('/public/:token', (req, res) => {
const s = db.prepare(
"SELECT id, expires_at, max_size_mb, is_active FROM upload_shares WHERE token=?"
).get(req.params.token);
if (!s || !s.is_active) return res.status(404).json({ error: 'Link ungültig oder deaktiviert' });
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error: 'Link abgelaufen' });
res.json({ ok: true, max_size_mb: s.max_size_mb, expires_at: s.expires_at });
});
// ── Öffentlicher Endpunkt: Passwort prüfen ────────────────────────────────────
router.post('/public/:token/verify', (req, res) => {
const s = db.prepare('SELECT * FROM upload_shares WHERE token=?').get(req.params.token);
if (!s || !s.is_active) return res.status(404).json({ error: 'Link ungültig' });
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error: 'Link abgelaufen' });
if (!bcrypt.compareSync(req.body.password || '', s.password_hash))
return res.status(401).json({ error: 'Falsches Passwort' });
res.json({ ok: true, max_size_mb: s.max_size_mb, expires_at: s.expires_at });
});
// ── Öffentlicher Upload ───────────────────────────────────────────────────────
router.post('/public/:token/upload', (req, res) => {
const s = db.prepare('SELECT * FROM upload_shares WHERE token=?').get(req.params.token);
if (!s || !s.is_active) return res.status(404).json({ error: 'Link ungültig' });
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error: 'Link abgelaufen' });
// Passwort im Header prüfen
const pw = req.headers['x-upload-password'] || '';
if (!bcrypt.compareSync(pw, s.password_hash))
return res.status(401).json({ error: 'Nicht autorisiert' });
const maxBytes = s.max_size_mb * 1024 * 1024;
const storage = multer.diskStorage({ destination: UPLOAD_DIR });
const upload = multer({
storage,
limits: { fileSize: maxBytes },
}).single('file');
upload(req, res, err => {
if (err) {
if (err.code === 'LIMIT_FILE_SIZE')
return res.status(413).json({ error: `Datei zu groß (max ${s.max_size_mb} MB)` });
return res.status(500).json({ error: err.message });
}
if (!req.file) return res.status(400).json({ error: 'Keine Datei' });
try {
const r = db.prepare(`
INSERT INTO files (user_id, filename, originalname, mimetype, size, folder_id, created_at)
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
`).run(s.user_id, req.file.filename, req.file.originalname, req.file.mimetype, req.file.size, s.folder_id);
db.prepare(`
INSERT INTO upload_share_logs (share_id, user_id, originalname, size, ip_address, uploaded_at)
VALUES (?,?,?,?,?,datetime('now','localtime'))
`).run(s.id, s.user_id, req.file.originalname, req.file.size, req.ip || '');
res.json({ ok: true, filename: req.file.originalname });
} catch(e) {
try { fs.unlinkSync(path.join(UPLOAD_DIR, req.file.filename)); } catch {}
res.status(500).json({ error: e.message });
}
});
});
module.exports = router;

147
backend/upload-page.html Normal file
View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Datei hochladen DickenDock</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0d0d0f;color:#fff;font-family:'Courier New',monospace;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
.card{background:#1a1a1e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:32px;width:100%;max-width:440px}
h1{font-size:18px;margin-bottom:6px;color:#4ecdc4}
.sub{color:rgba(255,255,255,0.4);font-size:12px;margin-bottom:24px}
label{display:block;color:rgba(255,255,255,0.5);font-size:10px;letter-spacing:2px;margin-bottom:4px}
input[type=password],input[type=file]{width:100%;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:10px 12px;color:#fff;font-family:inherit;font-size:14px;outline:none}
input[type=file]{cursor:pointer;padding:8px}
input[type=file]::file-selector-button{background:#4ecdc4;border:none;border-radius:6px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-weight:700;padding:4px 12px;margin-right:10px}
.btn{width:100%;background:#4ecdc4;border:none;border-radius:8px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-size:14px;font-weight:700;padding:12px;margin-top:12px}
.btn:disabled{opacity:0.4;cursor:default}
.btn.danger{background:#ff6b9d}
.info{background:rgba(78,205,196,0.08);border:1px solid rgba(78,205,196,0.2);border-radius:8px;padding:10px 12px;font-size:12px;color:rgba(255,255,255,0.6);margin-bottom:16px}
.msg{border-radius:8px;padding:10px 12px;font-size:13px;margin-top:12px}
.msg.ok{background:rgba(78,205,196,0.12);border:1px solid rgba(78,205,196,0.3);color:#4ecdc4}
.msg.err{background:rgba(255,107,157,0.12);border:1px solid rgba(255,107,157,0.3);color:#ff6b9d}
.hidden{display:none}
.done-list{margin-top:12px;font-size:12px;color:rgba(255,255,255,0.5)}
.done-list li{padding:3px 0;list-style:none}
.done-list li::before{content:'✓ ';color:#4ecdc4}
#progress{height:4px;background:rgba(78,205,196,0.2);border-radius:2px;margin-top:8px;overflow:hidden}
#progress-bar{height:100%;background:#4ecdc4;width:0;transition:width 0.3s}
</style>
</head>
<body>
<div class="card">
<h1>📤 Datei-Upload</h1>
<p class="sub" id="sub">Dateien hochladen</p>
<!-- Passwort-Sektion -->
<div id="pw-section">
<div class="info" id="share-info">Link wird geprüft…</div>
<label>PASSWORT</label>
<input type="password" id="pw" placeholder="Passwort eingeben" autocomplete="off"/>
<button class="btn" id="pw-btn" onclick="verify()">Zugang bestätigen</button>
<div id="pw-msg" class="msg hidden"></div>
</div>
<!-- Upload-Sektion -->
<div id="upload-section" class="hidden">
<div class="info" id="upload-info"></div>
<label>DATEI AUSWÄHLEN</label>
<input type="file" id="file-input" multiple/>
<div id="progress"><div id="progress-bar"></div></div>
<button class="btn" id="upload-btn" onclick="upload()">⬆ Hochladen</button>
<div id="upload-msg" class="msg hidden"></div>
<ul class="done-list" id="done-list"></ul>
</div>
</div>
<script>
const token = location.pathname.replace('/u/','').replace('/','');
let password = '';
let maxMb = 10;
async function init() {
try {
const r = await fetch(`/api/upload-shares/public/${token}`);
const d = await r.json();
if (!r.ok) return showFatal(d.error || 'Ungültiger Link');
maxMb = d.max_size_mb;
const exp = new Date(d.expires_at).toLocaleString('de-DE', {timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
document.getElementById('share-info').textContent = `Gültig bis: ${exp} · Max ${d.max_size_mb} MB pro Datei`;
} catch { showFatal('Verbindung fehlgeschlagen'); }
}
function showFatal(msg) {
document.getElementById('pw-section').innerHTML = `<div class="msg err">${msg}</div>`;
}
async function verify() {
const pw = document.getElementById('pw').value;
if (!pw) return;
document.getElementById('pw-btn').disabled = true;
try {
const r = await fetch(`/api/upload-shares/public/${token}/verify`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ password: pw })
});
const d = await r.json();
if (!r.ok) {
document.getElementById('pw-msg').className = 'msg err';
document.getElementById('pw-msg').textContent = d.error;
document.getElementById('pw-msg').classList.remove('hidden');
document.getElementById('pw-btn').disabled = false;
return;
}
password = pw;
maxMb = d.max_size_mb;
document.getElementById('pw-section').classList.add('hidden');
document.getElementById('upload-section').classList.remove('hidden');
const exp = new Date(d.expires_at).toLocaleString('de-DE', {timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
document.getElementById('upload-info').textContent = `Gültig bis ${exp} · Max ${d.max_size_mb} MB pro Datei`;
document.getElementById('sub').textContent = 'Dateien hochladen ✓';
} catch {
document.getElementById('pw-btn').disabled = false;
}
}
async function upload() {
const files = document.getElementById('file-input').files;
if (!files.length) return;
const btn = document.getElementById('upload-btn');
btn.disabled = true;
const msg = document.getElementById('upload-msg');
const bar = document.getElementById('progress-bar');
const list = document.getElementById('done-list');
for (let i=0; i<files.length; i++) {
const file = files[i];
if (file.size > maxMb*1024*1024) {
msg.className = 'msg err'; msg.textContent = `"${file.name}" ist zu groß (max ${maxMb} MB)`; msg.classList.remove('hidden');
continue;
}
bar.style.width = ((i+0.5)/files.length*100)+'%';
const fd = new FormData(); fd.append('file', file);
try {
const r = await fetch(`/api/upload-shares/public/${token}/upload`, {
method:'POST', headers:{'x-upload-password': password}, body: fd
});
const d = await r.json();
if (r.ok) {
const li = document.createElement('li'); li.textContent = file.name; list.appendChild(li);
msg.className = 'msg ok'; msg.textContent = `${list.children.length} Datei(en) erfolgreich hochgeladen`; msg.classList.remove('hidden');
} else {
msg.className = 'msg err'; msg.textContent = d.error; msg.classList.remove('hidden');
}
} catch { msg.className='msg err'; msg.textContent='Upload fehlgeschlagen'; msg.classList.remove('hidden'); }
}
bar.style.width = '100%';
setTimeout(()=>{ bar.style.width='0'; }, 1500);
btn.disabled = false;
document.getElementById('file-input').value = '';
}
document.getElementById('pw').addEventListener('keydown', e => { if(e.key==='Enter') verify(); });
init();
</script>
</body>
</html>