fix: öffentlicher Share-Endpunkt getrennt, Passwort Pflicht + Generator, kein unbegrenzter Ablauf

This commit is contained in:
2026-06-13 11:38:44 +02:00
parent ac5084e1ee
commit 5d4c6cae17
4 changed files with 151 additions and 24 deletions

View File

@@ -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/tools/qrcodes', require('./tools/qrcodes/routes'));
app.use('/api/upload-shares', require('./tools/dateien/upload-share')); 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/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')); app.use('/api/search', require('./routes/search'));
// Öffentliche Upload-Seite: React-App ausliefern App erkennt /u/ Pfad selbst // Öffentliche Upload-Seite: React-App ausliefern App erkennt /u/ Pfad selbst

View File

@@ -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;

View File

@@ -49,6 +49,8 @@ router.get('/', authenticate, (req, res) => {
router.post('/', authenticate, (req, res) => { router.post('/', authenticate, (req, res) => {
const { file_id, folder_id, password, expires_hours, label } = req.body; 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 (!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 // Sicherstellen dass die Datei/Ordner dem User gehört
if (file_id) { if (file_id) {

View File

@@ -28,16 +28,23 @@ const IconBtn = ({ onClick, color, title, children, stop }) => (
// ── Öffentlicher Link-Share Modal ───────────────────────────────────────────── // ── Öffentlicher Link-Share Modal ─────────────────────────────────────────────
function PublicShareModal({ item, itemType, onClose, toast }) { 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 [expiresH, setExpiresH] = useState(24);
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [shares, setShares] = useState([]); const [shares, setShares] = useState([]);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [newLink, setNewLink] = useState(null); const [newLink, setNewLink] = useState(null);
const [newPw, setNewPw] = useState('');
const isFolder = itemType === 'folder'; const isFolder = itemType === 'folder';
const base = '/tools/dateien/public-shares'; 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(() => { useEffect(() => {
api(base).then(d => setShares((d.shares||[]).filter(s => isFolder api(base).then(d => setShares((d.shares||[]).filter(s => isFolder
? s.folder_id === item.id ? s.folder_id === item.id
@@ -46,20 +53,23 @@ function PublicShareModal({ item, itemType, onClose, toast }) {
}, []); }, []);
async function createShare() { async function createShare() {
if (!password.trim()) { toast('Passwort ist Pflicht'); return; }
setCreating(true); setCreating(true);
try { try {
const body = { const body = {
[isFolder ? 'folder_id' : 'file_id']: item.id, [isFolder ? 'folder_id' : 'file_id']: item.id,
password: password || undefined, password: password.trim(),
expires_hours: expiresH > 0 ? expiresH : undefined, expires_hours: expiresH,
label: label || undefined, label: label || undefined,
}; };
const d = await api(base, { body }); const d = await api(base, { body });
const link = `${window.location.origin}/s/${d.token}`; const link = `${window.location.origin}/s/${d.token}`;
setNewLink(link); setNewLink(link);
setNewPw(password.trim());
setShares(p => [d.share, ...p]); setShares(p => [d.share, ...p]);
navigator.clipboard?.writeText(link).catch(()=>{}); navigator.clipboard?.writeText(link).catch(()=>{});
toast('Link erstellt & kopiert ✓'); toast('Link erstellt & kopiert ✓');
setPassword(genPw());
} catch(e) { toast('Fehler: ' + e.message); } } catch(e) { toast('Fehler: ' + e.message); }
finally { setCreating(false); } finally { setCreating(false); }
} }
@@ -86,7 +96,6 @@ function PublicShareModal({ item, itemType, onClose, toast }) {
{ label:'24 Stunden', h:24 }, { label:'24 Stunden', h:24 },
{ label:'7 Tage', h:168 }, { label:'7 Tage', h:168 },
{ label:'30 Tage', h:720 }, { label:'30 Tage', h:720 },
{ label:'Kein Ablauf', h:0 },
]; ];
return ( return (
@@ -94,9 +103,7 @@ function PublicShareModal({ item, itemType, onClose, toast }) {
onClick={e=>e.target===e.currentTarget&&onClose()}> onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:14,padding:24,width:'100%',maxWidth:440,maxHeight:'85vh',overflowY:'auto'}}> <div style={{background:'#1a1d2e',border:'1px solid rgba(255,255,255,0.1)',borderRadius:14,padding:24,width:'100%',maxWidth:440,maxHeight:'85vh',overflowY:'auto'}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}> <div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:14,fontWeight:700}}> <div style={{color:'#fff',fontFamily:'monospace',fontSize:14,fontWeight:700}}>🔗 Öffentlicher Link</div>
🔗 Öffentlicher Link
</div>
<button onClick={onClose} style={{background:'none',border:'none',color:'rgba(255,255,255,0.5)',fontSize:18,cursor:'pointer'}}></button> <button onClick={onClose} style={{background:'none',border:'none',color:'rgba(255,255,255,0.5)',fontSize:18,cursor:'pointer'}}></button>
</div> </div>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,marginBottom:20}}> <div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,marginBottom:20}}>
@@ -107,17 +114,32 @@ function PublicShareModal({ item, itemType, onClose, toast }) {
<div style={{marginBottom:20}}> <div style={{marginBottom:20}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:8,letterSpacing:1}}>NEUEN LINK ERSTELLEN</div> <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:8,letterSpacing:1}}>NEUEN LINK ERSTELLEN</div>
{/* Label */}
<input value={label} onChange={e=>setLabel(e.target.value)} <input value={label} onChange={e=>setLabel(e.target.value)}
placeholder="Bezeichnung (optional)" placeholder="Bezeichnung (optional)"
style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} /> style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} />
{/* Passwort */} {/* Passwort Pflicht + Generator */}
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} <div style={{display:'flex',gap:6,marginBottom:8}}>
placeholder="Passwort (optional)" <div style={{flex:1,position:'relative'}}>
style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} /> <input
type={showPw ? 'text' : 'password'}
value={password}
onChange={e=>setPassword(e.target.value)}
placeholder="Passwort (Pflicht)"
style={{...S.inp, width:'100%', boxSizing:'border-box', paddingRight:36}}
/>
<button onClick={()=>setShowPw(v=>!v)}
style={{position:'absolute',right:8,top:'50%',transform:'translateY(-50%)',background:'none',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:13}}>
{showPw ? '🙈' : '👁'}
</button>
</div>
<button onClick={()=>setPassword(genPw())} title="Passwort generieren"
style={{background:'rgba(245,158,11,0.12)',border:'1px solid rgba(245,158,11,0.3)',borderRadius:7,padding:'0 10px',color:'#f59e0b',cursor:'pointer',fontSize:14,flexShrink:0}}>
🔀
</button>
</div>
{/* Ablauf */} {/* Ablauf kein "Kein Ablauf" */}
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:12}}> <div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:12}}>
{expiryOptions.map(o => ( {expiryOptions.map(o => (
<button key={o.h} onClick={()=>setExpiresH(o.h)} <button key={o.h} onClick={()=>setExpiresH(o.h)}
@@ -133,15 +155,23 @@ function PublicShareModal({ item, itemType, onClose, toast }) {
))} ))}
</div> </div>
<button onClick={createShare} disabled={creating} <button onClick={createShare} disabled={creating||!password.trim()}
style={{...S.btn('#4ade80'), width:'100%', opacity:creating?0.5:1}}> style={{...S.btn('#4ade80'), width:'100%', opacity:(creating||!password.trim())?0.5:1}}>
{creating ? '⏳ Erstelle...' : '🔗 Link erstellen'} {creating ? '⏳ Erstelle...' : '🔗 Link erstellen'}
</button> </button>
{newLink && ( {newLink && (
<div style={{marginTop:10, padding:'8px 12px', background:'rgba(74,222,128,0.08)', border:'1px solid rgba(74,222,128,0.3)', borderRadius:8}}> <div style={{marginTop:10, padding:'10px 12px', background:'rgba(74,222,128,0.08)', border:'1px solid rgba(74,222,128,0.3)', borderRadius:8}}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9,marginBottom:4}}>LINK (kopiert)</div> <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9,marginBottom:4}}>LINK (in Zwischenablage)</div>
<div style={{color:'#4ade80',fontFamily:'monospace',fontSize:10,wordBreak:'break-all'}}>{newLink}</div> <div style={{color:'#4ade80',fontFamily:'monospace',fontSize:10,wordBreak:'break-all',marginBottom:6}}>{newLink}</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9,marginBottom:2}}>PASSWORT</div>
<div style={{display:'flex',alignItems:'center',gap:8}}>
<div style={{color:'#f59e0b',fontFamily:'monospace',fontSize:12,letterSpacing:2}}>{newPw}</div>
<button onClick={()=>navigator.clipboard?.writeText(newPw).then(()=>toast('Passwort kopiert ✓'))}
style={{background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.3)',borderRadius:5,padding:'2px 8px',color:'#f59e0b',fontFamily:'monospace',fontSize:9,cursor:'pointer'}}>
📋
</button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -154,8 +184,7 @@ function PublicShareModal({ item, itemType, onClose, toast }) {
<div key={s.id} style={{padding:'10px 12px',background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.08)',borderRadius:8,marginBottom:8}}> <div key={s.id} style={{padding:'10px 12px',background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.08)',borderRadius:8,marginBottom:8}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:4}}> <div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:4}}>
<div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:11}}> <div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:11}}>
{s.label || '—'} {s.label || '—'} <span style={{color:'#f59e0b',fontSize:9}}>🔒</span>
{!!s.password_hash && <span style={{marginLeft:6,color:'#f59e0b',fontSize:9}}>🔒</span>}
</div> </div>
<div style={{display:'flex',gap:6}}> <div style={{display:'flex',gap:6}}>
<button onClick={()=>copyLink(s.token)} <button onClick={()=>copyLink(s.token)}