Nutzerverwaltung-Upgrade: 3 Rollen, Ordner-Zuweisung, Passwort-Aenderung
- 3 Rollen: admin (voll), user (permanent), client (30 Tage Ablauf) - Ordner-basierte Bildzuweisung statt Einzelbilder (user_folders Tabelle) - Root-Bilder oeffentlich, Unterordner nur fuer zugewiesene Nutzer - Passwort-Aenderung fuer alle Nutzer (Self-Service + Admin) - Admin kann beliebige Admins erstellen/loeschen - Thumbnail-Generierung fuer Unterordner - Admin-Dashboard komplett ueberarbeitet Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,13 +8,29 @@ const db = new sqlite3.Database(dbPath);
|
||||
console.log('Using database at', dbPath);
|
||||
|
||||
db.serialize(() => {
|
||||
// Users: mit created_at fuer Client-Ablauf (30 Tage)
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL
|
||||
role TEXT NOT NULL DEFAULT 'client',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
// Migration: created_at hinzufuegen falls Tabelle schon existiert
|
||||
db.run(`ALTER TABLE users ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP`, (err) => {
|
||||
// Ignoriere Fehler wenn Spalte schon existiert
|
||||
});
|
||||
|
||||
// Ordner-Zuweisung (neu)
|
||||
db.run(`CREATE TABLE IF NOT EXISTS user_folders (
|
||||
user_id INTEGER,
|
||||
folder_name TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
UNIQUE(user_id, folder_name)
|
||||
)`);
|
||||
|
||||
// Alte Tabelle behalten fuer Kompatibilitaet, aber nicht mehr nutzen
|
||||
db.run(`CREATE TABLE IF NOT EXISTS user_images (
|
||||
user_id INTEGER,
|
||||
image_name TEXT,
|
||||
@@ -22,12 +38,12 @@ db.serialize(() => {
|
||||
UNIQUE(user_id, image_name)
|
||||
)`);
|
||||
|
||||
// Create default admin user if none exists
|
||||
// Default-Admin erstellen wenn keiner existiert
|
||||
db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => {
|
||||
if (!row) {
|
||||
const hash = bcrypt.hashSync('admin123', 10);
|
||||
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']);
|
||||
console.log('Default admin created with credentials: admin / admin123');
|
||||
console.log('Default admin created: admin / admin123');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,30 +4,44 @@ const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { exec } = require('child_process');
|
||||
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 to verify JWT cookie
|
||||
// ==================== MIDDLEWARE ====================
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const token = req.cookies.token;
|
||||
if (!token) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
if (!token) { req.user = null; return next(); }
|
||||
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||
if (err) req.user = null;
|
||||
else req.user = 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' });
|
||||
@@ -37,6 +51,28 @@ function requireAdmin(req, res, 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) => {
|
||||
@@ -46,7 +82,18 @@ app.post('/api/auth/login', (req, res) => {
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
|
||||
// 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 });
|
||||
});
|
||||
@@ -62,123 +109,160 @@ app.get('/api/auth/me', (req, res) => {
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
// ==================== IMAGE PROXY ====================
|
||||
|
||||
// Bilder-Listing: Oeffentlich = alle Bilder, eingeloggt = zugewiesene Bilder
|
||||
app.get('/images/fulls/', (req, res) => {
|
||||
const fullsDir = path.join(__dirname, '../public/images/fulls');
|
||||
|
||||
fs.readdir(fullsDir, (err, files) => {
|
||||
if (err) return res.json([]);
|
||||
|
||||
const imageFiles = files.filter(f => f.match(/\.(jpg|jpeg|png|gif|webp)$/i));
|
||||
|
||||
function buildResult(names) {
|
||||
return names.map(name => {
|
||||
try {
|
||||
const stats = fs.statSync(path.join(fullsDir, name));
|
||||
return { name, type: 'file', mtime: stats.mtime.toUTCString(), size: stats.size };
|
||||
} catch (e) {
|
||||
return { name, type: 'file' };
|
||||
}
|
||||
});
|
||||
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' });
|
||||
}
|
||||
|
||||
// Nicht eingeloggt oder Admin: alle Bilder anzeigen
|
||||
if (!req.user || req.user.role === 'admin') {
|
||||
return res.json(buildResult(imageFiles));
|
||||
}
|
||||
|
||||
// Normaler User: nur zugewiesene Bilder
|
||||
db.all('SELECT image_name FROM user_images WHERE user_id = ?', [req.user.id], (err, rows) => {
|
||||
if (err) return res.json([]);
|
||||
const assignedNames = rows.map(r => r.image_name);
|
||||
const assignedFiles = imageFiles.filter(name => assignedNames.includes(name));
|
||||
return res.json(buildResult(assignedFiles));
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Proxy image files directly, but we let Express serve static later.
|
||||
// BUT we should restrict access to physical image files?
|
||||
// If we want to prevent direct URL guessing, we would need to serve them via app.get('/images/fulls/:name').
|
||||
// For now, let's just use express.static to serve the actual binary files.
|
||||
// Nextcloud folder names are hard to guess anyway.
|
||||
// ==================== 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: Root + alle Ordner
|
||||
if (req.user.role === 'admin') {
|
||||
const allImages = [...rootImages];
|
||||
folders.forEach(folder => {
|
||||
listImagesInDir(path.join(FULLS_DIR, folder)).forEach(name => {
|
||||
allImages.push({ name, type: 'file', folder });
|
||||
});
|
||||
});
|
||||
return res.json(allImages);
|
||||
}
|
||||
|
||||
// User/Client: Root + zugewiesene Ordner
|
||||
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 = [...rootImages];
|
||||
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 FROM users', (err, rows) => {
|
||||
res.json(rows);
|
||||
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, role || 'user'], function(err) {
|
||||
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) => {
|
||||
if (req.params.id == 1) return res.status(400).json({ error: 'Cannot delete primary admin' });
|
||||
db.run('DELETE FROM users WHERE id = ?', [req.params.id], err => {
|
||||
db.run('DELETE FROM user_images WHERE user_id = ?', [req.params.id]);
|
||||
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) => {
|
||||
db.all('SELECT * FROM user_images', (err, rows) => {
|
||||
res.json(rows);
|
||||
});
|
||||
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, image_names } = req.body; // image_names is array of strings
|
||||
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('BEGIN TRANSACTION');
|
||||
db.run('DELETE FROM user_images WHERE user_id = ?', [user_id]);
|
||||
const stmt = db.prepare('INSERT INTO user_images (user_id, image_name) VALUES (?, ?)');
|
||||
image_names.forEach(name => {
|
||||
stmt.run(user_id, name);
|
||||
});
|
||||
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();
|
||||
db.run('COMMIT');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/admin/update', requireAdmin, (req, res) => {
|
||||
console.log("Triggering Docker Update...");
|
||||
|
||||
// Neues Image pullen und Container neu starten (ohne docker-compose)
|
||||
const image = 'ghcr.io/kroonk/photography:latest';
|
||||
const cmd = `docker pull ${image} && docker stop photography-website && docker rm photography-website`;
|
||||
|
||||
res.json({ success: true, message: 'Update gestartet. Container wird neu gestartet... Seite laedt in 30 Sekunden neu.' });
|
||||
|
||||
setTimeout(() => {
|
||||
exec(cmd, (err, stdout, stderr) => {
|
||||
if (err) console.error("Update failed:", err);
|
||||
else console.log("Update triggered:", stdout);
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
|
||||
// ==================== STATIC FILES ====================
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Catch all - send index.html
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
const PORT = 8090;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Backend server listening on port ${PORT}`);
|
||||
console.log(`Mischkomposition Backend on port ${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user