From 941d041b5d86624edbef63fb5afb6fbf1addc15a Mon Sep 17 00:00:00 2001 From: Kroonk Date: Sun, 12 Apr 2026 17:53:46 +0200 Subject: [PATCH] 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 --- admin.html | 78 +++++++++---- assets/js/admin.js | 168 ++++++++++++++++++--------- assets/js/admin.min.js | 2 +- assets/js/main.js | 26 ++++- assets/js/main.min.js | 2 +- backend/database.js | 22 +++- backend/server.js | 250 +++++++++++++++++++++++++++-------------- entrypoint.sh | 33 ++++-- index.html | 19 ++++ 9 files changed, 429 insertions(+), 171 deletions(-) diff --git a/admin.html b/admin.html index 63f51a7..1f7835d 100644 --- a/admin.html +++ b/admin.html @@ -7,48 +7,88 @@
-
- +
+
-

System

-

Aktualisiert den Docker-Container aus dem GitHub Repository. Container startet danach neu.

- -

+

Nutzer erstellen

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

Nutzerverwaltung

-
- - - -
- +
IDUsernameAktion
IDUsernameRolleErstelltAktionen
-

BildZuweisung

+

Ordner-Zuweisung

+

Weise Unterordner aus dem Websitebilder-Verzeichnis einem Nutzer zu.

-
- +
+ + +
+ +
+

Eigenes Passwort aendern

