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