From dd9a91b5d7e9e043dc356ab8a67bc2c8148656c6 Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 5 Jun 2026 20:01:52 +0200 Subject: [PATCH] =?UTF-8?q?Upload-Freigabe:=20=C3=B6ffentlicher=20Upload-L?= =?UTF-8?q?ink=20mit=20Passwort,=20Ablaufzeit,=20max.=20Gr=C3=B6=C3=9Fe;?= =?UTF-8?q?=20Admin-Verwaltung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 42 +++++ backend/src/index.js | 8 + backend/src/tools/dateien/upload-share.js | 183 +++++++++++++++++++++ backend/upload-page.html | 147 +++++++++++++++++ frontend/src/App.jsx | 87 ++++++++-- frontend/src/tools/dateien.jsx | 192 +++++++++++++++++++++- 6 files changed, 647 insertions(+), 12 deletions(-) create mode 100644 backend/src/tools/dateien/upload-share.js create mode 100644 backend/upload-page.html diff --git a/backend/src/db.js b/backend/src/db.js index 500cbe4..9d35203 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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; diff --git a/backend/src/index.js b/backend/src/index.js index 47d7d95..9f71d54 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 diff --git a/backend/src/tools/dateien/upload-share.js b/backend/src/tools/dateien/upload-share.js new file mode 100644 index 0000000..38e6e27 --- /dev/null +++ b/backend/src/tools/dateien/upload-share.js @@ -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; diff --git a/backend/upload-page.html b/backend/upload-page.html new file mode 100644 index 0000000..11231dc --- /dev/null +++ b/backend/upload-page.html @@ -0,0 +1,147 @@ + + + + + +Datei hochladen – DickenDock + + + +
+

📤 Datei-Upload

+

Dateien hochladen

