diff --git a/backend/src/index.js b/backend/src/index.js index b36237b..2e5b5f0 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -70,6 +70,8 @@ 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/tools/dateien/public-shares', require('./tools/dateien/public-share')); +app.use('/api/public/file-share', require('./tools/dateien/public-share')); app.use('/api/search', require('./routes/search')); // Öffentliche Upload-Seite: React-App ausliefern – App erkennt /u/ Pfad selbst @@ -78,6 +80,12 @@ app.get('/u/:token', (_req, res) => { res.sendFile(require('path').join(PUBLIC, 'index.html')); }); +// Öffentliche Datei-Share-Seite: React-App ausliefern – App erkennt /s/ Pfad selbst +app.get('/s/:token', (_req, res) => { + res.setHeader('Cache-Control', 'no-store, no-cache'); + res.sendFile(require('path').join(PUBLIC, 'index.html')); +}); + const PUBLIC = path.join(__dirname, '../public'); app.get('/manifest.json', (_req, res) => { diff --git a/backend/src/tools/dateien/public-share.js b/backend/src/tools/dateien/public-share.js new file mode 100644 index 0000000..9661f04 --- /dev/null +++ b/backend/src/tools/dateien/public-share.js @@ -0,0 +1,223 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const path = require('path'); +const fs = require('fs'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; + +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 +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 + 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.user_id = ? + ORDER BY s.created_at DESC + `).all(req.user.id); + res.json({ shares }); +}); + +// ── POST /api/tools/dateien/public-shares ───────────────────────────────── +// Neuen Share erstellen +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' }); + + // 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' }); + } + if (folder_id) { + const fo = db.prepare('SELECT id FROM folders WHERE id=? AND user_id=?').get(folder_id, req.user.id); + if (!fo) return res.status(403).json({ error: 'Keine Berechtigung' }); + } + + 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 r = db.prepare(` + INSERT INTO public_file_shares (token, user_id, file_id, folder_id, label, password_hash, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(token, req.user.id, file_id || null, folder_id || null, label || null, password_hash, expires_at); + + const share = db.prepare('SELECT * FROM public_file_shares WHERE id=?').get(r.lastInsertRowid); + res.json({ share, token }); +}); + +// ── DELETE /api/tools/dateien/public-shares/:id ─────────────────────────── +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' }); + db.prepare('DELETE FROM public_file_shares WHERE id=?').run(s.id); + 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 c6cf09e..45fc86c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1513,6 +1513,7 @@ const SEARCH_TOOL_INDEX = [ {type:'tool',label:'Code-Schnipsel',icon:'📄',sub:'Werkzeuge',id:'codeschnipsel',keywords:['code','snippet','skript','programmierung','source']}, {type:'tool',label:'CAD-Skizzen',icon:'📐',sub:'Werkzeuge',id:'skizze',keywords:['cad','skizze','zeichnung','sketch']}, {type:'tool',label:'Dev-Tools',icon:'🔧',sub:'Werkzeuge',id:'devtools',keywords:['entwickler','developer','tools','werkzeuge']}, + {type:'tool',label:'Paywall-Killer',icon:'🔓',sub:'Werkzeuge',id:'paywallkiller',keywords:['paywall','archiv','archive','artikel','zeitung','bild','waz','heise','spiegel','zeit','bypass','umgehen','lesen','gesperrt','bezahlschranke']}, {type:'tool',label:'Media',icon:'🎬',sub:'Freizeit',id:'media',keywords:['kino','film','movie','streaming','demnächst','favoriten','cinema']}, {type:'tool',label:'Kino – Aktuell',icon:'🎬',sub:'Media',id:'media',keywords:['kino','kinocharts','charts','laufend','now playing']}, {type:'tool',label:'Kino – Demnächst',icon:'🗓',sub:'Media',id:'media',keywords:['demnächst','neustart','upcoming','vorschau','kino']}, @@ -2894,6 +2895,157 @@ function AdminPanel({ toast, mobile, user, nav }) { // ── App Shell ───────────────────────────────────────────────────────────────── // ── Öffentliche Upload-Seite ───────────────────────────────────────────────── + +// ── Öffentliche Datei/Ordner-Download-Seite ──────────────────────────────── +function PublicFileShare({ token }) { + const [phase, setPhase] = useState('loading'); + const [info, setInfo] = useState(null); + const [files, setFiles] = useState([]); + const [pw, setPw] = useState(''); + const [password, setPassword] = useState(''); + const [errMsg, setErrMsg] = useState(''); + 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 }, + }; + + useEffect(() => { + fetch('/api/public/file-share/public/' + token) + .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(''); + }) + .catch(() => { setErrMsg('Verbindung fehlgeschlagen'); setPhase('error'); }); + }, [token]); + + async function loadFolder(pw2) { + const res = await fetch('/api/public/file-share/public/' + 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/public/' + token + (info?.is_folder ? '/folder' : '/download'), { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ password: pw }), + }); + const d = await res.json(); + if (!res.ok) { setErrMsg(d.error); return; } + setPassword(pw); + setErrMsg(''); + if (info?.is_folder) { setFiles(d.files || []); setPhase('ready'); } + else { setPhase('ready'); } + } + + async function downloadFile(fileId, fileName) { + setDlLoading(fileId || 'single'); + try { + const res = await fetch( + '/api/public/file-share/public/' + token + (fileId ? '/folder/' + fileId : '/download'), + { 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; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 5000); + } finally { setDlLoading(null); } + } + + function fmtSize(b) { + if (!b) return ''; + if (b < 1024) return b + ' B'; + if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB'; + return (b/1024/1024).toFixed(1) + ' MB'; + } + + return ( +
+
+
🔗 {info?.is_folder ? '📁 Ordner' : '📄 Datei'} – Freigabe
+
+ {info?.folder_name || info?.file_name || info?.label || 'Öffentlicher Link'} + {info?.expires_at && · Gültig bis {new Date(info.expires_at*1000).toLocaleDateString('de-DE')}} +
+ + {phase === 'loading' &&
Lade...
} + + {phase === 'error' &&
⚠️ {errMsg}
} + + {phase === 'pw' && ( + <> + Passwort + setPw(e.target.value)} + onKeyDown={e=>e.key==='Enter'&&handlePw()} autoFocus + placeholder="Passwort eingeben" style={S2.inp} /> + {errMsg &&
{errMsg}
} + + + )} + + {phase === 'ready' && !info?.is_folder && ( + <> +
+ 📄 +
+
{info.file_name}
+
{fmtSize(info.file_size)}
+
+
+ + + )} + + {phase === 'ready' && info?.is_folder && ( + <> +
+ {files.length} Datei{files.length!==1?'en':''} im Ordner +
+ {files.map(f => ( +
+ 📄 +
+
{f.name}
+
{fmtSize(f.size)}
+
+ +
+ ))} + {files.length === 0 &&
Ordner ist leer.
} + + )} +
+
+ ); +} + function PublicUpload({ token }) { const [phase, setPhase] = useState('loading'); const [info, setInfo] = useState(null); @@ -3041,6 +3193,8 @@ export default function App() { // Öffentliche Upload-Seite – kein Login, kein Auth const _uploadMatch = window.location.pathname.match(/^\/u\/(.+)$/); if (_uploadMatch) return ; + const _shareMatch = window.location.pathname.match(/^\/s\/(.+)$/); + if (_shareMatch) return ; const mobile = useIsMobile(); const [splash, setSplash] = useState(() => { diff --git a/frontend/src/tools/dateien.jsx b/frontend/src/tools/dateien.jsx index 61a7de7..4bc7d46 100644 --- a/frontend/src/tools/dateien.jsx +++ b/frontend/src/tools/dateien.jsx @@ -26,6 +26,160 @@ const IconBtn = ({ onClick, color, title, children, stop }) => ( ); +// ── Öffentlicher Link-Share Modal ───────────────────────────────────────────── +function PublicShareModal({ item, itemType, onClose, toast }) { + const [password, setPassword] = useState(''); + const [expiresH, setExpiresH] = useState(24); + const [label, setLabel] = useState(''); + const [shares, setShares] = useState([]); + const [creating, setCreating] = useState(false); + const [newLink, setNewLink] = useState(null); + + const isFolder = itemType === 'folder'; + const base = '/tools/dateien/public-shares'; + + useEffect(() => { + api(base).then(d => setShares((d.shares||[]).filter(s => isFolder + ? s.folder_id === item.id + : s.file_id === item.id + ))).catch(()=>{}); + }, []); + + async function createShare() { + setCreating(true); + try { + const body = { + [isFolder ? 'folder_id' : 'file_id']: item.id, + password: password || undefined, + expires_hours: expiresH > 0 ? expiresH : undefined, + label: label || undefined, + }; + const d = await api(base, { body }); + const link = `${window.location.origin}/s/${d.token}`; + setNewLink(link); + setShares(p => [d.share, ...p]); + navigator.clipboard?.writeText(link).catch(()=>{}); + toast('Link erstellt & kopiert ✓'); + } catch(e) { toast('Fehler: ' + e.message); } + finally { setCreating(false); } + } + + async function deleteShare(id) { + await api(`${base}/${id}`, { method:'DELETE' }); + setShares(p => p.filter(s => s.id !== id)); + toast('Link gelöscht'); + } + + function copyLink(token) { + const link = `${window.location.origin}/s/${token}`; + navigator.clipboard?.writeText(link).then(() => toast('Link kopiert ✓')).catch(() => toast(link)); + } + + function fmtExp(exp) { + if (!exp) return 'Kein Ablauf'; + const d = new Date(exp * 1000); + return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit'}); + } + + const expiryOptions = [ + { 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 }, + ]; + + return ( +
e.target===e.currentTarget&&onClose()}> +
+
+
+ 🔗 Öffentlicher Link +
+ +
+
+ {isFolder ? '📁' : '📄'} {item.name} +
+ + {/* Neuer Link */} +
+
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}} /> + + {/* Ablauf */} +
+ {expiryOptions.map(o => ( + + ))} +
+ + + + {newLink && ( +
+
LINK (kopiert)
+
{newLink}
+
+ )} +
+ + {/* Bestehende Links */} + {shares.length > 0 && ( +
+
AKTIVE LINKS
+ {shares.map(s => ( +
+
+
+ {s.label || '—'} + {!!s.password_hash && 🔒} +
+
+ + +
+
+
+ Ablauf: {fmtExp(s.expires_at)} · {s.download_count} Abrufe +
+
+ ))} +
+ )} +
+
+ ); +} + // ── Share Modal ─────────────────────────────────────────────────────────────── function ShareModal({ item, type, onClose, toast }) { const [username, setUsername] = useState(''); @@ -405,6 +559,7 @@ export default function Dateien({ toast, mobile }) { const [tab, setTab] = useState('own'); const [search, setSearch] = useState(''); const [shareItem, setShareItem] = useState(null); + const [publicShareItem, setPublicShareItem] = useState(null); const [pwPrompt, setPwPrompt] = useState(null); // {file} const [uploading, setUploading] = useState(false); const [dragOver, setDragOver] = useState(false); @@ -542,6 +697,7 @@ export default function Dateien({ toast, mobile }) { )} {shareItem && {setShareItem(null);load();}} toast={toast}/>} + {publicShareItem && setPublicShareItem(null)} toast={toast}/>} {pwPrompt && { setPwPrompt(null); download(pwPrompt, pw); }} @@ -681,6 +837,7 @@ export default function Dateien({ toast, mobile }) {
{canManage && ( setShareItem({item:folder,type:'folder'})} color="#ffe66d" title="Teilen" stop>🤝 + {e.stopPropagation();setPublicShareItem({item:folder,type:'folder'});}} color="#4ade80" title="Öffentlicher Link" stop>🔗 )} {canManage&&tab==='own' && ( delFolder(folder)} color="#ff6b9d" title="Löschen" stop> @@ -729,6 +886,7 @@ export default function Dateien({ toast, mobile }) { {canManage && ( setShareItem({item:file,type:'file'})} color="#ffe66d" title="Teilen">🤝 + setPublicShareItem({item:file,type:'file'})} color="#4ade80" title="Öffentlicher Link">🔗 )} {isOwn && ( delFile(file)} color="#ff6b9d" title="Löschen">