feat: öffentliche Datei/Ordner-Freigabe mit Link, Ablauf & Passwort; Paywall-Killer in Suche

This commit is contained in:
2026-06-13 10:53:32 +02:00
parent b9a596a3b5
commit c3140aab7e
4 changed files with 543 additions and 0 deletions

View File

@@ -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 (
<div style={S2.wrap}>
<div style={S2.box}>
<div style={S2.head}>🔗 {info?.is_folder ? '📁 Ordner' : '📄 Datei'} Freigabe</div>
<div style={S2.sub}>
{info?.folder_name || info?.file_name || info?.label || 'Öffentlicher Link'}
{info?.expires_at && <span style={{marginLeft:8}}>· Gültig bis {new Date(info.expires_at*1000).toLocaleDateString('de-DE')}</span>}
</div>
{phase === 'loading' && <div style={S2.sub}>Lade...</div>}
{phase === 'error' && <div style={S2.err}> {errMsg}</div>}
{phase === 'pw' && (
<>
<span style={S2.label}>Passwort</span>
<input type="password" value={pw} onChange={e=>setPw(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handlePw()} autoFocus
placeholder="Passwort eingeben" style={S2.inp} />
{errMsg && <div style={S2.err}>{errMsg}</div>}
<button onClick={handlePw} style={S2.btn('#4ecdc4')}>🔓 Entsperren</button>
</>
)}
{phase === 'ready' && !info?.is_folder && (
<>
<div style={{ ...S2.fileRow, marginBottom:16 }}>
<span style={{fontSize:24}}>📄</span>
<div>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{info.file_name}</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11}}>{fmtSize(info.file_size)}</div>
</div>
</div>
<button onClick={() => downloadFile(null, info.file_name)}
disabled={dlLoading === 'single'}
style={S2.btn('#4ade80')}>
{dlLoading === 'single' ? '⏳ Lädt...' : '⬇ Datei herunterladen'}
</button>
</>
)}
{phase === 'ready' && info?.is_folder && (
<>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,marginBottom:12}}>
{files.length} Datei{files.length!==1?'en':''} im Ordner
</div>
{files.map(f => (
<div key={f.id} style={S2.fileRow}>
<span style={{fontSize:18}}>📄</span>
<div style={{flex:1}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12}}>{f.name}</div>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>{fmtSize(f.size)}</div>
</div>
<button onClick={() => downloadFile(f.id, f.name)}
disabled={!!dlLoading}
style={{...S2.btn('#4ade80'), width:'auto', marginTop:0, padding:'6px 14px', fontSize:11}}>
{dlLoading === f.id ? '⏳' : '⬇'}
</button>
</div>
))}
{files.length === 0 && <div style={S2.sub}>Ordner ist leer.</div>}
</>
)}
</div>
</div>
);
}
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 <PublicUpload token={_uploadMatch[1]}/>;
const _shareMatch = window.location.pathname.match(/^\/s\/(.+)$/);
if (_shareMatch) return <PublicFileShare token={_shareMatch[1]}/>;
const mobile = useIsMobile();
const [splash, setSplash] = useState(() => {