diff --git a/backend/src/db.js b/backend/src/db.js index 500cbe4..9d35203 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -521,4 +521,46 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cal `); } +// ── Upload-Freigaben (externer Upload-Link) ─────────────────────────────────── +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); + if (!userCols.includes('allow_upload_share')) + db.exec('ALTER TABLE users ADD COLUMN allow_upload_share INTEGER DEFAULT 0'); +} +// Upload-Ordner Flag auf Folders-Tabelle +{ + 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; diff --git a/backend/src/index.js b/backend/src/index.js index 47d7d95..9f71d54 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -68,6 +68,7 @@ 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/search', require('./routes/search')); const PUBLIC = path.join(__dirname, '../public'); @@ -84,6 +85,13 @@ app.get('/sw.js', (_req, res) => { res.sendFile(path.join(PUBLIC, 'sw.js')); }); +// Öffentliche Upload-Seite (kein Login erforderlich) +const UPLOAD_HTML = path.join(__dirname, '../upload-page.html'); +app.get('/u/:token', (_req, res) => { + if (require('fs').existsSync(UPLOAD_HTML)) res.sendFile(UPLOAD_HTML); + else res.status(404).send('Upload-Seite nicht gefunden'); +}); + app.use(express.static(PUBLIC, { setHeaders: (res, filePath) => { // JS/CSS Assets haben Hash im Namen → lang cachen diff --git a/backend/src/tools/dateien/upload-share.js b/backend/src/tools/dateien/upload-share.js new file mode 100644 index 0000000..38e6e27 --- /dev/null +++ b/backend/src/tools/dateien/upload-share.js @@ -0,0 +1,183 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const db = require('../../db'); +const { authenticate, requireAdmin } = require('../../middleware/auth'); +const router = express.Router(); + +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; + +// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── +function generateToken() { + return crypto.randomBytes(20).toString('base64url'); +} + +function getOrCreateUploadFolder(userId) { + let folder = db.prepare( + "SELECT * FROM folders WHERE user_id=? AND is_upload_folder=1 LIMIT 1" + ).get(userId); + if (!folder) { + 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; +} + +function canUseUploadShare(userId) { + const user = db.prepare('SELECT role, allow_upload_share FROM users WHERE id=?').get(userId); + return user && (user.role === 'admin' || !!user.allow_upload_share); +} + +// ── Eigene Shares verwalten ─────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error: 'Keine Berechtigung' }); + const folder = getOrCreateUploadFolder(req.user.id); + const shares = db.prepare(` + SELECT s.*, COUNT(l.id) as upload_count + FROM upload_shares s + LEFT JOIN upload_share_logs l ON l.share_id=s.id + WHERE s.user_id=? + GROUP BY s.id + ORDER BY s.created_at DESC + `).all(req.user.id); + res.json({ shares, folder }); +}); + +router.post('/', authenticate, (req, res) => { + if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error: 'Keine Berechtigung' }); + const { password, expires_hours=24, max_size_mb=10 } = req.body; + if (!password?.trim()) return res.status(400).json({ error: 'Passwort erforderlich' }); + const folder = getOrCreateUploadFolder(req.user.id); + const token = generateToken(); + const hash = bcrypt.hashSync(password.trim(), 10); + const expires = new Date(Date.now() + Number(expires_hours) * 3600000).toISOString(); + const r = db.prepare(` + INSERT INTO upload_shares (user_id, token, password_hash, expires_at, max_size_mb, folder_id, created_at) + VALUES (?,?,?,?,?,?,datetime('now','localtime')) + `).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)); +}); + +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); + if (!s) return res.status(404).json({ error: 'Nicht gefunden' }); + db.prepare('UPDATE upload_shares SET is_active=0 WHERE id=?').run(s.id); + res.json({ ok: true }); +}); + +router.get('/logs', authenticate, (req, res) => { + if (!canUseUploadShare(req.user.id)) return res.status(403).json({ error: 'Keine Berechtigung' }); + const logs = db.prepare(` + SELECT l.*, s.expires_at, s.max_size_mb + FROM upload_share_logs l + JOIN upload_shares s ON s.id=l.share_id + WHERE l.user_id=? + ORDER BY l.uploaded_at DESC + LIMIT 100 + `).all(req.user.id); + res.json(logs); +}); + +// ── Admin: alle Logs sehen ──────────────────────────────────────────────────── +router.get('/admin/logs', authenticate, requireAdmin, (req, res) => { + const logs = db.prepare(` + SELECT l.*, s.expires_at, s.max_size_mb, u.username + FROM upload_share_logs l + JOIN upload_shares s ON s.id=l.share_id + JOIN users u ON u.id=l.user_id + ORDER BY l.uploaded_at DESC + LIMIT 500 + `).all(); + res.json(logs); +}); + +router.get('/admin/shares', authenticate, requireAdmin, (req, res) => { + const shares = db.prepare(` + SELECT s.*, u.username, COUNT(l.id) as upload_count + FROM upload_shares s + JOIN users u ON u.id=s.user_id + LEFT JOIN upload_share_logs l ON l.share_id=s.id + GROUP BY s.id + ORDER BY s.created_at DESC + `).all(); + res.json(shares); +}); + +// ── Admin: Berechtigung pro User setzen ────────────────────────────────────── +router.post('/admin/permission', authenticate, requireAdmin, (req, res) => { + const { user_id, allow } = req.body; + db.prepare('UPDATE users SET allow_upload_share=? WHERE id=?').run(allow?1:0, user_id); + res.json({ ok: true }); +}); + +// ── Öffentlicher Endpunkt: Infos zum Share ──────────────────────────────────── +router.get('/public/:token', (req, res) => { + const s = db.prepare( + "SELECT id, expires_at, max_size_mb, is_active FROM upload_shares WHERE token=?" + ).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' }); + res.json({ ok: true, max_size_mb: s.max_size_mb, expires_at: s.expires_at }); +}); + +// ── Öffentlicher Endpunkt: Passwort prüfen ──────────────────────────────────── +router.post('/public/:token/verify', (req, res) => { + 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 (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' }); + res.json({ ok: true, max_size_mb: s.max_size_mb, expires_at: s.expires_at }); +}); + +// ── Öffentlicher Upload ─────────────────────────────────────────────────────── +router.post('/public/:token/upload', (req, res) => { + 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 (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'] || ''; + if (!bcrypt.compareSync(pw, s.password_hash)) + return res.status(401).json({ error: 'Nicht autorisiert' }); + + const maxBytes = s.max_size_mb * 1024 * 1024; + const storage = multer.diskStorage({ destination: UPLOAD_DIR }); + const upload = multer({ + storage, + limits: { fileSize: maxBytes }, + }).single('file'); + + upload(req, res, err => { + if (err) { + if (err.code === 'LIMIT_FILE_SIZE') + return res.status(413).json({ error: `Datei zu groß (max ${s.max_size_mb} MB)` }); + return res.status(500).json({ error: err.message }); + } + if (!req.file) return res.status(400).json({ error: 'Keine Datei' }); + + try { + const r = 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); + + 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 }); + } catch(e) { + try { fs.unlinkSync(path.join(UPLOAD_DIR, req.file.filename)); } catch {} + res.status(500).json({ error: e.message }); + } + }); +}); + +module.exports = router; diff --git a/backend/upload-page.html b/backend/upload-page.html new file mode 100644 index 0000000..11231dc --- /dev/null +++ b/backend/upload-page.html @@ -0,0 +1,147 @@ + + +
+ + +Dateien hochladen
+ + +