Implement Node.js backend rewrite with SQLite authentication, admin dashboard and One-Click docker update

This commit is contained in:
Kroonk
2026-04-12 15:40:46 +02:00
parent 1c233db10e
commit 5747813d2d
11 changed files with 2910 additions and 105 deletions

View File

@@ -12,23 +12,23 @@ COPY . .
RUN bundle exec jekyll build
# === Serve-Phase: nginx + ImageMagick fuer Thumbnails ===
FROM nginx:alpine
# === Serve-Phase: node.js backend + ImageMagick ===
FROM node:20-alpine
# Nginx als Root laufen lassen, um Berechtigungsprobleme mit Nextcloud-Dateien zu vermeiden
RUN sed -i 's/user nginx;/user root;/g' /etc/nginx/nginx.conf
RUN apk add --no-cache imagemagick docker-cli
RUN apk add --no-cache imagemagick
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /site/_site /usr/share/nginx/html
COPY backend ./backend
COPY --from=builder /site/_site ./public
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Leere Bildverzeichnisse anlegen (werden durch Volumes ueberschrieben)
RUN mkdir -p /usr/share/nginx/html/images/fulls \
/usr/share/nginx/html/images/thumbs
# Verzeichnisse anlegen
RUN mkdir -p /app/public/images/fulls /app/public/images/thumbs /app/data
EXPOSE 80
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]

59
admin.html Normal file
View File

@@ -0,0 +1,59 @@
<!DOCTYPE HTML>
<html>
<head>
<title>Admin Dashboard - Mischkomposition</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="/assets/css/main.min.css" />
<style>
.admin-section { background: rgba(0,0,0,0.5); padding: 2em; margin-bottom: 2em; border-radius: 4px; }
.img-check { display: inline-block; width: 100px; margin: 5px; text-align: center; }
.img-check img { width: 100px; height: 100px; object-fit: cover; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.2); }
</style>
</head>
<body class="is-preload">
<div id="wrapper">
<header id="header">
<h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1>
<nav><ul><li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li></ul></nav>
</header>
<div id="main" style="display:block; padding: 2em; max-width: 900px; margin: 0 auto; width: 100%;">
<div class="admin-section">
<h2>System</h2>
<p>Aktualisiert den Docker-Container aus dem GitHub Repository. Container startet danach neu.</p>
<button id="update-btn" class="primary">Website Aktualisieren (Docker Pull)</button>
<p id="update-status" style="margin-top: 10px;"></p>
</div>
<div class="admin-section">
<h2>Nutzerverwaltung</h2>
<form id="create-user-form" style="display:flex; gap:10px; margin-bottom:1em;">
<input type="text" id="new-username" placeholder="Nutzername" required />
<input type="password" id="new-password" placeholder="Passwort" required />
<button type="submit">Erstellen</button>
</form>
<table>
<thead><tr><th>ID</th><th>Username</th><th>Aktion</th></tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
<div class="admin-section">
<h2>BildZuweisung</h2>
<select id="user-select" style="margin-bottom: 1em;">
<option value="">Nutzer wählen...</option>
</select>
<div id="image-checkboxes" style="display:flex; flex-wrap:wrap; gap:10px;"></div>
<button id="save-assignments" style="margin-top:1em;">Zuweisung Speichern</button>
</div>
</div>
</div>
<script src="/assets/js/jquery.min.js"></script>
<script src="/assets/js/admin.js"></script>
</body>
</html>

104
assets/js/admin.js Normal file
View File

