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 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 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 })); // 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) return res.json([]); // 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 }); }); }); // ==================== 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}`); });