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 { execFile } = require('child_process'); 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 SYNC_IMAGE = process.env.SYNC_IMAGE || 'git.mischlabs.de/mrdiderot/photography:latest'; const SYNC_CONTAINER = process.env.SYNC_CONTAINER || 'photography-website'; const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i; let syncInProgress = false; 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); }); } function runDocker(args, callback) { execFile('docker', args, { timeout: 5 * 60 * 1000 }, (err, stdout, stderr) => { callback(err, `${stdout || ''}${stderr || ''}`.trim()); }); } // ==================== 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 }); }); }); app.post('/api/admin/sync', requireAdmin, (req, res) => { if (syncInProgress) { return res.status(409).json({ error: 'Sync laeuft bereits' }); } syncInProgress = true; runDocker(['pull', SYNC_IMAGE], (pullErr, pullOutput) => { syncInProgress = false; if (pullErr) { console.error('Sync pull failed:', pullOutput || pullErr.message); return res.status(500).json({ error: 'Docker pull fehlgeschlagen', details: pullOutput || pullErr.message }); } res.json({ success: true, message: 'Neues Image geladen. Container startet neu.', output: pullOutput }); setTimeout(() => { runDocker(['restart', SYNC_CONTAINER], (restartErr, restartOutput) => { if (restartErr) { console.error('Sync restart failed:', restartOutput || restartErr.message); return; } console.log('Sync restart complete:', restartOutput); }); }, 500); }); }); // ==================== 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.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}`); });