const express = require('express'); const cookieParser = require('cookie-parser'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const fs = require('fs'); const path = require('path'); const archiver = require('archiver'); const db = require('./database'); const app = express(); const JWT_SECRET = process.env.JWT_SECRET || 'mischkomposition-super-secret-key-change-me'; const FULLS_DIR = path.join(__dirname, '../public/images/fulls'); const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i; app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); // ==================== MIDDLEWARE ==================== function authMiddleware(req, res, next) { const token = req.cookies.token; if (!token) { req.user = null; return next(); } jwt.verify(token, JWT_SECRET, (err, decoded) => { if (err) { req.user = null; return next(); } // Client-Ablauf pruefen (30 Tage) if (decoded.role === 'client' && decoded.created_at) { const expires = new Date(decoded.created_at); expires.setDate(expires.getDate() + 30); if (new Date() > expires) { res.clearCookie('token'); req.user = null; return next(); } } req.user = decoded; next(); }); } function requireAuth(req, res, next) { if (!req.user) return res.status(401).json({ error: 'Not logged in' }); next(); } function requireAdmin(req, res, next) { if (!req.user || req.user.role !== 'admin') { return res.status(403).json({ error: 'Forbidden' }); } next(); } app.use(authMiddleware); // ==================== HELPER ==================== function listImagesInDir(dir) { try { const files = fs.readdirSync(dir); return files.filter(f => IMAGE_RE.test(f) && fs.statSync(path.join(dir, f)).isFile()); } catch (e) { return []; } } function listSubfolders(dir) { try { return fs.readdirSync(dir).filter(f => { try { return fs.statSync(path.join(dir, f)).isDirectory(); } catch (e) { return false; } }); } catch (e) { return []; } } // ==================== AUTH API ==================== app.post('/api/auth/login', (req, res) => { const { username, password } = req.body; db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => { if (err) return res.status(500).json({ error: 'DB Error' }); if (!user || !bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ error: 'Invalid credentials' }); } // Client-Ablauf pruefen if (user.role === 'client' && user.created_at) { const expires = new Date(user.created_at); expires.setDate(expires.getDate() + 30); if (new Date() > expires) { return res.status(401).json({ error: 'Account abgelaufen' }); } } const token = jwt.sign( { id: user.id, username: user.username, role: user.role, created_at: user.created_at }, JWT_SECRET, { expiresIn: '7d' } ); res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 }); res.json({ success: true, role: user.role }); }); }); app.post('/api/auth/logout', (req, res) => { res.clearCookie('token'); res.json({ success: true }); }); app.get('/api/auth/me', (req, res) => { if (!req.user) return res.status(401).json({ error: 'Not logged in' }); res.json(req.user); }); app.post('/api/auth/change-password', requireAuth, (req, res) => { const { old_password, new_password } = req.body; if (!new_password || new_password.length < 4) { return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' }); } db.get('SELECT * FROM users WHERE id = ?', [req.user.id], (err, user) => { if (err || !user) return res.status(500).json({ error: 'DB Error' }); if (!bcrypt.compareSync(old_password, user.password_hash)) { return res.status(401).json({ error: 'Altes Passwort ist falsch' }); } const hash = bcrypt.hashSync(new_password, 10); db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, user.id], (err) => { if (err) return res.status(500).json({ error: 'DB Error' }); res.json({ success: true }); }); }); }); // ==================== IMAGE LISTING ==================== app.get('/images/fulls/', (req, res) => { // Root-Bilder sind immer oeffentlich const rootImages = listImagesInDir(FULLS_DIR).map(name => ({ name, type: 'file', folder: null })); // Nicht eingeloggt: nur Root-Bilder if (!req.user) return res.json(rootImages); const folders = listSubfolders(FULLS_DIR); // Admin: Nur Root-Bilder (damit die Startseite sauber bleibt) if (req.user.role === 'admin') { return res.json(rootImages); } // User/Client: NUR zugewiesene Ordner (keine Root-Bilder untermischen) db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [req.user.id], (err, rows) => { const assignedFolders = err ? [] : rows.map(r => r.folder_name); const result = []; assignedFolders.forEach(folder => { if (folders.includes(folder)) { listImagesInDir(path.join(FULLS_DIR, folder)).forEach(name => { result.push({ name, type: 'file', folder }); }); } }); return res.json(result); }); }); // ==================== ADMIN API ==================== app.get('/api/admin/users', requireAdmin, (req, res) => { db.all('SELECT id, username, role, created_at FROM users', (err, rows) => { if (err) { console.error('User-Abfrage fehlgeschlagen:', err.message); // Fallback: einfache Abfrage ohne neue Spalten return db.all('SELECT * FROM users', (err2, rows2) => { if (err2) return res.status(500).json({ error: 'DB Error: ' + err2.message }); res.json(rows2.map(u => ({ id: u.id, username: u.username, role: u.role || 'client', created_at: u.created_at || null }))); }); } // Ablaufdatum berechnen fuer Clients const result = rows.map(u => { const out = { ...u }; if (u.role === 'client' && u.created_at) { const expires = new Date(u.created_at); expires.setDate(expires.getDate() + 30); out.expires_at = expires.toISOString(); out.expired = new Date() > expires; } return out; }); res.json(result); }); }); app.post('/api/admin/users', requireAdmin, (req, res) => { const { username, password, role } = req.body; if (!username || !password) return res.status(400).json({ error: 'Username und Passwort erforderlich' }); const validRoles = ['admin', 'user', 'client']; const userRole = validRoles.includes(role) ? role : 'client'; const hash = bcrypt.hashSync(password, 10); db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, userRole], function(err) { if (err) return res.status(400).json({ error: err.message }); res.json({ success: true, id: this.lastID }); }); }); app.delete('/api/admin/users/:id', requireAdmin, (req, res) => { const userId = parseInt(req.params.id); // Sich selbst loeschen verhindern if (userId === req.user.id) { return res.status(400).json({ error: 'Du kannst dich nicht selbst loeschen' }); } db.run('DELETE FROM users WHERE id = ?', [userId], (err) => { if (err) return res.status(500).json({ error: 'DB Error' }); db.run('DELETE FROM user_folders WHERE user_id = ?', [userId]); res.json({ success: true }); }); }); app.put('/api/admin/users/:id/password', requireAdmin, (req, res) => { const { new_password } = req.body; if (!new_password || new_password.length < 4) { return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' }); } const hash = bcrypt.hashSync(new_password, 10); db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, parseInt(req.params.id)], (err) => { if (err) return res.status(500).json({ error: 'DB Error' }); res.json({ success: true }); }); }); // Ordner-Verwaltung app.get('/api/admin/folders', requireAdmin, (req, res) => { res.json(listSubfolders(FULLS_DIR)); }); app.get('/api/admin/assign', requireAdmin, (req, res) => { const userId = req.query.user_id; if (userId) { db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [userId], (err, rows) => { res.json(err ? [] : rows.map(r => r.folder_name)); }); } else { db.all('SELECT * FROM user_folders', (err, rows) => { res.json(err ? [] : rows); }); } }); app.post('/api/admin/assign', requireAdmin, (req, res) => { const { user_id, folders } = req.body; if (!user_id) return res.status(400).json({ error: 'user_id erforderlich' }); const folderList = Array.isArray(folders) ? folders : []; db.serialize(() => { db.run('DELETE FROM user_folders WHERE user_id = ?', [user_id]); const stmt = db.prepare('INSERT INTO user_folders (user_id, folder_name) VALUES (?, ?)'); folderList.forEach(name => stmt.run(user_id, name)); stmt.finalize(); res.json({ success: true }); }); }); // ==================== DOWNLOAD API ==================== function isValidName(name) { return name && !name.includes('/') && !name.includes('\\') && !name.includes('..'); } app.post('/api/download/selected', (req, res) => { const { images } = req.body; if (!Array.isArray(images) || images.length === 0) { return res.status(400).json({ error: 'Keine Bilder ausgewaehlt' }); } // Alle Bilder validieren und Zugriff pruefen let pending = images.length; const verified = []; let responded = false; images.forEach((img, idx) => { if (responded) return; const name = img.name; const folder = img.folder || null; // Dateiname + Ordnername validieren if (!isValidName(name) || !IMAGE_RE.test(name) || (folder && !isValidName(folder))) { responded = true; return res.status(400).json({ error: 'Ungueltiger Dateiname' }); } const checkAccess = (ok) => { if (responded) return; if (!ok) { responded = true; return res.status(403).json({ error: 'Kein Zugriff auf: ' + name }); } verified[idx] = img; pending--; if (pending === 0) sendFiles(); }; if (!folder) { // Root-Bild: fuer alle zugaenglich const filePath = path.join(FULLS_DIR, name); checkAccess(fs.existsSync(filePath)); } else { // Ordner-Bild: nur wenn User den Ordner zugewiesen hat if (!req.user) return checkAccess(false); db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?', [req.user.id, folder], (err, row) => { if (err || !row) return checkAccess(false); const filePath = path.join(FULLS_DIR, folder, name); checkAccess(fs.existsSync(filePath)); }); } }); function sendFiles() { if (verified.length <= 3) { // Einzelne Downloads: URLs zurueckgeben const urls = verified.map(img => { const prefix = img.folder ? encodeURIComponent(img.folder) + '/' : ''; return '/images/fulls/' + prefix + encodeURIComponent(img.name); }); return res.json({ mode: 'individual', urls }); } // 4+ Bilder: ZIP streamen res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', 'attachment; filename="mischkomposition-auswahl.zip"'); const archive = archiver('zip', { zlib: { level: 5 } }); archive.on('error', () => { if (!res.headersSent) res.status(500).json({ error: 'ZIP-Fehler' }); }); archive.pipe(res); verified.forEach(img => { const filePath = img.folder ? path.join(FULLS_DIR, img.folder, img.name) : path.join(FULLS_DIR, img.name); archive.file(filePath, { name: img.name }); }); archive.finalize(); } }); app.get('/api/download/folder/:folderName', (req, res) => { const folderName = req.params.folderName; if (!isValidName(folderName)) { return res.status(400).json({ error: 'Ungueltiger Ordnername' }); } if (!req.user) { return res.status(401).json({ error: 'Nicht eingeloggt' }); } db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?', [req.user.id, folderName], (err, row) => { if (err || !row) { return res.status(403).json({ error: 'Kein Zugriff auf diesen Ordner' }); } const folderPath = path.join(FULLS_DIR, folderName); const images = listImagesInDir(folderPath); if (images.length === 0) { return res.status(404).json({ error: 'Keine Bilder im Ordner' }); } res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(folderName) + '.zip"'); const archive = archiver('zip', { zlib: { level: 5 } }); archive.on('error', () => { if (!res.headersSent) res.status(500).json({ error: 'ZIP-Fehler' }); }); archive.pipe(res); images.forEach(name => { archive.file(path.join(folderPath, name), { name }); }); archive.finalize(); }); }); // ==================== STATIC FILES ==================== app.use(express.static(path.join(__dirname, '../public'))); app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); const PORT = 8090; app.listen(PORT, () => { console.log(`Mischkomposition Backend on port ${PORT}`); });