From 3ad76e841dbceb39f35289514f5707015fef3de6 Mon Sep 17 00:00:00 2001 From: Kroonk Date: Wed, 20 May 2026 19:13:51 +0200 Subject: [PATCH] Rework Photography admin dashboard --- Dockerfile | 2 - README.md | 8 +- admin.html | 365 +++++++++++++++++++++++++++++++-------------- assets/js/admin.js | 179 +++++++++++++++++----- backend/server.js | 45 ------ docker-compose.yml | 12 +- 6 files changed, 395 insertions(+), 216 deletions(-) diff --git a/Dockerfile b/Dockerfile index aa7aee9..cb7877f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,6 @@ RUN npm ci --omit=dev && npm cache clean --force # === Serve-Phase: node.js backend + Runtime-Tools === FROM node:20-alpine -# Getrennte RUNs halten einzelne Registry-Layer klein genug fuer Proxy-Limits. -RUN apk add --no-cache docker-cli RUN apk add --no-cache imagemagick libjpeg-turbo libpng libwebp RUN apk add --no-cache sqlite-libs diff --git a/README.md b/README.md index 06318a6..b739f61 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The project features a fast static frontend (based on HTML5 UP Multiverse) power - **Nextcloud Integration:** Photos placed into the Nextcloud directory are automatically detected. - **Client Separation:** Guests see the public portfolio, while logged-in clients see their designated, private sub-folders. - **Auto-Thumbnails:** Automatic resizing and EXIF extraction (shutter speed, ISO, aperture) for fast loading times. -- **Admin Dashboard:** Manage users, assign private folders, and trigger One-Click container updates directly from the browser. +- **Admin Dashboard:** Manage users, roles, private folders, access rights, account security, and deployment status. - **Light/Dark Mode:** Dynamic theme toggle saving preferences in the browser. ## Deployment Setup (Docker) @@ -26,14 +26,10 @@ services: - JWT_SECRET=replace-with-a-long-random-secret - FULLS_DIR=/app/public/images/fulls - THUMBS_DIR=/app/public/images/thumbs - - SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest - - SYNC_CONTAINER=photography-website volumes: - /path/to/nextcloud/data:/app/public/images/fulls:ro - thumbs:/app/public/images/thumbs - database:/app/data - - /var/run/docker.sock:/var/run/docker.sock - - ~/.docker/config.json:/root/.docker/config.json:ro ``` Run `docker compose up -d` to launch the site. @@ -46,7 +42,7 @@ node -e "const bcrypt = require('bcryptjs'); console.log(bcrypt.hashSync('your-a Then start once with `INITIAL_ADMIN_USERNAME=MrDiderot` and `INITIAL_ADMIN_PASSWORD_HASH=`. Existing installations keep their SQLite users in the persistent `database` volume. -The admin dashboard includes a sync button that pulls `SYNC_IMAGE` and restarts `SYNC_CONTAINER`. This requires the Docker socket mount shown above. +Container updates are handled by Watchtower, which pulls from `git.mischlabs.de/mrdiderot/photography:latest`. ## Credits & License - Originally based on a Jekyll template. diff --git a/admin.html b/admin.html index 340bdaa..ccc6e17 100644 --- a/admin.html +++ b/admin.html @@ -6,55 +6,88 @@ @@ -64,96 +97,200 @@ -
-
- -
-

Nutzer erstellen

-
-
- - -
-
- - -
-
- - -
-
- -
-
-
- -
-

Website aktualisieren

-

Laedt das neueste Registry-Image und startet den Container neu.

- - -
- -
-

Nutzer, Rollen und Ordnerzugriffe

- - - -
IDUsernameRolleStatusOrdnerzugriffAktionen
-
- -
-

Ordner im Bilderpfad

-

Unterordner sind privat. Nur explizit freigegebene Nutzer sehen sie nach dem Login.

-
-
- -
-

Ordner-Zuweisung

-

Nutzer waehlen, Ordner anhaken, fertig.

- -
-
- +
+
+ + + -
-

Ordnerinhalt

-

Waehle in der Ordneruebersicht "Ansehen".

-
-
+
+
+
+
+

Uebersicht

+

Aktuelle Nutzer, private Ordner und offene Freigaben.

+
+
+ +
+
+
+
Nutzer
0
0 Admins
+
Private Ordner
0
0 Bilder
+
Freigaben
0
0 ohne Zugriff
+
Clients
0
0 abgelaufen
+
+

Aufmerksamkeit

+
+
+
+

Rollenverteilung

+
+
+
+
-
-

Eigenes Passwort aendern

-
-
- - +
+
+
+

Nutzer und Rollen