@@ -0,0 +1,104 @@
$(document).ready(function() {
// Check if admin
$.get('/api/auth/me')
.done(function(user) {
console.log("Logged in:", user);
if(user.role !== 'admin') {
alert("Kein Zugriff!");
window.location.href = '/';
} else {
loadUsers();
loadAssignmentImages();
$('body').removeClass('is-preload');
}
})
.fail(function() {
window.location.href = '/';
});
$('#logout-btn').click(function(e) {
e.preventDefault();
$.post('/api/auth/logout').done(function() { window.location.href = '/'; });
});
function loadUsers() {
$.get('/api/admin/users').done(function(users) {
$('#users-tbody').empty();
$('#user-select').html('<option value="">Nutzer wählen...</option>');
users.forEach(u => {
$('#users-tbody').append(`
<tr>
<td>${u.id}</td>
<td>${u.username} ${u.role==='admin'?'(Admin)':''}</td>
<td>${u.id !== 1 ? `<button onclick="deleteUser(${u.id})">Löschen</button>` : ''}</td>
</tr>
`);
if(u.role !== 'admin') {
$('#user-select').append(`<option value="${u.id}">${u.username}</option>`);
}
});
});
}
window.deleteUser = function(id) {
if(confirm("Nutzer löschen?")) {
$.ajax({url: '/api/admin/users/'+id, type: 'DELETE'}).done(loadUsers);
}
};
$('#create-user-form').submit(function(e) {
e.preventDefault();
$.post('/api/admin/users', { username: $('#new-username').val(), password: $('#new-password').val(), role: 'user' })
.done(function() {
$('#new-username').val('');
$('#new-password').val('');
loadUsers();
}).fail(function(err) { alert("Fehler: " + err.responseJSON.error); });
});
let allImages = [];
function loadAssignmentImages() {
$.get('/images/fulls/').done(function(files) {
allImages = files;
$('#image-checkboxes').empty();
files.forEach(f => {
$('#image-checkboxes').append(`
<label class="img-check">
<img src="/images/thumbs/${encodeURIComponent(f.name)}" />
<br/><input type="checkbox" value="${f.name}" class="img-cb"/>
</label>
`);
});
});
}
$('#user-select').change(function() {
const uid = $(this).val();
$('.img-cb').prop('checked', false);
if(!uid) return;
$.get('/api/admin/assign').done(function(assignments) {
assignments.filter(a => a.user_id == uid).forEach(a => {
$(`.img-cb[value="${a.image_name}"]`).prop('checked', true);
});
});
});
$('#save-assignments').click(function() {
const uid = $('#user-select').val();
if(!uid) return alert('Bitte Nutzer wählen');
const selected = [];
$('.img-cb:checked').each(function() { selected.push($(this).val()); });
$.post('/api/admin/assign', { user_id: uid, image_names: selected })
.done(function() { alert("Zuweisung gespeichert!"); });
});
$('#update-btn').click(function() {
if(!confirm("Achtung: Dies startet den Server neu. Fortfahren?")) return;
$('#update-status').text('Update läuft...');
$.post('/api/admin/update').done(function(res) {
$('#update-status').text(res.message);
setTimeout(() => { window.location.reload(); }, 5000);
});
});
});

View File

