Upload-Share: Route registriert, Fehlversuche-Limit, PW kopieren, bessere Fehlerbehandlung
This commit is contained in:
@@ -521,46 +521,13 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cal
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Upload-Freigaben (externer Upload-Link) ───────────────────────────────────
|
// upload_shares Fehlversuche-Tracking
|
||||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='upload_shares'").get()) {
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE upload_shares (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
expires_at DATETIME NOT NULL,
|
|
||||||
max_size_mb INTEGER NOT NULL DEFAULT 10,
|
|
||||||
folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at DATETIME DEFAULT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='upload_share_logs'").get()) {
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE upload_share_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
share_id INTEGER NOT NULL REFERENCES upload_shares(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
originalname TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL DEFAULT 0,
|
|
||||||
ip_address TEXT NOT NULL DEFAULT '',
|
|
||||||
uploaded_at DATETIME DEFAULT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
// allow_upload_share Recht pro User
|
|
||||||
{
|
{
|
||||||
const userCols = db.pragma('table_info(users)').map(c => c.name);
|
const usCols = db.pragma('table_info(upload_shares)').map(c => c.name);
|
||||||
if (!userCols.includes('allow_upload_share'))
|
if (!usCols.includes('failed_attempts'))
|
||||||
db.exec('ALTER TABLE users ADD COLUMN allow_upload_share INTEGER DEFAULT 0');
|
db.exec('ALTER TABLE upload_shares ADD COLUMN failed_attempts INTEGER DEFAULT 0');
|
||||||
}
|
if (!usCols.includes('deactivated_reason'))
|
||||||
// Upload-Ordner Flag auf Folders-Tabelle
|
db.exec("ALTER TABLE upload_shares ADD COLUMN deactivated_reason TEXT DEFAULT NULL");
|
||||||
{
|
|
||||||
const folderCols = db.pragma('table_info(folders)').map(c => c.name);
|
|
||||||
if (!folderCols.includes('is_upload_folder'))
|
|
||||||
db.exec('ALTER TABLE folders ADD COLUMN is_upload_folder INTEGER DEFAULT 0');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
@@ -10,30 +10,23 @@ const router = express.Router();
|
|||||||
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||||
|
|
||||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
function generateToken() { return crypto.randomBytes(20).toString('base64url'); }
|
||||||
function generateToken() {
|
|
||||||
return crypto.randomBytes(20).toString('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateUploadFolder(userId) {
|
function getOrCreateUploadFolder(userId) {
|
||||||
let folder = db.prepare(
|
let f = db.prepare("SELECT * FROM folders WHERE user_id=? AND is_upload_folder=1 LIMIT 1").get(userId);
|
||||||
"SELECT * FROM folders WHERE user_id=? AND is_upload_folder=1 LIMIT 1"
|
if (!f) {
|
||||||
).get(userId);
|
const r = db.prepare("INSERT INTO folders (user_id,name,is_upload_folder,created_at) VALUES (?,?,1,datetime('now','localtime'))").run(userId,'Upload-Ordner');
|
||||||
if (!folder) {
|
f = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
|
||||||
const r = db.prepare(
|
|
||||||
"INSERT INTO folders (user_id, name, is_upload_folder, created_at) VALUES (?,?,1,datetime('now','localtime'))"
|
|
||||||
).run(userId, 'Upload-Ordner');
|
|
||||||
folder = db.prepare('SELECT * FROM folders WHERE id=?').get(r.lastInsertRowid);
|
|
||||||
}
|
}
|
||||||
return folder;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUseUploadShare(userId) {
|
function canUseUploadShare(userId) {
|
||||||
const user = db.prepare('SELECT role, allow_upload_share FROM users WHERE id=?').get(userId);
|
const u = db.prepare('SELECT role,allow_upload_share FROM users WHERE id=?').get(userId);
|
||||||
return user && (user.role === 'admin' || !!user.allow_upload_share);
|
return u && (u.role==='admin' || !!u.allow_upload_share);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Eigene Shares verwalten ───────────────────────────────────────────────────
|
// ── Eigene Shares ─────────────────────────────────────────────────────────────
|
||||||
router.get('/', authenticate, (req, res) => {
|
router.get('/', authenticate, (req, res) => {
|
||||||
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
|
if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error:'Keine Berechtigung' });
|
||||||
const folder = getOrCreateUploadFolder(req.user.id);
|
const folder = getOrCreateUploadFolder(req.user.id);
|
||||||
@@ -42,8 +35,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
FROM upload_shares s
|
FROM upload_shares s
|
||||||
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
||||||
WHERE s.user_id=?
|
WHERE s.user_id=?
|
||||||
GROUP BY s.id
|
GROUP BY s.id ORDER BY s.created_at DESC
|
||||||
ORDER BY s.created_at DESC
|
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
res.json({ shares, folder });
|
res.json({ shares, folder });
|
||||||
});
|
});
|
||||||
@@ -60,13 +52,14 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
INSERT INTO upload_shares (user_id,token,password_hash,expires_at,max_size_mb,folder_id,created_at)
|
INSERT INTO upload_shares (user_id,token,password_hash,expires_at,max_size_mb,folder_id,created_at)
|
||||||
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
|
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
|
||||||
`).run(req.user.id, token, hash, expires, Number(max_size_mb), folder.id);
|
`).run(req.user.id, token, hash, expires, Number(max_size_mb), folder.id);
|
||||||
res.json(db.prepare('SELECT * FROM upload_shares WHERE id=?').get(r.lastInsertRowid));
|
const share = db.prepare('SELECT * FROM upload_shares WHERE id=?').get(r.lastInsertRowid);
|
||||||
|
res.json({ ...share, url:`/u/${token}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', authenticate, (req, res) => {
|
router.delete('/:id', authenticate, (req, res) => {
|
||||||
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
const s = db.prepare('SELECT * FROM upload_shares WHERE id=? AND user_id=?').get(req.params.id, req.user.id);
|
||||||
if (!s) return res.status(404).json({ error:'Nicht gefunden' });
|
if (!s) return res.status(404).json({ error:'Nicht gefunden' });
|
||||||
db.prepare('UPDATE upload_shares SET is_active=0 WHERE id=?').run(s.id);
|
db.prepare("UPDATE upload_shares SET is_active=0, deactivated_reason='manual' WHERE id=?").run(s.id);
|
||||||
res.json({ ok:true });
|
res.json({ ok:true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,22 +69,19 @@ router.get('/logs', authenticate, (req, res) => {
|
|||||||
SELECT l.*, s.expires_at, s.max_size_mb
|
SELECT l.*, s.expires_at, s.max_size_mb
|
||||||
FROM upload_share_logs l
|
FROM upload_share_logs l
|
||||||
JOIN upload_shares s ON s.id=l.share_id
|
JOIN upload_shares s ON s.id=l.share_id
|
||||||
WHERE l.user_id=?
|
WHERE l.user_id=? ORDER BY l.uploaded_at DESC LIMIT 100
|
||||||
ORDER BY l.uploaded_at DESC
|
|
||||||
LIMIT 100
|
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
res.json(logs);
|
res.json(logs);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Admin: alle Logs sehen ────────────────────────────────────────────────────
|
// ── Admin ─────────────────────────────────────────────────────────────────────
|
||||||
router.get('/admin/logs', authenticate, requireAdmin, (req, res) => {
|
router.get('/admin/logs', authenticate, requireAdmin, (req, res) => {
|
||||||
const logs = db.prepare(`
|
const logs = db.prepare(`
|
||||||
SELECT l.*, s.expires_at, s.max_size_mb, u.username
|
SELECT l.*, s.expires_at, s.max_size_mb, u.username
|
||||||
FROM upload_share_logs l
|
FROM upload_share_logs l
|
||||||
JOIN upload_shares s ON s.id=l.share_id
|
JOIN upload_shares s ON s.id=l.share_id
|
||||||
JOIN users u ON u.id=l.user_id
|
JOIN users u ON u.id=l.user_id
|
||||||
ORDER BY l.uploaded_at DESC
|
ORDER BY l.uploaded_at DESC LIMIT 500
|
||||||
LIMIT 500
|
|
||||||
`).all();
|
`).all();
|
||||||
res.json(logs);
|
res.json(logs);
|
||||||
});
|
});
|
||||||
@@ -99,79 +89,70 @@ router.get('/admin/logs', authenticate, requireAdmin, (req, res) => {
|
|||||||
router.get('/admin/shares', authenticate, requireAdmin, (req, res) => {
|
router.get('/admin/shares', authenticate, requireAdmin, (req, res) => {
|
||||||
const shares = db.prepare(`
|
const shares = db.prepare(`
|
||||||
SELECT s.*, u.username, COUNT(l.id) as upload_count
|
SELECT s.*, u.username, COUNT(l.id) as upload_count
|
||||||
FROM upload_shares s
|
FROM upload_shares s JOIN users u ON u.id=s.user_id
|
||||||
JOIN users u ON u.id=s.user_id
|
|
||||||
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
LEFT JOIN upload_share_logs l ON l.share_id=s.id
|
||||||
GROUP BY s.id
|
GROUP BY s.id ORDER BY s.created_at DESC
|
||||||
ORDER BY s.created_at DESC
|
|
||||||
`).all();
|
`).all();
|
||||||
res.json(shares);
|
res.json(shares);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Admin: Berechtigung pro User setzen ──────────────────────────────────────
|
|
||||||
router.post('/admin/permission', authenticate, requireAdmin, (req, res) => {
|
router.post('/admin/permission', authenticate, requireAdmin, (req, res) => {
|
||||||
const { user_id, allow } = req.body;
|
const { user_id, allow } = req.body;
|
||||||
db.prepare('UPDATE users SET allow_upload_share=? WHERE id=?').run(allow?1:0, user_id);
|
db.prepare('UPDATE users SET allow_upload_share=? WHERE id=?').run(allow?1:0, user_id);
|
||||||
res.json({ ok:true });
|
res.json({ ok:true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Öffentlicher Endpunkt: Infos zum Share ────────────────────────────────────
|
// ── Öffentlich: Share-Info ────────────────────────────────────────────────────
|
||||||
router.get('/public/:token', (req, res) => {
|
router.get('/public/:token', (req, res) => {
|
||||||
const s = db.prepare(
|
const s = db.prepare("SELECT id,expires_at,max_size_mb,is_active,failed_attempts,deactivated_reason FROM upload_shares WHERE token=?").get(req.params.token);
|
||||||
"SELECT id, expires_at, max_size_mb, is_active FROM upload_shares WHERE token=?"
|
if (!s || !s.is_active) return res.status(404).json({ error: s?.deactivated_reason==='too_many_attempts' ? 'Link wegen zu vieler Fehlversuche gesperrt' : 'Link ungültig oder deaktiviert' });
|
||||||
).get(req.params.token);
|
|
||||||
if (!s || !s.is_active) return res.status(404).json({ error: 'Link ungültig oder deaktiviert' });
|
|
||||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
||||||
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
|
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Öffentlicher Endpunkt: Passwort prüfen ────────────────────────────────────
|
// ── Öffentlich: Passwort prüfen ───────────────────────────────────────────────
|
||||||
router.post('/public/:token/verify', (req, res) => {
|
router.post('/public/:token/verify', (req, res) => {
|
||||||
const s = db.prepare('SELECT * FROM upload_shares WHERE token=?').get(req.params.token);
|
const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token);
|
||||||
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
|
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
|
||||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
||||||
if (!bcrypt.compareSync(req.body.password || '', s.password_hash))
|
|
||||||
return res.status(401).json({ error: 'Falsches Passwort' });
|
if (!bcrypt.compareSync(req.body?.password || '', s.password_hash)) {
|
||||||
|
const attempts = (s.failed_attempts||0) + 1;
|
||||||
|
if (attempts >= 3) {
|
||||||
|
// Link sperren
|
||||||
|
db.prepare("UPDATE upload_shares SET is_active=0, failed_attempts=?, deactivated_reason='too_many_attempts' WHERE id=?").run(attempts, s.id);
|
||||||
|
return res.status(401).json({ error:'Zu viele Fehlversuche – Link wurde gesperrt', locked:true });
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE upload_shares SET failed_attempts=? WHERE id=?").run(attempts, s.id);
|
||||||
|
return res.status(401).json({ error:'Falsches Passwort', attempts_left: 3-attempts });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erfolgreich – Fehlversuche zurücksetzen
|
||||||
|
db.prepare("UPDATE upload_shares SET failed_attempts=0 WHERE id=?").run(s.id);
|
||||||
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
|
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Öffentlicher Upload ───────────────────────────────────────────────────────
|
// ── Öffentlich: Upload ────────────────────────────────────────────────────────
|
||||||
router.post('/public/:token/upload', (req, res) => {
|
router.post('/public/:token/upload', (req, res) => {
|
||||||
const s = db.prepare('SELECT * FROM upload_shares WHERE token=?').get(req.params.token);
|
const s = db.prepare("SELECT * FROM upload_shares WHERE token=?").get(req.params.token);
|
||||||
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
|
if (!s || !s.is_active) return res.status(404).json({ error:'Link ungültig' });
|
||||||
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' });
|
||||||
|
|
||||||
// Passwort im Header prüfen
|
|
||||||
const pw = req.headers['x-upload-password'] || '';
|
const pw = req.headers['x-upload-password'] || '';
|
||||||
if (!bcrypt.compareSync(pw, s.password_hash))
|
if (!bcrypt.compareSync(pw, s.password_hash)) return res.status(401).json({ error:'Nicht autorisiert' });
|
||||||
return res.status(401).json({ error: 'Nicht autorisiert' });
|
|
||||||
|
|
||||||
const maxBytes = s.max_size_mb * 1024 * 1024;
|
|
||||||
const storage = multer.diskStorage({ destination: UPLOAD_DIR });
|
const storage = multer.diskStorage({ destination: UPLOAD_DIR });
|
||||||
const upload = multer({
|
const upload = multer({ storage, limits:{ fileSize: s.max_size_mb*1024*1024 } }).single('file');
|
||||||
storage,
|
|
||||||
limits: { fileSize: maxBytes },
|
|
||||||
}).single('file');
|
|
||||||
|
|
||||||
upload(req, res, err => {
|
upload(req, res, err => {
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE')
|
if (err.code==='LIMIT_FILE_SIZE') return res.status(413).json({ error:`Datei zu groß (max ${s.max_size_mb} MB)` });
|
||||||
return res.status(413).json({ error: `Datei zu groß (max ${s.max_size_mb} MB)` });
|
|
||||||
return res.status(500).json({ error:err.message });
|
return res.status(500).json({ error:err.message });
|
||||||
}
|
}
|
||||||
if (!req.file) return res.status(400).json({ error:'Keine Datei' });
|
if (!req.file) return res.status(400).json({ error:'Keine Datei' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = db.prepare(`
|
db.prepare(`INSERT INTO files (user_id,filename,originalname,mimetype,size,folder_id,created_at) VALUES (?,?,?,?,?,?,datetime('now','localtime'))`).run(s.user_id,req.file.filename,req.file.originalname,req.file.mimetype,req.file.size,s.folder_id);
|
||||||
INSERT INTO files (user_id, filename, originalname, mimetype, size, folder_id, created_at)
|
db.prepare(`INSERT INTO upload_share_logs (share_id,user_id,originalname,size,ip_address,uploaded_at) VALUES (?,?,?,?,?,datetime('now','localtime'))`).run(s.id,s.user_id,req.file.originalname,req.file.size,req.ip||'');
|
||||||
VALUES (?,?,?,?,?,?,datetime('now','localtime'))
|
|
||||||
`).run(s.user_id, req.file.filename, req.file.originalname, req.file.mimetype, req.file.size, s.folder_id);
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO upload_share_logs (share_id, user_id, originalname, size, ip_address, uploaded_at)
|
|
||||||
VALUES (?,?,?,?,?,datetime('now','localtime'))
|
|
||||||
`).run(s.id, s.user_id, req.file.originalname, req.file.size, req.ip || '');
|
|
||||||
|
|
||||||
res.json({ ok:true, filename:req.file.originalname });
|
res.json({ ok:true, filename:req.file.originalname });
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
try { fs.unlinkSync(path.join(UPLOAD_DIR, req.file.filename)); } catch {}
|
try { fs.unlinkSync(path.join(UPLOAD_DIR, req.file.filename)); } catch {}
|
||||||
|
|||||||
@@ -7,140 +7,181 @@
|
|||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{background:#0d0d0f;color:#fff;font-family:'Courier New',monospace;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
|
body{background:#0d0d0f;color:#fff;font-family:'Courier New',monospace;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||||
.card{background:#1a1a1e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:32px;width:100%;max-width:440px}
|
.card{background:#1a1a1e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:28px;width:100%;max-width:440px}
|
||||||
h1{font-size:18px;margin-bottom:6px;color:#4ecdc4}
|
h1{font-size:18px;margin-bottom:6px;color:#4ecdc4}
|
||||||
.sub{color:rgba(255,255,255,0.4);font-size:12px;margin-bottom:24px}
|
.sub{color:rgba(255,255,255,0.4);font-size:12px;margin-bottom:20px}
|
||||||
label{display:block;color:rgba(255,255,255,0.5);font-size:10px;letter-spacing:2px;margin-bottom:4px}
|
label{display:block;color:rgba(255,255,255,0.5);font-size:10px;letter-spacing:2px;margin-bottom:4px;margin-top:12px}
|
||||||
input[type=password],input[type=file]{width:100%;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:10px 12px;color:#fff;font-family:inherit;font-size:14px;outline:none}
|
input[type=password],input[type=file]{width:100%;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:10px 12px;color:#fff;font-family:inherit;font-size:14px;outline:none;transition:border 0.2s}
|
||||||
|
input[type=password]:focus{border-color:#4ecdc4}
|
||||||
input[type=file]{cursor:pointer;padding:8px}
|
input[type=file]{cursor:pointer;padding:8px}
|
||||||
input[type=file]::file-selector-button{background:#4ecdc4;border:none;border-radius:6px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-weight:700;padding:4px 12px;margin-right:10px}
|
input[type=file]::file-selector-button{background:#4ecdc4;border:none;border-radius:6px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-weight:700;padding:4px 12px;margin-right:10px}
|
||||||
.btn{width:100%;background:#4ecdc4;border:none;border-radius:8px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-size:14px;font-weight:700;padding:12px;margin-top:12px}
|
.btn{width:100%;background:#4ecdc4;border:none;border-radius:8px;color:#0d0d0f;cursor:pointer;font-family:inherit;font-size:14px;font-weight:700;padding:12px;margin-top:12px;transition:opacity 0.2s}
|
||||||
|
.btn:hover:not(:disabled){opacity:0.85}
|
||||||
.btn:disabled{opacity:0.4;cursor:default}
|
.btn:disabled{opacity:0.4;cursor:default}
|
||||||
.btn.danger{background:#ff6b9d}
|
.info{background:rgba(78,205,196,0.08);border:1px solid rgba(78,205,196,0.2);border-radius:8px;padding:10px 12px;font-size:12px;color:rgba(255,255,255,0.6);margin-bottom:16px;margin-top:4px}
|
||||||
.info{background:rgba(78,205,196,0.08);border:1px solid rgba(78,205,196,0.2);border-radius:8px;padding:10px 12px;font-size:12px;color:rgba(255,255,255,0.6);margin-bottom:16px}
|
.msg{border-radius:8px;padding:10px 12px;font-size:13px;margin-top:10px}
|
||||||
.msg{border-radius:8px;padding:10px 12px;font-size:13px;margin-top:12px}
|
|
||||||
.msg.ok{background:rgba(78,205,196,0.12);border:1px solid rgba(78,205,196,0.3);color:#4ecdc4}
|
.msg.ok{background:rgba(78,205,196,0.12);border:1px solid rgba(78,205,196,0.3);color:#4ecdc4}
|
||||||
.msg.err{background:rgba(255,107,157,0.12);border:1px solid rgba(255,107,157,0.3);color:#ff6b9d}
|
.msg.err{background:rgba(255,107,157,0.12);border:1px solid rgba(255,107,157,0.3);color:#ff6b9d}
|
||||||
|
.msg.warn{background:rgba(255,230,109,0.12);border:1px solid rgba(255,230,109,0.3);color:#ffe66d}
|
||||||
.hidden{display:none}
|
.hidden{display:none}
|
||||||
.done-list{margin-top:12px;font-size:12px;color:rgba(255,255,255,0.5)}
|
.done-list{margin-top:10px;font-size:12px;color:rgba(255,255,255,0.5)}
|
||||||
.done-list li{padding:3px 0;list-style:none}
|
.done-list li{padding:2px 0;list-style:none}
|
||||||
.done-list li::before{content:'✓ ';color:#4ecdc4}
|
.done-list li::before{content:'✓ ';color:#4ecdc4}
|
||||||
#progress{height:4px;background:rgba(78,205,196,0.2);border-radius:2px;margin-top:8px;overflow:hidden}
|
#progress{height:4px;background:rgba(78,205,196,0.15);border-radius:2px;margin-top:10px;overflow:hidden}
|
||||||
#progress-bar{height:100%;background:#4ecdc4;width:0;transition:width 0.3s}
|
#progress-bar{height:100%;background:#4ecdc4;width:0;transition:width 0.3s}
|
||||||
|
.attempts{color:#ffe66d;font-size:11px;margin-top:6px;font-family:monospace}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>📤 Datei-Upload</h1>
|
<h1>📤 Datei hochladen</h1>
|
||||||
<p class="sub" id="sub">Dateien hochladen</p>
|
<p class="sub" id="sub">Dateien senden</p>
|
||||||
|
|
||||||
<!-- Passwort-Sektion -->
|
|
||||||
<div id="pw-section">
|
<div id="pw-section">
|
||||||
<div class="info" id="share-info">Link wird geprüft…</div>
|
<div class="info" id="share-info">⏳ Link wird geprüft…</div>
|
||||||
<label>PASSWORT</label>
|
<label>PASSWORT</label>
|
||||||
<input type="password" id="pw" placeholder="Passwort eingeben" autocomplete="off"/>
|
<input type="password" id="pw" placeholder="Passwort eingeben" autocomplete="off" onkeydown="if(event.key==='Enter')verify()"/>
|
||||||
<button class="btn" id="pw-btn" onclick="verify()">Zugang bestätigen</button>
|
<button class="btn" id="pw-btn" onclick="verify()">Zugang bestätigen</button>
|
||||||
<div id="pw-msg" class="msg hidden"></div>
|
<div id="pw-msg" class="hidden"></div>
|
||||||
|
<div id="attempts-info" class="attempts hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload-Sektion -->
|
|
||||||
<div id="upload-section" class="hidden">
|
<div id="upload-section" class="hidden">
|
||||||
<div class="info" id="upload-info"></div>
|
<div class="info" id="upload-info"></div>
|
||||||
<label>DATEI AUSWÄHLEN</label>
|
<label>DATEI(EN) AUSWÄHLEN</label>
|
||||||
<input type="file" id="file-input" multiple/>
|
<input type="file" id="file-input" multiple/>
|
||||||
<div id="progress"><div id="progress-bar"></div></div>
|
<div id="progress"><div id="progress-bar"></div></div>
|
||||||
<button class="btn" id="upload-btn" onclick="upload()">⬆ Hochladen</button>
|
<button class="btn" id="upload-btn" onclick="uploadFiles()">⬆ Hochladen</button>
|
||||||
<div id="upload-msg" class="msg hidden"></div>
|
<div id="upload-msg" class="hidden"></div>
|
||||||
<ul class="done-list" id="done-list"></ul>
|
<ul class="done-list" id="done-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const token = location.pathname.replace('/u/','').replace('/','');
|
const token = location.pathname.split('/u/')[1] || '';
|
||||||
let password = '';
|
let password = '';
|
||||||
let maxMb = 10;
|
let maxMb = 10;
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
if (!token) return showFatal('Ungültiger Link-Aufbau');
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/upload-shares/public/${token}`);
|
const r = await fetch('/api/upload-shares/public/' + encodeURIComponent(token));
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!r.ok) return showFatal(d.error || 'Ungültiger Link');
|
if (!r.ok) return showFatal(d.error || 'Link ungültig');
|
||||||
maxMb = d.max_size_mb;
|
maxMb = d.max_size_mb;
|
||||||
const exp = new Date(d.expires_at).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
const exp = new Date(d.expires_at).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||||
document.getElementById('share-info').textContent = `Gültig bis: ${exp} · Max ${d.max_size_mb} MB pro Datei`;
|
document.getElementById('share-info').textContent = '🔐 Gültig bis ' + exp + ' · max ' + d.max_size_mb + ' MB pro Datei';
|
||||||
} catch { showFatal('Verbindung fehlgeschlagen'); }
|
} catch(e) {
|
||||||
|
showFatal('Verbindung fehlgeschlagen: ' + e.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showFatal(msg) {
|
function showFatal(msg) {
|
||||||
document.getElementById('pw-section').innerHTML = `<div class="msg err">${msg}</div>`;
|
document.getElementById('pw-section').innerHTML = '<div class="msg err">' + msg + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verify() {
|
async function verify() {
|
||||||
const pw = document.getElementById('pw').value;
|
const pw = document.getElementById('pw').value;
|
||||||
if (!pw) return;
|
if (!pw) { document.getElementById('pw').focus(); return; }
|
||||||
document.getElementById('pw-btn').disabled = true;
|
const btn = document.getElementById('pw-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
const msg = document.getElementById('pw-msg');
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/upload-shares/public/${token}/verify`, {
|
const r = await fetch('/api/upload-shares/public/' + encodeURIComponent(token) + '/verify', {
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password: pw })
|
body: JSON.stringify({ password: pw })
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
document.getElementById('pw-msg').className = 'msg err';
|
msg.className = 'msg err';
|
||||||
document.getElementById('pw-msg').textContent = d.error;
|
msg.textContent = d.error;
|
||||||
document.getElementById('pw-msg').classList.remove('hidden');
|
msg.classList.remove('hidden');
|
||||||
document.getElementById('pw-btn').disabled = false;
|
if (d.locked) {
|
||||||
|
showFatal(d.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (d.attempts_left !== undefined) {
|
||||||
|
const ai = document.getElementById('attempts-info');
|
||||||
|
ai.textContent = '⚠ Noch ' + d.attempts_left + ' Versuch' + (d.attempts_left===1?'':'e') + ' übrig';
|
||||||
|
ai.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Zugang bestätigen';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
password = pw;
|
password = pw;
|
||||||
maxMb = d.max_size_mb;
|
maxMb = d.max_size_mb;
|
||||||
document.getElementById('pw-section').classList.add('hidden');
|
document.getElementById('pw-section').classList.add('hidden');
|
||||||
document.getElementById('upload-section').classList.remove('hidden');
|
const us = document.getElementById('upload-section');
|
||||||
|
us.classList.remove('hidden');
|
||||||
const exp = new Date(d.expires_at).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
const exp = new Date(d.expires_at).toLocaleString('de-DE',{timeZone:'Europe/Berlin',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||||
document.getElementById('upload-info').textContent = `Gültig bis ${exp} · Max ${d.max_size_mb} MB pro Datei`;
|
document.getElementById('upload-info').textContent = '✓ Zugang bestätigt · Gültig bis ' + exp + ' · max ' + d.max_size_mb + ' MB pro Datei';
|
||||||
document.getElementById('sub').textContent = 'Dateien hochladen ✓';
|
document.getElementById('sub').textContent = 'Bereit zum Hochladen';
|
||||||
} catch {
|
} catch(e) {
|
||||||
document.getElementById('pw-btn').disabled = false;
|
msg.className = 'msg err';
|
||||||
|
msg.textContent = 'Verbindung fehlgeschlagen: ' + e.message;
|
||||||
|
msg.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Zugang bestätigen';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload() {
|
async function uploadFiles() {
|
||||||
const files = document.getElementById('file-input').files;
|
const files = document.getElementById('file-input').files;
|
||||||
if (!files.length) return;
|
if (!files.length) { document.getElementById('file-input').click(); return; }
|
||||||
const btn = document.getElementById('upload-btn');
|
const btn = document.getElementById('upload-btn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const msg = document.getElementById('upload-msg');
|
const msg = document.getElementById('upload-msg');
|
||||||
const bar = document.getElementById('progress-bar');
|
const bar = document.getElementById('progress-bar');
|
||||||
const list = document.getElementById('done-list');
|
const list = document.getElementById('done-list');
|
||||||
|
msg.classList.add('hidden');
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
if (file.size > maxMb * 1024 * 1024) {
|
if (file.size > maxMb * 1024 * 1024) {
|
||||||
msg.className = 'msg err'; msg.textContent = `"${file.name}" ist zu groß (max ${maxMb} MB)`; msg.classList.remove('hidden');
|
msg.className = 'msg warn';
|
||||||
|
msg.textContent = '"' + file.name + '" ist zu groß (max ' + maxMb + ' MB)';
|
||||||
|
msg.classList.remove('hidden');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bar.style.width = ((i + 0.5) / files.length * 100) + '%';
|
bar.style.width = ((i + 0.5) / files.length * 100) + '%';
|
||||||
const fd = new FormData(); fd.append('file', file);
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/upload-shares/public/${token}/upload`, {
|
const r = await fetch('/api/upload-shares/public/' + encodeURIComponent(token) + '/upload', {
|
||||||
method:'POST', headers:{'x-upload-password': password}, body: fd
|
method: 'POST',
|
||||||
|
headers: { 'x-upload-password': password },
|
||||||
|
body: fd
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const li = document.createElement('li'); li.textContent = file.name; list.appendChild(li);
|
const li = document.createElement('li');
|
||||||
msg.className = 'msg ok'; msg.textContent = `${list.children.length} Datei(en) erfolgreich hochgeladen`; msg.classList.remove('hidden');
|
li.textContent = file.name;
|
||||||
|
list.appendChild(li);
|
||||||
} else {
|
} else {
|
||||||
msg.className = 'msg err'; msg.textContent = d.error; msg.classList.remove('hidden');
|
msg.className = 'msg err';
|
||||||
|
msg.textContent = d.error;
|
||||||
|
msg.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
msg.className = 'msg err';
|
||||||
|
msg.textContent = 'Upload fehlgeschlagen: ' + e.message;
|
||||||
|
msg.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
} catch { msg.className='msg err'; msg.textContent='Upload fehlgeschlagen'; msg.classList.remove('hidden'); }
|
|
||||||
}
|
}
|
||||||
bar.style.width = '100%';
|
bar.style.width = '100%';
|
||||||
setTimeout(()=>{ bar.style.width='0'; }, 1500);
|
setTimeout(() => bar.style.width = '0', 1500);
|
||||||
|
if (list.children.length > 0) {
|
||||||
|
msg.className = 'msg ok';
|
||||||
|
msg.textContent = list.children.length + ' Datei' + (list.children.length !== 1 ? 'en' : '') + ' erfolgreich hochgeladen';
|
||||||
|
msg.classList.remove('hidden');
|
||||||
|
}
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
document.getElementById('file-input').value = '';
|
document.getElementById('file-input').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('pw').addEventListener('keydown', e => { if(e.key==='Enter') verify(); });
|
|
||||||
init();
|
init();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ function UploadShareManager({ toast }) {
|
|||||||
const [form, setForm] = useState({ password:'', expires_hours:24, max_size_mb:10 });
|
const [form, setForm] = useState({ password:'', expires_hours:24, max_size_mb:10 });
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [copied, setCopied] = useState(null);
|
const [copied, setCopied] = useState(null);
|
||||||
|
const [lastPw, setLastPw] = useState(''); // für Passwort-Kopieren nach Erstellung
|
||||||
|
const [lastToken, setLastToken] = useState('');
|
||||||
|
|
||||||
const load = () => api('/upload-shares').then(d=>{ setShares(d.shares||[]); setFolder(d.folder); }).catch(()=>{});
|
const load = () => api('/upload-shares').then(d=>{ setShares(d.shares||[]); setFolder(d.folder); }).catch(()=>{});
|
||||||
useEffect(()=>{ load(); },[]);
|
useEffect(()=>{ load(); },[]);
|
||||||
@@ -182,7 +184,8 @@ function UploadShareManager({ toast }) {
|
|||||||
if (!form.password.trim()) { toast('Passwort erforderlich','error'); return; }
|
if (!form.password.trim()) { toast('Passwort erforderlich','error'); return; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api('/upload-shares', { body: form });
|
const r = await api('/upload-shares', { body: form });
|
||||||
|
setLastPw(form.password); setLastToken(r.token||'');
|
||||||
toast('Upload-Link erstellt ✓'); setShowCreate(false); setForm({password:'',expires_hours:24,max_size_mb:10}); load();
|
toast('Upload-Link erstellt ✓'); setShowCreate(false); setForm({password:'',expires_hours:24,max_size_mb:10}); load();
|
||||||
} catch(e) { toast(e.message,'error'); }
|
} catch(e) { toast(e.message,'error'); }
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -272,7 +275,7 @@ function UploadShareManager({ toast }) {
|
|||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={create} disabled={busy} style={{...S.btn('#4ecdc4'),width:'100%',textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
|
<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'}
|
{busy?'…':'🔗 Link erstellen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -293,9 +296,9 @@ function UploadShareManager({ toast }) {
|
|||||||
<div style={{display:'flex',alignItems:'center',gap:10,flexWrap:'wrap'}}>
|
<div style={{display:'flex',alignItems:'center',gap:10,flexWrap:'wrap'}}>
|
||||||
<div style={{flex:1,minWidth:0}}>
|
<div style={{flex:1,minWidth:0}}>
|
||||||
<div style={{display:'flex',alignItems:'center',gap:6,flexWrap:'wrap'}}>
|
<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,
|
<span style={{color:!s.is_active?'#ff6b9d':expired?'#ffe66d':'#4ecdc4',fontFamily:'monospace',fontSize:11,
|
||||||
border:`1px solid currentColor`,borderRadius:4,padding:'1px 6px',flexShrink:0}}>
|
border:`1px solid currentColor`,borderRadius:4,padding:'1px 6px',flexShrink:0}}>
|
||||||
{!s.is_active?'Deaktiviert':expired?'Abgelaufen':'Aktiv'}
|
{!s.is_active?(s.deactivated_reason==='too_many_attempts'?'⚠ Gesperrt (Fehlversuche)':'Deaktiviert'):expired?'Abgelaufen':'Aktiv'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>
|
<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)
|
bis {fmtDate(s.expires_at)} · max {s.max_size_mb} MB · {s.upload_count||0} Upload(s)
|
||||||
@@ -308,10 +311,16 @@ function UploadShareManager({ toast }) {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{display:'flex',gap:5,flexShrink:0}}>
|
<div style={{display:'flex',gap:5,flexShrink:0}}>
|
||||||
{s.is_active && !expired && (
|
{s.is_active && !expired && (
|
||||||
|
<>
|
||||||
<button onClick={()=>copyLink(s.token)}
|
<button onClick={()=>copyLink(s.token)}
|
||||||
style={{...S.btn(copied===s.token?'#4ecdc4':'#ffe66d',copied!==s.token),fontSize:10,padding:'4px 10px'}}>
|
style={{...S.btn(copied===s.token?'#4ecdc4':'#ffe66d',copied!==s.token),fontSize:10,padding:'4px 10px'}}>
|
||||||
{copied===s.token?'✓ Kopiert':'📋 Link'}
|
{copied===s.token?'✓ Kopiert':'📋 Link'}
|
||||||
</button>
|
</button>
|
||||||
|
{lastToken===s.token && lastPw && (
|
||||||
|
<button onClick={()=>{ navigator.clipboard?.writeText(lastPw).then(()=>toast('Passwort kopiert ✓')).catch(()=>toast(lastPw)); }}
|
||||||
|
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 10px'}}>🔑 PW</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{s.is_active && (
|
{s.is_active && (
|
||||||
<button onClick={()=>deactivate(s.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}>✕</button>
|
<button onClick={()=>deactivate(s.id)} style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 10px'}}>✕</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user