+
+
+
+ +
+
+
+ +
+
+ +
+
diff --git a/assets/js/admin.js b/assets/js/admin.js index 636c47c..01592e8 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -1,101 +1,167 @@ $(document).ready(function() { - // Check if admin + // Auth check $.get('/api/auth/me') .done(function(user) { if(user.role !== 'admin') { - alert("Kein Zugriff!"); + alert('Kein Zugriff!'); window.location.href = '/'; } else { loadUsers(); - loadAssignmentImages(); + loadFolders(); $('body').removeClass('is-preload'); } }) - .fail(function() { - window.location.href = '/'; - }); + .fail(function() { window.location.href = '/'; }); + // Logout $('#logout-btn').click(function(e) { e.preventDefault(); $.post('/api/auth/logout').done(function() { window.location.href = '/'; }); }); + // ==================== NUTZER ==================== + function loadUsers() { $.get('/api/admin/users').done(function(users) { $('#users-tbody').empty(); - $('#user-select').html(''); + $('#user-select').html(''); + users.forEach(function(u) { + var badge = '' + 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'; + info = 'Abgelaufen: ' + expDate; + } else { + info = 'Bis: ' + expDate; + } + } + + var actions = ''; + actions += ''; + $('#users-tbody').append( - '' + - '' + u.id + '' + - '' + u.username + ' ' + (u.role === 'admin' ? '(Admin)' : '') + '' + - '' + (u.id !== 1 ? '' : '') + '' + - '' + '' + u.id + '' + u.username + ' ' + badge + + '' + u.role + '' + info + + '' + actions + '' ); - if(u.role !== 'admin') { - $('#user-select').append(''); + + // Dropdown fuer Ordner-Zuweisung (nur non-admin) + if (u.role !== 'admin') { + $('#user-select').append(''); } }); }); } - window.deleteUser = function(id) { - if(confirm("Nutzer loeschen?")) { - $.ajax({url: '/api/admin/users/'+id, type: 'DELETE'}).done(loadUsers); + // Nutzer erstellen + $('#create-user-form').submit(function(e) { + 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) { - e.preventDefault(); - $.post('/api/admin/users', { username: $('#new-username').val(), password: $('#new-password').val(), role: 'user' }) - .done(function() { - $('#new-username').val(''); - $('#new-password').val(''); - loadUsers(); - }).fail(function(err) { alert("Fehler: " + err.responseJSON.error); }); - }); + // Passwort aendern (Admin setzt fuer anderen User) + window.changePassword = function(id, name) { + var newPw = prompt('Neues Passwort fuer "' + name + '":'); + if (!newPw) return; + $.ajax({ + url: '/api/admin/users/' + id + '/password', + type: 'PUT', + 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() { - $.get('/images/fulls/').done(function(files) { - $('#image-checkboxes').empty(); - files.forEach(function(f) { - $('#image-checkboxes').append( - '' + // ==================== ORDNER-ZUWEISUNG ==================== + + function loadFolders() { + $.get('/api/admin/folders').done(function(folders) { + $('#folder-checkboxes').empty(); + if (folders.length === 0) { + $('#folder-checkboxes').html('

Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.

'); + return; + } + folders.forEach(function(f) { + $('#folder-checkboxes').append( + '
' ); }); }); } + // Bei User-Auswahl: zugewiesene Ordner laden $('#user-select').change(function() { var uid = $(this).val(); - $('.img-cb').prop('checked', false); - if(!uid) return; - $.get('/api/admin/assign').done(function(assignments) { - assignments.filter(function(a) { return a.user_id == uid; }).forEach(function(a) { - $('.img-cb[value="' + a.image_name + '"]').prop('checked', true); + $('.folder-cb').prop('checked', false); + if (!uid) return; + $.get('/api/admin/assign?user_id=' + uid).done(function(assignedFolders) { + assignedFolders.forEach(function(f) { + $('.folder-cb[value="' + f + '"]').prop('checked', true); }); }); }); + // Zuweisung speichern $('#save-assignments').click(function() { var uid = $('#user-select').val(); - if(!uid) return alert('Bitte Nutzer waehlen'); + if (!uid) return alert('Bitte Nutzer waehlen'); var selected = []; - $('.img-cb:checked').each(function() { selected.push($(this).val()); }); - - $.post('/api/admin/assign', { user_id: uid, image_names: selected }) - .done(function() { alert("Zuweisung gespeichert!"); }); + $('.folder-cb:checked').each(function() { selected.push($(this).val()); }); + + $.ajax({ + 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() { - if(!confirm("Achtung: Dies startet den Server auf dem NAS neu. Fortfahren?")) return; - $('#update-status').text('Update laeuft...'); - $.post('/api/admin/update').done(function(res) { - $('#update-status').text(res.message); - setTimeout(function() { window.location.reload(); }, 5000); + // ==================== EIGENES PASSWORT ==================== + + $('#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(err) { + alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); }); }); }); diff --git a/assets/js/admin.min.js b/assets/js/admin.min.js index 0b20f97..4a0f3a9 100644 --- a/assets/js/admin.min.js +++ b/assets/js/admin.min.js @@ -1 +1 @@ -$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html(''),e.forEach(function(e){$("#users-tbody").append(""+e.id+""+e.username+" "+("admin"===e.role?"(Admin)":"")+""+(1!==e.id?'':"")+""),"admin"!==e.role&&$("#user-select").append('")})})}$.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('')})}),$("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)}))})}); \ No newline at end of file +$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html(''),e.forEach(function(e){var n=''+e.role+"",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='abgelaufen',a="Abgelaufen: "+t):a="Bis: "+t}var r='";r+='",$("#users-tbody").append(""+e.id+""+e.username+" "+n+""+e.role+""+a+""+r+""),"admin"!==e.role&&$("#user-select").append('")})})}$.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('
")}):$("#folder-checkboxes").html("

Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.

")}),$("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")})})}); \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index 5faed73..fa6808e 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -182,8 +182,11 @@ images.forEach(function(file) { var article = $('
'); - var link = $(''); - var img = $(''); + var prefix = file.folder ? encodeURIComponent(file.folder) + '/' : ''; + var fullPath = '/images/fulls/' + prefix + encodeURIComponent(file.name); + var thumbPath = '/images/thumbs/' + prefix + encodeURIComponent(file.name); + var link = $(''); + var img = $(''); link.append(img); article.append(link); $main.append(article); @@ -286,6 +289,7 @@ $.get('/api/auth/me').done(function(u) { $('#login-username, #login-password, #login-submit').hide(); $('#logout-btn').show().css('display', 'inline-block'); + $('#pw-change-section').show(); if(u.role === 'admin') { $('#admin-link').html('Admin-Dashboard'); } @@ -296,7 +300,13 @@ e.preventDefault(); $.post('/api/auth/login', { username: $('#login-username').val(), password: $('#login-password').val() }) .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) { @@ -304,4 +314,14 @@ $.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); diff --git a/assets/js/main.min.js b/assets/js/main.min.js index f6ded1e..4d0176d 100644 --- a/assets/js/main.min.js +++ b/assets/js/main.min.js @@ -1 +1 @@ -!function(e){var n,a=e(window),o=e("body");e("#wrapper");(breakpoints({xlarge:["1281px","1680px"],large:["981px","1280px"],medium:["737px","980px"],small:["481px","736px"],xsmall:[null,"480px"]}),"ie"==browser.name&&o.addClass("ie"),browser.mobile&&o.addClass("touch"),browser.canUse("transition"))&&(a.on("load",function(){window.setTimeout(function(){o.removeClass("is-preload")},100)}),a.on("resize",function(){window.clearTimeout(n),o.addClass("is-resizing"),n=window.setTimeout(function(){o.removeClass("is-resizing")},100)}));a.scrollTop(0);var t=e(".panel");t.each(function(){var n=e(this),a=e('[href="#'+n.attr("id")+'"]');e('
').appendTo(n).on("click",function(e){n.trigger("---hide")}),n.on("click",function(e){e.stopPropagation()}).on("---toggle",function(){n.hasClass("active")?n.triggerHandler("---hide"):n.triggerHandler("---show")}).on("---show",function(){o.hasClass("content-active")&&t.trigger("---hide"),n.addClass("active"),a.addClass("active"),o.addClass("content-active")}).on("---hide",function(){n.removeClass("active"),a.removeClass("active"),o.removeClass("content-active")}),a.removeAttr("href").css("cursor","pointer").on("click",function(e){e.preventDefault(),e.stopPropagation(),n.trigger("---toggle")})}),o.on("click",function(e){o.hasClass("content-active")&&(e.preventDefault(),e.stopPropagation(),t.trigger("---hide"))}),a.on("keyup",function(e){27==e.keyCode&&o.hasClass("content-active")&&(e.preventDefault(),e.stopPropagation(),t.trigger("---hide"))}),e("#header").find("a").each(function(){var n=e(this),a=n.attr("href");a&&"#"!=a.charAt(0)&&n.removeAttr("href").css("cursor","pointer").on("click",function(e){e.preventDefault(),e.stopPropagation(),window.location.href=a})}),e("#footer").find(".copyright").each(function(){var n=e(this),a=n.parent(),o=a.parent().children().last();breakpoints.on("<=medium",function(){n.appendTo(o)}),breakpoints.on(">medium",function(){n.appendTo(a)})});var i=e("#main"),r={},s=["jpg","jpeg","png","gif","webp"];function l(n){var a=e("#main").data("exif"),o="";for(var t in a){var i=a[t],r=EXIF.getTag(n,i.tag);void 0!==r&&(o+=' '+r+"  ")}return o}e.ajax({url:"/images/fulls/",dataType:"json",cache:!1,success:function(n){if(0!==n.length){var a=n.filter(function(e){if("file"!==e.type)return!1;var n=e.name.split(".").pop().toLowerCase();return-1!==s.indexOf(n)});0!==a.length?(a.sort(function(e,n){return e.name.localeCompare(n.name)}),a.forEach(function(n){var a=e('
'),o=e(''),t=e('');o.append(t),a.append(o),i.append(a)}),i.children(".thumb").each(function(){var n,a=e(this),o=a.find(".image"),t=o.children("img");0!=o.length&&(o.css("background-image","url("+t.attr("src")+")"),(n=t.data("position"))&&o.css("background-position",n),t.hide(),t[0].addEventListener("load",function(){EXIF.getData(t[0],function(){r[t.data("name")]=l(this)})}))}),i.poptrox({baseZIndex:2e4,caption:function(e){var n=e.children("img"),a=r[n.data("name")];void 0===a&&EXIF.getData(n[0],function(){a=r[n.data("name")]=l(this)});var o='
Bild Speichern ';return void 0!==a?"

"+a+o+"

":"

"+o+"

"},fadeSpeed:300,onPopupClose:function(){o.removeClass("modal-active")},onPopupOpen:function(){o.addClass("modal-active")},overlayOpacity:0,popupCloserText:"",popupHeight:150,popupLoaderText:"",popupSpeed:300,popupWidth:150,selector:".thumb > a.image",usePopupCaption:!0,usePopupCloser:!0,usePopupDefaultStyling:!1,usePopupForceClose:!0,usePopupLoader:!0,usePopupNav:!0,windowMargin:50}),breakpoints.on("<=xsmall",function(){i[0]._poptrox.windowMargin=0}),breakpoints.on(">xsmall",function(){i[0]._poptrox.windowMargin=50})):i.html('

Es wurden noch keine passenden Bilder für diesen Account hochgeladen oder zugewiesen.

')}else i.html('

Noch keine Bilder vorhanden.

')},error:function(){i.html('

Keine Bilder gefunden. Lade Bilder in deinen Nextcloud-Ordner hoch.

')}}),e.get("/api/auth/me").done(function(n){e("#login-username, #login-password, #login-submit").hide(),e("#logout-btn").show().css("display","inline-block"),"admin"===n.role&&e("#admin-link").html('Admin-Dashboard'),e("#login-nav").html('Account ')}),e("#login-form").submit(function(n){n.preventDefault(),e.post("/api/auth/login",{username:e("#login-username").val(),password:e("#login-password").val()}).done(function(){window.location.reload()}).fail(function(e){alert("Login fehlgeschlagen. Bitte Zugangsdaten ueberpruefen.")})}),e("#logout-btn").click(function(n){n.preventDefault(),e.post("/api/auth/logout").done(function(){window.location.reload()})})}(jQuery); \ No newline at end of file +!function(e){var n,o=e(window),a=e("body");e("#wrapper");(breakpoints({xlarge:["1281px","1680px"],large:["981px","1280px"],medium:["737px","980px"],small:["481px","736px"],xsmall:[null,"480px"]}),"ie"==browser.name&&a.addClass("ie"),browser.mobile&&a.addClass("touch"),browser.canUse("transition"))&&(o.on("load",function(){window.setTimeout(function(){a.removeClass("is-preload")},100)}),o.on("resize",function(){window.clearTimeout(n),a.addClass("is-resizing"),n=window.setTimeout(function(){a.removeClass("is-resizing")},100)}));o.scrollTop(0);var t=e(".panel");t.each(function(){var n=e(this),o=e('[href="#'+n.attr("id")+'"]');e('
').appendTo(n).on("click",function(e){n.trigger("---hide")}),n.on("click",function(e){e.stopPropagation()}).on("---toggle",function(){n.hasClass("active")?n.triggerHandler("---hide"):n.triggerHandler("---show")}).on("---show",function(){a.hasClass("content-active")&&t.trigger("---hide"),n.addClass("active"),o.addClass("active"),a.addClass("content-active")}).on("---hide",function(){n.removeClass("active"),o.removeClass("active"),a.removeClass("content-active")}),o.removeAttr("href").css("cursor","pointer").on("click",function(e){e.preventDefault(),e.stopPropagation(),n.trigger("---toggle")})}),a.on("click",function(e){a.hasClass("content-active")&&(e.preventDefault(),e.stopPropagation(),t.trigger("---hide"))}),o.on("keyup",function(e){27==e.keyCode&&a.hasClass("content-active")&&(e.preventDefault(),e.stopPropagation(),t.trigger("---hide"))}),e("#header").find("a").each(function(){var n=e(this),o=n.attr("href");o&&"#"!=o.charAt(0)&&n.removeAttr("href").css("cursor","pointer").on("click",function(e){e.preventDefault(),e.stopPropagation(),window.location.href=o})}),e("#footer").find(".copyright").each(function(){var n=e(this),o=n.parent(),a=o.parent().children().last();breakpoints.on("<=medium",function(){n.appendTo(a)}),breakpoints.on(">medium",function(){n.appendTo(o)})});var i=e("#main"),r={},s=["jpg","jpeg","png","gif","webp"];function l(n){var o=e("#main").data("exif"),a="";for(var t in o){var i=o[t],r=EXIF.getTag(n,i.tag);void 0!==r&&(a+=' '+r+"  ")}return a}e.ajax({url:"/images/fulls/",dataType:"json",cache:!1,success:function(n){if(0!==n.length){var o=n.filter(function(e){if("file"!==e.type)return!1;var n=e.name.split(".").pop().toLowerCase();return-1!==s.indexOf(n)});0!==o.length?(o.sort(function(e,n){return e.name.localeCompare(n.name)}),o.forEach(function(n){var o=e('
'),a=n.folder?encodeURIComponent(n.folder)+"/":"",t="/images/fulls/"+a+encodeURIComponent(n.name),r="/images/thumbs/"+a+encodeURIComponent(n.name),s=e(''),l=e('');s.append(l),o.append(s),i.append(o)}),i.children(".thumb").each(function(){var n,o=e(this),a=o.find(".image"),t=a.children("img");0!=a.length&&(a.css("background-image","url("+t.attr("src")+")"),(n=t.data("position"))&&a.css("background-position",n),t.hide(),t[0].addEventListener("load",function(){EXIF.getData(t[0],function(){r[t.data("name")]=l(this)})}))}),i.poptrox({baseZIndex:2e4,caption:function(e){var n=e.children("img"),o=r[n.data("name")];void 0===o&&EXIF.getData(n[0],function(){o=r[n.data("name")]=l(this)});var a='
Bild Speichern ';return void 0!==o?"

"+o+a+"

":"

"+a+"

"},fadeSpeed:300,onPopupClose:function(){a.removeClass("modal-active")},onPopupOpen:function(){a.addClass("modal-active")},overlayOpacity:0,popupCloserText:"",popupHeight:150,popupLoaderText:"",popupSpeed:300,popupWidth:150,selector:".thumb > a.image",usePopupCaption:!0,usePopupCloser:!0,usePopupDefaultStyling:!1,usePopupForceClose:!0,usePopupLoader:!0,usePopupNav:!0,windowMargin:50}),breakpoints.on("<=xsmall",function(){i[0]._poptrox.windowMargin=0}),breakpoints.on(">xsmall",function(){i[0]._poptrox.windowMargin=50})):i.html('

Es wurden noch keine passenden Bilder für diesen Account hochgeladen oder zugewiesen.

')}else i.html('

Noch keine Bilder vorhanden.

')},error:function(){i.html('

Keine Bilder gefunden. Lade Bilder in deinen Nextcloud-Ordner hoch.

')}}),e.get("/api/auth/me").done(function(n){e("#login-username, #login-password, #login-submit").hide(),e("#logout-btn").show().css("display","inline-block"),e("#pw-change-section").show(),"admin"===n.role&&e("#admin-link").html('Admin-Dashboard'),e("#login-nav").html('Account ')}),e("#login-form").submit(function(n){n.preventDefault(),e.post("/api/auth/login",{username:e("#login-username").val(),password:e("#login-password").val()}).done(function(){window.location.reload()}).fail(function(e){var n="Login fehlgeschlagen.";e.responseJSON&&"Account abgelaufen"===e.responseJSON.error&&(n="Dein Account ist abgelaufen. Bitte kontaktiere den Administrator."),alert(n)})}),e("#logout-btn").click(function(n){n.preventDefault(),e.post("/api/auth/logout").done(function(){window.location.reload()})}),e("#pw-change-form").submit(function(n){n.preventDefault();var o=e("#pw-new").val();o===e("#pw-confirm").val()?e.post("/api/auth/change-password",{old_password:e("#pw-old").val(),new_password:o}).done(function(){alert("Passwort geaendert!"),e("#pw-old, #pw-new, #pw-confirm").val("")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")}):alert("Passwoerter stimmen nicht ueberein.")})}(jQuery); \ No newline at end of file diff --git a/backend/database.js b/backend/database.js index 4901f9a..f25e366 100644 --- a/backend/database.js +++ b/backend/database.js @@ -8,13 +8,29 @@ const db = new sqlite3.Database(dbPath); console.log('Using database at', dbPath); db.serialize(() => { + // Users: mit created_at fuer Client-Ablauf (30 Tage) db.run(`CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE 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 ( user_id INTEGER, image_name TEXT, @@ -22,12 +38,12 @@ db.serialize(() => { 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) => { 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 with credentials: admin / admin123'); + console.log('Default admin created: admin / admin123'); } }); }); diff --git a/backend/server.js b/backend/server.js index 0e288c3..798ef5b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,30 +4,44 @@ const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const fs = require('fs'); const path = require('path'); -const { exec } = require('child_process'); 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 IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i; app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); -// Middleware to verify JWT cookie +// ==================== MIDDLEWARE ==================== + function authMiddleware(req, res, next) { const token = req.cookies.token; - if (!token) { - req.user = null; - return next(); - } + if (!token) { req.user = null; return next(); } jwt.verify(token, JWT_SECRET, (err, decoded) => { - if (err) req.user = null; - else req.user = decoded; + if (err) { req.user = null; return next(); } + // 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(); }); } +function requireAuth(req, res, next) { + if (!req.user) return res.status(401).json({ error: 'Not logged in' }); + next(); +} + function requireAdmin(req, res, next) { if (!req.user || req.user.role !== 'admin') { return res.status(403).json({ error: 'Forbidden' }); @@ -37,6 +51,28 @@ function requireAdmin(req, res, next) { 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 ==================== 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)) { 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.json({ success: true, role: user.role }); }); @@ -62,123 +109,160 @@ app.get('/api/auth/me', (req, res) => { res.json(req.user); }); -// ==================== IMAGE PROXY ==================== - -// Bilder-Listing: Oeffentlich = alle Bilder, eingeloggt = zugewiesene Bilder -app.get('/images/fulls/', (req, res) => { - const fullsDir = path.join(__dirname, '../public/images/fulls'); - - fs.readdir(fullsDir, (err, files) => { - if (err) return res.json([]); - - 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' }; - } - }); +app.post('/api/auth/change-password', requireAuth, (req, res) => { + const { old_password, new_password } = req.body; + if (!new_password || new_password.length < 4) { + return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' }); + } + db.get('SELECT * FROM users WHERE id = ?', [req.user.id], (err, user) => { + if (err || !user) return res.status(500).json({ error: 'DB Error' }); + if (!bcrypt.compareSync(old_password, user.password_hash)) { + return res.status(401).json({ error: 'Altes Passwort ist falsch' }); } - - // Nicht eingeloggt oder Admin: alle Bilder anzeigen - if (!req.user || req.user.role === 'admin') { - return res.json(buildResult(imageFiles)); - } - - // 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)); + const hash = bcrypt.hashSync(new_password, 10); + db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, user.id], (err) => { + if (err) return res.status(500).json({ error: 'DB Error' }); + res.json({ success: true }); }); }); }); -// Proxy image files directly, but we let Express serve static later. -// 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'). -// For now, let's just use express.static to serve the actual binary files. -// Nextcloud folder names are hard to guess anyway. +// ==================== IMAGE LISTING ==================== + +app.get('/images/fulls/', (req, res) => { + // Root-Bilder sind immer oeffentlich + 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 ==================== app.get('/api/admin/users', requireAdmin, (req, res) => { - db.all('SELECT id, username, role FROM users', (err, rows) => { - res.json(rows); + db.all('SELECT id, username, role, created_at FROM users', (err, 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) => { 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); - 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 }); res.json({ success: true, id: this.lastID }); }); }); app.delete('/api/admin/users/:id', requireAdmin, (req, res) => { - if (req.params.id == 1) return res.status(400).json({ error: 'Cannot delete primary admin' }); - db.run('DELETE FROM users WHERE id = ?', [req.params.id], err => { - db.run('DELETE FROM user_images WHERE user_id = ?', [req.params.id]); + const userId = parseInt(req.params.id); + // Sich selbst loeschen verhindern + 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 }); }); }); +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) => { - db.all('SELECT * FROM user_images', (err, rows) => { - res.json(rows); - }); + const userId = req.query.user_id; + 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) => { - 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.run('BEGIN TRANSACTION'); - db.run('DELETE FROM user_images WHERE user_id = ?', [user_id]); - const stmt = db.prepare('INSERT INTO user_images (user_id, image_name) VALUES (?, ?)'); - image_names.forEach(name => { - stmt.run(user_id, name); - }); + db.run('DELETE FROM user_folders WHERE user_id = ?', [user_id]); + const stmt = db.prepare('INSERT INTO user_folders (user_id, folder_name) VALUES (?, ?)'); + folderList.forEach(name => stmt.run(user_id, name)); stmt.finalize(); - db.run('COMMIT'); 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 ==================== app.use(express.static(path.join(__dirname, '../public'))); -// Catch all - send index.html app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); const PORT = 8090; app.listen(PORT, () => { - console.log(`Backend server listening on port ${PORT}`); + console.log(`Mischkomposition Backend on port ${PORT}`); }); diff --git a/entrypoint.sh b/entrypoint.sh index 5c8cabb..f1f5621 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,36 +3,49 @@ FULLS_DIR="/app/public/images/fulls" THUMBS_DIR="/app/public/images/thumbs" -generate_thumbs() { - mkdir -p "$THUMBS_DIR" - for img in "$FULLS_DIR"/*; do +# Thumbnails fuer einen Quell- und Zielordner generieren +generate_thumbs_for_dir() { + src_dir="$1" + dst_dir="$2" + mkdir -p "$dst_dir" + for img in "$src_dir"/*; do [ -f "$img" ] || continue filename=$(basename "$img") ext=$(echo "${filename##*.}" | tr '[:upper:]' '[:lower:]') - # Nur Bilddateien verarbeiten case "$ext" in jpg|jpeg|png|gif|webp) ;; *) continue ;; esac - # Thumbnail nur erstellen wenn noch nicht vorhanden oder aelter als Original - if [ ! -f "$THUMBS_DIR/$filename" ] || [ "$img" -nt "$THUMBS_DIR/$filename" ]; then - echo "Thumbnail erstellen: $filename" - convert "$img" -resize 512x512 -quality 80 "$THUMBS_DIR/$filename" 2>/dev/null + if [ ! -f "$dst_dir/$filename" ] || [ "$img" -nt "$dst_dir/$filename" ]; then + echo "Thumbnail erstellen: $dst_dir/$filename" + convert "$img" -resize 512x512 -quality 80 "$dst_dir/$filename" 2>/dev/null fi 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 ===" -generate_thumbs +generate_all_thumbs echo "=== Thumbnail-Generierung abgeschlossen ===" # Hintergrund-Prozess: Alle 60 Sekunden nach neuen Bildern suchen (while true; do sleep 60 - generate_thumbs + generate_all_thumbs done) & # Node Server starten diff --git a/index.html b/index.html index 82c6b24..9d2a652 100644 --- a/index.html +++ b/index.html @@ -130,6 +130,25 @@ layout: default +