@@ -160,6 +160,11 @@
dataType: 'json',
cache: false,
success: function(files) {
if(files.length === 0) {
$main.html('<p style="text-align:center;color:#fff;padding:2em;width:100%">Bitte logge dich über den Login-Button unten links ein, um deine zugewiesenen Bilder zu sehen.</p>');
return;
}
var images = files.filter(function(f) {
if (f.type !== 'file') return false;
var ext = f.name.split('.').pop().toLowerCase();
@@ -167,7 +172,7 @@
});
if (images.length === 0) {
$main.html('<p style="text-align:center;color:#fff;padding:2em;">JSON Array from Nginx was loaded, but 0 images matched!<br/>Raw files: ' + JSON.stringify(files) + '</p>');
$main.html('<p style="text-align:center;color:#fff;padding:2em;width:100%">Es wurden noch keine passenden Bilder für diesen Account hochgeladen oder zugewiesen.</p>');
return;
}
@@ -225,7 +230,8 @@
data = exifDatas[$image_img.data('name')] = getExifDataMarkup(this);
});
}
return data !== undefined ? '<p>' + data + '</p>' : ' ';
var downBtn = '<br/><a href="' + $a.attr('href') + '" download="' + $image_img.data('name') + '" class="button small" style="margin-top:10px;">Bild Speichern <i class="fa fa-download"></i></a>';
return data !== undefined ? '<p>' + data + downBtn + '</p>' : '<p>' + downBtn + '</p>';
},
fadeSpeed: 300,
onPopupClose: function () {
@@ -273,7 +279,26 @@
return template;
}
// Galerie laden
loadGallery();
// Login Handling
$.get('/api/auth/me').done(function(u) {
$('#login-username, #login-password, #login-submit').hide();
$('#logout-btn').show().css('display', 'inline-block');
if(u.role === 'admin') {
$('#admin-link').html('<a href="/admin.html" class="button">Admin-Dashboard</a>');
}
$('#login-nav').html('Account <i class="fa fa-user"></i>');
});
$('#login-form').submit(function(e) {
e.preventDefault();
$.post('/api/auth/login', { username: $('#login-username').val(), password: $('#login-password').val() })
.done(function() { window.location.reload(); })
.fail(function(err) { alert("Login fehlgeschlagen. Bitte Zugangsdaten ueberpruefen."); });
});
$('#logout-btn').click(function(e) {
e.preventDefault();
$.post('/api/auth/logout').done(function() { window.location.reload(); });
});
})(jQuery);

35
backend/database.js Normal file
View File

