Secure image access and bootstrap admin
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 1m50s
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 1m50s
This commit is contained in:
12
README.md
12
README.md
@@ -22,6 +22,12 @@ services:
|
|||||||
container_name: photography-website
|
container_name: photography-website
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "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:
|
volumes:
|
||||||
- /path/to/nextcloud/data:/app/public/images/fulls:ro
|
- /path/to/nextcloud/data:/app/public/images/fulls:ro
|
||||||
- thumbs:/app/public/images/thumbs
|
- thumbs:/app/public/images/thumbs
|
||||||
@@ -32,6 +38,12 @@ services:
|
|||||||
|
|
||||||
Run `docker compose up -d` to launch the site.
|
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
|
## Credits & License
|
||||||
- Originally based on a Jekyll template.
|
- Originally based on a Jekyll template.
|
||||||
- UI Design by [AJ / HTML5 UP](https://html5up.net).
|
- UI Design by [AJ / HTML5 UP](https://html5up.net).
|
||||||
|
|||||||
@@ -27,32 +27,42 @@ $(document).ready(function() {
|
|||||||
$('#user-select').html('<option value="">Nutzer waehlen...</option>');
|
$('#user-select').html('<option value="">Nutzer waehlen...</option>');
|
||||||
|
|
||||||
users.forEach(function(u) {
|
users.forEach(function(u) {
|
||||||
var badge = '<span class="badge badge-' + u.role + '">' + u.role + '</span>';
|
var badge = $('<span></span>').addClass('badge badge-' + u.role).text(u.role);
|
||||||
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-';
|
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-';
|
||||||
var info = created;
|
var info = created;
|
||||||
|
|
||||||
if (u.role === 'client' && u.expires_at) {
|
if (u.role === 'client' && u.expires_at) {
|
||||||
var expDate = new Date(u.expires_at).toLocaleDateString('de-DE');
|
var expDate = new Date(u.expires_at).toLocaleDateString('de-DE');
|
||||||
if (u.expired) {
|
if (u.expired) {
|
||||||
badge = '<span class="badge badge-expired">abgelaufen</span>';
|
badge = $('<span></span>').addClass('badge badge-expired').text('abgelaufen');
|
||||||
info = 'Abgelaufen: ' + expDate;
|
info = 'Abgelaufen: ' + expDate;
|
||||||
} else {
|
} else {
|
||||||
info = 'Bis: ' + expDate;
|
info = 'Bis: ' + expDate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var actions = '<button class="btn-small" onclick="changePassword(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">PW</button>';
|
var row = $('<tr></tr>');
|
||||||
actions += '<button class="btn-small" onclick="deleteUser(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">X</button>';
|
var userCell = $('<td></td>').text(u.username + ' ').append(badge);
|
||||||
|
var actions = $('<td></td>');
|
||||||
|
$('<button type="button" class="btn-small user-password">PW</button>')
|
||||||
|
.data('user-id', u.id)
|
||||||
|
.data('username', u.username)
|
||||||
|
.appendTo(actions);
|
||||||
|
$('<button type="button" class="btn-small user-delete">X</button>')
|
||||||
|
.data('user-id', u.id)
|
||||||
|
.data('username', u.username)
|
||||||
|
.appendTo(actions);
|
||||||
|
|
||||||
$('#users-tbody').append(
|
row.append($('<td></td>').text(u.id));
|
||||||
'<tr><td>' + u.id + '</td><td>' + u.username + ' ' + badge +
|
row.append(userCell);
|
||||||
'</td><td>' + u.role + '</td><td>' + info +
|
row.append($('<td></td>').text(u.role));
|
||||||
'</td><td>' + actions + '</td></tr>'
|
row.append($('<td></td>').text(info));
|
||||||
);
|
row.append(actions);
|
||||||
|
$('#users-tbody').append(row);
|
||||||
|
|
||||||
// Dropdown fuer Ordner-Zuweisung (nur non-admin)
|
// Dropdown fuer Ordner-Zuweisung (nur non-admin)
|
||||||
if (u.role !== 'admin') {
|
if (u.role !== 'admin') {
|
||||||
$('#user-select').append('<option value="' + u.id + '">' + u.username + ' (' + u.role + ')</option>');
|
$('#user-select').append($('<option></option>').val(u.id).text(u.username + ' (' + u.role + ')'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -75,16 +85,20 @@ $(document).ready(function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Nutzer loeschen
|
// Nutzer loeschen
|
||||||
window.deleteUser = function(id, name) {
|
function deleteUser(id, name) {
|
||||||
if (confirm('Nutzer "' + name + '" wirklich loeschen?')) {
|
if (confirm('Nutzer "' + name + '" wirklich loeschen?')) {
|
||||||
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
|
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
|
||||||
.done(loadUsers)
|
.done(loadUsers)
|
||||||
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
|
.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)
|
// Passwort aendern (Admin setzt fuer anderen User)
|
||||||
window.changePassword = function(id, name) {
|
function changePassword(id, name) {
|
||||||
var newPw = prompt('Neues Passwort fuer "' + name + '":');
|
var newPw = prompt('Neues Passwort fuer "' + name + '":');
|
||||||
if (!newPw) return;
|
if (!newPw) return;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@@ -97,7 +111,11 @@ $(document).ready(function() {
|
|||||||
}).fail(function(err) {
|
}).fail(function(err) {
|
||||||
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
|
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
|
$('#users-tbody').on('click', '.user-password', function() {
|
||||||
|
changePassword($(this).data('user-id'), $(this).data('username'));
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== ORDNER-ZUWEISUNG ====================
|
// ==================== ORDNER-ZUWEISUNG ====================
|
||||||
|
|
||||||
@@ -109,12 +127,14 @@ $(document).ready(function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
folders.forEach(function(f) {
|
folders.forEach(function(f) {
|
||||||
$('#folder-checkboxes').append(
|
var wrapper = $('<div class="folder-check"></div>');
|
||||||
'<div class="folder-check"><label>' +
|
var label = $('<label></label>');
|
||||||
'<input type="checkbox" value="' + f + '" class="folder-cb"/> ' +
|
$('<input type="checkbox" class="folder-cb"/>').val(f).appendTo(label);
|
||||||
'<i class="fa fa-folder"></i> ' + f +
|
label.append(' ');
|
||||||
'</label></div>'
|
$('<i class="fa fa-folder"></i>').appendTo(label);
|
||||||
);
|
label.append(' ' + f);
|
||||||
|
wrapper.append(label);
|
||||||
|
$('#folder-checkboxes').append(wrapper);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -126,7 +146,9 @@ $(document).ready(function() {
|
|||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
$.get('/api/admin/assign?user_id=' + uid).done(function(assignedFolders) {
|
$.get('/api/admin/assign?user_id=' + uid).done(function(assignedFolders) {
|
||||||
assignedFolders.forEach(function(f) {
|
assignedFolders.forEach(function(f) {
|
||||||
$('.folder-cb[value="' + f + '"]').prop('checked', true);
|
$('.folder-cb').filter(function() {
|
||||||
|
return $(this).val() === f;
|
||||||
|
}).prop('checked', true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,13 +38,44 @@ db.serialize(() => {
|
|||||||
UNIQUE(user_id, image_name)
|
UNIQUE(user_id, image_name)
|
||||||
)`);
|
)`);
|
||||||
|
|
||||||
// Default-Admin erstellen wenn keiner existiert
|
// Initialen Admin nur mit expliziten Credentials aus der Umgebung anlegen.
|
||||||
db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => {
|
db.serialize(() => {
|
||||||
if (!row) {
|
const username = process.env.INITIAL_ADMIN_USERNAME;
|
||||||
const hash = bcrypt.hashSync('admin123', 10);
|
const password = process.env.INITIAL_ADMIN_PASSWORD;
|
||||||
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']);
|
const passwordHash = process.env.INITIAL_ADMIN_PASSWORD_HASH || (password ? bcrypt.hashSync(password, 10) : null);
|
||||||
console.log('Default admin created: admin / admin123');
|
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}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,22 @@ const path = require('path');
|
|||||||
const db = require('./database');
|
const db = require('./database');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'mischkomposition-super-secret-key-change-me';
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
const FULLS_DIR = path.join(__dirname, '../public/images/fulls');
|
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;
|
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.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(cookieParser());
|
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 ====================
|
// ==================== AUTH API ====================
|
||||||
|
|
||||||
app.post('/api/auth/login', (req, res) => {
|
app.post('/api/auth/login', (req, res) => {
|
||||||
@@ -250,10 +311,12 @@ app.post('/api/admin/assign', requireAdmin, (req, res) => {
|
|||||||
|
|
||||||
// ==================== STATIC FILES ====================
|
// ==================== 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) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = 8090;
|
const PORT = 8090;
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ services:
|
|||||||
container_name: photography-website
|
container_name: photography-website
|
||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "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:
|
volumes:
|
||||||
# Nextcloud-Ordner mit Originalbildern (read-only)
|
# Nextcloud-Ordner mit Originalbildern (read-only)
|
||||||
- /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro
|
- /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro
|
||||||
|
|||||||
Reference in New Issue
Block a user