+ + +
+
Link wird geprüft…
+ + + + +
+ + + +
+ + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a2a5b96..e693be0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1481,7 +1481,6 @@ function SearchResultItem({ item, idx, activeIdx, onNavigate, onHover }) { if (item.type==='calendar_feed') { icon='📅'; primary=item.name; secondary='Kalender-Abo'; } if (item.type==='calendar_event') { icon='📆'; primary=item.summary; secondary=`${item.feed_name||'Kalender'}${item.start_dt?' · '+formatIcalDate(item.start_dt):''}`; } if (item.type==='qr') { icon='◻'; primary=item.label||item.url; secondary=`QR-Code · ${item.url}`; } - if (item.type==='user') { icon='💬'; primary=item.username; secondary='Nachricht schreiben → Chat öffnen'; } return (
onNavigate(item)} onMouseEnter={()=>onHover(idx)} style={{display:'flex',alignItems:'center',gap:10,padding:'9px 14px',cursor:'pointer', @@ -1643,7 +1642,6 @@ function GlobalSearch({ setActive, setDevToolsNav, mobile }) { ...(results.calendar_feeds||[]).map(f=>({...f,type:'calendar_feed'})), ...(results.calendar_events||[]).map(e=>({...e,type:'calendar_event'})), ...(results.qr_codes||[]).map(q=>({...q,type:'qr'})), - ...(results.users||[]).map(u=>({...u,type:'user'})), ] : []; const navigate = (item) => { @@ -1663,8 +1661,7 @@ function GlobalSearch({ setActive, setDevToolsNav, mobile }) { else if (item.type === 'file' || item.type === 'file_folder') { setActive('dateien'); } else if (item.type === 'push') { setActive('dashboard'); } else if (item.type === 'calendar_feed' || item.type === 'calendar_event') { setActive('dashboard'); } - else if (item.type === 'qr') { setActive('devtools'); setDevToolsNav({tool:'qrcode', ts: Date.now()}); } - else if (item.type === 'user') { setActive('nachrichten'); setDevToolsNav({userId: item.id, ts: Date.now()}); } + else if (item.type === 'qr') { setActive('devtools'); setDevToolsNav({tool:'qrcode', ts: Date.now()}); } }; const onKey = e => { @@ -1803,12 +1800,6 @@ function GlobalSearch({ setActive, setDevToolsNav, mobile }) { {results.qr_codes.map((item,i)=>({}}/>))}
)} - {results.users?.length>0 && ( -
-
BENUTZER / CHAT
- {results.users.map((item,i)=>({}}/>))} -
- )} {results.notes?.length>0 && (
NOTIZEN
@@ -2594,6 +2585,77 @@ const Field = ({ label, type='text', value, onChange, placeholder='', autoCapita
); +// ── Admin: Upload-Freigabe Berechtigungen ───────────────────────────────────── +function UploadShareAdminPanel({ toast }) { + const [users, setUsers] = useState([]); + const [logs, setLogs] = useState([]); + const [showLogs, setShowLogs] = useState(false); + + const load = () => { + api('/users').then(setUsers).catch(()=>{}); + }; + const loadLogs = () => { + api('/upload-shares/admin/logs').then(setLogs).catch(()=>{}); + setShowLogs(true); + }; + useEffect(()=>{ load(); },[]); + + const toggle = async (uid, current) => { + try { + await api('/upload-shares/admin/permission', { body:{ user_id: uid, allow: !current } }); + setUsers(p => p.map(u => u.id===uid ? {...u, allow_upload_share: current?0:1} : u)); + toast(current ? 'Berechtigung entzogen' : 'Berechtigung erteilt'); + } catch(e) { toast(e.message,'error'); } + }; + + const fmtDate = d => d ? new Date(d).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '–'; + const fmtSize = b => b>1024*1024 ? (b/1024/1024).toFixed(1)+' MB' : (b/1024).toFixed(0)+' KB'; + + return ( +
+
+ Admins haben immer Zugriff auf Upload-Freigaben.
+ Hier kannst du die Funktion für einzelne Mitglieder freischalten. +
+ {users.filter(u=>u.role!=='admin').map(u => ( +
+ {u.username} + +
+ ))} + + {showLogs && ( +
+
ALLE UPLOADS ÜBER FREIGABE-LINKS
+ {logs.length===0 + ?
Noch keine Uploads.
+ : logs.map(l=>( +
+ {l.username} +
+
{l.originalname}
+
{fmtDate(l.uploaded_at)} · {fmtSize(l.size)} · Max: {l.max_size_mb} MB
+
+
+ )) + } +
+ )} +
+ ); +} + + function AdminPanel({ toast, mobile, user, nav }) { const [section, setSection] = useState('profil'); useEffect(() => { if (nav?.section) setSection(nav.section); }, [nav?.ts]); @@ -2740,6 +2802,9 @@ function AdminPanel({ toast, mobile, user, nav }) { + + + )} @@ -2898,7 +2963,7 @@ export default function App() {
{active==='dashboard' && { setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} user={user}/>} {active==='admin' && } - {activeTool && } + {activeTool && }
{mobile && ( diff --git a/frontend/src/tools/dateien.jsx b/frontend/src/tools/dateien.jsx index 97413f2..06a40d6 100644 --- a/frontend/src/tools/dateien.jsx +++ b/frontend/src/tools/dateien.jsx @@ -158,6 +158,192 @@ function PasswordPromptModal({ filename, onSubmit, onCancel }) { ); } + +// ── Upload-Ordner & Share-Verwaltung ───────────────────────────────────────── +function UploadShareManager({ toast }) { + const [shares, setShares] = useState([]); + const [folder, setFolder] = useState(null); + const [logs, setLogs] = useState([]); + const [showCreate, setShowCreate] = useState(false); + const [showLogs, setShowLogs] = useState(false); + const [form, setForm] = useState({ password:'', expires_hours:24, max_size_mb:10 }); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(null); + + const load = () => api('/upload-shares').then(d=>{ setShares(d.shares||[]); setFolder(d.folder); }).catch(()=>{}); + useEffect(()=>{ load(); },[]); + + const genPw = () => { + const chars='ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%'; + setForm(p=>({...p, password: Array.from({length:12},()=>chars[Math.floor(Math.random()*chars.length)]).join('')})); + }; + + const create = async () => { + if (!form.password.trim()) { toast('Passwort erforderlich','error'); return; } + setBusy(true); + try { + await api('/upload-shares', { body: form }); + toast('Upload-Link erstellt ✓'); setShowCreate(false); setForm({password:'',expires_hours:24,max_size_mb:10}); load(); + } catch(e) { toast(e.message,'error'); } + setBusy(false); + }; + + const deactivate = async id => { + await api(`/upload-shares/${id}`, { method:'DELETE' }); + load(); toast('Link deaktiviert'); + }; + + const copyLink = (token) => { + const url = `${window.location.origin}/u/${token}`; + navigator.clipboard?.writeText(url).then(()=>{ setCopied(token); setTimeout(()=>setCopied(null),2000); }).catch(()=>{ + toast(url); + }); + }; + + const loadLogs = () => { + api('/upload-shares/logs').then(setLogs).catch(()=>{}); + setShowLogs(true); + }; + + const fmtDate = d => d ? new Date(d).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '–'; + const fmtSize = b => b>1024*1024 ? (b/1024/1024).toFixed(1)+' MB' : (b/1024).toFixed(0)+' KB'; + + const DURATION_OPTS = [ + [1,'1 Stunde'],[2,'2 Stunden'],[6,'6 Stunden'],[12,'12 Stunden'], + [24,'1 Tag'],[48,'2 Tage'],[72,'3 Tage'],[168,'1 Woche'],[336,'2 Wochen'], + ]; + const SIZE_OPTS = [1,2,5,10,20,50,100,250]; + + return ( +
+
+
+
+ 📤 Upload-Ordner +
+ {folder &&
+ Dateien landen in: 📁 {folder.name} +
} +
+
+ + +
+
+ + {/* Erstellen-Formular */} + {showCreate && ( +
+
UPLOAD-LINK KONFIGURIEREN
+
+
+
GÜLTIGKEITSDAUER
+ +
+
+
MAX. DATEIGRÖSSE
+ +
+
+
+
PASSWORT
+
+ setForm(p=>({...p,password:e.target.value}))} + placeholder="Passwort vergeben" style={{...S.inp,flex:1,fontFamily:'monospace'}}/> + +
+ {form.password &&
+ Passwort: {form.password} – notiere es jetzt +
} +
+ +
+ )} + + {/* Aktive Links */} + {shares.length===0 && !showCreate && ( +
+
📤
+
+ Noch kein Upload-Link. Erstelle einen Link um anderen das Hochladen zu erlauben. +
+
+ )} + {shares.map(s => { + const expired = new Date(s.expires_at) < new Date(); + return ( +
+
+
+
+ + {!s.is_active?'Deaktiviert':expired?'Abgelaufen':'Aktiv'} + + + bis {fmtDate(s.expires_at)} · max {s.max_size_mb} MB · {s.upload_count||0} Upload(s) + +
+
+ /u/{s.token} +
+
+
+ {s.is_active && !expired && ( + + )} + {s.is_active && ( + + )} +
+
+
+ ); + })} + + {/* Log-Ansicht */} + {showLogs && ( +
+
+
UPLOAD-PROTOKOLL
+ +
+ {logs.length===0 + ?
Noch keine Uploads über Freigabe-Links.
+ : logs.map(l=>( +
+ 📄 +
+
{l.originalname}
+
{fmtDate(l.uploaded_at)} · {fmtSize(l.size)}
+
+
+ )) + } +
+ )} +
+ ); +} + export default function Dateien({ toast, mobile }) { const [data, setData] = useState({own:[],shared:[],sharedByMe:[]}); const [folders, setFolders] = useState({own:[],shared:[],sharedByMe:[]}); @@ -329,6 +515,7 @@ export default function Dateien({ toast, mobile }) { ['own','Dateien', null], ['shared','Geteilt mit mir', sharedCnt||null], ['sharedByMe','Geteilt von mir', byMeCnt||null], + ['upload','📤 Upload-Ordner', null], ].map(([k,l,cnt])=>(