Nutzerverwaltung-Upgrade: 3 Rollen, Ordner-Zuweisung, Passwort-Aenderung

- 3 Rollen: admin (voll), user (permanent), client (30 Tage Ablauf)
- Ordner-basierte Bildzuweisung statt Einzelbilder (user_folders Tabelle)
- Root-Bilder oeffentlich, Unterordner nur fuer zugewiesene Nutzer
- Passwort-Aenderung fuer alle Nutzer (Self-Service + Admin)
- Admin kann beliebige Admins erstellen/loeschen
- Thumbnail-Generierung fuer Unterordner
- Admin-Dashboard komplett ueberarbeitet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kroonk
2026-04-12 17:53:46 +02:00
parent a4f3cf738c
commit 941d041b5d
9 changed files with 429 additions and 171 deletions

View File

@@ -7,48 +7,88 @@
<link rel="stylesheet" href="/assets/css/main.min.css" /> <link rel="stylesheet" href="/assets/css/main.min.css" />
<style> <style>
.admin-section { background: rgba(0,0,0,0.5); padding: 2em; margin-bottom: 2em; border-radius: 4px; } .admin-section { background: rgba(0,0,0,0.5); padding: 2em; margin-bottom: 2em; border-radius: 4px; }
.img-check { display: inline-block; width: 100px; margin: 5px; text-align: center; } .folder-check { display: inline-block; margin: 5px 10px; padding: 8px 12px; background: rgba(255,255,255,0.1); border-radius: 4px; }
.img-check img { width: 100px; height: 100px; object-fit: cover; } .folder-check label { cursor: pointer; }
table { width: 100%; border-collapse: collapse; } table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.2); } th, td { text-align: left; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.2); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 0.8em; }
.badge-admin { background: #e44d26; }
.badge-user { background: #2196F3; }
.badge-client { background: #4CAF50; }
.badge-expired { background: #666; }
.btn-small { padding: 4px 10px; font-size: 0.85em; margin: 2px; cursor: pointer; }
</style> </style>
</head> </head>
<body class="is-preload"> <body class="is-preload">
<div id="wrapper"> <div id="wrapper">
<header id="header"> <header id="header">
<h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1> <h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1>
<nav><ul><li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li></ul></nav> <nav><ul>
<li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li>
</ul></nav>
</header> </header>
<div id="main" style="display:block; padding: 2em; max-width: 900px; margin: 0 auto; width: 100%;"> <div id="main" style="display:block; padding: 2em; max-width: 1000px; margin: 0 auto; width: 100%;">
<div class="admin-section"> <div class="admin-section">
<h2>System</h2> <h2>Nutzer erstellen</h2>
<p>Aktualisiert den Docker-Container aus dem GitHub Repository. Container startet danach neu.</p> <form id="create-user-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<button id="update-btn" class="primary">Website Aktualisieren (Docker Pull)</button> <div>
<p id="update-status" style="margin-top: 10px;"></p> <label>Username</label><br/>
<input type="text" id="new-username" placeholder="Nutzername" required />
</div>
<div>
<label>Passwort</label><br/>
<input type="password" id="new-password" placeholder="Passwort" required />
</div>
<div>
<label>Rolle</label><br/>
<select id="new-role">
<option value="client">Client (30 Tage)</option>
<option value="user">User (permanent)</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<button type="submit">Erstellen</button>
</div>
</form>
</div> </div>
<div class="admin-section"> <div class="admin-section">
<h2>Nutzerverwaltung</h2> <h2>Nutzerverwaltung</h2>
<form id="create-user-form" style="display:flex; gap:10px; margin-bottom:1em;">
<input type="text" id="new-username" placeholder="Nutzername" required />
<input type="password" id="new-password" placeholder="Passwort" required />
<button type="submit">Erstellen</button>
</form>
<table> <table>
<thead><tr><th>ID</th><th>Username</th><th>Aktion</th></tr></thead> <thead><tr><th>ID</th><th>Username</th><th>Rolle</th><th>Erstellt</th><th>Aktionen</th></tr></thead>
<tbody id="users-tbody"></tbody> <tbody id="users-tbody"></tbody>
</table> </table>
</div> </div>
<div class="admin-section"> <div class="admin-section">
<h2>BildZuweisung</h2> <h2>Ordner-Zuweisung</h2>
<p>Weise Unterordner aus dem Websitebilder-Verzeichnis einem Nutzer zu.</p>
<select id="user-select" style="margin-bottom: 1em;"> <select id="user-select" style="margin-bottom: 1em;">
<option value="">Nutzer wählen...</option> <option value="">Nutzer waehlen...</option>
</select> </select>
<div id="image-checkboxes" style="display:flex; flex-wrap:wrap; gap:10px;"></div> <div id="folder-checkboxes" style="display:flex; flex-wrap:wrap; gap:5px; margin-bottom:1em;"></div>
<button id="save-assignments" style="margin-top:1em;">Zuweisung Speichern</button> <button id="save-assignments">Zuweisung Speichern</button>
<span id="assign-status" style="margin-left:10px;"></span>
</div>
<div class="admin-section">
<h2>Eigenes Passwort aendern</h2>
<form id="admin-pw-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<div>
<label>Altes Passwort</label><br/>
<input type="password" id="admin-pw-old" required />
</div>
<div>
<label>Neues Passwort</label><br/>
<input type="password" id="admin-pw-new" required />
</div>
<div>
<button type="submit">Aendern</button>
</div>
</form>
</div> </div>
</div> </div>

View File

@@ -1,101 +1,167 @@
$(document).ready(function() { $(document).ready(function() {
// Check if admin // Auth check
$.get('/api/auth/me') $.get('/api/auth/me')
.done(function(user) { .done(function(user) {
if(user.role !== 'admin') { if(user.role !== 'admin') {
alert("Kein Zugriff!"); alert('Kein Zugriff!');
window.location.href = '/'; window.location.href = '/';
} else { } else {
loadUsers(); loadUsers();
loadAssignmentImages(); loadFolders();
$('body').removeClass('is-preload'); $('body').removeClass('is-preload');
} }
}) })
.fail(function() { .fail(function() { window.location.href = '/'; });
window.location.href = '/';
});
// Logout
$('#logout-btn').click(function(e) { $('#logout-btn').click(function(e) {
e.preventDefault(); e.preventDefault();
$.post('/api/auth/logout').done(function() { window.location.href = '/'; }); $.post('/api/auth/logout').done(function() { window.location.href = '/'; });
}); });
// ==================== NUTZER ====================
function loadUsers() { function loadUsers() {
$.get('/api/admin/users').done(function(users) { $.get('/api/admin/users').done(function(users) {
$('#users-tbody').empty(); $('#users-tbody').empty();
$('#user-select').html('<option value="">Nutzer wählen...</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 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 = '<span class="badge badge-expired">abgelaufen</span>';
info = 'Abgelaufen: ' + expDate;
} else {
info = 'Bis: ' + expDate;
}
}
var actions = '<button class="btn-small" onclick="changePassword(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">PW</button>';
actions += '<button class="btn-small" onclick="deleteUser(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">X</button>';
$('#users-tbody').append( $('#users-tbody').append(
'<tr>' + '<tr><td>' + u.id + '</td><td>' + u.username + ' ' + badge +
'<td>' + u.id + '</td>' + '</td><td>' + u.role + '</td><td>' + info +
'<td>' + u.username + ' ' + (u.role === 'admin' ? '(Admin)' : '') + '</td>' + '</td><td>' + actions + '</td></tr>'
'<td>' + (u.id !== 1 ? '<button onclick="deleteUser(' + u.id + ')">Löschen</button>' : '') + '</td>' +
'</tr>'
); );
if(u.role !== 'admin') {
$('#user-select').append('<option value="' + u.id + '">' + u.username + '</option>'); // Dropdown fuer Ordner-Zuweisung (nur non-admin)
if (u.role !== 'admin') {
$('#user-select').append('<option value="' + u.id + '">' + u.username + ' (' + u.role + ')</option>');
} }
}); });
}); });
} }
window.deleteUser = function(id) { // Nutzer erstellen
if(confirm("Nutzer loeschen?")) { $('#create-user-form').submit(function(e) {
$.ajax({url: '/api/admin/users/'+id, type: 'DELETE'}).done(loadUsers); e.preventDefault();
$.post('/api/admin/users', {
username: $('#new-username').val(),
password: $('#new-password').val(),
role: $('#new-role').val()
}).done(function() {
$('#new-username').val('');
$('#new-password').val('');
loadUsers();
}).fail(function(err) {
alert('Fehler: ' + (err.responseJSON ? err.responseJSON.error : 'Unbekannt'));
});
});
// Nutzer loeschen
window.deleteUser = function(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'); });
} }
}; };
$('#create-user-form').submit(function(e) { // Passwort aendern (Admin setzt fuer anderen User)
e.preventDefault(); window.changePassword = function(id, name) {
$.post('/api/admin/users', { username: $('#new-username').val(), password: $('#new-password').val(), role: 'user' }) var newPw = prompt('Neues Passwort fuer "' + name + '":');
.done(function() { if (!newPw) return;
$('#new-username').val(''); $.ajax({
$('#new-password').val(''); url: '/api/admin/users/' + id + '/password',
loadUsers(); type: 'PUT',
}).fail(function(err) { alert("Fehler: " + err.responseJSON.error); }); contentType: 'application/json',
}); data: JSON.stringify({ new_password: newPw })
}).done(function() {
alert('Passwort geaendert!');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
});
};
function loadAssignmentImages() { // ==================== ORDNER-ZUWEISUNG ====================
$.get('/images/fulls/').done(function(files) {
$('#image-checkboxes').empty(); function loadFolders() {
files.forEach(function(f) { $.get('/api/admin/folders').done(function(folders) {
$('#image-checkboxes').append( $('#folder-checkboxes').empty();
'<label class="img-check">' + if (folders.length === 0) {
'<img src="/images/thumbs/' + encodeURIComponent(f.name) + '" />' + $('#folder-checkboxes').html('<p>Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.</p>');
'<br/><input type="checkbox" value="' + f.name + '" class="img-cb"/>' + return;
'</label>' }
folders.forEach(function(f) {
$('#folder-checkboxes').append(
'<div class="folder-check"><label>' +
'<input type="checkbox" value="' + f + '" class="folder-cb"/> ' +
'<i class="fa fa-folder"></i> ' + f +
'</label></div>'
); );
}); });
}); });
} }
// Bei User-Auswahl: zugewiesene Ordner laden
$('#user-select').change(function() { $('#user-select').change(function() {
var uid = $(this).val(); var uid = $(this).val();
$('.img-cb').prop('checked', false); $('.folder-cb').prop('checked', false);
if(!uid) return; if (!uid) return;
$.get('/api/admin/assign').done(function(assignments) { $.get('/api/admin/assign?user_id=' + uid).done(function(assignedFolders) {
assignments.filter(function(a) { return a.user_id == uid; }).forEach(function(a) { assignedFolders.forEach(function(f) {
$('.img-cb[value="' + a.image_name + '"]').prop('checked', true); $('.folder-cb[value="' + f + '"]').prop('checked', true);
}); });
}); });
}); });
// Zuweisung speichern
$('#save-assignments').click(function() { $('#save-assignments').click(function() {
var uid = $('#user-select').val(); var uid = $('#user-select').val();
if(!uid) return alert('Bitte Nutzer waehlen'); if (!uid) return alert('Bitte Nutzer waehlen');
var selected = []; var selected = [];
$('.img-cb:checked').each(function() { selected.push($(this).val()); }); $('.folder-cb:checked').each(function() { selected.push($(this).val()); });
$.post('/api/admin/assign', { user_id: uid, image_names: selected }) $.ajax({
.done(function() { alert("Zuweisung gespeichert!"); }); url: '/api/admin/assign',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ user_id: uid, folders: selected })
}).done(function() {
$('#assign-status').text('Gespeichert!').fadeIn().delay(2000).fadeOut();
}).fail(function() {
alert('Fehler beim Speichern');
});
}); });
$('#update-btn').click(function() { // ==================== EIGENES PASSWORT ====================
if(!confirm("Achtung: Dies startet den Server auf dem NAS neu. Fortfahren?")) return;
$('#update-status').text('Update laeuft...'); $('#admin-pw-form').submit(function(e) {
$.post('/api/admin/update').done(function(res) { e.preventDefault();
$('#update-status').text(res.message); $.post('/api/auth/change-password', {
setTimeout(function() { window.location.reload(); }, 5000); old_password: $('#admin-pw-old').val(),
new_password: $('#admin-pw-new').val()
}).done(function() {
alert('Passwort geaendert!');
$('#admin-pw-old, #admin-pw-new').val('');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
}); });
}); });
}); });

