From a59457a8bb4f43b18540c78bd19a6f66af493fe6 Mon Sep 17 00:00:00 2001 From: Tom Misch Date: Mon, 11 May 2026 10:35:05 +0200 Subject: [PATCH] Secure image access and bootstrap admin --- README.md | 12 ++++++++ assets/js/admin.js | 64 ++++++++++++++++++++++++++-------------- backend/database.js | 43 +++++++++++++++++++++++---- backend/server.js | 71 ++++++++++++++++++++++++++++++++++++++++++--- docker-compose.yml | 6 ++++ 5 files changed, 165 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 60c676b..59c2591 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ services: container_name: photography-website ports: - "8090:8090" + environment: + - JWT_SECRET=replace-with-a-long-random-secret + - INITIAL_ADMIN_USERNAME=MrDiderot + - INITIAL_ADMIN_PASSWORD=Start123 + - FULLS_DIR=/app/public/images/fulls + - THUMBS_DIR=/app/public/images/thumbs volumes: - /path/to/nextcloud/data:/app/public/images/fulls:ro - thumbs:/app/public/images/thumbs @@ -32,6 +38,12 @@ services: Run `docker compose up -d` to launch the site. +Change the initial admin password after the first login. If you prefer not to store the initial password in `docker-compose.yml`, create a bcrypt hash and use `INITIAL_ADMIN_PASSWORD_HASH` instead: + +```bash +node -e "const bcrypt = require('bcryptjs'); console.log(bcrypt.hashSync('your-admin-password', 10));" +``` + ## Credits & License - Originally based on a Jekyll template. - UI Design by [AJ / HTML5 UP](https://html5up.net). diff --git a/assets/js/admin.js b/assets/js/admin.js index 01592e8..f1570c0 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -27,32 +27,42 @@ $(document).ready(function() { $('#user-select').html(''); users.forEach(function(u) { - var badge = '' + u.role + ''; + var badge = $('').addClass('badge badge-' + u.role).text(u.role); var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-'; var info = created; if (u.role === 'client' && u.expires_at) { var expDate = new Date(u.expires_at).toLocaleDateString('de-DE'); if (u.expired) { - badge = 'abgelaufen'; + badge = $('').addClass('badge badge-expired').text('abgelaufen'); info = 'Abgelaufen: ' + expDate; } else { info = 'Bis: ' + expDate; } } - var actions = ''; - actions += ''; + var row = $(''); + var userCell = $('').text(u.username + ' ').append(badge); + var actions = $(''); + $('') + .data('user-id', u.id) + .data('username', u.username) + .appendTo(actions); + $('') + .data('user-id', u.id) + .data('username', u.username) + .appendTo(actions); - $('#users-tbody').append( - '' + u.id + '' + u.username + ' ' + badge + - '' + u.role + '' + info + - '' + actions + '' - ); + row.append($('').text(u.id)); + row.append(userCell); + row.append($('').text(u.role)); + row.append($('').text(info)); + row.append(actions); + $('#users-tbody').append(row); // Dropdown fuer Ordner-Zuweisung (nur non-admin) if (u.role !== 'admin') { - $('#user-select').append(''); + $('#user-select').append($('').val(u.id).text(u.username + ' (' + u.role + ')')); } }); }); @@ -75,16 +85,20 @@ $(document).ready(function() { }); // Nutzer loeschen - window.deleteUser = function(id, name) { + function deleteUser(id, name) { if (confirm('Nutzer "' + name + '" wirklich loeschen?')) { $.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' }) .done(loadUsers) .fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); }); } - }; + } + + $('#users-tbody').on('click', '.user-delete', function() { + deleteUser($(this).data('user-id'), $(this).data('username')); + }); // Passwort aendern (Admin setzt fuer anderen User) - window.changePassword = function(id, name) { + function changePassword(id, name) { var newPw = prompt('Neues Passwort fuer "' + name + '":'); if (!newPw) return; $.ajax({ @@ -97,7 +111,11 @@ $(document).ready(function() { }).fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); }); - }; + } + + $('#users-tbody').on('click', '.user-password', function() { + changePassword($(this).data('user-id'), $(this).data('username')); + }); // ==================== ORDNER-ZUWEISUNG ==================== @@ -109,12 +127,14 @@ $(document).ready(function() { return; } folders.forEach(function(f) { - $('#folder-checkboxes').append( - '
' - ); + var wrapper = $('
'); + var label = $(''); + $('').val(f).appendTo(label); + label.append(' '); + $('').appendTo(label); + label.append(' ' + f); + wrapper.append(label); + $('#folder-checkboxes').append(wrapper); }); }); } @@ -126,7 +146,9 @@ $(document).ready(function() { if (!uid) return; $.get('/api/admin/assign?user_id=' + uid).done(function(assignedFolders) { assignedFolders.forEach(function(f) { - $('.folder-cb[value="' + f + '"]').prop('checked', true); + $('.folder-cb').filter(function() { + return $(this).val() === f; + }).prop('checked', true); }); }); }); diff --git a/backend/database.js b/backend/database.js index f25e366..ad1b9a2 100644 --- a/backend/database.js +++ b/backend/database.js @@ -38,13 +38,44 @@ db.serialize(() => { UNIQUE(user_id, image_name) )`); - // 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: admin / admin123'); + // Initialen Admin nur mit expliziten Credentials aus der Umgebung anlegen. + db.serialize(() => { + const username = process.env.INITIAL_ADMIN_USERNAME; + const password = process.env.INITIAL_ADMIN_PASSWORD; + const passwordHash = process.env.INITIAL_ADMIN_PASSWORD_HASH || (password ? bcrypt.hashSync(password, 10) : null); + if (!username || !passwordHash) { + console.warn('Set INITIAL_ADMIN_USERNAME with INITIAL_ADMIN_PASSWORD or INITIAL_ADMIN_PASSWORD_HASH to bootstrap an admin.'); + return; } + + db.get('SELECT id FROM users WHERE username = ?', [username], (err, row) => { + if (err || row) return; + + db.run( + 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', + [username, passwordHash, 'admin'], + (insertErr) => { + if (insertErr) { + console.error('Failed to create initial admin:', insertErr.message); + return; + } + console.log(`Initial admin created: ${username}`); + } + ); + }); + + db.get('SELECT id, role FROM users WHERE username = ?', [username], (err, row) => { + if (err || !row || row.role === 'admin') return; + + db.run('UPDATE users SET role = ? WHERE id = ?', ['admin', row.id], (updateErr) => { + if (updateErr) { + console.error('Failed to promote initial admin:', updateErr.message); + return; + } + console.log(`Initial admin promoted: ${username}`); + } + ); + }); }); }); diff --git a/backend/server.js b/backend/server.js index c5ee595..55f6323 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,10 +7,22 @@ const path = require('path'); 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 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 IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i; +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()); @@ -73,6 +85,55 @@ function listSubfolders(dir) { } } +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); + }); +} + // ==================== AUTH API ==================== app.post('/api/auth/login', (req, res) => { @@ -250,10 +311,12 @@ app.post('/api/admin/assign', requireAdmin, (req, res) => { // ==================== STATIC FILES ==================== -app.use(express.static(path.join(__dirname, '../public'))); +app.get(/^\/images\/(fulls|thumbs)\/(.+)$/, serveProtectedImage); + +app.use(express.static(PUBLIC_DIR)); app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, '../public/index.html')); + res.sendFile(path.join(PUBLIC_DIR, 'index.html')); }); const PORT = 8090; diff --git a/docker-compose.yml b/docker-compose.yml index a34ba04..6597104 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,12 @@ services: container_name: photography-website ports: - "8090:8090" + environment: + - JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment} + - INITIAL_ADMIN_USERNAME=MrDiderot + - INITIAL_ADMIN_PASSWORD=Start123 + - FULLS_DIR=/app/public/images/fulls + - THUMBS_DIR=/app/public/images/thumbs volumes: # Nextcloud-Ordner mit Originalbildern (read-only) - /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro