feat: öffentliche Datei/Ordner-Freigabe mit Link, Ablauf & Passwort; Paywall-Killer in Suche
This commit is contained in:
@@ -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) => {
|
||||
|
||||
223
backend/src/tools/dateien/public-share.js
Normal file
223
backend/src/tools/dateien/public-share.js
Normal file
@@ -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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -26,6 +26,160 @@ const IconBtn = ({ onClick, color, title, children, stop }) => (
|
||||
</button>
|
||||
);
|
||||
|
||||
// ── Ö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 (
|
||||
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.7)',zIndex:1000,display:'flex',alignItems:'center',justifyContent:'center',padding:16}}
|
||||
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={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:16}}>
|
||||
<div style={{color:'#fff',fontFamily:'monospace',fontSize:14,fontWeight:700}}>
|
||||
🔗 Öffentlicher Link
|
||||
</div>
|
||||
<button onClick={onClose} style={{background:'none',border:'none',color:'rgba(255,255,255,0.5)',fontSize:18,cursor:'pointer'}}>✕</button>
|
||||
</div>
|
||||
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,marginBottom:20}}>
|
||||
{isFolder ? '📁' : '📄'} {item.name}
|
||||
</div>
|
||||
|
||||
{/* Neuer Link */}
|
||||
<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>
|
||||
|
||||
{/* Label */}
|
||||
<input value={label} onChange={e=>setLabel(e.target.value)}
|
||||
placeholder="Bezeichnung (optional)"
|
||||
style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} />
|
||||
|
||||
{/* Passwort */}
|
||||
<input type="password" value={password} onChange={e=>setPassword(e.target.value)}
|
||||
placeholder="Passwort (optional)"
|
||||
style={{...S.inp, width:'100%', boxSizing:'border-box', marginBottom:8}} />
|
||||
|
||||
{/* Ablauf */}
|
||||
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:12}}>
|
||||
{expiryOptions.map(o => (
|
||||
<button key={o.h} onClick={()=>setExpiresH(o.h)}
|
||||
style={{
|
||||
background: expiresH===o.h ? '#f59e0b22' : 'rgba(255,255,255,0.05)',
|
||||
border: `1px solid ${expiresH===o.h ? '#f59e0b88' : 'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius:6, padding:'4px 10px',
|
||||
color: expiresH===o.h ? '#f59e0b' : 'rgba(255,255,255,0.5)',
|
||||
fontFamily:'monospace', fontSize:10, cursor:'pointer',
|
||||
}}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={createShare} disabled={creating}
|
||||
style={{...S.btn('#4ade80'), width:'100%', opacity:creating?0.5:1}}>
|
||||
{creating ? '⏳ Erstelle...' : '🔗 Link erstellen'}
|
||||
</button>
|
||||
|
||||
{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={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:9,marginBottom:4}}>LINK (kopiert)</div>
|
||||
<div style={{color:'#4ade80',fontFamily:'monospace',fontSize:10,wordBreak:'break-all'}}>{newLink}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bestehende Links */}
|
||||
{shares.length > 0 && (
|
||||
<div>
|
||||
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10,marginBottom:8,letterSpacing:1}}>AKTIVE LINKS</div>
|
||||
{shares.map(s => (
|
||||
<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={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:11}}>
|
||||
{s.label || '—'}
|
||||
{!!s.password_hash && <span style={{marginLeft:6,color:'#f59e0b',fontSize:9}}>🔒</span>}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:6}}>
|
||||
<button onClick={()=>copyLink(s.token)}
|
||||
style={{background:'rgba(74,222,128,0.1)',border:'1px solid rgba(74,222,128,0.3)',borderRadius:5,padding:'3px 8px',color:'#4ade80',fontFamily:'monospace',fontSize:9,cursor:'pointer'}}>
|
||||
📋 Kopieren
|
||||
</button>
|
||||
<button onClick={()=>deleteShare(s.id)}
|
||||
style={{background:'rgba(248,113,113,0.1)',border:'1px solid rgba(248,113,113,0.3)',borderRadius:5,padding:'3px 8px',color:'#f87171',fontFamily:'monospace',fontSize:9,cursor:'pointer'}}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:9}}>
|
||||
Ablauf: {fmtExp(s.expires_at)} · {s.download_count} Abrufe
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 }) {
|
||||
</div>
|
||||
)}
|
||||
{shareItem && <ShareModal item={shareItem.item} type={shareItem.type} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
|
||||
{publicShareItem && <PublicShareModal item={publicShareItem.item} itemType={publicShareItem.type} onClose={()=>setPublicShareItem(null)} toast={toast}/>}
|
||||
{pwPrompt && <PasswordPromptModal
|
||||
filename={pwPrompt.originalname}
|
||||
onSubmit={pw=>{ setPwPrompt(null); download(pwPrompt, pw); }}
|
||||
@@ -681,6 +837,7 @@ export default function Dateien({ toast, mobile }) {
|
||||
<div style={{display:'flex',gap:5,flexShrink:0,alignItems:'center'}}>
|
||||
{canManage && (
|
||||
<IconBtn onClick={()=>setShareItem({item:folder,type:'folder'})} color="#ffe66d" title="Teilen" stop>🤝</IconBtn>
|
||||
<IconBtn onClick={e=>{e.stopPropagation();setPublicShareItem({item:folder,type:'folder'});}} color="#4ade80" title="Öffentlicher Link" stop>🔗</IconBtn>
|
||||
)}
|
||||
{canManage&&tab==='own' && (
|
||||
<IconBtn onClick={()=>delFolder(folder)} color="#ff6b9d" title="Löschen" stop>
|
||||
@@ -729,6 +886,7 @@ export default function Dateien({ toast, mobile }) {
|
||||
</IconBtn>
|
||||
{canManage && (
|
||||
<IconBtn onClick={()=>setShareItem({item:file,type:'file'})} color="#ffe66d" title="Teilen">🤝</IconBtn>
|
||||
<IconBtn onClick={()=>setPublicShareItem({item:file,type:'file'})} color="#4ade80" title="Öffentlicher Link">🔗</IconBtn>
|
||||
)}
|
||||
{isOwn && (
|
||||
<IconBtn onClick={()=>delFile(file)} color="#ff6b9d" title="Löschen">
|
||||
|
||||
Reference in New Issue
Block a user