View File

@@ -1 +1 @@
$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer wählen...</option>'),e.forEach(function(e){$("#users-tbody").append("<tr><td>"+e.id+"</td><td>"+e.username+" "+("admin"===e.role?"(Admin)":"")+"</td><td>"+(1!==e.id?'<button onclick="deleteUser('+e.id+')">Löschen</button>':"")+"</td></tr>"),"admin"!==e.role&&$("#user-select").append('<option value="'+e.id+'">'+e.username+"</option>")})})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/images/fulls/").done(function(e){$("#image-checkboxes").empty(),e.forEach(function(e){$("#image-checkboxes").append('<label class="img-check"><img src="/images/thumbs/'+encodeURIComponent(e.name)+'" /><br/><input type="checkbox" value="'+e.name+'" class="img-cb"/></label>')})}),$("body").removeClass("is-preload"))}).fail(function(){window.location.href="/"}),$("#logout-btn").click(function(e){e.preventDefault(),$.post("/api/auth/logout").done(function(){window.location.href="/"})}),window.deleteUser=function(n){confirm("Nutzer loeschen?")&&$.ajax({url:"/api/admin/users/"+n,type:"DELETE"}).done(e)},$("#create-user-form").submit(function(n){n.preventDefault(),$.post("/api/admin/users",{username:$("#new-username").val(),password:$("#new-password").val(),role:"user"}).done(function(){$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){alert("Fehler: "+e.responseJSON.error)})}),$("#user-select").change(function(){var e=$(this).val();$(".img-cb").prop("checked",!1),e&&$.get("/api/admin/assign").done(function(n){n.filter(function(n){return n.user_id==e}).forEach(function(e){$('.img-cb[value="'+e.image_name+'"]').prop("checked",!0)})})}),$("#save-assignments").click(function(){var e=$("#user-select").val();if(!e)return alert("Bitte Nutzer waehlen");var n=[];$(".img-cb:checked").each(function(){n.push($(this).val())}),$.post("/api/admin/assign",{user_id:e,image_names:n}).done(function(){alert("Zuweisung gespeichert!")})}),$("#update-btn").click(function(){confirm("Achtung: Dies startet den Server auf dem NAS neu. Fortfahren?")&&($("#update-status").text("Update laeuft..."),$.post("/api/admin/update").done(function(e){$("#update-status").text(e.message),setTimeout(function(){window.location.reload()},5e3)}))})}); $(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer waehlen...</option>'),e.forEach(function(e){var n='<span class="badge badge-'+e.role+'">'+e.role+"</span>",a=e.created_at?new Date(e.created_at).toLocaleDateString("de-DE"):"-";if("client"===e.role&&e.expires_at){var t=new Date(e.expires_at).toLocaleDateString("de-DE");e.expired?(n='<span class="badge badge-expired">abgelaufen</span>',a="Abgelaufen: "+t):a="Bis: "+t}var r='<button class="btn-small" onclick="changePassword('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">PW</button>";r+='<button class="btn-small" onclick="deleteUser('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">X</button>",$("#users-tbody").append("<tr><td>"+e.id+"</td><td>"+e.username+" "+n+"</td><td>"+e.role+"</td><td>"+a+"</td><td>"+r+"</td></tr>"),"admin"!==e.role&&$("#user-select").append('<option value="'+e.id+'">'+e.username+" ("+e.role+")</option>")})})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/api/admin/folders").done(function(e){$("#folder-checkboxes").empty(),0!==e.length?e.forEach(function(e){$("#folder-checkboxes").append('<div class="folder-check"><label><input type="checkbox" value="'+e+'" class="folder-cb"/> <i class="fa fa-folder"></i> '+e+"</label></div>")}):$("#folder-checkboxes").html("<p>Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.</p>")}),$("body").removeClass("is-preload"))}).fail(function(){window.location.href="/"}),$("#logout-btn").click(function(e){e.preventDefault(),$.post("/api/auth/logout").done(function(){window.location.href="/"})}),$("#create-user-form").submit(function(n){n.preventDefault(),$.post("/api/admin/users",{username:$("#new-username").val(),password:$("#new-password").val(),role:$("#new-role").val()}).done(function(){$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){alert("Fehler: "+(e.responseJSON?e.responseJSON.error:"Unbekannt"))})}),window.deleteUser=function(n,a){confirm('Nutzer "'+a+'" wirklich loeschen?')&&$.ajax({url:"/api/admin/users/"+n,type:"DELETE"}).done(e).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},window.changePassword=function(e,n){var a=prompt('Neues Passwort fuer "'+n+'":');a&&$.ajax({url:"/api/admin/users/"+e+"/password",type:"PUT",contentType:"application/json",data:JSON.stringify({new_password:a})}).done(function(){alert("Passwort geaendert!")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},$("#user-select").change(function(){var e=$(this).val();$(".folder-cb").prop("checked",!1),e&&$.get("/api/admin/assign?user_id="+e).done(function(e){e.forEach(function(e){$('.folder-cb[value="'+e+'"]').prop("checked",!0)})})}),$("#save-assignments").click(function(){var e=$("#user-select").val();if(!e)return alert("Bitte Nutzer waehlen");var n=[];$(".folder-cb:checked").each(function(){n.push($(this).val())}),$.ajax({url:"/api/admin/assign",type:"POST",contentType:"application/json",data:JSON.stringify({user_id:e,folders:n})}).done(function(){$("#assign-status").text("Gespeichert!").fadeIn().delay(2e3).fadeOut()}).fail(function(){alert("Fehler beim Speichern")})}),$("#admin-pw-form").submit(function(e){e.preventDefault(),$.post("/api/auth/change-password",{old_password:$("#admin-pw-old").val(),new_password:$("#admin-pw-new").val()}).done(function(){alert("Passwort geaendert!"),$("#admin-pw-old, #admin-pw-new").val("")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})})});

