- Selection-Mode: Bilder in Galerie per Klick auswaehlen - 1-3 Bilder: einzeln herunterladen, 4+: als ZIP - Ordner-Download: ganzen zugewiesenen Ordner als ZIP - Sicherheit: Zugriffspruefung + Path-Traversal-Schutz - archiver Package fuer Streaming-ZIP-Erstellung Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
386 lines
14 KiB
JavaScript
386 lines
14 KiB
JavaScript
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}`);
|
|
});
|