Upload-Freigabe: öffentlicher Upload-Link mit Passwort, Ablaufzeit, max. Größe; Admin-Verwaltung

This commit is contained in:
2026-06-05 20:01:52 +02:00
parent 8a5e8cc003
commit dd9a91b5d7
6 changed files with 647 additions and 12 deletions

View File

@@ -1481,7 +1481,6 @@ function SearchResultItem({ item, idx, activeIdx, onNavigate, onHover }) {
if (item.type==='calendar_feed') { icon='📅'; primary=item.name; secondary='Kalender-Abo'; }
if (item.type==='calendar_event') { icon='📆'; primary=item.summary; secondary=`${item.feed_name||'Kalender'}${item.start_dt?' · '+formatIcalDate(item.start_dt):''}`; }
if (item.type==='qr') { icon='◻'; primary=item.label||item.url; secondary=`QR-Code · ${item.url}`; }
if (item.type==='user') { icon='💬'; primary=item.username; secondary='Nachricht schreiben → Chat öffnen'; }
return (
<div onClick={()=>onNavigate(item)} onMouseEnter={()=>onHover(idx)}
style={{display:'flex',alignItems:'center',gap:10,padding:'9px 14px',cursor:'pointer',
@@ -1643,7 +1642,6 @@ function GlobalSearch({ setActive, setDevToolsNav, mobile }) {
...(results.calendar_feeds||[]).map(f=>({...f,type:'calendar_feed'})),
...(results.calendar_events||[]).map(e=>({...e,type:'calendar_event'})),
...(results.qr_codes||[]).map(q=>({...q,type:'qr'})),
...(results.users||[]).map(u=>({...u,type:'user'})),
] : [];
const navigate = (item) => {
@@ -1663,8 +1661,7 @@ function GlobalSearch({ setActive, setDevToolsNav, mobile }) {
else if (item.type === 'file' || item.type === 'file_folder') { setActive('dateien'); }
else if (item.type === 'push') { setActive('dashboard'); }
else if (item.type === 'calendar_feed' || item.type === 'calendar_event') { setActive('dashboard'); }
else if (item.type === 'qr') { setActive('devtools'); setDevToolsNav({tool:'qrcode', ts: Date.now()}); }
else if (item.type === 'user') { setActive('nachrichten'); setDevToolsNav({userId: item.id, ts: Date.now()}); }
else if (item.type === 'qr') { setActive('devtools'); setDevToolsNav({tool:'qrcode', ts: Date.now()}); }
};
const onKey = e => {
@@ -1803,12 +1800,6 @@ function GlobalSearch({ setActive, setDevToolsNav, mobile }) {
{results.qr_codes.map((item,i)=>(<SearchResultItem key={`qr${i}`} item={{...item,type:'qr'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
</div>
)}
{results.users?.length>0 && (
<div>
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>BENUTZER / CHAT</div>
{results.users.map((item,i)=>(<SearchResultItem key={`u${i}`} item={{...item,type:'user'}} idx={0} activeIdx={-1} onNavigate={navigate} onHover={()=>{}}/>))}
</div>
)}
{results.notes?.length>0 && (
<div>
<div style={{padding:'7px 14px 4px',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9,letterSpacing:1}}>NOTIZEN</div>
@@ -2594,6 +2585,77 @@ const Field = ({ label, type='text', value, onChange, placeholder='', autoCapita
</div>
);
// ── Admin: Upload-Freigabe Berechtigungen ─────────────────────────────────────
function UploadShareAdminPanel({ toast }) {
const [users, setUsers] = useState([]);
const [logs, setLogs] = useState([]);
const [showLogs, setShowLogs] = useState(false);
const load = () => {
api('/users').then(setUsers).catch(()=>{});
};
const loadLogs = () => {
api('/upload-shares/admin/logs').then(setLogs).catch(()=>{});
setShowLogs(true);
};
useEffect(()=>{ load(); },[]);
const toggle = async (uid, current) => {
try {
await api('/upload-shares/admin/permission', { body:{ user_id: uid, allow: !current } });
setUsers(p => p.map(u => u.id===uid ? {...u, allow_upload_share: current?0:1} : u));
toast(current ? 'Berechtigung entzogen' : 'Berechtigung erteilt');
} catch(e) { toast(e.message,'error'); }
};
const fmtDate = d => d ? new Date(d).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '';
const fmtSize = b => b>1024*1024 ? (b/1024/1024).toFixed(1)+' MB' : (b/1024).toFixed(0)+' KB';
return (
<div>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12,marginBottom:12,lineHeight:1.7}}>
Admins haben immer Zugriff auf Upload-Freigaben.<br/>
Hier kannst du die Funktion für einzelne Mitglieder freischalten.
</div>
{users.filter(u=>u.role!=='admin').map(u => (
<div key={u.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.06)'}}>
<span style={{flex:1,color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{u.username}</span>
<button onClick={()=>toggle(u.id, !!u.allow_upload_share)}
style={{
padding:'4px 14px',borderRadius:16,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background: u.allow_upload_share ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.05)',
border: `1px solid ${u.allow_upload_share ? '#4ecdc4' : 'rgba(255,255,255,0.15)'}`,
color: u.allow_upload_share ? '#4ecdc4' : 'rgba(255,255,255,0.4)',
}}>
{u.allow_upload_share ? '● Erlaubt' : '○ Gesperrt'}
</button>
</div>
))}
<button onClick={loadLogs} style={{...S.btn('#ffe66d',true),marginTop:14,fontSize:11}}>
📋 Nutzungsprotokoll anzeigen
</button>
{showLogs && (
<div style={{marginTop:12}}>
<div style={{...S.head,marginBottom:8}}>ALLE UPLOADS ÜBER FREIGABE-LINKS</div>
{logs.length===0
? <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Noch keine Uploads.</div>
: logs.map(l=>(
<div key={l.id} style={{display:'flex',gap:10,padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)',flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,flexShrink:0,minWidth:90}}>{l.username}</span>
<div style={{flex:1,minWidth:120}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{l.originalname}</div>
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:10}}>{fmtDate(l.uploaded_at)} · {fmtSize(l.size)} · Max: {l.max_size_mb} MB</div>
</div>
</div>
))
}
</div>
)}
</div>
);
}
function AdminPanel({ toast, mobile, user, nav }) {
const [section, setSection] = useState('profil');
useEffect(() => { if (nav?.section) setSection(nav.section); }, [nav?.ts]);
@@ -2740,6 +2802,9 @@ function AdminPanel({ toast, mobile, user, nav }) {
<Sec title="DATEI-MANAGER LIMITS">
<DateiSettings toast={toast}/>
</Sec>
<Sec title="UPLOAD-FREIGABE">
<UploadShareAdminPanel toast={toast}/>
</Sec>
</div>
)}
@@ -2898,7 +2963,7 @@ export default function App() {
<main style={mainStyle}>
{active==='dashboard' && <Dashboard toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} user={user}/>}
{active==='admin' && <AdminPanel toast={toast} mobile={mobile} user={user} nav={devToolsNav}/>}
{activeTool && <activeTool.component toast={toast} mobile={mobile} setActive={setActive} {...(activeTool.id==='devtools'||activeTool.id==='nachrichten'?{nav:devToolsNav}:{})}/>}
{activeTool && <activeTool.component toast={toast} mobile={mobile} setActive={setActive} {...(activeTool.id==='devtools'?{nav:devToolsNav}:{})}/>}
</main>
</div>
{mobile && (

View File

@@ -158,6 +158,192 @@ function PasswordPromptModal({ filename, onSubmit, onCancel }) {
);
}
// ── Upload-Ordner & Share-Verwaltung ─────────────────────────────────────────
function UploadShareManager({ toast }) {
const [shares, setShares] = useState([]);
const [folder, setFolder] = useState(null);
const [logs, setLogs] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const [showLogs, setShowLogs] = useState(false);
const [form, setForm] = useState({ password:'', expires_hours:24, max_size_mb:10 });
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(null);
const load = () => api('/upload-shares').then(d=>{ setShares(d.shares||[]); setFolder(d.folder); }).catch(()=>{});
useEffect(()=>{ load(); },[]);
const genPw = () => {
const chars='ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%';
setForm(p=>({...p, password: Array.from({length:12},()=>chars[Math.floor(Math.random()*chars.length)]).join('')}));
};
const create = async () => {
if (!form.password.trim()) { toast('Passwort erforderlich','error'); return; }
setBusy(true);
try {
await api('/upload-shares', { body: form });
toast('Upload-Link erstellt ✓'); setShowCreate(false); setForm({password:'',expires_hours:24,max_size_mb:10}); load();
} catch(e) { toast(e.message,'error'); }
setBusy(false);
};
const deactivate = async id => {
await api(`/upload-shares/${id}`, { method:'DELETE' });
load(); toast('Link deaktiviert');
};
const copyLink = (token) => {
const url = `${window.location.origin}/u/${token}`;
navigator.clipboard?.writeText(url).then(()=>{ setCopied(token); setTimeout(()=>setCopied(null),2000); }).catch(()=>{
toast(url);
});
};
const loadLogs = () => {
api('/upload-shares/logs').then(setLogs).catch(()=>{});
setShowLogs(true);
};
const fmtDate = d => d ? new Date(d).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '';
const fmtSize = b => b>1024*1024 ? (b/1024/1024).toFixed(1)+' MB' : (b/1024).toFixed(0)+' KB';
const DURATION_OPTS = [
[1,'1 Stunde'],[2,'2 Stunden'],[6,'6 Stunden'],[12,'12 Stunden'],
[24,'1 Tag'],[48,'2 Tage'],[72,'3 Tage'],[168,'1 Woche'],[336,'2 Wochen'],
];
const SIZE_OPTS = [1,2,5,10,20,50,100,250];
return (
<div>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:12,flexWrap:'wrap',gap:8}}>
<div>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:14,fontWeight:700}}>
📤 Upload-Ordner
</div>
{folder && <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,marginTop:2}}>
Dateien landen in: 📁 {folder.name}
</div>}
</div>
<div style={{display:'flex',gap:6}}>
<button onClick={loadLogs} style={{...S.btn('rgba(255,255,255,0.3)',true),fontSize:11,padding:'6px 12px'}}>
📋 Log
</button>
<button onClick={()=>setShowCreate(p=>!p)} style={{...S.btn('#4ecdc4'),padding:'7px 14px',fontSize:12}}>
{showCreate?'Abbrechen':'+ Link erstellen'}
</button>
</div>
</div>
{/* Erstellen-Formular */}
{showCreate && (
<div style={{...S.card,marginBottom:12}}>
<div style={{...S.head,marginBottom:12}}>UPLOAD-LINK KONFIGURIEREN</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:8}}>
<div>
<div style={{...S.head,fontSize:9,marginBottom:4}}>GÜLTIGKEITSDAUER</div>
<select value={form.expires_hours} onChange={e=>setForm(p=>({...p,expires_hours:Number(e.target.value)}))}
style={{...S.inp,width:'100%',cursor:'pointer'}}>
{DURATION_OPTS.map(([v,l])=><option key={v} value={v}>{l}</option>)}
</select>
</div>
<div>
<div style={{...S.head,fontSize:9,marginBottom:4}}>MAX. DATEIGRÖSSE</div>
<select value={form.max_size_mb} onChange={e=>setForm(p=>({...p,max_size_mb:Number(e.target.value)}))}
style={{...S.inp,width:'100%',cursor:'pointer'}}>
{SIZE_OPTS.map(v=><option key={v} value={v}>{v} MB</option>)}
</select>
</div>
</div>
<div style={{marginBottom:12}}>
<div style={{...S.head,fontSize:9,marginBottom:4}}>PASSWORT</div>
<div style={{display:'flex',gap:6}}>
<input value={form.password} onChange={e=>setForm(p=>({...p,password:e.target.value}))}
placeholder="Passwort vergeben" style={{...S.inp,flex:1,fontFamily:'monospace'}}/>
<button onClick={genPw} style={{...S.btn('#ffe66d',true),fontSize:11,padding:'0 12px',whiteSpace:'nowrap'}}>
Generieren
</button>
</div>
{form.password && <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,marginTop:4}}>
Passwort: <strong style={{color:'#ffe66d'}}>{form.password}</strong> notiere es jetzt
</div>}
</div>
<button onClick={create} disabled={busy} style={{...S.btn('#4ecdc4'),width:'100%',textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
{busy?'…':'🔗 Link erstellen & kopieren'}
</button>
</div>
)}
{/* Aktive Links */}
{shares.length===0 && !showCreate && (
<div style={{...S.card,textAlign:'center',padding:32}}>
<div style={{fontSize:28,marginBottom:8}}>📤</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
Noch kein Upload-Link. Erstelle einen Link um anderen das Hochladen zu erlauben.
</div>
</div>
)}
{shares.map(s => {
const expired = new Date(s.expires_at) < new Date();
return (
<div key={s.id} style={{...S.card,marginBottom:8,opacity:(!s.is_active||expired)?0.5:1}}>
<div style={{display:'flex',alignItems:'center',gap:10,flexWrap:'wrap'}}>
<div style={{flex:1,minWidth:0}}>
<div style={{display:'flex',alignItems:'center',gap:6,flexWrap:'wrap'}}>
<span style={{color:!s.is_active||expired?'#ff6b9d':expired?'#ffe66d':'#4ecdc4',fontFamily:'monospace',fontSize:11,
border:`1px solid currentColor`,borderRadius:4,padding:'1px 6px',flexShrink:0}}>
{!s.is_active?'Deaktiviert':expired?'Abgelaufen':'Aktiv'}
</span>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>
bis {fmtDate(s.expires_at)} · max {s.max_size_mb} MB · {s.upload_count||0} Upload(s)
</span>
</div>
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:10,marginTop:3,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
/u/{s.token}
</div>
</div>
<div style={{display:'flex',gap:5,flexShrink:0}}>
{s.is_active && !expired && (
<button onClick={()=>copyLink(s.token)}
style={{...S.btn(copied===s.token?'#4ecdc4':'#ffe66d',copied!==s.token),fontSize:10,padding:'4px 10px'}}>
{copied===s.token?'✓ Kopiert':'📋 Link'}
</button>
)}
{s.is_active && (
<button onClick={()=>deactivate(s.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}></button>
)}
</div>
</div>
</div>
);
})}
{/* Log-Ansicht */}
{showLogs && (
<div style={{...S.card,marginTop:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:10}}>
<div style={{...S.head,marginBottom:0}}>UPLOAD-PROTOKOLL</div>
<button onClick={()=>setShowLogs(false)} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:16}}></button>
</div>
{logs.length===0
? <div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11}}>Noch keine Uploads über Freigabe-Links.</div>
: logs.map(l=>(
<div key={l.id} style={{display:'flex',gap:10,padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{fontSize:14,flexShrink:0}}>📄</span>
<div style={{flex:1,minWidth:0}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{l.originalname}</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>{fmtDate(l.uploaded_at)} · {fmtSize(l.size)}</div>
</div>
</div>
))
}
</div>
)}
</div>
);
}
export default function Dateien({ toast, mobile }) {
const [data, setData] = useState({own:[],shared:[],sharedByMe:[]});
const [folders, setFolders] = useState({own:[],shared:[],sharedByMe:[]});
@@ -329,6 +515,7 @@ export default function Dateien({ toast, mobile }) {
['own','Dateien', null],
['shared','Geteilt mit mir', sharedCnt||null],
['sharedByMe','Geteilt von mir', byMeCnt||null],
['upload','📤 Upload-Ordner', null],
].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');setCurrentFolderId(null);setFolderPath([]);}} style={{
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
@@ -504,11 +691,14 @@ export default function Dateien({ toast, mobile }) {
<div style={{...S.card,textAlign:'center',padding:40}}>
<div style={{fontSize:28,marginBottom:10}}>📂</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
{search?'Keine Treffer.':tab==='own'?'Noch leer Datei hochladen oder Ordner anlegen.':'Noch nichts geteilt.'}
{search?'Keine Treffer.':tab==='own'?'Noch leer Datei hochladen oder Ordner anlegen.':tab==='upload'?'':' Noch nichts geteilt.'}
</div>
</div>
)}
</div>
</>
}
</div>
);
}