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; if (!JWT_SECRET) { throw new Error('JWT_SECRET must be set before starting the server'); } const PUBLIC_DIR = path.join(__dirname, '../public'); const DEFAULT_FULLS_DIR = path.join(PUBLIC_DIR, 'images/fulls'); const DEFAULT_THUMBS_DIR = path.join(PUBLIC_DIR, 'images/thumbs'); const DEV_FULLS_DIR = path.join(__dirname, '../images/fulls'); const DEV_THUMBS_DIR = path.join(__dirname, '../images/thumbs'); const FULLS_DIR = process.env.FULLS_DIR || (fs.existsSync(DEFAULT_FULLS_DIR) ? DEFAULT_FULLS_DIR : DEV_FULLS_DIR); const THUMBS_DIR = process.env.THUMBS_DIR || (fs.existsSync(DEFAULT_THUMBS_DIR) ? DEFAULT_THUMBS_DIR : DEV_THUMBS_DIR); const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i; console.log('Using full-size images at', FULLS_DIR); console.log('Using thumbnails at', THUMBS_DIR); 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 []; } } function getFolderInfo(folder) { const fullPath = path.join(FULLS_DIR, folder); const thumbPath = path.join(THUMBS_DIR, folder); const images = listImagesInDir(fullPath); return { name: folder, image_count: images.length, thumb_count: listImagesInDir(thumbPath).length, sample: images[0] || null }; } function isSafePath(baseDir, requestedPath) { const resolvedBase = path.resolve(baseDir); const resolvedPath = path.resolve(resolvedBase, requestedPath); return resolvedPath === resolvedBase || resolvedPath.startsWith(resolvedBase + path.sep); } function hasAssignedFolder(userId, folder, callback) { db.get( 'SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?', [userId, folder], (err, row) => callback(!err && !!row) ); } function serveProtectedImage(req, res) { const kind = req.params[0]; const relativePath = req.params[1] || ''; const baseDir = kind === 'thumbs' ? THUMBS_DIR : FULLS_DIR; const segments = relativePath.split('/').filter(Boolean); const fileName = segments[segments.length - 1]; if (!fileName || !IMAGE_RE.test(fileName) || !isSafePath(baseDir, relativePath)) { return res.status(404).end(); } const absolutePath = path.resolve(baseDir, relativePath); if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) { return res.status(404).end(); } // Bilder direkt im Root-Verzeichnis sind oeffentlich. Kundenordner sind geschuetzt. if (segments.length === 1) { return res.sendFile(absolutePath); } if (!req.user) { return res.status(401).json({ error: 'Not logged in' }); } if (req.user.role === 'admin') { return res.sendFile(absolutePath); } hasAssignedFolder(req.user.id, segments[0], (allowed) => { if (!allowed) return res.status(403).json({ error: 'Forbidden' }); res.sendFile(absolutePath); }); } // ==================== 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 })); const requestedFolder = req.query.folder; if (requestedFolder) { if (!isValidName(requestedFolder)) { return res.status(400).json({ error: 'Ungueltiger Ordnername' }); } if (!req.user) { return res.status(401).json({ error: 'Nicht eingeloggt' }); } const sendFolderImages = () => { const folderPath = path.join(FULLS_DIR, requestedFolder); if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) { return res.status(404).json({ error: 'Ordner nicht gefunden' }); } return res.json(listImagesInDir(folderPath).map(name => ({ name, type: 'file', folder: requestedFolder }))); }; if (req.user.role === 'admin') { return sendFolderImages(); } return db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?', [req.user.id, requestedFolder], (err, row) => { if (err || !row) return res.status(403).json({ error: 'Kein Zugriff auf diesen Ordner' }); return sendFolderImages(); }); } // Nicht eingeloggt: nur Root-Bilder if (!req.user) return res.json(rootImages); // Eingeloggt: Startseite bleibt die normale oeffentliche Galerie. return res.json(rootImages); }); app.get('/api/folders/available', requireAuth, (req, res) => { const folders = listSubfolders(FULLS_DIR); if (req.user.role === 'admin') { return res.json(folders.map(getFolderInfo)); } 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); return res.json(assignedFolders.filter(folder => folders.includes(folder)).map(getFolderInfo)); }); }); // ==================== 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 }))); }); } db.all('SELECT user_id, folder_name FROM user_folders ORDER BY folder_name', (folderErr, folderRows) => { const foldersByUser = {}; if (!folderErr && folderRows) { folderRows.forEach(row => { if (!foldersByUser[row.user_id]) foldersByUser[row.user_id] = []; foldersByUser[row.user_id].push(row.folder_name); }); } // 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; } out.folders = foldersByUser[u.id] || []; 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) => { const folders = listSubfolders(FULLS_DIR).map(getFolderInfo); db.all( `SELECT uf.folder_name, u.id, u.username, u.role FROM user_folders uf JOIN users u ON u.id = uf.user_id ORDER BY uf.folder_name, u.username`, (err, rows) => { const assignedByFolder = {}; if (!err && rows) { rows.forEach(row => { if (!assignedByFolder[row.folder_name]) assignedByFolder[row.folder_name] = []; assignedByFolder[row.folder_name].push({ id: row.id, username: row.username, role: row.role || 'client' }); }); } res.json(folders.map(folder => ({ ...folder, assigned_users: assignedByFolder[folder.name] || [], private: true }))); } ); }); app.get('/api/admin/folders/:folderName/images', requireAdmin, (req, res) => { const folderName = req.params.folderName; if (!isValidName(folderName)) { return res.status(400).json({ error: 'Ungueltiger Ordnername' }); } const folderPath = path.join(FULLS_DIR, folderName); if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) { return res.status(404).json({ error: 'Ordner nicht gefunden' }); } res.json(listImagesInDir(folderPath).map(name => ({ name, folder: folderName, full_url: '/images/fulls/' + encodeURIComponent(folderName) + '/' + encodeURIComponent(name), thumb_url: '/images/thumbs/' + encodeURIComponent(folderName) + '/' + encodeURIComponent(name) }))); }); 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 existingFolders = listSubfolders(FULLS_DIR); const folderList = Array.isArray(folders) ? folders.filter(name => isValidName(name) && existingFolders.includes(name)) : []; 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); if (req.user.role === 'admin') { const filePath = path.join(FULLS_DIR, folder, name); return checkAccess(fs.existsSync(filePath)); } 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' }); } if (req.user.role === 'admin') { return sendFolderDownload(folderName, res); } 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' }); } return sendFolderDownload(folderName, res); }); }); function sendFolderDownload(folderName, res) { 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.get(/^\/images\/(fulls|thumbs)\/(.+)$/, serveProtectedImage); app.use(express.static(PUBLIC_DIR)); app.get('*', (req, res) => { res.sendFile(path.join(PUBLIC_DIR, 'index.html')); }); const PORT = 8090; app.listen(PORT, () => { console.log(`Mischkomposition Backend on port ${PORT}`); });