diff --git a/backend/src/index.js b/backend/src/index.js index 2e5b5f0..30a1260 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -71,7 +71,7 @@ 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/tools/dateien/public-shares', require('./tools/dateien/public-share')); -app.use('/api/public/file-share', require('./tools/dateien/public-share')); +app.use('/api/public/file-share', require('./tools/dateien/public-share-public')); app.use('/api/search', require('./routes/search')); // Öffentliche Upload-Seite: React-App ausliefern – App erkennt /u/ Pfad selbst diff --git a/backend/src/tools/dateien/public-share-public.js b/backend/src/tools/dateien/public-share-public.js new file mode 100644 index 0000000..e0a5f34 --- /dev/null +++ b/backend/src/tools/dateien/public-share-public.js @@ -0,0 +1,96 @@ +// Ö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(); + +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 + 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(token); +} + +function checkExpiry(s) { + return s.expires_at && s.expires_at < Math.floor(Date.now() / 1000); +} + +// GET /:token – Metadaten (Name, Passwort nötig, abgelaufen?) +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' }); + 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 /:token/download – Datei herunterladen +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' }); + } + 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 /:token/folder – Ordnerinhalt abrufen +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' }); + } + 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 /:token/folder/:fileId – Einzelne Datei aus Ordner +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' }); + } + 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/backend/src/tools/dateien/public-share.js b/backend/src/tools/dateien/public-share.js index 9661f04..16927d7 100644 --- a/backend/src/tools/dateien/public-share.js +++ b/backend/src/tools/dateien/public-share.js @@ -49,6 +49,8 @@ router.get('/', authenticate, (req, res) => { router.post('/', authenticate, (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' }); // Sicherstellen dass die Datei/Ordner dem User gehört if (file_id) { diff --git a/frontend/src/tools/dateien.jsx b/frontend/src/tools/dateien.jsx index abfcc4e..80b287f 100644 --- a/frontend/src/tools/dateien.jsx +++ b/frontend/src/tools/dateien.jsx @@ -28,16 +28,23 @@ const IconBtn = ({ onClick, color, title, children, stop }) => ( // ── Öffentlicher Link-Share Modal ───────────────────────────────────────────── function PublicShareModal({ item, itemType, onClose, toast }) { - const [password, setPassword] = useState(''); + const [password, setPassword] = useState(() => genPw()); + const [showPw, setShowPw] = useState(false); const [expiresH, setExpiresH] = useState(24); const [label, setLabel] = useState(''); const [shares, setShares] = useState([]); const [creating, setCreating] = useState(false); const [newLink, setNewLink] = useState(null); + const [newPw, setNewPw] = useState(''); const isFolder = itemType === 'folder'; const base = '/tools/dateien/public-shares'; + function genPw() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; + return Array.from({length:10}, () => chars[Math.floor(Math.random()*chars.length)]).join(''); + } + useEffect(() => { api(base).then(d => setShares((d.shares||[]).filter(s => isFolder ? s.folder_id === item.id @@ -46,20 +53,23 @@ function PublicShareModal({ item, itemType, onClose, toast }) { }, []); async function createShare() { + if (!password.trim()) { toast('Passwort ist Pflicht'); return; } setCreating(true); try { const body = { [isFolder ? 'folder_id' : 'file_id']: item.id, - password: password || undefined, - expires_hours: expiresH > 0 ? expiresH : undefined, + password: password.trim(), + expires_hours: expiresH, label: label || undefined, }; const d = await api(base, { body }); const link = `${window.location.origin}/s/${d.token}`; setNewLink(link); + setNewPw(password.trim()); setShares(p => [d.share, ...p]); navigator.clipboard?.writeText(link).catch(()=>{}); toast('Link erstellt & kopiert ✓'); + setPassword(genPw()); } catch(e) { toast('Fehler: ' + e.message); } finally { setCreating(false); } } @@ -82,11 +92,10 @@ function PublicShareModal({ item, itemType, onClose, toast }) { } const expiryOptions = [ - { label:'1 Stunde', h:1 }, + { label:'1 Stunde', h:1 }, { label:'24 Stunden', h:24 }, - { label:'7 Tage', h:168 }, - { label:'30 Tage', h:720 }, - { label:'Kein Ablauf', h:0 }, + { label:'7 Tage', h:168 }, + { label:'30 Tage', h:720 }, ]; return ( @@ -94,9 +103,7 @@ function PublicShareModal({ item, itemType, onClose, toast }) { onClick={e=>e.target===e.currentTarget&&onClose()}>
-
- 🔗 Öffentlicher Link -
+
🔗 Öffentlicher Link
@@ -107,17 +114,32 @@ function PublicShareModal({ item, itemType, onClose, toast }) {
NEUEN LINK ERSTELLEN
- {/* Label */} setLabel(e.target.value)} placeholder="Bezeichnung (optional)" style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} /> - {/* Passwort */} - setPassword(e.target.value)} - placeholder="Passwort (optional)" - style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} /> + {/* Passwort Pflicht + Generator */} +
+
+ setPassword(e.target.value)} + placeholder="Passwort (Pflicht)" + style={{...S.inp, width:'100%', boxSizing:'border-box', paddingRight:36}} + /> + +
+ +
- {/* Ablauf */} + {/* Ablauf – kein "Kein Ablauf" */}
{expiryOptions.map(o => (
- {newLink && ( -
-
LINK (kopiert)
-
{newLink}
+
+
LINK (in Zwischenablage)
+
{newLink}
+
PASSWORT
+
+
{newPw}
+ +
)}
@@ -154,8 +184,7 @@ function PublicShareModal({ item, itemType, onClose, toast }) {
- {s.label || '—'} - {!!s.password_hash && 🔒} + {s.label || '—'} 🔒