Files
Photography/backend/server.js
Kroonk e6a11f2eed
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 20s
Improve folder access admin dashboard
2026-05-20 18:44:47 +02:00

596 lines
22 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 { 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 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);
});
}
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
}));
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 });
});
});
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);
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}`);
});