+

Accounts, Laufzeiten, Rollen und Ordnerzugriffe.

+
-
- - +
+
+

Nutzer erstellen

+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

Bestand

+
+ + + +
IDUsernameRolleStatusOrdnerzugriffAktionen
+
+
-
- -
- -
+
+
+
+
+

Ordner

+

Unterordner im Bilderpfad sind privat und werden nur explizit freigegeben.

+
+
+
+
+
+
+ +
+
+
+

Freigaben

+

Nutzer auswaehlen und private Ordner zuweisen.

+
+
+
+
+

Nutzer

+ +
+
+
+

Ordnerzugriff

+ +

Kein Nutzer ausgewaehlt.

+
+
+
+ +
+
+
+

Ordnerinhalt

+

Waehle einen Ordner aus der Ordneruebersicht.

+
+
+
+
+
+
+ +
+
+
+

Account

+

Passwort fuer den aktuellen Admin-Zugang.

+
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+
+

System

+

Deployment- und Laufzeitdaten fuer tom.mischlabs.de.

+
+
+
+
+

Deployment

+
+
Registrygit.mischlabs.de/mrdiderot/photography:latest
+
UpdatesWatchtower
+
Port8090
+
+
+
+

Speicherorte

+
+
Bilder/app/public/images/fulls
+
Thumbnails/app/public/images/thumbs
+
Datenbank/app/data/database.sqlite
+
+
+
+
-
+
- + diff --git a/assets/js/admin.js b/assets/js/admin.js index 9ce99f0..fa0df52 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -1,4 +1,5 @@ $(document).ready(function() { + var currentAdmin = null; var users = []; var folders = []; var assignedFolders = []; @@ -6,28 +7,46 @@ $(document).ready(function() { $.get('/api/auth/me') .done(function(user) { - if(user.role !== 'admin') { + if (user.role !== 'admin') { alert('Kein Zugriff!'); window.location.href = '/'; return; } + + currentAdmin = user; + $('#admin-user-label').text(user.username || 'Admin'); refreshDashboard(); $('body').removeClass('is-preload'); }) .fail(function() { window.location.href = '/'; }); + function showView(name) { + $('.admin-view').removeClass('active').filter('[data-view="' + name + '"]').addClass('active'); + $('.nav-item').removeClass('active').filter('[data-view="' + name + '"]').addClass('active'); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + $('.admin-nav').on('click', '.nav-item', function() { + showView($(this).data('view')); + }); + $('#logout-btn').click(function(e) { e.preventDefault(); $.post('/api/auth/logout').done(function() { window.location.href = '/'; }); }); + $('#refresh-dashboard').click(refreshDashboard); + function refreshDashboard() { $.when(loadUsers(), loadFolders()).done(function() { + renderStats(); renderUsers(); renderUserSelect(); renderFolders(); if (selectedUserId) { loadAssignments(selectedUserId); + } else { + renderSelectedUserSummary(null); } }); } @@ -51,6 +70,72 @@ $(document).ready(function() { }); } + function roleCounts() { + return users.reduce(function(acc, user) { + var role = user.role || 'client'; + acc[role] = (acc[role] || 0) + 1; + return acc; + }, { admin: 0, user: 0, client: 0 }); + } + + function totalAssignments() { + return users.reduce(function(total, user) { + return total + ((user.folders && user.folders.length) || 0); + }, 0); + } + + function totalImages() { + return folders.reduce(function(total, folder) { + return total + (folder.image_count || 0); + }, 0); + } + + function renderStats() { + var roles = roleCounts(); + var expiredClients = users.filter(function(user) { return user.expired; }).length; + var unassignedUsers = users.filter(function(user) { + return (user.role || 'client') !== 'admin' && (!user.folders || !user.folders.length); + }).length; + var unsharedFolders = folders.filter(function(folder) { + return !folder.assigned_users || !folder.assigned_users.length; + }).length; + + $('#stat-users').text(users.length); + $('#stat-users-sub').text(roles.admin + ' Admins, ' + roles.user + ' User'); + $('#stat-folders').text(folders.length); + $('#stat-folders-sub').text(totalImages() + ' Bilder in privaten Ordnern'); + $('#stat-access').text(totalAssignments()); + $('#stat-access-sub').text(unassignedUsers + ' Nutzer ohne Ordner'); + $('#stat-clients').text(roles.client); + $('#stat-clients-sub').text(expiredClients + ' abgelaufen'); + + renderAttentionList(unassignedUsers, unsharedFolders, expiredClients); + renderRoleList(roles); + } + + function appendSignal($target, label, value, muted) { + var $item = $('
').addClass('signal-item'); + $('').text(label).appendTo($item); + $('').toggleClass('muted', !!muted).text(value).appendTo($item); + $target.append($item); + } + + function renderAttentionList(unassignedUsers, unsharedFolders, expiredClients) { + var $list = $('#attention-list').empty(); + appendSignal($list, 'Nutzer ohne Ordner', unassignedUsers, unassignedUsers === 0); + appendSignal($list, 'Ordner ohne Freigabe', unsharedFolders, unsharedFolders === 0); + appendSignal($list, 'Abgelaufene Clients', expiredClients, expiredClients === 0); + appendSignal($list, 'Gesamtbilder privat', totalImages(), totalImages() === 0); + } + + function renderRoleList(roles) { + var $list = $('#role-list').empty(); + appendSignal($list, 'Admins', roles.admin || 0); + appendSignal($list, 'Permanente User', roles.user || 0); + appendSignal($list, 'Clients', roles.client || 0); + appendSignal($list, 'Freigaben', totalAssignments()); + } + function getRoleBadge(role, expired) { if (expired) return $('').addClass('badge badge-expired').text('abgelaufen'); return $('').addClass('badge badge-' + (role || 'client')).text(role || 'client'); @@ -63,20 +148,20 @@ $(document).ready(function() { return; } - users.forEach(function(u) { - var role = u.role || 'client'; - var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-'; + users.forEach(function(user) { + var role = user.role || 'client'; + var created = user.created_at ? new Date(user.created_at).toLocaleDateString('de-DE') : '-'; var status = created; - if (role === 'client' && u.expires_at) { - status = (u.expired ? 'Abgelaufen: ' : 'Bis: ') + - new Date(u.expires_at).toLocaleDateString('de-DE'); + if (role === 'client' && user.expires_at) { + status = (user.expired ? 'Abgelaufen: ' : 'Bis: ') + + new Date(user.expires_at).toLocaleDateString('de-DE'); } var $row = $(''); - var $userCell = $('').text(u.username + ' ').append(getRoleBadge(role, u.expired)); + var $userCell = $('').text(user.username + ' ').append(getRoleBadge(role, user.expired)); var $foldersCell = $(''); - if (u.folders && u.folders.length) { - u.folders.forEach(function(folder) { + if (user.folders && user.folders.length) { + user.folders.forEach(function(folder) { $('').addClass('badge folder-badge').text(folder).appendTo($foldersCell); }); } else { @@ -85,18 +170,18 @@ $(document).ready(function() { var $actions = $(''); $('') - .data('user-id', u.id) + .data('user-id', user.id) .appendTo($actions); $('') - .data('user-id', u.id) - .data('username', u.username) + .data('user-id', user.id) + .data('username', user.username) .appendTo($actions); $('') - .data('user-id', u.id) - .data('username', u.username) + .data('user-id', user.id) + .data('username', user.username) .appendTo($actions); - $row.append($('').text(u.id)); + $row.append($('').text(user.id)); $row.append($userCell); $row.append($('').text(role)); $row.append($('').text(status)); @@ -108,11 +193,11 @@ $(document).ready(function() { function renderUserSelect() { var $select = $('#user-select').html(''); - users.forEach(function(u) { - if ((u.role || 'client') === 'admin') return; + users.forEach(function(user) { + if ((user.role || 'client') === 'admin') return; $('') - .val(u.id) - .text(u.username + ' (' + (u.role || 'client') + ')') + .val(user.id) + .text(user.username + ' (' + (user.role || 'client') + ')') .appendTo($select); }); if (selectedUserId) $select.val(selectedUserId); @@ -174,11 +259,31 @@ $(document).ready(function() { }); } + function findUser(userId) { + var id = String(userId || ''); + return users.find(function(user) { return String(user.id) === id; }) || null; + } + + function renderSelectedUserSummary(user) { + var $summary = $('#selected-user-summary').empty(); + if (!user) { + appendSignal($summary, 'Auswahl', 'Kein Nutzer', true); + return; + } + appendSignal($summary, 'Rolle', user.role || 'client'); + appendSignal($summary, 'Ordner', (assignedFolders.length || 0) + ' freigegeben'); + if (user.expires_at) { + appendSignal($summary, 'Client bis', new Date(user.expires_at).toLocaleDateString('de-DE'), !!user.expired); + } + } + function loadAssignments(userId) { selectedUserId = String(userId || ""); if (!selectedUserId) { $('#folder-table-wrap').hide(); + $('#folder-access-empty').show(); assignedFolders = []; + renderSelectedUserSummary(null); return; } @@ -186,10 +291,14 @@ $(document).ready(function() { $.get('/api/admin/assign?user_id=' + encodeURIComponent(selectedUserId)).done(function(data) { assignedFolders = data || []; renderAccessList(); + renderSelectedUserSummary(findUser(selectedUserId)); + $('#folder-access-empty').hide(); $('#folder-table-wrap').show(); }).fail(function() { assignedFolders = []; renderAccessList(); + renderSelectedUserSummary(findUser(selectedUserId)); + $('#folder-access-empty').hide(); $('#folder-table-wrap').show(); }); } @@ -215,7 +324,7 @@ $(document).ready(function() { $('#users-tbody').on('click', '.user-access', function() { loadAssignments($(this).data('user-id')); - document.getElementById('user-select').scrollIntoView({ behavior: 'smooth', block: 'center' }); + showView('access'); }); $('#user-select').change(function() { @@ -226,8 +335,9 @@ $(document).ready(function() { $('#folders-overview').on('click', '.folder-assign-btn', function() { var folder = $(this).data('folder'); + showView('access'); if (!selectedUserId) { - alert('Bitte zuerst einen Nutzer in der Ordner-Zuweisung waehlen.'); + alert('Bitte zuerst einen Nutzer in den Freigaben waehlen.'); return; } var $checkbox = $('#folder-access-list input').filter(function() { @@ -240,6 +350,7 @@ $(document).ready(function() { $('#folders-overview').on('click', '.folder-preview-btn', function() { var folder = $(this).data('folder'); + showView('preview'); $('#folder-preview-title').text('Ordnerinhalt: ' + folder); $('#folder-preview-note').text('Lade Bilder...'); $('#folder-preview').empty(); @@ -288,7 +399,13 @@ $(document).ready(function() { var name = $(this).data('username'); if (!confirm('Nutzer "' + name + '" wirklich loeschen?')) return; $.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' }) - .done(refreshDashboard) + .done(function() { + if (String(selectedUserId) === String(id)) { + selectedUserId = ""; + assignedFolders = []; + } + refreshDashboard(); + }) .fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); }); }); @@ -309,22 +426,6 @@ $(document).ready(function() { }); }); - $('#sync-site').click(function() { - $('#sync-status').text('Sync laeuft...'); - $('#sync-site').prop('disabled', true); - $.post('/api/admin/sync') - .done(function(resp) { - $('#sync-status').text(resp.message || 'Sync gestartet.'); - }) - .fail(function(xhr) { - var msg = (xhr.responseJSON && (xhr.responseJSON.error || xhr.responseJSON.details)) || 'Sync fehlgeschlagen'; - $('#sync-status').text(msg); - }) - .always(function() { - $('#sync-site').prop('disabled', false); - }); - }); - $('#admin-pw-form').submit(function(e) { e.preventDefault(); $.post('/api/auth/change-password', { diff --git a/backend/server.js b/backend/server.js index ed51de4..eb07a22 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,7 +4,6 @@ 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'); @@ -20,10 +19,7 @@ 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); @@ -151,12 +147,6 @@ function serveProtectedImage(req, res) { }); } -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) => { @@ -418,41 +408,6 @@ app.post('/api/admin/assign', requireAdmin, (req, res) => { }); }); -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) { diff --git a/docker-compose.yml b/docker-compose.yml index efcee3e..922915f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,6 @@ services: - JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment} - FULLS_DIR=/app/public/images/fulls - THUMBS_DIR=/app/public/images/thumbs - - SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest - - SYNC_CONTAINER=photography-website volumes: # Nextcloud-Ordner mit Originalbildern (read-only) - /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro @@ -17,25 +15,19 @@ services: - thumbs:/app/public/images/thumbs # SQLite Database (persistent) - database:/app/data - # Zugriff auf Docker & Credentials fuer den One-Click "Website Aktualisieren" Button - - /var/run/docker.sock:/var/run/docker.sock - - ~/.docker/config.json:/root/.docker/config.json:ro restart: unless-stopped - # Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert den Container + # Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert Container watchtower: image: containrrr/watchtower container_name: watchtower - # Nur unsere Webseite updaten, andere UGREEN Container ignorieren! - command: photography-website volumes: - /var/run/docker.sock:/var/run/docker.sock # Registry Login von deinem NAS uebernehmen, um private Images lesen zu duerfen - - ~/.docker/config.json:/config.json:ro + - /volume2/docker/watchtower/config.json:/config.json:ro environment: - WATCHTOWER_CLEANUP=true - WATCHTOWER_POLL_INTERVAL=300 - - WATCHTOWER_LABEL_ENABLE=false restart: unless-stopped volumes: