fix: öffentlicher Share-Endpunkt getrennt, Passwort Pflicht + Generator, kein unbegrenzter Ablauf
This commit is contained in:
@@ -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
|
||||||
|
|||||||
96
backend/src/tools/dateien/public-share-public.js
Normal file
96
backend/src/tools/dateien/public-share-public.js
Normal 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;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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); }
|
||||||
}
|
}
|
||||||
@@ -82,11 +92,10 @@ function PublicShareModal({ item, itemType, onClose, toast }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expiryOptions = [
|
const expiryOptions = [
|
||||||
{ label:'1 Stunde', h:1 },
|
{ label:'1 Stunde', h:1 },
|
||||||
{ 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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user