diff --git a/backend/src/db.js b/backend/src/db.js index 59577bb..ca67c9f 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -571,4 +571,22 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='xre if (!db.prepare("SELECT value FROM admin_settings WHERE key='tmdb_token'").get()) db.prepare("INSERT INTO admin_settings (key,value) VALUES ('tmdb_token','')").run(); +// Öffentliche Datei/Ordner-Freigabe Links +if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='public_file_shares'").get()) { + db.exec(` + CREATE TABLE public_file_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + label TEXT, + password_hash TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + download_count INTEGER NOT NULL DEFAULT 0 + ) + `); +} + module.exports = db; diff --git a/backend/src/tools/dateien/public-share-public.js b/backend/src/tools/dateien/public-share-public.js index e0a5f34..a1665ad 100644 --- a/backend/src/tools/dateien/public-share-public.js +++ b/backend/src/tools/dateien/public-share-public.js @@ -1,42 +1,43 @@ -// Öffentliche Endpunkte für Datei/Ordner-Download (kein Login nötig) -const express = require('express'); -const bcrypt = require('bcryptjs'); -const path = require('path'); -const fs = require('fs'); -const db = require('../../db'); -const router = express.Router(); +// Öffentliche Endpunkte – kein Login nötig +const express = require('express'); +const bcrypt = require('bcryptjs'); +const path = require('path'); +const fs = require('fs'); +const db = require('../../db'); +const router = express.Router(); const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; function getShare(token) { return db.prepare(` - SELECT s.*, f.name as file_name, f.size as file_size, f.mime_type, f.path as file_path, - fo.name as folder_name + SELECT s.*, + f.name as file_name, f.size as file_size, f.mime_type, f.path as file_path, + fo.name as folder_name FROM public_file_shares s LEFT JOIN files f ON f.id = s.file_id LEFT JOIN folders fo ON fo.id = s.folder_id - WHERE s.token=? + WHERE s.token = ? `).get(token); } -function checkExpiry(s) { +function isExpired(s) { return s.expires_at && s.expires_at < Math.floor(Date.now() / 1000); } -// GET /:token – Metadaten (Name, Passwort nötig, abgelaufen?) +// GET /:token – Metadaten router.get('/:token', (req, res) => { const s = getShare(req.params.token); if (!s) return res.status(404).json({ error: 'Link nicht gefunden' }); - if (checkExpiry(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); res.json({ - label: s.label, - file_name: s.file_name, - file_size: s.file_size, - mime_type: s.mime_type, - folder_name: s.folder_name, - is_folder: !!s.folder_id, + label: s.label, + file_name: s.file_name, + file_size: s.file_size, + mime_type: s.mime_type, + folder_name: s.folder_name, + is_folder: !!s.folder_id, needs_password: !!s.password_hash, - expires_at: s.expires_at, + expires_at: s.expires_at, }); }); @@ -44,11 +45,9 @@ router.get('/:token', (req, res) => { router.post('/:token/download', async (req, res) => { const s = getShare(req.params.token); if (!s || !s.file_id) return res.status(404).json({ error: 'Nicht gefunden' }); - if (checkExpiry(s)) return res.status(410).json({ error: 'Link abgelaufen' }); - if (s.password_hash) { - const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); - if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); - } + if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); + if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); const filePath = path.join(UPLOAD_DIR, s.file_path); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' }); db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id); @@ -57,17 +56,15 @@ router.post('/:token/download', async (req, res) => { fs.createReadStream(filePath).pipe(res); }); -// POST /:token/folder – Ordnerinhalt abrufen +// POST /:token/folder – Ordnerinhalt router.post('/:token/folder', async (req, res) => { const s = getShare(req.params.token); if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' }); - if (checkExpiry(s)) return res.status(410).json({ error: 'Link abgelaufen' }); - if (s.password_hash) { - const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); - if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); - } + if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); + if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); const files = db.prepare(` - SELECT id, name, size, mime_type, created_at FROM files + SELECT id, name, size, mime_type FROM files WHERE folder_id=? AND user_id=? ORDER BY name `).all(s.folder_id, s.user_id); db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id); @@ -78,14 +75,12 @@ router.post('/:token/folder', async (req, res) => { router.post('/:token/folder/:fileId', async (req, res) => { const s = getShare(req.params.token); if (!s || !s.folder_id) return res.status(404).json({ error: 'Nicht gefunden' }); - if (checkExpiry(s)) return res.status(410).json({ error: 'Link abgelaufen' }); - if (s.password_hash) { - const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); - if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); - } + if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + const ok = await bcrypt.compare(req.body?.password || '', s.password_hash); + if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); const file = db.prepare('SELECT * FROM files WHERE id=? AND folder_id=? AND user_id=?') .get(req.params.fileId, s.folder_id, s.user_id); - if (!file) return res.status(404).json({ error: 'Datei nicht im freigegebenen Ordner' }); + if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' }); const filePath = path.join(UPLOAD_DIR, file.path); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' }); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name)}"`); diff --git a/backend/src/tools/dateien/public-share.js b/backend/src/tools/dateien/public-share.js index 16927d7..e564564 100644 --- a/backend/src/tools/dateien/public-share.js +++ b/backend/src/tools/dateien/public-share.js @@ -1,40 +1,18 @@ -const express = require('express'); -const bcrypt = require('bcryptjs'); -const crypto = require('crypto'); -const path = require('path'); -const fs = require('fs'); -const db = require('../../db'); +// Authentifizierte Endpunkte zum Verwalten öffentlicher Datei-Links +const express = require('express'); +const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const db = require('../../db'); const { authenticate } = require('../../middleware/auth'); -const router = express.Router(); - -const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; +const router = express.Router(); function generateToken() { return crypto.randomBytes(24).toString('base64url'); } -// ── Migration: Tabelle erstellen falls nicht vorhanden ───────────────────── -db.exec(` - CREATE TABLE IF NOT EXISTS public_file_shares ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - token TEXT NOT NULL UNIQUE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, - folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, - label TEXT, - password_hash TEXT, - expires_at INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - download_count INTEGER NOT NULL DEFAULT 0, - CHECK (file_id IS NOT NULL OR folder_id IS NOT NULL) - ) -`); - -// ── GET /api/tools/dateien/public-shares ────────────────────────────────── -// Alle eigenen Shares auflisten +// GET / – eigene Shares auflisten router.get('/', authenticate, (req, res) => { const shares = db.prepare(` - SELECT s.*, - f.name as file_name, f.size as file_size, f.mime_type, - fo.name as folder_name + SELECT s.*, f.name as file_name, f.size as file_size, + fo.name as folder_name FROM public_file_shares s LEFT JOIN files f ON f.id = s.file_id LEFT JOIN folders fo ON fo.id = s.folder_id @@ -44,15 +22,13 @@ router.get('/', authenticate, (req, res) => { res.json({ shares }); }); -// ── POST /api/tools/dateien/public-shares ───────────────────────────────── -// Neuen Share erstellen -router.post('/', authenticate, (req, res) => { +// POST / – neuen Share erstellen +router.post('/', authenticate, async (req, res) => { const { file_id, folder_id, password, expires_hours, label } = req.body; if (!file_id && !folder_id) return res.status(400).json({ error: 'file_id oder folder_id erforderlich' }); - if (!password) return res.status(400).json({ error: 'Passwort ist Pflicht' }); - if (!expires_hours || expires_hours <= 0) return res.status(400).json({ error: 'Ablaufzeit ist Pflicht' }); + if (!password || !password.trim()) return res.status(400).json({ error: 'Passwort ist Pflicht' }); + if (!expires_hours || Number(expires_hours) <= 0) return res.status(400).json({ error: 'Ablaufzeit ist Pflicht' }); - // Sicherstellen dass die Datei/Ordner dem User gehört if (file_id) { const f = db.prepare('SELECT id FROM files WHERE id=? AND user_id=?').get(file_id, req.user.id); if (!f) return res.status(403).json({ error: 'Keine Berechtigung' }); @@ -63,8 +39,8 @@ router.post('/', authenticate, (req, res) => { } const token = generateToken(); - const password_hash = password ? bcrypt.hashSync(password, 10) : null; - const expires_at = expires_hours ? Math.floor(Date.now() / 1000) + expires_hours * 3600 : null; + const password_hash = await bcrypt.hash(password.trim(), 10); + const expires_at = Math.floor(Date.now() / 1000) + Number(expires_hours) * 3600; const r = db.prepare(` INSERT INTO public_file_shares (token, user_id, file_id, folder_id, label, password_hash, expires_at) @@ -75,7 +51,7 @@ router.post('/', authenticate, (req, res) => { res.json({ share, token }); }); -// ── DELETE /api/tools/dateien/public-shares/:id ─────────────────────────── +// DELETE /:id – Share löschen router.delete('/:id', authenticate, (req, res) => { const s = db.prepare('SELECT id FROM public_file_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id); if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); @@ -83,143 +59,4 @@ router.delete('/:id', authenticate, (req, res) => { res.json({ ok: true }); }); -// ── GET /api/tools/dateien/public-shares/:id/info ───────────────────────── -// Eigene Share-Infos (für Management) -router.get('/:id/info', authenticate, (req, res) => { - const s = db.prepare(` - SELECT s.*, f.name as file_name, fo.name as folder_name - FROM public_file_shares s - LEFT JOIN files f ON f.id = s.file_id - LEFT JOIN folders fo ON fo.id = s.folder_id - WHERE s.id=? AND s.user_id=? - `).get(req.params.id, req.user.id); - if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); - res.json({ share: s }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// ÖFFENTLICHE ENDPUNKTE (kein Login nötig) -// ═══════════════════════════════════════════════════════════════════════════ - -// ── GET /api/public/file-share/:token ───────────────────────────────────── -// Share-Metadaten abrufen (Name, ob Passwort nötig, abgelaufen?) -router.get('/public/:token', (req, res) => { - const s = db.prepare(` - SELECT s.*, f.name as file_name, f.size as file_size, f.mime_type, f.path as file_path, - fo.name as folder_name - FROM public_file_shares s - LEFT JOIN files f ON f.id = s.file_id - LEFT JOIN folders fo ON fo.id = s.folder_id - WHERE s.token=? - `).get(req.params.token); - - if (!s) return res.status(404).json({ error: 'Link nicht gefunden' }); - if (s.expires_at && s.expires_at < Math.floor(Date.now() / 1000)) { - return res.status(410).json({ error: 'Link abgelaufen' }); - } - - res.json({ - label: s.label, - file_name: s.file_name, - file_size: s.file_size, - mime_type: s.mime_type, - folder_name: s.folder_name, - is_folder: !!s.folder_id, - needs_password: !!s.password_hash, - expires_at: s.expires_at, - }); -}); - -// ── POST /api/public/file-share/:token/download ─────────────────────────── -// Datei herunterladen (mit optionalem Passwort) -router.post('/public/:token/download', async (req, res) => { - const s = db.prepare(` - SELECT s.*, f.name as file_name, f.size as file_size, f.mime_type, f.path as file_path - FROM public_file_shares s - LEFT JOIN files f ON f.id = s.file_id - WHERE s.token=? AND s.file_id IS NOT NULL - `).get(req.params.token); - - if (!s) return res.status(404).json({ error: 'Link nicht gefunden oder kein Datei-Link' }); - if (s.expires_at && s.expires_at < Math.floor(Date.now() / 1000)) { - return res.status(410).json({ error: 'Link abgelaufen' }); - } - if (s.password_hash) { - const { password } = req.body; - if (!password) return res.status(401).json({ error: 'Passwort erforderlich' }); - const ok = await bcrypt.compare(password, s.password_hash); - if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); - } - - const filePath = path.join(UPLOAD_DIR, s.file_path); - if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' }); - - db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id); - - res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(s.file_name)}"`); - res.setHeader('Content-Type', s.mime_type || 'application/octet-stream'); - fs.createReadStream(filePath).pipe(res); -}); - -// ── POST /api/public/file-share/:token/folder ───────────────────────────── -// Ordnerinhalt abrufen (mit optionalem Passwort) -router.post('/public/:token/folder', async (req, res) => { - const s = db.prepare(` - SELECT s.*, fo.name as folder_name - FROM public_file_shares s - LEFT JOIN folders fo ON fo.id = s.folder_id - WHERE s.token=? AND s.folder_id IS NOT NULL - `).get(req.params.token); - - if (!s) return res.status(404).json({ error: 'Link nicht gefunden oder kein Ordner-Link' }); - if (s.expires_at && s.expires_at < Math.floor(Date.now() / 1000)) { - return res.status(410).json({ error: 'Link abgelaufen' }); - } - if (s.password_hash) { - const { password } = req.body; - if (!password) return res.status(401).json({ error: 'Passwort erforderlich' }); - const ok = await bcrypt.compare(password, s.password_hash); - if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); - } - - const files = db.prepare(` - SELECT id, name, size, mime_type, created_at FROM files - WHERE folder_id=? AND user_id=? - ORDER BY name - `).all(s.folder_id, s.user_id); - - db.prepare('UPDATE public_file_shares SET download_count = download_count + 1 WHERE id=?').run(s.id); - res.json({ folder_name: s.folder_name, files, token: s.token }); -}); - -// ── POST /api/public/file-share/:token/folder/:fileId ───────────────────── -// Einzelne Datei aus freigegebenem Ordner herunterladen -router.post('/public/:token/folder/:fileId', async (req, res) => { - const s = db.prepare(` - SELECT s.* FROM public_file_shares s WHERE s.token=? AND s.folder_id IS NOT NULL - `).get(req.params.token); - - if (!s) return res.status(404).json({ error: 'Link nicht gefunden' }); - if (s.expires_at && s.expires_at < Math.floor(Date.now() / 1000)) { - return res.status(410).json({ error: 'Link abgelaufen' }); - } - if (s.password_hash) { - const { password } = req.body; - if (!password) return res.status(401).json({ error: 'Passwort erforderlich' }); - const ok = await bcrypt.compare(password, s.password_hash); - if (!ok) return res.status(403).json({ error: 'Falsches Passwort' }); - } - - const file = db.prepare('SELECT * FROM files WHERE id=? AND folder_id=? AND user_id=?') - .get(req.params.fileId, s.folder_id, s.user_id); - if (!file) return res.status(404).json({ error: 'Datei nicht im freigegebenen Ordner' }); - - const filePath = path.join(UPLOAD_DIR, file.path); - if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' }); - - res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name)}"`); - res.setHeader('Content-Type', file.mime_type || 'application/octet-stream'); - fs.createReadStream(filePath).pipe(res); -}); - module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fbe7708..6a45399 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2907,48 +2907,38 @@ function PublicFileShare({ token }) { const [dlLoading,setDlLoading]= useState(null); const S2 = { - wrap: { minHeight:'100vh', background:'#0f1117', display:'flex', alignItems:'center', justifyContent:'center', padding:20 }, - box: { background:'#1a1d2e', border:'1px solid rgba(255,255,255,0.08)', borderRadius:16, padding:32, maxWidth:480, width:'100%' }, - head: { color:'#fff', fontFamily:'monospace', fontSize:18, fontWeight:700, marginBottom:6 }, - sub: { color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:12, marginBottom:24 }, - label: { color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:11, marginBottom:6, display:'block' }, - inp: { width:'100%', boxSizing:'border-box', background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)', borderRadius:8, padding:'10px 14px', color:'#fff', fontFamily:'monospace', fontSize:13, outline:'none' }, - btn: (c='#4ecdc4') => ({ background:`${c}18`, border:`1px solid ${c}44`, borderRadius:8, padding:'10px 16px', color:c, fontFamily:'monospace', fontSize:13, cursor:'pointer', width:'100%', marginTop:10 }), - fileRow:{ display:'flex', alignItems:'center', gap:12, padding:'10px 14px', background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:8, marginBottom:8 }, - err: { color:'#f87171', fontFamily:'monospace', fontSize:12, marginTop:8 }, + wrap: { minHeight:'100vh', background:'#0f1117', display:'flex', alignItems:'center', justifyContent:'center', padding:20 }, + box: { background:'#1a1d2e', border:'1px solid rgba(255,255,255,0.08)', borderRadius:16, padding:32, maxWidth:480, width:'100%' }, + head: { color:'#fff', fontFamily:'monospace', fontSize:18, fontWeight:700, marginBottom:6 }, + sub: { color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:12, marginBottom:24 }, + inp: { width:'100%', boxSizing:'border-box', background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.12)', borderRadius:8, padding:'10px 14px', color:'#fff', fontFamily:'monospace', fontSize:13, outline:'none' }, + btn: (c='#4ecdc4') => ({ background:`${c}18`, border:`1px solid ${c}44`, borderRadius:8, padding:'10px 16px', color:c, fontFamily:'monospace', fontSize:13, cursor:'pointer', width:'100%', marginTop:10 }), + fileRow: { display:'flex', alignItems:'center', gap:12, padding:'10px 14px', background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.07)', borderRadius:8, marginBottom:8 }, + err: { color:'#f87171', fontFamily:'monospace', fontSize:12, marginTop:8 }, }; + const BASE = '/api/public/file-share/' + token; + useEffect(() => { - fetch('/api/public/file-share/' + token) + fetch(BASE) .then(async r => { const d = await r.json(); if (!r.ok) { setErrMsg(d.error || 'Link ungültig'); setPhase('error'); return; } setInfo(d); - setPhase(d.needs_password ? 'pw' : 'ready'); - if (!d.needs_password && d.is_folder) loadFolder(''); + setPhase('pw'); // Passwort immer Pflicht }) .catch(() => { setErrMsg('Verbindung fehlgeschlagen'); setPhase('error'); }); }, [token]); - async function loadFolder(pw2) { - const res = await fetch('/api/public/file-share/' + token + '/folder', { - method:'POST', headers:{'Content-Type':'application/json'}, - body: JSON.stringify({ password: pw2 }), - }); - const d = await res.json(); - if (!res.ok) { setErrMsg(d.error); return; } - setFiles(d.files || []); - setPhase('ready'); - } - async function handlePw() { if (!pw.trim()) return; - const res = await fetch('/api/public/file-share/' + token + (info?.is_folder ? '/folder' : '/download'), { + const url = BASE + (info?.is_folder ? '/folder' : '/download'); + const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ password: pw }), }); const d = await res.json(); - if (!res.ok) { setErrMsg(d.error); return; } + if (!res.ok) { setErrMsg(d.error || 'Falsches Passwort'); return; } setPassword(pw); setErrMsg(''); if (info?.is_folder) { setFiles(d.files || []); setPhase('ready'); } @@ -2958,16 +2948,17 @@ function PublicFileShare({ token }) { async function downloadFile(fileId, fileName) { setDlLoading(fileId || 'single'); try { - const res = await fetch( - '/api/public/file-share/' + token + (fileId ? '/folder/' + fileId : '/download'), - { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ password }) } - ); + const url = BASE + (fileId ? '/folder/' + fileId : '/download'); + const res = await fetch(url, { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ password }), + }); if (!res.ok) { const d = await res.json(); setErrMsg(d.error); return; } const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); a.href = url; a.download = fileName; + const objUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = objUrl; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 5000); + setTimeout(() => URL.revokeObjectURL(objUrl), 5000); } finally { setDlLoading(null); } } @@ -2978,43 +2969,50 @@ function PublicFileShare({ token }) { return (b/1024/1024).toFixed(1) + ' MB'; } + // Abgelaufen/ungültig → Redirect zu google.de + if (phase === 'error') { + window.location.replace('https://www.google.de'); + return