View File

@@ -182,8 +182,11 @@
images.forEach(function(file) { images.forEach(function(file) {
var article = $('<article class="thumb"></article>'); var article = $('<article class="thumb"></article>');
var link = $('<a class="image" href="/images/fulls/' + encodeURIComponent(file.name) + '"></a>'); var prefix = file.folder ? encodeURIComponent(file.folder) + '/' : '';
var img = $('<img src="/images/thumbs/' + encodeURIComponent(file.name) + '" alt="" data-name="' + file.name.replace(/"/g, '&quot;') + '" />'); var fullPath = '/images/fulls/' + prefix + encodeURIComponent(file.name);
var thumbPath = '/images/thumbs/' + prefix + encodeURIComponent(file.name);
var link = $('<a class="image" href="' + fullPath + '"></a>');
var img = $('<img src="' + thumbPath + '" alt="" data-name="' + file.name.replace(/"/g, '&quot;') + '" />');
link.append(img); link.append(img);
article.append(link); article.append(link);
$main.append(article); $main.append(article);
@@ -286,6 +289,7 @@
$.get('/api/auth/me').done(function(u) { $.get('/api/auth/me').done(function(u) {
$('#login-username, #login-password, #login-submit').hide(); $('#login-username, #login-password, #login-submit').hide();
$('#logout-btn').show().css('display', 'inline-block'); $('#logout-btn').show().css('display', 'inline-block');
$('#pw-change-section').show();
if(u.role === 'admin') { if(u.role === 'admin') {
$('#admin-link').html('<a href="/admin.html" class="button">Admin-Dashboard</a>'); $('#admin-link').html('<a href="/admin.html" class="button">Admin-Dashboard</a>');
} }
@@ -296,7 +300,13 @@
e.preventDefault(); e.preventDefault();
$.post('/api/auth/login', { username: $('#login-username').val(), password: $('#login-password').val() }) $.post('/api/auth/login', { username: $('#login-username').val(), password: $('#login-password').val() })
.done(function() { window.location.reload(); }) .done(function() { window.location.reload(); })
.fail(function(err) { alert("Login fehlgeschlagen. Bitte Zugangsdaten ueberpruefen."); }); .fail(function(xhr) {
var msg = 'Login fehlgeschlagen.';
if (xhr.responseJSON && xhr.responseJSON.error === 'Account abgelaufen') {
msg = 'Dein Account ist abgelaufen. Bitte kontaktiere den Administrator.';
}
alert(msg);
});
}); });
$('#logout-btn').click(function(e) { $('#logout-btn').click(function(e) {
@@ -304,4 +314,14 @@
$.post('/api/auth/logout').done(function() { window.location.reload(); }); $.post('/api/auth/logout').done(function() { window.location.reload(); });
}); });
$('#pw-change-form').submit(function(e) {
e.preventDefault();
var newPw = $('#pw-new').val();
var confirmPw = $('#pw-confirm').val();
if (newPw !== confirmPw) { alert('Passwoerter stimmen nicht ueberein.'); return; }
$.post('/api/auth/change-password', { old_password: $('#pw-old').val(), new_password: newPw })
.done(function() { alert('Passwort geaendert!'); $('#pw-old, #pw-new, #pw-confirm').val(''); })
.fail(function(xhr) { alert(xhr.responseJSON ? xhr.responseJSON.error : 'Fehler'); });
});
})(jQuery); })(jQuery);

File diff suppressed because one or more lines are too long

View File

@@ -8,13 +8,29 @@ const db = new sqlite3.Database(dbPath);
console.log('Using database at', dbPath); console.log('Using database at', dbPath);
db.serialize(() => { db.serialize(() => {
// Users: mit created_at fuer Client-Ablauf (30 Tage)
db.run(`CREATE TABLE IF NOT EXISTS users ( db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL role TEXT NOT NULL DEFAULT 'client',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`); )`);
// Migration: created_at hinzufuegen falls Tabelle schon existiert
db.run(`ALTER TABLE users ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP`, (err) => {
// Ignoriere Fehler wenn Spalte schon existiert
});
// Ordner-Zuweisung (neu)
db.run(`CREATE TABLE IF NOT EXISTS user_folders (
user_id INTEGER,
folder_name TEXT,
FOREIGN KEY(user_id) REFERENCES users(id),
UNIQUE(user_id, folder_name)
)`);
// Alte Tabelle behalten fuer Kompatibilitaet, aber nicht mehr nutzen
db.run(`CREATE TABLE IF NOT EXISTS user_images ( db.run(`CREATE TABLE IF NOT EXISTS user_images (
user_id INTEGER, user_id INTEGER,
image_name TEXT, image_name TEXT,
@@ -22,12 +38,12 @@ db.serialize(() => {
UNIQUE(user_id, image_name) UNIQUE(user_id, image_name)
)`); )`);
// Create default admin user if none exists // Default-Admin erstellen wenn keiner existiert
db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => { db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => {
if (!row) { if (!row) {
const hash = bcrypt.hashSync('admin123', 10); const hash = bcrypt.hashSync('admin123', 10);
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']); db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']);
console.log('Default admin created with credentials: admin / admin123'); console.log('Default admin created: admin / admin123');
} }
}); });
}); });

View File

@@ -4,30 +4,44 @@ const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { exec } = require('child_process');
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 || 'mischkomposition-super-secret-key-change-me';
const FULLS_DIR = path.join(__dirname, '../public/images/fulls');
const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i;
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());
// Middleware to verify JWT cookie // ==================== MIDDLEWARE ====================
function authMiddleware(req, res, next) { function authMiddleware(req, res, next) {
const token = req.cookies.token; const token = req.cookies.token;
if (!token) { if (!token) { req.user = null; return next(); }
req.user = null;
return next();
}
jwt.verify(token, JWT_SECRET, (err, decoded) => { jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) req.user = null; if (err) { req.user = null; return next(); }
else req.user = decoded; // Client-Ablauf pruefen (30 Tage)
if (decoded.role === 'client' && decoded.created_at) {
const expires = new Date(decoded.created_at);
expires.setDate(expires.getDate() + 30);
if (new Date() > expires) {
res.clearCookie('token');
req.user = null;
return next();
}
}
req.user = decoded;
next(); next();
}); });
} }
function requireAuth(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'Not logged in' });
next();
}
function requireAdmin(req, res, next) { function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') { if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' }); return res.status(403).json({ error: 'Forbidden' });
@@ -37,6 +51,28 @@ function requireAdmin(req, res, next) {
app.use(authMiddleware); app.use(authMiddleware);
// ==================== HELPER ====================
function listImagesInDir(dir) {
try {
const files = fs.readdirSync(dir);
return files.filter(f => IMAGE_RE.test(f) && fs.statSync(path.join(dir, f)).isFile());
} catch (e) {
return [];
}
}
function listSubfolders(dir) {
try {
return fs.readdirSync(dir).filter(f => {
try { return fs.statSync(path.join(dir, f)).isDirectory(); }
catch (e) { return false; }
});
} catch (e) {
return [];
}
}
// ==================== AUTH API ==================== // ==================== AUTH API ====================
app.post('/api/auth/login', (req, res) => { app.post('/api/auth/login', (req, res) => {
@@ -46,7 +82,18 @@ app.post('/api/auth/login', (req, res) => {
if (!user || !bcrypt.compareSync(password, user.password_hash)) { if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' }); 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' }); // Client-Ablauf pruefen
if (user.role === 'client' && user.created_at) {
const expires = new Date(user.created_at);
expires.setDate(expires.getDate() + 30);
if (new Date() > expires) {
return res.status(401).json({ error: 'Account abgelaufen' });
}
}
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role, created_at: user.created_at },
JWT_SECRET, { expiresIn: '7d' }
);
res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 }); res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
res.json({ success: true, role: user.role }); res.json({ success: true, role: user.role });
}); });
@@ -62,123 +109,160 @@ app.get('/api/auth/me', (req, res) => {
res.json(req.user); res.json(req.user);
}); });
// ==================== IMAGE PROXY ==================== app.post('/api/auth/change-password', requireAuth, (req, res) => {
const { old_password, new_password } = req.body;
// Bilder-Listing: Oeffentlich = alle Bilder, eingeloggt = zugewiesene Bilder if (!new_password || new_password.length < 4) {
app.get('/images/fulls/', (req, res) => { return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' });
const fullsDir = path.join(__dirname, '../public/images/fulls'); }
db.get('SELECT * FROM users WHERE id = ?', [req.user.id], (err, user) => {
fs.readdir(fullsDir, (err, files) => { if (err || !user) return res.status(500).json({ error: 'DB Error' });
if (err) return res.json([]); if (!bcrypt.compareSync(old_password, user.password_hash)) {
return res.status(401).json({ error: 'Altes Passwort ist falsch' });
const imageFiles = files.filter(f => f.match(/\.(jpg|jpeg|png|gif|webp)$/i));
function buildResult(names) {
return names.map(name => {
try {
const stats = fs.statSync(path.join(fullsDir, name));
return { name, type: 'file', mtime: stats.mtime.toUTCString(), size: stats.size };
} catch (e) {
return { name, type: 'file' };
}
});
} }
const hash = bcrypt.hashSync(new_password, 10);
// Nicht eingeloggt oder Admin: alle Bilder anzeigen db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, user.id], (err) => {
if (!req.user || req.user.role === 'admin') { if (err) return res.status(500).json({ error: 'DB Error' });
return res.json(buildResult(imageFiles)); res.json({ success: true });
}
// Normaler User: nur zugewiesene Bilder
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));
return res.json(buildResult(assignedFiles));
}); });
}); });
}); });
// Proxy image files directly, but we let Express serve static later. // ==================== IMAGE LISTING ====================
// 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'). app.get('/images/fulls/', (req, res) => {
// For now, let's just use express.static to serve the actual binary files. // Root-Bilder sind immer oeffentlich
// Nextcloud folder names are hard to guess anyway. const rootImages = listImagesInDir(FULLS_DIR).map(name => ({
name, type: 'file', folder: null
}));
// Nicht eingeloggt: nur Root-Bilder
if (!req.user) return res.json(rootImages);
const folders = listSubfolders(FULLS_DIR);
// Admin: Root + alle Ordner
if (req.user.role === 'admin') {
const allImages = [...rootImages];
folders.forEach(folder => {
listImagesInDir(path.join(FULLS_DIR, folder)).forEach(name => {
allImages.push({ name, type: 'file', folder });
});
});
return res.json(allImages);
}
// User/Client: Root + zugewiesene Ordner
db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [req.user.id], (err, rows) => {
const assignedFolders = err ? [] : rows.map(r => r.folder_name);
const result = [...rootImages];
assignedFolders.forEach(folder => {
if (folders.includes(folder)) {
listImagesInDir(path.join(FULLS_DIR, folder)).forEach(name => {
result.push({ name, type: 'file', folder });
});
}
});
return res.json(result);
});
});
// ==================== ADMIN API ==================== // ==================== ADMIN API ====================
app.get('/api/admin/users', requireAdmin, (req, res) => { app.get('/api/admin/users', requireAdmin, (req, res) => {
db.all('SELECT id, username, role FROM users', (err, rows) => { db.all('SELECT id, username, role, created_at FROM users', (err, rows) => {
res.json(rows); if (err) return res.json([]);
// Ablaufdatum berechnen fuer Clients
const result = rows.map(u => {
const out = { ...u };
if (u.role === 'client' && u.created_at) {
const expires = new Date(u.created_at);
expires.setDate(expires.getDate() + 30);
out.expires_at = expires.toISOString();
out.expired = new Date() > expires;
}
return out;
});
res.json(result);
}); });
}); });
app.post('/api/admin/users', requireAdmin, (req, res) => { app.post('/api/admin/users', requireAdmin, (req, res) => {
const { username, password, role } = req.body; const { username, password, role } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username und Passwort erforderlich' });
const validRoles = ['admin', 'user', 'client'];
const userRole = validRoles.includes(role) ? role : 'client';
const hash = bcrypt.hashSync(password, 10); const hash = bcrypt.hashSync(password, 10);
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, role || 'user'], function(err) { db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, userRole], function(err) {
if (err) return res.status(400).json({ error: err.message }); if (err) return res.status(400).json({ error: err.message });
res.json({ success: true, id: this.lastID }); res.json({ success: true, id: this.lastID });
}); });
}); });
app.delete('/api/admin/users/:id', requireAdmin, (req, res) => { app.delete('/api/admin/users/:id', requireAdmin, (req, res) => {
if (req.params.id == 1) return res.status(400).json({ error: 'Cannot delete primary admin' }); const userId = parseInt(req.params.id);
db.run('DELETE FROM users WHERE id = ?', [req.params.id], err => { // Sich selbst loeschen verhindern
db.run('DELETE FROM user_images WHERE user_id = ?', [req.params.id]); if (userId === req.user.id) {
return res.status(400).json({ error: 'Du kannst dich nicht selbst loeschen' });
}
db.run('DELETE FROM users WHERE id = ?', [userId], (err) => {
if (err) return res.status(500).json({ error: 'DB Error' });
db.run('DELETE FROM user_folders WHERE user_id = ?', [userId]);
res.json({ success: true }); res.json({ success: true });
}); });
}); });
app.put('/api/admin/users/:id/password', requireAdmin, (req, res) => {
const { new_password } = req.body;
if (!new_password || new_password.length < 4) {
return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' });
}
const hash = bcrypt.hashSync(new_password, 10);
db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, parseInt(req.params.id)], (err) => {
if (err) return res.status(500).json({ error: 'DB Error' });
res.json({ success: true });
});
});
// Ordner-Verwaltung
app.get('/api/admin/folders', requireAdmin, (req, res) => {
res.json(listSubfolders(FULLS_DIR));
});
app.get('/api/admin/assign', requireAdmin, (req, res) => { app.get('/api/admin/assign', requireAdmin, (req, res) => {
db.all('SELECT * FROM user_images', (err, rows) => { const userId = req.query.user_id;
res.json(rows); if (userId) {
}); db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [userId], (err, rows) => {
res.json(err ? [] : rows.map(r => r.folder_name));
});
} else {
db.all('SELECT * FROM user_folders', (err, rows) => {
res.json(err ? [] : rows);
});
}
}); });
app.post('/api/admin/assign', requireAdmin, (req, res) => { app.post('/api/admin/assign', requireAdmin, (req, res) => {
const { user_id, image_names } = req.body; // image_names is array of strings const { user_id, folders } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id erforderlich' });
const folderList = Array.isArray(folders) ? folders : [];
db.serialize(() => { db.serialize(() => {
db.run('BEGIN TRANSACTION'); db.run('DELETE FROM user_folders WHERE user_id = ?', [user_id]);
db.run('DELETE FROM user_images WHERE user_id = ?', [user_id]); const stmt = db.prepare('INSERT INTO user_folders (user_id, folder_name) VALUES (?, ?)');
const stmt = db.prepare('INSERT INTO user_images (user_id, image_name) VALUES (?, ?)'); folderList.forEach(name => stmt.run(user_id, name));
image_names.forEach(name => {
stmt.run(user_id, name);
});
stmt.finalize(); stmt.finalize();
db.run('COMMIT');
res.json({ success: true }); res.json({ success: true });
}); });
}); });
app.post('/api/admin/update', requireAdmin, (req, res) => {
console.log("Triggering Docker Update...");
// Neues Image pullen und Container neu starten (ohne docker-compose)
const image = 'ghcr.io/kroonk/photography:latest';
const cmd = `docker pull ${image} && docker stop photography-website && docker rm photography-website`;
res.json({ success: true, message: 'Update gestartet. Container wird neu gestartet... Seite laedt in 30 Sekunden neu.' });
setTimeout(() => {
exec(cmd, (err, stdout, stderr) => {
if (err) console.error("Update failed:", err);
else console.log("Update triggered:", stdout);
});
}, 1000);
});
// ==================== STATIC FILES ==================== // ==================== STATIC FILES ====================
app.use(express.static(path.join(__dirname, '../public'))); app.use(express.static(path.join(__dirname, '../public')));
// Catch all - send index.html
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html')); res.sendFile(path.join(__dirname, '../public/index.html'));
}); });
const PORT = 8090; const PORT = 8090;
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Backend server listening on port ${PORT}`); console.log(`Mischkomposition Backend on port ${PORT}`);
}); });

View File

@@ -3,36 +3,49 @@
FULLS_DIR="/app/public/images/fulls" FULLS_DIR="/app/public/images/fulls"
THUMBS_DIR="/app/public/images/thumbs" THUMBS_DIR="/app/public/images/thumbs"
generate_thumbs() { # Thumbnails fuer einen Quell- und Zielordner generieren
mkdir -p "$THUMBS_DIR" generate_thumbs_for_dir() {
for img in "$FULLS_DIR"/*; do src_dir="$1"
dst_dir="$2"
mkdir -p "$dst_dir"
for img in "$src_dir"/*; do
[ -f "$img" ] || continue [ -f "$img" ] || continue
filename=$(basename "$img") filename=$(basename "$img")
ext=$(echo "${filename##*.}" | tr '[:upper:]' '[:lower:]') ext=$(echo "${filename##*.}" | tr '[:upper:]' '[:lower:]')
# Nur Bilddateien verarbeiten
case "$ext" in case "$ext" in
jpg|jpeg|png|gif|webp) ;; jpg|jpeg|png|gif|webp) ;;
*) continue ;; *) continue ;;
esac esac
# Thumbnail nur erstellen wenn noch nicht vorhanden oder aelter als Original if [ ! -f "$dst_dir/$filename" ] || [ "$img" -nt "$dst_dir/$filename" ]; then
if [ ! -f "$THUMBS_DIR/$filename" ] || [ "$img" -nt "$THUMBS_DIR/$filename" ]; then echo "Thumbnail erstellen: $dst_dir/$filename"
echo "Thumbnail erstellen: $filename" convert "$img" -resize 512x512 -quality 80 "$dst_dir/$filename" 2>/dev/null
convert "$img" -resize 512x512 -quality 80 "$THUMBS_DIR/$filename" 2>/dev/null
fi fi
done done
} }
generate_all_thumbs() {
# Root-Bilder
generate_thumbs_for_dir "$FULLS_DIR" "$THUMBS_DIR"
# Unterordner (Kunden-Ordner)
for dir in "$FULLS_DIR"/*/; do
[ -d "$dir" ] || continue
foldername=$(basename "$dir")
generate_thumbs_for_dir "$dir" "$THUMBS_DIR/$foldername"
done
}
echo "=== Mischkomposition - Starte Thumbnail-Generierung ===" echo "=== Mischkomposition - Starte Thumbnail-Generierung ==="
generate_thumbs generate_all_thumbs
echo "=== Thumbnail-Generierung abgeschlossen ===" echo "=== Thumbnail-Generierung abgeschlossen ==="
# Hintergrund-Prozess: Alle 60 Sekunden nach neuen Bildern suchen # Hintergrund-Prozess: Alle 60 Sekunden nach neuen Bildern suchen
(while true; do (while true; do
sleep 60 sleep 60
generate_thumbs generate_all_thumbs
done) & done) &
# Node Server starten # Node Server starten

View File

@@ -130,6 +130,25 @@ layout: default
<li id="admin-link"></li> <li id="admin-link"></li>
</ul> </ul>
</form> </form>
<div id="pw-change-section" style="display:none; margin-top:1.5em; border-top:1px solid rgba(255,255,255,0.2); padding-top:1.5em;">
<h3>Passwort aendern</h3>
<form id="pw-change-form">
<div class="fields">
<div class="field">
<input type="password" id="pw-old" placeholder="Altes Passwort" required />
</div>
<div class="field half">
<input type="password" id="pw-new" placeholder="Neues Passwort" required />
</div>
<div class="field half">
<input type="password" id="pw-confirm" placeholder="Passwort bestaetigen" required />
</div>
</div>
<ul class="actions">
<li><input type="submit" value="Passwort aendern" class="primary" /></li>
</ul>
</form>
</div>
</section> </section>
</div> </div>
</footer> </footer>