@@ -0,0 +1,35 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const bcrypt = require('bcryptjs');
const dbPath = process.env.DB_PATH || path.join(__dirname, '../data/database.sqlite');
const db = new sqlite3.Database(dbPath);
console.log('Using database at', dbPath);
db.serialize(() => {
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
)`);
db.run(`CREATE TABLE IF NOT EXISTS user_images (
user_id INTEGER,
image_name TEXT,
FOREIGN KEY(user_id) REFERENCES users(id),
UNIQUE(user_id, image_name)
)`);
// Create default admin user if none exists
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');
}
});
});
module.exports = db;

183
backend/server.js Normal file
View File

@@ -0,0 +1,183 @@
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 { exec } = require('child_process');
const db = require('./database');
const app = express();
const JWT_SECRET = process.env.JWT_SECRET || 'mischkomposition-super-secret-key-change-me';
app.use(express.json());
app.use(cookieParser());
// Middleware to verify JWT cookie
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;
else req.user = decoded;
next();
});
}
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
next();
}
app.use(authMiddleware);
// ==================== 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' });
}
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, 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);
});
// ==================== IMAGE PROXY ====================
// We must override the nginx `/images/fulls/` JSON behavior here:
app.get('/images/fulls/', (req, res) => {
if (!req.user) return res.json([]); // Not logged in -> empty gallery
const fullsDir = path.join(__dirname, '../public/images/fulls');
fs.readdir(fullsDir, (err, files) => {
if (err) return res.json([]);
let imageFiles = files.filter(f => f.match(/\.(jpg|jpeg|png|gif|webp)$/i));
if (req.user.role === 'admin') {
// Admin sees all
const result = imageFiles.map(name => {
const stats = fs.statSync(path.join(fullsDir, name));
return { name, type: 'file', mtime: stats.mtime.toUTCString(), size: stats.size };
});
return res.json(result);
} else {
// Normal user sees only assigned
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));
const result = assignedFiles.map(name => {
const stats = fs.statSync(path.join(fullsDir, name));
return { name, type: 'file', mtime: stats.mtime.toUTCString(), size: stats.size };
});
return res.json(result);
});
}
});
});
// 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.
// ==================== ADMIN API ====================
app.get('/api/admin/users', requireAdmin, (req, res) => {
db.all('SELECT id, username, role FROM users', (err, rows) => {
res.json(rows);
});
});
app.post('/api/admin/users', requireAdmin, (req, res) => {
const { username, password, role } = req.body;
const hash = bcrypt.hashSync(password, 10);
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, role || 'user'], 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]);
res.json({ success: true });
});
});
app.get('/api/admin/assign', requireAdmin, (req, res) => {
db.all('SELECT * FROM user_images', (err, rows) => {
res.json(rows);
});
});
app.post('/api/admin/assign', requireAdmin, (req, res) => {
const { user_id, image_names } = req.body; // image_names is array of strings
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);
});
stmt.finalize();
db.run('COMMIT');
res.json({ success: true });
});
});
app.post('/api/admin/update', requireAdmin, (req, res) => {
console.log("Triggering Docker Compose Update...");
// Wir fuehren den Docker compose pull/down/up aus.
// ACHTUNG: Der Server stuertzt danach ohnehin im Container ab (da er neu gestartet wird).
const cmd = "docker compose pull && docker compose down && docker compose up -d";
// Return early to ensure client gets success BEFORE container dies
res.json({ success: true, message: 'Update gestartet. Container wird neu gestartet...' });
setTimeout(() => {
exec(cmd, { cwd: '/app' }, (err, stdout, stderr) => {
if (err) console.error("Update failed:", err);
else console.log("Update success:", 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 = 8080;
app.listen(PORT, () => {
console.log(`Backend server listening on port ${PORT}`);
});

View File

@@ -3,13 +3,18 @@ services:
image: ghcr.io/kroonk/photography:latest
container_name: photography-website
ports:
- "8090:80"
- "8090:8080"
volumes:
# Nextcloud-Ordner mit Originalbildern (read-only)
- /volume1/nextcloud/data/Tom/files/Websitebilder:/usr/share/nginx/html/images/fulls:ro
- /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro
# Thumbnails werden automatisch generiert und hier gespeichert
- thumbs:/usr/share/nginx/html/images/thumbs
- thumbs:/app/public/images/thumbs
# SQLite Database Volume
- database:/app/data
# Docker Socket fuer One-Click Update
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
volumes:
thumbs:
database:

View File

@@ -1,7 +1,7 @@
#!/bin/sh
FULLS_DIR="/usr/share/nginx/html/images/fulls"
THUMBS_DIR="/usr/share/nginx/html/images/thumbs"
FULLS_DIR="/app/public/images/fulls"
THUMBS_DIR="/app/public/images/thumbs"
generate_thumbs() {
mkdir -p "$THUMBS_DIR"
@@ -35,5 +35,5 @@ echo "=== Thumbnail-Generierung abgeschlossen ==="
generate_thumbs
done) &
# nginx starten
nginx -g 'daemon off;'
# Node Server starten
cd /app && node backend/server.js

View File

@@ -10,6 +10,7 @@ layout: default
<h1><a href="index.html"><strong>{{ site.header.title }}</strong> {{ site.header.subtitle }}</a></h1>
<nav>
<ul>
<li><a href="#login-panel" id="login-nav">Login&nbsp;&nbsp;<i class="fa fa-user"></i></a></li>
<li><a href="#footer">About&nbsp;&nbsp;<i class="fa fa-info-circle text-fg"></i></a></li>
</ul>
</nav>
@@ -108,4 +109,28 @@ layout: default
</div>
</div>
</footer>
<footer id="login-panel" class="panel">
<div class="inner">
<section>
<h2>Kunden-Portal</h2>
<p>Logge dich ein, um die Bilder deiner Fotosession zu sehen und herunterzuladen.</p>
<form id="login-form">
<div class="fields">
<div class="field">
<input type="text" id="login-username" placeholder="Nutzername" required />
</div>
<div class="field">
<input type="password" id="login-password" placeholder="Passwort" required />
</div>
</div>
<ul class="actions">
<li><input type="submit" value="Login" class="primary" id="login-submit" /></li>
<li><a href="#" id="logout-btn" style="display:none;" class="button">Logout</a></li>
<li id="admin-link"></li>
</ul>
</form>
</section>
</div>
</footer>
</div>

2528
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,13 @@
"url": "https://github.com/Kroonk/Photography/issues"
},
"homepage": "https://github.com/Kroonk/Photography#readme",
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"del": "^4.1.1",
"gulp": "^4.0.2",