System
+Aktualisiert den Docker-Container aus dem GitHub Repository. Container startet danach neu.
+ + +Nutzerverwaltung
+ +| ID | Username | Aktion |
|---|
diff --git a/Dockerfile b/Dockerfile index 54a2b4d..2eccf89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..63f51a7 --- /dev/null +++ b/admin.html @@ -0,0 +1,59 @@ + + +
+Aktualisiert den Docker-Container aus dem GitHub Repository. Container startet danach neu.
+ + +| ID | Username | Aktion |
|---|
Bitte logge dich über den Login-Button unten links ein, um deine zugewiesenen Bilder zu sehen.
'); + 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('JSON Array from Nginx was loaded, but 0 images matched!
Raw files: ' + JSON.stringify(files) + '
Es wurden noch keine passenden Bilder für diesen Account hochgeladen oder zugewiesen.
'); return; } @@ -225,7 +230,8 @@ data = exifDatas[$image_img.data('name')] = getExifDataMarkup(this); }); } - return data !== undefined ? '' + data + '
' : ' '; + var downBtn = '' + data + downBtn + '
' : '' + downBtn + '
'; }, 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('Admin-Dashboard'); + } + $('#login-nav').html('Account '); + }); + + $('#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); diff --git a/backend/database.js b/backend/database.js new file mode 100644 index 0000000..4901f9a --- /dev/null +++ b/backend/database.js @@ -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; diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..14c83fa --- /dev/null +++ b/backend/server.js @@ -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}`); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 393a690..464b607 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/entrypoint.sh b/entrypoint.sh index 0904b32..5c8cabb 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 diff --git a/index.html b/index.html index cd378eb..82c6b24 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,7 @@ layout: default