Compare commits

..

26 Commits

Author SHA1 Message Date
Kroonk
3ea02bab3c feat: add SSO environment variables to docker-compose.yml and create .env.example
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 19s
2026-05-21 10:55:48 +02:00
Kroonk
75e4fbaa19 fix: sync package-lock.json with package.json dependencies
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 34s
2026-05-21 10:51:12 +02:00
Kroonk
163d8a20f1 feat: implement Keycloak SSO integration with dynamic configuration and JIT provisioning
Some checks failed
Build & Push Docker Image to Gitea Registry / build-and-push (push) Failing after 17s
2026-05-21 10:45:22 +02:00
Kroonk
d9334dae00 Fix admin dashboard panel visibility
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 19s
2026-05-20 19:18:18 +02:00
Kroonk
3ad76e841d Rework Photography admin dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 30s
2026-05-20 19:13:51 +02:00
Kroonk
64fe667240 Fix Photography deploy metadata and SQLite migration
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 33s
2026-05-20 18:54:10 +02:00
Kroonk
e6a11f2eed Improve folder access admin dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 20s
2026-05-20 18:44:47 +02:00
Kroonk
c211d2bf9d Merge branch 'master' of github.com/Kroonk/Photography into master (Resolved docker-build.yml)
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 2m6s
2026-05-20 16:40:05 +02:00
Kroonk
beb123b144 Optimize: Add robust Docker CLI install and native docker build to workflow 2026-05-20 16:39:41 +02:00
Tom Misch
a588bd36d6 Add admin dashboard card layout
All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 1m50s
2026-05-11 16:09:31 +02:00
Tom Misch
caf56b4117 Skip image build on GitHub mirror
All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 1m50s
2026-05-11 15:55:10 +02:00
Tom Misch
5266f36820 Merge GitHub updates into Gitea master
All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 1m53s
2026-05-11 13:03:58 +02:00
Kroonk
95f2847155 Fix Download: Saubere Trennung JSON (1-3) vs ZIP (4+) Response
- 1-3 Bilder: normales AJAX ohne blob, JSON-Response direkt parsen
- 4+ Bilder: nativer XMLHttpRequest mit blob fuer ZIP-Download
- Fehler-Handler zeigt jetzt den echten Server-Fehler an
  (vorher war xhr.responseJSON immer undefined wegen blob)
- ZIP-Fehler: Blob wird als Text gelesen und JSON geparst
- Download-Dateiname aus URL extrahiert statt leer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:52:47 +02:00
Kroonk
b65ade8381 Selection-Mode: Gesamtes Bild klickbar, Checkbox nach unten-rechts
- Capture-Listener auf document statt #main (aggressivster Ansatz)
- stopImmediatePropagation blockt Poptrox komplett
- Checkbox von oben-links nach unten-rechts verschoben
  (wird nicht mehr von Toolbar ueberlagert)
- Nicht-ausgewaehlte Bilder leicht abgedunkelt (opacity 0.85)
- Ausgewaehlte Bilder volle Helligkeit + gruener Rahmen
- Mobile: Checkbox 2em gross fuer bessere Sichtbarkeit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:43:33 +02:00
Kroonk
096d766358 Fix Bilder-Auswahl (Capture-Listener) + Mobile UX verbessern
- Selection-Click nutzt jetzt addEventListener mit capture:true
  auf #main, feuert garantiert VOR Poptrox und kann nicht entfernt werden
- Bild-Name/Ordner als data-Attribute auf .thumb gespeichert
- Mobile: Nav-Icons 1rem -> 1.3rem, bessere Touch-Targets
- Mobile: Selection-Checkboxen groesser (1.8em statt 1.4em)
- Mobile: Toolbar-Buttons mit mehr Padding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:35:39 +02:00
Kroonk
75a5a596c5 Fix Selection-Toolbar: Position oben statt ueber Header, direktes Button-Binding
Toolbar wird jetzt am oberen Bildschirmrand angezeigt (Desktop),
damit sie nicht mehr den Header/Nav ueberlagert. Auf Mobile
bleibt sie unterhalb des Headers. Cancel/Download-Buttons nutzen
direktes Binding auf $toolbar statt Delegation auf document.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:29:28 +02:00
Kroonk
f4c106bd19 Fix Selection-Mode: Blockiere Lightbox wenn Auswahl aktiv
initSelectionMode wird jetzt VOR initPoptrox aufgerufen und nutzt
einen direkten Click-Handler auf a.image mit stopImmediatePropagation,
damit Poptrox im Selection-Mode nicht mehr die Lightbox oeffnet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:17:03 +02:00
Kroonk
74f1da13a3 Mobile-Optimierung: 2-Spalten-Grid, Icon-only Nav, kompakte Toolbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:00:56 +02:00
Kroonk
159071a814 Bilder-Download: Mehrfach-Auswahl + Ordner-ZIP
- Selection-Mode: Bilder in Galerie per Klick auswaehlen
- 1-3 Bilder: einzeln herunterladen, 4+: als ZIP
- Ordner-Download: ganzen zugewiesenen Ordner als ZIP
- Sicherheit: Zugriffspruefung + Path-Traversal-Schutz
- archiver Package fuer Streaming-ZIP-Erstellung

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:47:58 +02:00
Kroonk
43539fa1a3 Ordner-Tabelle: Fehlerbehandlung und Nachladen wenn leer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:17:52 +02:00
Kroonk
2c040b651d Ordner-Zuweisung als klickbare Tabelle statt Checkboxen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:06:43 +02:00
Kroonk
2dcd559501 Fix leere Nutzertabelle: Fehler-Handler + DB-Fallback fuer loadUsers
- admin.js: .fail() Handler zeigt Fehlermeldung in der Tabelle statt stille Leere
- server.js: Fallback-Query (SELECT *) wenn role/created_at Spalten fehlen
- Server loggt DB-Fehler statt sie zu verschlucken

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:48:19 +02:00
Kroonk
c37aa3261c Verbessere Nutzer-Erstellung: JSON statt URL-encoded, Erfolgs-Feedback
- Sende Nutzer-Daten als JSON (konsistent mit anderen Admin-Endpoints)
- Zeige Erfolgs-Alert nach Erstellung
- Bessere Fehlermeldungen mit HTTP-Status
- Client-side Validierung vor dem Absenden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:40:56 +02:00
Kroonk
e5e313924d Installiere JPEG/PNG/WebP Delegates fuer ImageMagick in Alpine
Alpine packt Format-Dekoder separat - ohne libjpeg-turbo, libpng, libwebp
kann magick keine JPG/PNG/WebP Dateien lesen ("no decode delegate").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:35:08 +02:00
Kroonk
83058f80c6 Entferne Galerie-Menuepunkt (Watchtower-Deployment-Test)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:27:31 +02:00
Kroonk
ea15be0368 Fix Thumbnail-Generierung (magick statt convert) und DB-Migration fuer role-Spalte
- entrypoint.sh: ImageMagick 7 nutzt 'magick' statt 'convert', Auto-Detection
- database.js: Migration fuer role-Spalte bei bestehender DB (ALTER TABLE)
- main.js: Fallback auf Vollbild wenn Thumbnail fehlt (error handler)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:40:18 +02:00
26 changed files with 3053 additions and 455 deletions

21
.env.example Normal file
View File

@@ -0,0 +1,21 @@
# JWT Secret used for admin/user authentication tokens (change this to a secure random value)
JWT_SECRET=your_jwt_secret_here
# SQLite Database path inside container (normally persistent volume, no need to change)
# FULLS_DIR=/app/public/images/fulls
# THUMBS_DIR=/app/public/images/thumbs
# ==========================================
# Keycloak SSO (OIDC) Configuration
# ==========================================
# Base Keycloak URL Realm Authority
SSO_AUTHORITY=https://auth.mischlabs.de/realms/mischlabs
# Client ID configured in Keycloak for Photography
SSO_CLIENT_ID=pocketbase
# Client Secret retrieved from Keycloak (Credentials tab)
SSO_CLIENT_SECRET=
# Public callback URI registered in Keycloak for the Photography app
SSO_REDIRECT_URI=https://tom.mischlabs.de/api/auth/sso/callback

View File

@@ -1,4 +1,4 @@
name: Build & Push Docker Image
name: Build & Push Docker Image to Gitea Registry
on:
push:
@@ -6,66 +6,55 @@ on:
env:
REGISTRY: git.mischlabs.de
REGISTRY_USER: MrDiderot
REGISTRY_IMAGE: git.mischlabs.de/mrdiderot/photography
jobs:
build-and-push:
if: ${{ github.server_url == 'https://git.mischlabs.de' }}
runs-on: ubuntu-latest
defaults:
run:
shell: sh
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Docker CLI
shell: sh
run: |
if command -v docker >/dev/null 2>&1; then
docker --version
exit 0
fi
if ! command -v docker >/dev/null 2>&1; then
echo "Docker CLI not found. Installing static binary..."
if command -v curl >/dev/null 2>&1; then
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
elif command -v wget >/dev/null 2>&1; then
wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
else
echo "Neither curl nor wget found! Attempting package manager install..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y docker.io
apt-get update && apt-get install -y docker.io
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache docker-cli
else
echo "No supported package manager found to install Docker CLI"
echo "Cannot install Docker CLI!"
exit 1
fi
fi
else
echo "Docker CLI already installed."
fi
docker --version
- name: Check Docker daemon
run: docker version
- name: Log in to package registry
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Log in to Gitea Package Registry
shell: sh
run: |
if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_TOKEN" ]; then
echo "Missing registry credentials."
echo "Create repository secrets REGISTRY_USER and REGISTRY_TOKEN."
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "Missing REGISTRY_TOKEN secret."
echo "REGISTRY_TOKEN must be a Gitea access token with package write permission."
exit 1
fi
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u "${{ env.REGISTRY_USER }}" --password-stdin
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY_IMAGE }}:latest
${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
shell: sh
run: |
docker build -t ${{ env.REGISTRY_IMAGE }}:latest -t ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} .
docker push ${{ env.REGISTRY_IMAGE }}:latest
docker push ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}

View File

@@ -10,7 +10,7 @@ Keine 1:1 Kopie, sondern eine individuell angepasste Version mit eigenen Ansprue
- **Domain**: tom.mischlabs.de
- **E-Mail**: tom@mischlabs.de
- **Instagram**: @Mischkomposition
- **GitHub Repo**: https://github.com/Kroonk/Photography.git
- **Gitea Repo**: https://git.mischlabs.de/MrDiderot/Photography.git
- **Lokaler Pfad**: d:\Vibecoding\Website\Photography
---
@@ -114,10 +114,10 @@ Photography/
- Zuweisung erfolgt auf Ordner-Ebene (nicht einzelne Bilder)
- Tabelle `user_folders` speichert Zuweisungen (user_id, folder_name)
### Standard-Admin
- **Username**: `admin`
- **Passwort**: `admin123` (SOFORT AENDERN nach erstem Login!)
- **Rolle**: admin
### Initialer Admin
- Es wird kein festes Standardkonto mehr angelegt.
- Bestehende Installationen behalten Nutzer und Rollen im persistenten SQLite-Volume.
- Fuer frische Installationen kann einmalig `INITIAL_ADMIN_USERNAME=MrDiderot` zusammen mit `INITIAL_ADMIN_PASSWORD_HASH` gesetzt werden.
### API-Endpunkte
| Endpunkt | Methode | Beschreibung |
@@ -177,7 +177,7 @@ Photography/
1. Aenderungen lokal machen (Code, Design)
2. `gulp build` ausfuehren (JS + CSS minifizieren)
3. Git commit & push nach master
4. GitHub Actions baut automatisch Docker Image -> GHCR
4. Gitea Actions baut automatisch Docker Image -> lokales Gitea Container Registry
5. Watchtower auf NAS erkennt neues Image (alle 5 Min)
6. Watchtower aktualisiert Container automatisch
7. Fertig - Website aktualisiert ohne SSH!

View File

@@ -24,9 +24,7 @@ RUN npm ci --omit=dev && npm cache clean --force
# === Serve-Phase: node.js backend + Runtime-Tools ===
FROM node:20-alpine
# Getrennte RUNs halten einzelne Registry-Layer klein genug fuer Proxy-Limits.
RUN apk add --no-cache docker-cli
RUN apk add --no-cache imagemagick
RUN apk add --no-cache imagemagick libjpeg-turbo libpng libwebp
RUN apk add --no-cache sqlite-libs
WORKDIR /app

View File

@@ -51,7 +51,7 @@ Erweitertes Nutzermanagement mit 3 Rollen und ordnerbasierter Bildzuweisung.
### Beschreibung
Watchtower laeuft als separater Docker-Container und prueft alle 5 Minuten auf neue Images.
Nach einem `git push` baut GitHub Actions das neue Image und Watchtower aktualisiert den Container automatisch.
Nach einem `git push` baut Gitea Actions das neue Image in der lokalen Gitea Container Registry und Watchtower aktualisiert den Container automatisch.
### Anforderungen
- Automatisches Update ohne SSH oder manuellen Eingriff

View File

@@ -7,46 +7,42 @@ The project features a fast static frontend (based on HTML5 UP Multiverse) power
- **Nextcloud Integration:** Photos placed into the Nextcloud directory are automatically detected.
- **Client Separation:** Guests see the public portfolio, while logged-in clients see their designated, private sub-folders.
- **Auto-Thumbnails:** Automatic resizing and EXIF extraction (shutter speed, ISO, aperture) for fast loading times.
- **Admin Dashboard:** Manage users, assign private folders, and trigger One-Click container updates directly from the browser.
- **Admin Dashboard:** Manage users, roles, private folders, access rights, account security, and deployment status.
- **Light/Dark Mode:** Dynamic theme toggle saving preferences in the browser.
## Deployment Setup (Docker)
This repository is automatically built into a Docker container via GitHub Actions.
This repository is automatically built into a Docker container via Gitea Actions and pushed to the local Gitea container registry.
To deploy on a NAS or Linux server, adjust the `docker-compose.yml` to match your Nextcloud mount:
```yaml
services:
photography-website:
photography:
image: git.mischlabs.de/mrdiderot/photography:latest
container_name: photography-website
ports:
- "8090:8090"
environment:
- JWT_SECRET=replace-with-a-long-random-secret
- INITIAL_ADMIN_USERNAME=MrDiderot
- INITIAL_ADMIN_PASSWORD=Start123
- FULLS_DIR=/app/public/images/fulls
- THUMBS_DIR=/app/public/images/thumbs
- SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest
- SYNC_CONTAINER=photography-website
volumes:
- /path/to/nextcloud/data:/app/public/images/fulls:ro
- thumbs:/app/public/images/thumbs
- database:/app/data
- /var/run/docker.sock:/var/run/docker.sock
- ~/.docker/config.json:/root/.docker/config.json:ro
```
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:
For a fresh installation, create the initial admin with explicit environment variables only once. Use a bcrypt hash instead of storing a plain password in `docker-compose.yml`:
```bash
node -e "const bcrypt = require('bcryptjs'); console.log(bcrypt.hashSync('your-admin-password', 10));"
```
The admin dashboard includes a sync button that pulls `SYNC_IMAGE` and restarts `SYNC_CONTAINER`. This requires the Docker socket mount shown above.
Then start once with `INITIAL_ADMIN_USERNAME=MrDiderot` and `INITIAL_ADMIN_PASSWORD_HASH=<hash>`. Existing installations keep their SQLite users in the persistent `database` volume.
Container updates are handled by Watchtower, which pulls from `git.mischlabs.de/mrdiderot/photography:latest`.
## Credits & License
- Originally based on a Jekyll template.

View File

@@ -5,4 +5,4 @@
<script src="assets/js/breakpoints.min.js" defer></script>
<script src="assets/js/util.min.js" defer></script>
<script src="assets/js/exif.min.js" defer></script>
<script src="{{ site.baseurl }}/assets/js/main.min.js?v={{ site.time | date: '%s' }}" defer></script>
<script src="{{ site.baseurl }}/assets/js/main.js?v={{ site.time | date: '%s' }}" defer></script>

View File

@@ -6,17 +6,90 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="/assets/css/main.min.css" />
<style>
.admin-section { background: rgba(0,0,0,0.5); padding: 2em; margin-bottom: 2em; border-radius: 4px; }
.folder-check { display: inline-block; margin: 5px 10px; padding: 8px 12px; background: rgba(255,255,255,0.1); border-radius: 4px; }
.folder-check label { cursor: pointer; }
table { width: 100%; border-collapse: collapse; }
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; }
body { background: #101316; color: rgba(255,255,255,.86); }
#wrapper { min-height: 100vh; }
#header { z-index: 20; }
.admin-shell { display: grid; grid-template-columns: 260px minmax(0, 1fr); min-height: calc(100vh - 3.25em); padding-top: 3.25em; }
.admin-sidebar { position: sticky; top: 3.25em; height: calc(100vh - 3.25em); padding: 1em; border-right: 1px solid rgba(255,255,255,.08); background: rgba(15,18,21,.96); display: flex; flex-direction: column; gap: 1em; }
.admin-brand { padding: .65em .45em; }
.admin-brand strong { display: block; color: #fff; font-size: 1em; letter-spacing: .02em; }
.admin-brand span { display: block; color: rgba(255,255,255,.48); font-size: .8em; margin-top: .2em; }
.admin-nav { display: grid; gap: .35em; }
.nav-item { width: 100%; height: auto; min-height: 2.75em; padding: .65em .75em; border: 1px solid transparent; border-radius: 7px; background: transparent; color: rgba(255,255,255,.68) !important; display: flex; align-items: center; gap: .75em; text-align: left; box-shadow: none; line-height: 1.2; }
.nav-item i { width: 1.15em; text-align: center; color: rgba(255,255,255,.42); }
.nav-item:hover, .nav-item.active { background: rgba(255,255,255,.06); border-color: rgba(255,255,255,.09); color: #fff !important; }
.nav-item.active i { color: #8ee6d2; }
.sidebar-footer { margin-top: auto; display: grid; gap: .5em; }
.sidebar-footer a { border-bottom: 0; color: rgba(255,255,255,.64); }
.admin-content { min-width: 0; padding: 1.1em; }
.admin-view { display: none; max-width: 1220px; margin: 0 auto; }
.admin-view.active { display: block; }
.view-head { display: flex; justify-content: space-between; gap: 1em; align-items: flex-end; margin-bottom: 1em; }
.view-head h2 { margin: 0; color: #fff; font-size: 1.35em; letter-spacing: .01em; }
.view-head p { margin: .25em 0 0; color: rgba(255,255,255,.54); font-size: .92em; }
.view-actions { display: flex; gap: .5em; flex-wrap: wrap; justify-content: flex-end; }
.admin-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: .85em; align-items: start; }
.admin-panel { background: rgba(22,25,29,.94); border: 1px solid rgba(255,255,255,.08); border-radius: 8px; padding: 1em; min-width: 0; box-shadow: 0 12px 30px rgba(0,0,0,.18); }
.span-3 { grid-column: span 3; }
.span-4 { grid-column: span 4; }
.span-5 { grid-column: span 5; }
.span-6 { grid-column: span 6; }
.span-7 { grid-column: span 7; }
.span-8 { grid-column: span 8; }
.span-12 { grid-column: 1 / -1; }
.admin-panel h3 { margin: 0 0 .75em; color: #fff; font-size: .92em; letter-spacing: .08em; text-transform: uppercase; }
.admin-panel p { margin: 0 0 .75em; color: rgba(255,255,255,.58); font-size: .9em; }
.stat-card { min-height: 106px; display: flex; flex-direction: column; justify-content: space-between; }
.stat-label { color: rgba(255,255,255,.52); font-size: .74em; letter-spacing: .09em; text-transform: uppercase; }
.stat-value { color: #fff; font-size: 2.05em; line-height: 1; margin-top: .25em; }
.stat-sub { color: rgba(255,255,255,.48); font-size: .82em; margin-top: .45em; }
.compact-form { display: grid; grid-template-columns: repeat(4, minmax(130px, 1fr)); gap: .65em; align-items: end; }
.compact-form .field-inline { min-width: 0; }
.compact-form label { display:block; margin: 0 0 .25em; font-size:.72em; text-transform:uppercase; letter-spacing:.08em; color:rgba(255,255,255,.55); }
.compact-form input, .compact-form select, .admin-panel select { height: 2.45em; }
table { width: 100%; border-collapse: collapse; font-size: .9em; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,.09); vertical-align: middle; }
th { color: rgba(255,255,255,.58); font-size: .72em; text-transform: uppercase; letter-spacing: .08em; font-weight: 400; }
.table-wrap { overflow-x: auto; }
.badge { display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: .76em; line-height: 1.6; white-space: nowrap; }
.badge-admin { background: #d76345; }
.badge-user { background: #2d7fb8; }
.badge-client { background: #3f9f79; }
.badge-expired { background: #666; }
.btn-small { padding: 4px 10px; font-size: 0.85em; margin: 2px; cursor: pointer; }
.folder-badge { background: rgba(52,165,142,.16); color:#8ee6d2; margin: 2px 3px 2px 0; border: 1px solid rgba(52,165,142,.28); }
.muted { color: rgba(255,255,255,.48); }
.btn-small { padding: 3px 9px; font-size: .78em; margin: 2px; cursor: pointer; height: auto; line-height: 1.6; border-radius: 5px; }
.btn-danger { border-color: rgba(228,77,38,.55) !important; color: #ffad9c !important; }
.folder-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap:.75em; }
.folder-card { border: 1px solid rgba(255,255,255,.09); border-radius: 8px; padding:.85em; background: rgba(255,255,255,.035); }
.folder-card h3 { margin:0 0 .35em; font-size:1em; color:#fff; text-transform:none; letter-spacing:0; }
.folder-meta { display:flex; gap:.45em; flex-wrap:wrap; margin:.5em 0; }
.folder-pill { border:1px solid rgba(255,255,255,.12); border-radius:999px; padding:.15em .55em; font-size:.78em; color:rgba(255,255,255,.68); }
.folder-actions { margin-top:.65em; display:flex; gap:.35em; flex-wrap:wrap; }
.access-list { display:grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap:.4em .7em; margin-top:.75em; }
.access-item { display:flex; align-items:center; gap:.45em; padding:.55em .65em; border:1px solid rgba(255,255,255,.08); border-radius:6px; background:rgba(255,255,255,.025); cursor:pointer; }
.access-item input { width:auto; margin:0; }
.preview-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap:.55em; margin-top:.75em; max-height: 560px; overflow:auto; padding-right:.25em; }
.preview-grid a { border:0; display:block; border-radius:6px; overflow:hidden; background:#0b0d10; aspect-ratio: 1 / 1; }
.preview-grid img { width:100%; height:100%; object-fit:cover; display:block; }
.signal-list { display: grid; gap: .55em; }
.signal-item { display: flex; align-items: center; justify-content: space-between; gap: .7em; padding: .6em .7em; border: 1px solid rgba(255,255,255,.08); border-radius: 7px; background: rgba(255,255,255,.03); }
.signal-item strong { color: #fff; font-weight: 400; }
#assign-status { font-size:.85em; color:#8ee6d2; display:inline-block; margin-top:10px; }
@media screen and (max-width: 1000px) {
.admin-shell { grid-template-columns: 1fr; padding-top: 3.25em; }
.admin-sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid rgba(255,255,255,.08); }
.admin-nav { grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); }
.sidebar-footer { margin-top: 0; }
.span-3, .span-4, .span-5, .span-6, .span-7, .span-8 { grid-column: 1 / -1; }
.compact-form { grid-template-columns: 1fr; }
}
@media screen and (max-width: 600px) {
.admin-content { padding: .75em; }
.view-head { display: block; }
.view-actions { justify-content: flex-start; margin-top: .75em; }
table { font-size:.82em; }
}
</style>
</head>
<body class="is-preload">
@@ -24,83 +97,200 @@
<header id="header">
<h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1>
<nav><ul>
<li><a href="/index.html">Website</a></li>
<li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li>
</ul></nav>
</header>
<div id="main" style="display:block; padding: 2em; max-width: 1000px; margin: 0 auto; width: 100%;">
<main id="main" class="admin-shell">
<aside class="admin-sidebar">
<div class="admin-brand">
<strong>Kontrollraum</strong>
<span id="admin-user-label">Admin</span>
</div>
<nav class="admin-nav" aria-label="Admin Bereiche">
<button type="button" class="nav-item active" data-view="overview"><i class="fa fa-chart-pie"></i><span>Uebersicht</span></button>
<button type="button" class="nav-item" data-view="users"><i class="fa fa-users"></i><span>Nutzer und Rollen</span></button>
<button type="button" class="nav-item" data-view="folders"><i class="fa fa-folder-open"></i><span>Ordner</span></button>
<button type="button" class="nav-item" data-view="access"><i class="fa fa-key"></i><span>Freigaben</span></button>
<button type="button" class="nav-item" data-view="preview"><i class="fa fa-images"></i><span>Ordnerinhalt</span></button>
<button type="button" class="nav-item" data-view="account"><i class="fa fa-user-shield"></i><span>Account</span></button>
<button type="button" class="nav-item" data-view="system"><i class="fa fa-server"></i><span>System</span></button>
</nav>
<div class="sidebar-footer">
<a href="/index.html"><i class="fa fa-arrow-left"></i> Zur Galerie</a>
</div>
</aside>
<div class="admin-section">
<h2>Nutzer erstellen</h2>
<form id="create-user-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<div class="admin-content">
<section class="admin-view active" data-view="overview">
<div class="view-head">
<div>
<label>Username</label><br/>
<h2>Uebersicht</h2>
<p>Aktuelle Nutzer, private Ordner und offene Freigaben.</p>
</div>
<div class="view-actions">
<button type="button" class="btn-small" id="refresh-dashboard">Aktualisieren</button>
</div>
</div>
<div class="admin-grid">
<div class="admin-panel stat-card span-3"><div><div class="stat-label">Nutzer</div><div class="stat-value" id="stat-users">0</div></div><div class="stat-sub" id="stat-users-sub">0 Admins</div></div>
<div class="admin-panel stat-card span-3"><div><div class="stat-label">Private Ordner</div><div class="stat-value" id="stat-folders">0</div></div><div class="stat-sub" id="stat-folders-sub">0 Bilder</div></div>
<div class="admin-panel stat-card span-3"><div><div class="stat-label">Freigaben</div><div class="stat-value" id="stat-access">0</div></div><div class="stat-sub" id="stat-access-sub">0 ohne Zugriff</div></div>
<div class="admin-panel stat-card span-3"><div><div class="stat-label">Clients</div><div class="stat-value" id="stat-clients">0</div></div><div class="stat-sub" id="stat-clients-sub">0 abgelaufen</div></div>
<div class="admin-panel span-6">
<h3>Aufmerksamkeit</h3>
<div id="attention-list" class="signal-list"></div>
</div>
<div class="admin-panel span-6">
<h3>Rollenverteilung</h3>
<div id="role-list" class="signal-list"></div>
</div>
</div>
</section>
<section class="admin-view" data-view="users">
<div class="view-head">
<div>
<h2>Nutzer und Rollen</h2>
<p>Accounts, Laufzeiten, Rollen und Ordnerzugriffe.</p>
</div>
</div>
<div class="admin-grid">
<div class="admin-panel span-12">
<h3>Nutzer erstellen</h3>
<form id="create-user-form" class="compact-form">
<div class="field-inline">
<label>Username</label>
<input type="text" id="new-username" placeholder="Nutzername" required />
</div>
<div>
<label>Passwort</label><br/>
<div class="field-inline">
<label>Passwort</label>
<input type="password" id="new-password" placeholder="Passwort" required />
</div>
<div>
<label>Rolle</label><br/>
<div class="field-inline">
<label>Rolle</label>
<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>
<div><button type="submit">Erstellen</button></div>
</form>
</div>
<div class="admin-section">
<h2>Nutzerverwaltung</h2>
<div class="admin-panel span-12">
<h3>Bestand</h3>
<div class="table-wrap">
<table>
<thead><tr><th>ID</th><th>Username</th><th>Rolle</th><th>Erstellt</th><th>Aktionen</th></tr></thead>
<thead><tr><th>ID</th><th>Username</th><th>Rolle</th><th>Status</th><th>Ordnerzugriff</th><th>Aktionen</th></tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
<div class="admin-section">
<h2>Ordner-Zuweisung</h2>
<p>Weise Unterordner aus dem Websitebilder-Verzeichnis einem Nutzer zu.</p>
<select id="user-select" style="margin-bottom: 1em;">
<option value="">Nutzer waehlen...</option>
</select>
<div id="folder-checkboxes" style="display:flex; flex-wrap:wrap; gap:5px; margin-bottom:1em;"></div>
<button id="save-assignments">Zuweisung Speichern</button>
<span id="assign-status" style="margin-left:10px;"></span>
</div>
<div class="admin-section">
<h2>Website aktualisieren</h2>
<p>Laedt das neueste Docker-Image und startet den Website-Container neu.</p>
<button id="sync-site">Sync starten</button>
<span id="sync-status" style="margin-left:10px;"></span>
</div>
</section>
<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;">
<section class="admin-view" data-view="folders">
<div class="view-head">
<div>
<label>Altes Passwort</label><br/>
<h2>Ordner</h2>
<p>Unterordner im Bilderpfad sind privat und werden nur explizit freigegeben.</p>
</div>
</div>
<div class="admin-panel">
<div id="folders-overview" class="folder-grid"></div>
</div>
</section>
<section class="admin-view" data-view="access">
<div class="view-head">
<div>
<h2>Freigaben</h2>
<p>Nutzer auswaehlen und private Ordner zuweisen.</p>
</div>
</div>
<div class="admin-grid">
<div class="admin-panel span-4">
<h3>Nutzer</h3>
<select id="user-select"><option value="">Nutzer waehlen...</option></select>
<div id="selected-user-summary" class="signal-list" style="margin-top:.75em;"></div>
</div>
<div class="admin-panel span-8">
<h3>Ordnerzugriff</h3>
<div id="folder-table-wrap" style="display:none;">
<div id="folder-access-list" class="access-list"></div>
<span id="assign-status"></span>
</div>
<p id="folder-access-empty" class="muted">Kein Nutzer ausgewaehlt.</p>
</div>
</div>
</section>
<section class="admin-view" data-view="preview">
<div class="view-head">
<div>
<h2 id="folder-preview-title">Ordnerinhalt</h2>
<p id="folder-preview-note">Waehle einen Ordner aus der Ordneruebersicht.</p>
</div>
</div>
<div class="admin-panel">
<div id="folder-preview" class="preview-grid"></div>
</div>
</section>
<section class="admin-view" data-view="account">
<div class="view-head">
<div>
<h2>Account</h2>
<p>Passwort fuer den aktuellen Admin-Zugang.</p>
</div>
</div>
<div class="admin-panel">
<form id="admin-pw-form" class="compact-form">
<div class="field-inline">
<label>Altes Passwort</label>
<input type="password" id="admin-pw-old" required />
</div>
<div>
<label>Neues Passwort</label><br/>
<div class="field-inline">
<label>Neues Passwort</label>
<input type="password" id="admin-pw-new" required />
</div>
<div>
<button type="submit">Aendern</button>
</div>
<div><button type="submit" class="btn-small">Aendern</button></div>
</form>
</div>
</section>
<section class="admin-view" data-view="system">
<div class="view-head">
<div>
<h2>System</h2>
<p>Deployment- und Laufzeitdaten fuer tom.mischlabs.de.</p>
</div>
</div>
<div class="admin-grid">
<div class="admin-panel span-6">
<h3>Deployment</h3>
<div class="signal-list">
<div class="signal-item"><strong>Registry</strong><span>git.mischlabs.de/mrdiderot/photography:latest</span></div>
<div class="signal-item"><strong>Updates</strong><span>Watchtower</span></div>
<div class="signal-item"><strong>Port</strong><span>8090</span></div>
</div>
</div>
<div class="admin-panel span-6">
<h3>Speicherorte</h3>
<div class="signal-list">
<div class="signal-item"><strong>Bilder</strong><span>/app/public/images/fulls</span></div>
<div class="signal-item"><strong>Thumbnails</strong><span>/app/public/images/thumbs</span></div>
<div class="signal-item"><strong>Datenbank</strong><span>/app/data/database.sqlite</span></div>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
<script src="/assets/js/jquery.min.js"></script>
<script src="/assets/js/admin.js"></script>
<script src="/assets/js/admin.js?v=3"></script>
</body>
</html>

View File

@@ -1 +1 @@
.text-fg{color:#a0a0a1}#footer .presentify{margin:0 0 1.4em 0;display:grid;grid-template-columns:auto 1fr;column-gap:1rem;align-items:center}#footer .presentify .presentify-logo{display:flex;align-items:center;justify-content:center}#footer .presentify .presentify-logo a{display:flex;align-items:center;justify-content:center;border:0}#footer .presentify .presentify-logo img{width:72px;height:72px;display:block}#footer .presentify .presentify-text{margin:0}
.text-fg{color:#a0a0a1}#folder-picker-wrap{display:inline-flex;align-items:center;padding:0 .5em}#folder-picker-wrap #folder-picker{width:auto;min-width:13rem;height:2.35em;margin:0;padding:0 2.25em 0 .8em;border-radius:4px;background-color:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);color:#fff;font-size:.78em;text-transform:none;letter-spacing:0}@media screen and (max-width:736px){#folder-picker-wrap{max-width:48vw;padding:0 .35em}#folder-picker-wrap #folder-picker{min-width:0;width:100%;font-size:.74rem}}#selection-toolbar{position:fixed;top:0;left:0;width:100%;background:rgba(31,34,36,.95);padding:.75em 1.5em;display:flex;align-items:center;gap:1em;z-index:10003;transform:translateY(-100%);transition:transform .3s ease;box-sizing:border-box}#selection-toolbar.visible{transform:translateY(0)}#selection-toolbar #sel-count{color:#fff;font-weight:400;margin-right:auto;white-space:nowrap}#selection-toolbar button{white-space:nowrap}body.selection-mode #main .thumb{cursor:pointer;position:relative}body.selection-mode #main .thumb::before{content:"";position:absolute;bottom:.6em;right:.6em;width:1.6em;height:1.6em;border:2px solid rgba(255,255,255,.8);border-radius:4px;background:rgba(0,0,0,.5);z-index:2;transition:all .2s ease;pointer-events:none}body.selection-mode #main .thumb .image{opacity:.85;transition:opacity .2s ease}body.selection-mode #main .thumb:active .image{opacity:.6}body.selection-mode #main .thumb.selected::before{background:#34a58e;border-color:#34a58e}body.selection-mode #main .thumb.selected .image{opacity:1;outline:3px solid #34a58e;outline-offset:-3px}@media screen and (max-width: 736px){#selection-toolbar{top:4em;padding:.6em 1em;gap:.6em}#selection-toolbar #sel-count{font-size:.9em}#selection-toolbar button{font-size:.85em;padding:.5em 1em}body.selection-mode #main .thumb::before{bottom:.3em;right:.3em;width:2em;height:2em;border-width:2.5px}body.selection-mode #main .thumb.selected .image{outline-width:4px}}#footer .presentify{margin:0 0 1.4em 0;display:grid;grid-template-columns:auto 1fr;column-gap:1rem;align-items:center}#footer .presentify .presentify-logo{display:flex;align-items:center;justify-content:center}#footer .presentify .presentify-logo a{display:flex;align-items:center;justify-content:center;border:0}#footer .presentify .presentify-logo img{width:72px;height:72px;display:block}#footer .presentify .presentify-text{margin:0}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,104 +1,417 @@
$(document).ready(function() {
// Auth check
var currentAdmin = null;
var users = [];
var folders = [];
var assignedFolders = [];
var selectedUserId = "";
$.get('/api/auth/me')
.done(function(user) {
if(user.role !== 'admin') {
if (user.role !== 'admin') {
alert('Kein Zugriff!');
window.location.href = '/';
} else {
loadUsers();
loadFolders();
$('body').removeClass('is-preload');
return;
}
currentAdmin = user;
$('#admin-user-label').text(user.username || 'Admin');
refreshDashboard();
$('body').removeClass('is-preload');
})
.fail(function() { window.location.href = '/'; });
// Logout
function showView(name) {
$('.admin-view').removeClass('active').filter('[data-view="' + name + '"]').addClass('active');
$('.nav-item').removeClass('active').filter('[data-view="' + name + '"]').addClass('active');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
$('.admin-nav').on('click', '.nav-item', function() {
showView($(this).data('view'));
});
$('#logout-btn').click(function(e) {
e.preventDefault();
$.post('/api/auth/logout').done(function() { window.location.href = '/'; });
});
// ==================== NUTZER ====================
$('#refresh-dashboard').click(refreshDashboard);
function refreshDashboard() {
$.when(loadUsers(), loadFolders()).done(function() {
renderStats();
renderUsers();
renderUserSelect();
renderFolders();
if (selectedUserId) {
loadAssignments(selectedUserId);
} else {
renderSelectedUserSummary(null);
}
});
}
function loadUsers() {
$.get('/api/admin/users').done(function(users) {
$('#users-tbody').empty();
$('#user-select').html('<option value="">Nutzer waehlen...</option>');
return $.get('/api/admin/users').done(function(data) {
users = data || [];
}).fail(function(xhr) {
$('#users-tbody').html(
'<tr><td colspan="6" style="color:#e44d26;">Fehler beim Laden: HTTP ' +
xhr.status + '</td></tr>'
);
});
}
users.forEach(function(u) {
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 info = created;
function loadFolders() {
return $.get('/api/admin/folders').done(function(data) {
folders = data || [];
}).fail(function() {
$('#folders-overview').html('<p style="color:#e44d26;">Ordner konnten nicht geladen werden.</p>');
});
}
if (u.role === 'client' && u.expires_at) {
var expDate = new Date(u.expires_at).toLocaleDateString('de-DE');
if (u.expired) {
badge = $('<span></span>').addClass('badge badge-expired').text('abgelaufen');
info = 'Abgelaufen: ' + expDate;
function roleCounts() {
return users.reduce(function(acc, user) {
var role = user.role || 'client';
acc[role] = (acc[role] || 0) + 1;
return acc;
}, { admin: 0, user: 0, client: 0 });
}
function totalAssignments() {
return users.reduce(function(total, user) {
return total + ((user.folders && user.folders.length) || 0);
}, 0);
}
function totalImages() {
return folders.reduce(function(total, folder) {
return total + (folder.image_count || 0);
}, 0);
}
function renderStats() {
var roles = roleCounts();
var expiredClients = users.filter(function(user) { return user.expired; }).length;
var unassignedUsers = users.filter(function(user) {
return (user.role || 'client') !== 'admin' && (!user.folders || !user.folders.length);
}).length;
var unsharedFolders = folders.filter(function(folder) {
return !folder.assigned_users || !folder.assigned_users.length;
}).length;
$('#stat-users').text(users.length);
$('#stat-users-sub').text(roles.admin + ' Admins, ' + roles.user + ' User');
$('#stat-folders').text(folders.length);
$('#stat-folders-sub').text(totalImages() + ' Bilder in privaten Ordnern');
$('#stat-access').text(totalAssignments());
$('#stat-access-sub').text(unassignedUsers + ' Nutzer ohne Ordner');
$('#stat-clients').text(roles.client);
$('#stat-clients-sub').text(expiredClients + ' abgelaufen');
renderAttentionList(unassignedUsers, unsharedFolders, expiredClients);
renderRoleList(roles);
}
function appendSignal($target, label, value, muted) {
var $item = $('<div></div>').addClass('signal-item');
$('<strong></strong>').text(label).appendTo($item);
$('<span></span>').toggleClass('muted', !!muted).text(value).appendTo($item);
$target.append($item);
}
function renderAttentionList(unassignedUsers, unsharedFolders, expiredClients) {
var $list = $('#attention-list').empty();
appendSignal($list, 'Nutzer ohne Ordner', unassignedUsers, unassignedUsers === 0);
appendSignal($list, 'Ordner ohne Freigabe', unsharedFolders, unsharedFolders === 0);
appendSignal($list, 'Abgelaufene Clients', expiredClients, expiredClients === 0);
appendSignal($list, 'Gesamtbilder privat', totalImages(), totalImages() === 0);
}
function renderRoleList(roles) {
var $list = $('#role-list').empty();
appendSignal($list, 'Admins', roles.admin || 0);
appendSignal($list, 'Permanente User', roles.user || 0);
appendSignal($list, 'Clients', roles.client || 0);
appendSignal($list, 'Freigaben', totalAssignments());
}
function getRoleBadge(role, expired) {
if (expired) return $('<span></span>').addClass('badge badge-expired').text('abgelaufen');
return $('<span></span>').addClass('badge badge-' + (role || 'client')).text(role || 'client');
}
function renderUsers() {
var $tbody = $('#users-tbody').empty();
if (!users.length) {
$tbody.html('<tr><td colspan="6" style="text-align:center;">Keine Nutzer gefunden</td></tr>');
return;
}
users.forEach(function(user) {
var role = user.role || 'client';
var created = user.created_at ? new Date(user.created_at).toLocaleDateString('de-DE') : '-';
var status = created;
if (role === 'client' && user.expires_at) {
status = (user.expired ? 'Abgelaufen: ' : 'Bis: ') +
new Date(user.expires_at).toLocaleDateString('de-DE');
}
var $row = $('<tr></tr>');
var $userCell = $('<td></td>').text(user.username + ' ').append(getRoleBadge(role, user.expired));
var $foldersCell = $('<td></td>');
if (user.folders && user.folders.length) {
user.folders.forEach(function(folder) {
$('<span></span>').addClass('badge folder-badge').text(folder).appendTo($foldersCell);
});
} else {
info = 'Bis: ' + expDate;
}
$foldersCell.append($('<span></span>').addClass('muted').text('Keine privaten Ordner'));
}
var row = $('<tr></tr>');
var userCell = $('<td></td>').text(u.username + ' ').append(badge);
var actions = $('<td></td>');
var $actions = $('<td></td>');
$('<button type="button" class="btn-small user-access">Zugriff</button>')
.data('user-id', user.id)
.appendTo($actions);
$('<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);
.data('user-id', user.id)
.data('username', user.username)
.appendTo($actions);
$('<button type="button" class="btn-small btn-danger user-delete">Loeschen</button>')
.data('user-id', user.id)
.data('username', user.username)
.appendTo($actions);
row.append($('<td></td>').text(u.id));
row.append(userCell);
row.append($('<td></td>').text(u.role));
row.append($('<td></td>').text(info));
row.append(actions);
$('#users-tbody').append(row);
// Dropdown fuer Ordner-Zuweisung (nur non-admin)
if (u.role !== 'admin') {
$('#user-select').append($('<option></option>').val(u.id).text(u.username + ' (' + u.role + ')'));
}
});
$row.append($('<td></td>').text(user.id));
$row.append($userCell);
$row.append($('<td></td>').text(role));
$row.append($('<td></td>').text(status));
$row.append($foldersCell);
$row.append($actions);
$tbody.append($row);
});
}
// Nutzer erstellen
function renderUserSelect() {
var $select = $('#user-select').html('<option value="">Nutzer waehlen...</option>');
users.forEach(function(user) {
if ((user.role || 'client') === 'admin') return;
$('<option></option>')
.val(user.id)
.text(user.username + ' (' + (user.role || 'client') + ')')
.appendTo($select);
});
if (selectedUserId) $select.val(selectedUserId);
}
function renderFolders() {
var $wrap = $('#folders-overview').empty();
if (!folders.length) {
$wrap.html('<p class="muted">Keine Unterordner im Bilderpfad gefunden.</p>');
return;
}
folders.forEach(function(folder) {
var $card = $('<div></div>').addClass('folder-card');
$('<h3></h3>').text(folder.name).appendTo($card);
var $meta = $('<div></div>').addClass('folder-meta').appendTo($card);
$('<span></span>').addClass('folder-pill').text((folder.image_count || 0) + ' Bilder').appendTo($meta);
$('<span></span>').addClass('folder-pill').text('privat').appendTo($meta);
$('<span></span>').addClass('folder-pill').text((folder.assigned_users || []).length + ' Freigaben').appendTo($meta);
var $assigned = $('<div></div>').appendTo($card);
if (folder.assigned_users && folder.assigned_users.length) {
folder.assigned_users.forEach(function(user) {
$('<span></span>').addClass('badge folder-badge').text(user.username).appendTo($assigned);
});
} else {
$assigned.append($('<span></span>').addClass('muted').text('Noch niemandem freigegeben'));
}
var $actions = $('<div></div>').addClass('folder-actions').appendTo($card);
$('<button type="button" class="btn-small folder-preview-btn">Ansehen</button>')
.data('folder', folder.name)
.appendTo($actions);
$('<button type="button" class="btn-small folder-assign-btn">Freigaben</button>')
.data('folder', folder.name)
.appendTo($actions);
$wrap.append($card);
});
}
function renderAccessList() {
var $list = $('#folder-access-list').empty();
if (!folders.length) {
$list.html('<p class="muted">Keine Unterordner gefunden.</p>');
return;
}
folders.forEach(function(folder) {
var checked = assignedFolders.indexOf(folder.name) !== -1;
var $label = $('<label></label>').addClass('access-item');
$('<input type="checkbox" />')
.val(folder.name)
.prop('checked', checked)
.appendTo($label);
$('<span></span>').text(folder.name + ' (' + (folder.image_count || 0) + ')').appendTo($label);
$list.append($label);
});
}
function findUser(userId) {
var id = String(userId || '');
return users.find(function(user) { return String(user.id) === id; }) || null;
}
function renderSelectedUserSummary(user) {
var $summary = $('#selected-user-summary').empty();
if (!user) {
appendSignal($summary, 'Auswahl', 'Kein Nutzer', true);
return;
}
appendSignal($summary, 'Rolle', user.role || 'client');
appendSignal($summary, 'Ordner', (assignedFolders.length || 0) + ' freigegeben');
if (user.expires_at) {
appendSignal($summary, 'Client bis', new Date(user.expires_at).toLocaleDateString('de-DE'), !!user.expired);
}
}
function loadAssignments(userId) {
selectedUserId = String(userId || "");
if (!selectedUserId) {
$('#folder-table-wrap').hide();
$('#folder-access-empty').show();
assignedFolders = [];
renderSelectedUserSummary(null);
return;
}
$('#user-select').val(selectedUserId);
$.get('/api/admin/assign?user_id=' + encodeURIComponent(selectedUserId)).done(function(data) {
assignedFolders = data || [];
renderAccessList();
renderSelectedUserSummary(findUser(selectedUserId));
$('#folder-access-empty').hide();
$('#folder-table-wrap').show();
}).fail(function() {
assignedFolders = [];
renderAccessList();
renderSelectedUserSummary(findUser(selectedUserId));
$('#folder-access-empty').hide();
$('#folder-table-wrap').show();
});
}
function saveAssignments() {
if (!selectedUserId) return;
assignedFolders = [];
$('#folder-access-list input:checked').each(function() {
assignedFolders.push($(this).val());
});
$.ajax({
url: '/api/admin/assign',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ user_id: selectedUserId, folders: assignedFolders })
}).done(function() {
$('#assign-status').text('Gespeichert').show().delay(1200).fadeOut();
refreshDashboard();
}).fail(function(xhr) {
alert((xhr.responseJSON && xhr.responseJSON.error) || 'Speichern fehlgeschlagen');
});
}
$('#users-tbody').on('click', '.user-access', function() {
loadAssignments($(this).data('user-id'));
showView('access');
});
$('#user-select').change(function() {
loadAssignments($(this).val());
});
$('#folder-access-list').on('change', 'input[type="checkbox"]', saveAssignments);
$('#folders-overview').on('click', '.folder-assign-btn', function() {
var folder = $(this).data('folder');
showView('access');
if (!selectedUserId) {
alert('Bitte zuerst einen Nutzer in den Freigaben waehlen.');
return;
}
var $checkbox = $('#folder-access-list input').filter(function() {
return $(this).val() === folder;
});
if ($checkbox.length) {
$checkbox.prop('checked', !$checkbox.prop('checked')).trigger('change');
}
});
$('#folders-overview').on('click', '.folder-preview-btn', function() {
var folder = $(this).data('folder');
showView('preview');
$('#folder-preview-title').text('Ordnerinhalt: ' + folder);
$('#folder-preview-note').text('Lade Bilder...');
$('#folder-preview').empty();
$.get('/api/admin/folders/' + encodeURIComponent(folder) + '/images').done(function(images) {
$('#folder-preview-note').text((images || []).length + ' Bilder im Ordner');
var $preview = $('#folder-preview').empty();
if (!images || !images.length) {
$preview.html('<p class="muted">Dieser Ordner ist leer.</p>');
return;
}
images.forEach(function(img) {
var $link = $('<a></a>').attr({ href: img.full_url, target: '_blank', rel: 'noopener' });
$('<img />').attr({ src: img.thumb_url, alt: img.name, title: img.name }).appendTo($link);
$preview.append($link);
});
}).fail(function(xhr) {
$('#folder-preview-note').text((xhr.responseJSON && xhr.responseJSON.error) || 'Ordner konnte nicht geladen werden.');
});
});
$('#create-user-form').submit(function(e) {
e.preventDefault();
$.post('/api/admin/users', {
username: $('#new-username').val(),
password: $('#new-password').val(),
role: $('#new-role').val()
var username = $('#new-username').val().trim();
var password = $('#new-password').val();
var role = $('#new-role').val();
if (!username || !password) {
alert('Bitte Username und Passwort eingeben.');
return;
}
$.ajax({
url: '/api/admin/users',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ username: username, password: password, role: role })
}).done(function() {
$('#new-username').val('');
$('#new-password').val('');
loadUsers();
}).fail(function(err) {
alert('Fehler: ' + (err.responseJSON ? err.responseJSON.error : 'Unbekannt'));
$('#new-username, #new-password').val('');
refreshDashboard();
}).fail(function(xhr) {
var msg = (xhr.responseJSON && xhr.responseJSON.error) || 'Unbekannter Fehler';
alert('Fehler beim Erstellen: ' + msg);
});
});
// Nutzer loeschen
function deleteUser(id, name) {
if (confirm('Nutzer "' + name + '" wirklich loeschen?')) {
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(loadUsers)
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
}
}
$('#users-tbody').on('click', '.user-delete', function() {
deleteUser($(this).data('user-id'), $(this).data('username'));
var id = $(this).data('user-id');
var name = $(this).data('username');
if (!confirm('Nutzer "' + name + '" wirklich loeschen?')) return;
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(function() {
if (String(selectedUserId) === String(id)) {
selectedUserId = "";
assignedFolders = [];
}
refreshDashboard();
})
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
});
// Passwort aendern (Admin setzt fuer anderen User)
function changePassword(id, name) {
$('#users-tbody').on('click', '.user-password', function() {
var id = $(this).data('user-id');
var name = $(this).data('username');
var newPw = prompt('Neues Passwort fuer "' + name + '":');
if (!newPw) return;
$.ajax({
@@ -107,107 +420,22 @@ $(document).ready(function() {
contentType: 'application/json',
data: JSON.stringify({ new_password: newPw })
}).done(function() {
alert('Passwort geaendert!');
alert('Passwort geaendert.');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
});
}
$('#users-tbody').on('click', '.user-password', function() {
changePassword($(this).data('user-id'), $(this).data('username'));
});
// ==================== ORDNER-ZUWEISUNG ====================
function loadFolders() {
$.get('/api/admin/folders').done(function(folders) {
$('#folder-checkboxes').empty();
if (folders.length === 0) {
$('#folder-checkboxes').html('<p>Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.</p>');
return;
}
folders.forEach(function(f) {
var wrapper = $('<div class="folder-check"></div>');
var label = $('<label></label>');
$('<input type="checkbox" class="folder-cb"/>').val(f).appendTo(label);
label.append(' ');
$('<i class="fa fa-folder"></i>').appendTo(label);
label.append(' ' + f);
wrapper.append(label);
$('#folder-checkboxes').append(wrapper);
});
});
}
// Bei User-Auswahl: zugewiesene Ordner laden
$('#user-select').change(function() {
var uid = $(this).val();
$('.folder-cb').prop('checked', false);
if (!uid) return;
$.get('/api/admin/assign?user_id=' + uid).done(function(assignedFolders) {
assignedFolders.forEach(function(f) {
$('.folder-cb').filter(function() {
return $(this).val() === f;
}).prop('checked', true);
});
});
});
// Zuweisung speichern
$('#save-assignments').click(function() {
var uid = $('#user-select').val();
if (!uid) return alert('Bitte Nutzer waehlen');
var selected = [];
$('.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');
});
});
// ==================== WEBSITE SYNC ====================
$('#sync-site').click(function() {
if (!confirm('Neueste Version laden und Website neu starten?')) return;
var $button = $(this);
var $status = $('#sync-status');
$button.prop('disabled', true);
$status.text('Sync laeuft...');
$.post('/api/admin/sync')
.done(function(res) {
$status.text(res.message || 'Sync gestartet. Website startet neu...');
window.setTimeout(function() {
window.location.reload();
}, 12000);
})
.fail(function(err) {
var msg = err.responseJSON ? err.responseJSON.error : 'Sync fehlgeschlagen';
$status.text(msg);
$button.prop('disabled', false);
});
});
// ==================== 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!');
alert('Passwort geaendert.');
$('#admin-pw-old, #admin-pw-new').val('');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
}).fail(function(xhr) {
alert(xhr.responseJSON ? xhr.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 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")})})});
$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer waehlen...</option>'),e&&0!==e.length?e.forEach(function(e){var t=e.role||"unbekannt",n='<span class="badge badge-'+t+'">'+t+"</span>",a=e.created_at?new Date(e.created_at).toLocaleDateString("de-DE"):"-";if("client"===t&&e.expires_at){var r=new Date(e.expires_at).toLocaleDateString("de-DE");e.expired?(n='<span class="badge badge-expired">abgelaufen</span>',a="Abgelaufen: "+r):a="Bis: "+r}var o='<button class="btn-small" onclick="changePassword('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">PW</button>";o+='<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>"+t+"</td><td>"+a+"</td><td>"+o+"</td></tr>"),"admin"!==t&&$("#user-select").append('<option value="'+e.id+'">'+e.username+" ("+t+")</option>")}):$("#users-tbody").html('<tr><td colspan="5" style="text-align:center;">Keine Nutzer gefunden</td></tr>')}).fail(function(e){var t="HTTP "+e.status+": "+e.statusText;$("#users-tbody").html('<tr><td colspan="5" style="color:#e44d26;">Fehler beim Laden: '+t+"</td></tr>")})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/api/admin/folders").done(function(e){t=e}),$("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(t){t.preventDefault();var n=$("#new-username").val().trim(),a=$("#new-password").val(),r=$("#new-role").val();n&&a?$.ajax({url:"/api/admin/users",type:"POST",contentType:"application/json",data:JSON.stringify({username:n,password:a,role:r})}).done(function(t){alert('Nutzer "'+n+'" erfolgreich erstellt!'),$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){var t="Unbekannter Fehler";e.responseJSON&&e.responseJSON.error?t=e.responseJSON.error:e.status&&(t="HTTP "+e.status+": "+e.statusText),alert("Fehler beim Erstellen: "+t)}):alert("Bitte Username und Passwort eingeben.")}),window.deleteUser=function(t,n){confirm('Nutzer "'+n+'" wirklich loeschen?')&&$.ajax({url:"/api/admin/users/"+t,type:"DELETE"}).done(e).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},window.changePassword=function(e,t){var n=prompt('Neues Passwort fuer "'+t+'":');n&&$.ajax({url:"/api/admin/users/"+e+"/password",type:"PUT",contentType:"application/json",data:JSON.stringify({new_password:n})}).done(function(){alert("Passwort geaendert!")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})};var t=[],n=[];function a(){var e=$("#folder-tbody");e.empty(),0!==t.length?t.forEach(function(t){var a=-1!==n.indexOf(t),r=a?"folder-row assigned":"folder-row",o=a?"folder-status yes":"folder-status no",s=a?"Zugewiesen":"—";e.append('<tr class="'+r+'" data-folder="'+t+'"><td><i class="fa fa-folder"></i> '+t+'</td><td style="text-align:center;"><span class="'+o+'">'+s+"</span></td></tr>")}):e.html('<tr><td colspan="2" style="text-align:center;">Keine Unterordner gefunden. Erstelle Ordner in Nextcloud.</td></tr>')}$("#user-select").change(function(){var e=$(this).val();if(!e)return $("#folder-table-wrap").hide(),void(n=[]);var r=function(){$.get("/api/admin/assign?user_id="+e).done(function(e){n=e||[],a(),$("#folder-table-wrap").show()}).fail(function(){n=[],a(),$("#folder-table-wrap").show()})};0===t.length?$.get("/api/admin/folders").done(function(e){t=e||[],r()}).fail(function(){t=[],r()}):r()}),$(document).on("click",".folder-row",function(){var e=$("#user-select").val();if(e){var t=$(this).data("folder"),r=n.indexOf(t);-1!==r?n.splice(r,1):n.push(t),a(),$.ajax({url:"/api/admin/assign",type:"POST",contentType:"application/json",data:JSON.stringify({user_id:e,folders:n})}).done(function(){$("#assign-status").text("Gespeichert!").show().delay(1500).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

@@ -153,10 +153,17 @@
exifDatas = {};
var imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
var currentFolder = null;
function loadGallery() {
function loadGallery(folderName) {
currentFolder = folderName || null;
$main.empty();
var galleryUrl = '/images/fulls/';
if (currentFolder) {
galleryUrl += '?folder=' + encodeURIComponent(currentFolder);
}
$.ajax({
url: '/images/fulls/',
url: galleryUrl,
dataType: 'json',
cache: false,
success: function(files) {
@@ -182,6 +189,8 @@
images.forEach(function(file) {
var article = $('<article class="thumb"></article>');
article.attr('data-name', file.name);
if (file.folder) article.attr('data-folder', file.folder);
var prefix = file.folder ? encodeURIComponent(file.folder) + '/' : '';
var fullPath = '/images/fulls/' + prefix + encodeURIComponent(file.name);
var thumbPath = '/images/thumbs/' + prefix + encodeURIComponent(file.name);
@@ -193,6 +202,7 @@
});
initThumbs();
initSelectionMode(images);
initPoptrox();
},
error: function() {
@@ -214,6 +224,14 @@
if ((x = $image_img.data("position"))) $image.css("background-position", x);
$image_img.hide();
// Fallback: Wenn Thumbnail fehlt, Vollbild als Vorschau nutzen
$image_img[0].addEventListener("error", function() {
var fullUrl = $image.attr("href");
if (fullUrl) {
$image.css("background-image", "url('" + fullUrl + "')");
}
});
$image_img[0].addEventListener("load", function() {
EXIF.getData($image_img[0], function () {
exifDatas[$image_img.data('name')] = getExifDataMarkup(this);
@@ -282,6 +300,224 @@
return template;
}
// ==================== SELECTION MODE ====================
function initSelectionMode(allImages) {
$('#selection-toolbar').remove();
$('#select-mode-toggle').closest('li').remove();
$('.folder-download-btn').closest('li').remove();
var selectedImages = [];
var selectionActive = false;
// Toolbar erstellen (versteckt)
var $toolbar = $('<div id="selection-toolbar"></div>');
$toolbar.html(
'<span id="sel-count">0 ausgewaehlt</span>' +
'<button id="sel-download" class="button small" disabled>' +
'<i class="fa fa-download"></i> Herunterladen</button>' +
'<button id="sel-cancel" class="button small">' +
'<i class="fa fa-times"></i> Abbrechen</button>'
);
$('body').append($toolbar);
// Selection-Toggle Button im Header
var $selectToggle = $('<li><a href="#" id="select-mode-toggle" title="Bilder auswaehlen">' +
'<i class="fa fa-check-square"></i></a></li>');
$('#header nav > ul').prepend($selectToggle);
// Ordner-Download Buttons (nur wenn Ordner-Bilder vorhanden)
var folders = [];
allImages.forEach(function(img) {
if (img.folder && folders.indexOf(img.folder) === -1) {
folders.push(img.folder);
}
});
if (folders.length > 0) {
folders.forEach(function(folder) {
var $folderBtn = $('<li><a href="#" class="folder-download-btn" ' +
'data-folder="' + folder + '" title="Ordner \'' + folder + '\' herunterladen">' +
'<i class="fa fa-folder-open"></i></a></li>');
$('#header nav > ul').prepend($folderBtn);
});
}
// Selection-Mode toggle
$(document).on('click', '#select-mode-toggle', function(e) {
e.preventDefault();
e.stopPropagation();
selectionActive = !selectionActive;
$('body').toggleClass('selection-mode', selectionActive);
if (!selectionActive) clearSelection();
$toolbar.toggleClass('visible', selectionActive);
});
// Klick auf Thumbnail im Selection-Mode
// Capture-Phase auf document: faengt JEDEN Klick ab bevor Poptrox ihn sieht
document.addEventListener('click', function(e) {
if (!selectionActive) return;
var thumb = e.target.closest('#main .thumb');
if (!thumb) return;
e.preventDefault();
e.stopImmediatePropagation();
var $thumb = $(thumb);
var imgName = $thumb.attr('data-name');
var folder = $thumb.attr('data-folder') || null;
var idx = -1;
for (var i = 0; i < selectedImages.length; i++) {
if (selectedImages[i].name === imgName && selectedImages[i].folder === folder) {
idx = i; break;
}
}
if (idx !== -1) {
selectedImages.splice(idx, 1);
$thumb.removeClass('selected');
} else {
selectedImages.push({ name: imgName, folder: folder });
$thumb.addClass('selected');
}
updateToolbar();
}, true);
function updateToolbar() {
var count = selectedImages.length;
$('#sel-count').text(count + ' ausgewaehlt');
$('#sel-download').prop('disabled', count === 0);
if (count > 3) {
$('#sel-download').html('<i class="fa fa-file-archive-o"></i> Als ZIP (' + count + ')');
} else if (count > 0) {
$('#sel-download').html('<i class="fa fa-download"></i> Herunterladen (' + count + ')');
} else {
$('#sel-download').html('<i class="fa fa-download"></i> Herunterladen');
}
}
function clearSelection() {
selectedImages = [];
$('.thumb').removeClass('selected');
updateToolbar();
}
// Download-Button (direkt gebunden, nicht delegiert)
$toolbar.on('click', '#sel-download', function() {
if (selectedImages.length === 0) return;
var payload = JSON.stringify({ images: selectedImages });
if (selectedImages.length <= 3) {
// 1-3 Bilder: normales AJAX, Server gibt JSON mit URLs
$.ajax({
url: '/api/download/selected',
type: 'POST',
contentType: 'application/json',
data: payload,
success: function(resp) {
if (resp.mode === 'individual') {
resp.urls.forEach(function(url, i) {
setTimeout(function() {
var a = document.createElement('a');
a.href = url;
a.download = url.split('/').pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}, i * 500);
});
}
},
error: function(xhr) {
var msg = (xhr.responseJSON && xhr.responseJSON.error) || 'Download fehlgeschlagen';
alert(msg);
}
});
} else {
// 4+ Bilder: Blob-Download fuer ZIP
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/download/selected', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.responseType = 'blob';
xhr.onload = function() {
if (xhr.status === 200) {
var url = window.URL.createObjectURL(xhr.response);
var a = document.createElement('a');
a.href = url;
a.download = 'mischkomposition-auswahl.zip';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
// Fehler-Blob als Text lesen
var reader = new FileReader();
reader.onload = function() {
try {
var resp = JSON.parse(this.result);
alert(resp.error || 'Download fehlgeschlagen');
} catch(e) {
alert('Download fehlgeschlagen (Status ' + xhr.status + ')');
}
};
reader.readAsText(xhr.response);
}
};
xhr.onerror = function() {
alert('Netzwerkfehler beim Download');
};
xhr.send(payload);
}
});
// Abbrechen-Button (direkt gebunden)
$toolbar.on('click', '#sel-cancel', function() {
selectionActive = false;
$('body').removeClass('selection-mode');
$toolbar.removeClass('visible');
clearSelection();
});
// Ordner-Download
$(document).on('click', '.folder-download-btn', function(e) {
e.preventDefault();
e.stopPropagation();
var folder = $(this).data('folder');
var a = document.createElement('a');
a.href = '/api/download/folder/' + encodeURIComponent(folder);
a.download = folder + '.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
}
function loadAvailableFolders() {
$.get('/api/folders/available').done(function(folders) {
renderFolderPicker(folders || []);
});
}
function renderFolderPicker(folders) {
$('#folder-picker-wrap').remove();
if (!folders.length) return;
var $item = $('<li id="folder-picker-wrap"></li>');
var $select = $('<select id="folder-picker" title="Galerie-Ordner waehlen"></select>');
$select.append($('<option></option>').val('').text('Oeffentliche Galerie'));
folders.forEach(function(folder) {
var label = folder.name + ' (' + (folder.image_count || 0) + ')';
$select.append($('<option></option>').val(folder.name).text(label));
});
$item.append($select);
$('#header nav > ul').prepend($item);
$select.on('change', function(e) {
e.preventDefault();
loadGallery($(this).val() || null);
});
}
// Galerie laden
loadGallery();
@@ -306,10 +542,18 @@
}
// Login Handling
$.get('/api/auth/sso/config').done(function(config) {
if (config.enabled) {
var $ssoBtn = $('<li id="sso-login-li" style="margin-top: 10px; width: 100%; display: block;"><a href="/api/auth/sso/login" id="sso-login-btn" class="button primary" style="background:#8ee6d2 !important; color:#101316 !important; box-shadow:none !important; border-color:#8ee6d2 !important; font-weight:bold; width: 100%; text-align: center;"><i class="fa fa-key"></i> Login mit Keycloak</a></li>');
$('#login-form .actions').append($ssoBtn);
}
});
$.get('/api/auth/me').done(function(u) {
$('#login-username, #login-password, #login-submit').hide();
$('#login-username, #login-password, #login-submit, #sso-login-li').hide();
$('#logout-btn').show().css('display', 'inline-block');
$('#pw-change-section').show();
loadAvailableFolders();
if(u.role === 'admin') {
$('#admin-link').html('<a href="/admin.html" class="button">Admin-Dashboard</a>');
}

File diff suppressed because one or more lines are too long

View File

@@ -6,6 +6,150 @@
color: _palette(fg);
}
#folder-picker-wrap {
display: inline-flex;
align-items: center;
padding: 0 0.5em;
#folder-picker {
width: auto;
min-width: 13rem;
height: 2.35em;
margin: 0;
padding: 0 2.25em 0 0.8em;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.18);
color: _palette(fg-bold);
font-size: 0.78em;
text-transform: none;
letter-spacing: 0;
}
}
@media screen and (max-width: 736px) {
#folder-picker-wrap {
max-width: 48vw;
padding: 0 0.35em;
#folder-picker {
min-width: 0;
width: 100%;
font-size: 0.74rem;
}
}
}
/* ==================== Selection Mode ==================== */
#selection-toolbar {
position: fixed;
top: 0;
left: 0;
width: 100%;
background: rgba(31, 34, 36, 0.95);
padding: 0.75em 1.5em;
display: flex;
align-items: center;
gap: 1em;
z-index: _misc(z-index-base) + 3;
transform: translateY(-100%);
transition: transform 0.3s ease;
box-sizing: border-box;
&.visible {
transform: translateY(0);
}
#sel-count {
color: _palette(fg-bold);
font-weight: _font(weight-extrabold);
margin-right: auto;
white-space: nowrap;
}
button {
white-space: nowrap;
}
}
body.selection-mode {
#main .thumb {
cursor: pointer;
position: relative;
// Checkbox unten-rechts (wird nicht von Toolbar ueberlagert)
&::before {
content: '';
position: absolute;
bottom: 0.6em;
right: 0.6em;
width: 1.6em;
height: 1.6em;
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: 4px;
background: rgba(0, 0, 0, 0.5);
z-index: 2;
transition: all 0.2s ease;
pointer-events: none;
}
// Leichtes Overlay damit klar ist: gesamtes Bild anklickbar
.image {
opacity: 0.85;
transition: opacity 0.2s ease;
}
&:active .image {
opacity: 0.6;
}
&.selected {
&::before {
background: _palette(accent1);
border-color: _palette(accent1);
}
.image {
opacity: 1;
outline: 3px solid _palette(accent1);
outline-offset: -3px;
}
}
}
}
@media screen and (max-width: 736px) {
#selection-toolbar {
top: _size(header);
padding: 0.6em 1em;
gap: 0.6em;
#sel-count {
font-size: 0.9em;
}
button {
font-size: 0.85em;
padding: 0.5em 1em;
}
}
body.selection-mode #main .thumb {
&::before {
bottom: 0.3em;
right: 0.3em;
width: 2em;
height: 2em;
border-width: 2.5px;
}
&.selected .image {
outline-width: 4px;
}
}
}
/* Footer */
#footer {
.presentify {

View File

@@ -110,15 +110,25 @@
}
h1 {
font-size: 0.9em;
font-size: 0.8em;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
nav {
> ul {
> li {
a {
font-size: 0.9em;
padding: 0 1.15em;
font-size: 0;
padding: 0 0.9em;
line-height: _size(header);
i {
font-size: 1.3rem;
vertical-align: middle;
}
}
}
}

View File

@@ -27,10 +27,10 @@
minHeight: 18em
),
xsmall: (
rows: 2,
columns: 1,
rows: 3,
columns: 2,
pad: 0.5,
minHeight: 18em
minHeight: 12em
)
)
);

View File

@@ -7,44 +7,16 @@ 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 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,
FOREIGN KEY(user_id) REFERENCES users(id),
UNIQUE(user_id, image_name)
)`);
// Initialen Admin nur mit expliziten Credentials aus der Umgebung anlegen.
db.serialize(() => {
function bootstrapInitialAdmin() {
const username = process.env.INITIAL_ADMIN_USERNAME;
const password = process.env.INITIAL_ADMIN_PASSWORD;
const passwordHash = process.env.INITIAL_ADMIN_PASSWORD_HASH || (password ? bcrypt.hashSync(password, 10) : null);
if (!username && !passwordHash) {
return;
}
if (!username || !passwordHash) {
console.warn('Set INITIAL_ADMIN_USERNAME with INITIAL_ADMIN_PASSWORD or INITIAL_ADMIN_PASSWORD_HASH to bootstrap an admin.');
console.warn('Set INITIAL_ADMIN_USERNAME together with INITIAL_ADMIN_PASSWORD_HASH to bootstrap an admin.');
return;
}
@@ -73,10 +45,94 @@ db.serialize(() => {
return;
}
console.log(`Initial admin promoted: ${username}`);
});
});
}
function runMigrations(migrations, index = 0) {
if (index >= migrations.length) {
bootstrapInitialAdmin();
return;
}
);
migrations[index](() => runMigrations(migrations, index + 1));
}
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 DEFAULT 'client',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Migration: Spalten hinzufuegen falls Tabelle schon existiert (alte DB ohne role/created_at).
// SQLite erlaubt CURRENT_TIMESTAMP nicht als DEFAULT bei ALTER TABLE, deshalb wird created_at
// ohne Default angelegt und danach fuer bestehende Nutzer befuellt.
db.all(`PRAGMA table_info(users)`, (err, columns) => {
if (err) {
console.error('User-Migration fehlgeschlagen:', err.message);
return;
}
const columnNames = new Set(columns.map((column) => column.name));
const migrations = [];
if (!columnNames.has('role')) {
migrations.push((done) => db.run(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'client'`, (alterErr) => {
if (alterErr) {
console.error('Migration role fehlgeschlagen:', alterErr.message);
done();
return;
}
// Alte Admins (die einzigen User vor dem Update) als admin markieren.
db.run(`UPDATE users SET role = 'admin' WHERE id = 1`, () => {
console.log('Migration: role-Spalte hinzugefuegt, User ID 1 als admin gesetzt');
done();
});
}));
}
if (!columnNames.has('created_at')) {
migrations.push((done) => db.run(`ALTER TABLE users ADD COLUMN created_at DATETIME`, (alterErr) => {
if (alterErr) {
console.error('Migration created_at fehlgeschlagen:', alterErr.message);
done();
return;
}
db.run(`UPDATE users SET created_at = datetime('now') WHERE created_at IS NULL`, () => {
console.log('Migration: created_at-Spalte hinzugefuegt');
done();
});
}));
} else {
migrations.push((done) => db.run(`UPDATE users SET created_at = datetime('now') WHERE created_at IS NULL`, done));
}
runMigrations(migrations);
});
// 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,
FOREIGN KEY(user_id) REFERENCES users(id),
UNIQUE(user_id, image_name)
)`);
});
module.exports = db;

View File

@@ -4,8 +4,10 @@ const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const archiver = require('archiver');
const db = require('./database');
const crypto = require('crypto');
const { Issuer } = require('openid-client');
const app = express();
const JWT_SECRET = process.env.JWT_SECRET;
@@ -19,10 +21,7 @@ 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 SYNC_IMAGE = process.env.SYNC_IMAGE || 'ghcr.io/kroonk/photography:latest';
const SYNC_CONTAINER = process.env.SYNC_CONTAINER || 'photography-website';
const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i;
let syncInProgress = false;
console.log('Using full-size images at', FULLS_DIR);
console.log('Using thumbnails at', THUMBS_DIR);
@@ -89,6 +88,18 @@ function listSubfolders(dir) {
}
}
function getFolderInfo(folder) {
const fullPath = path.join(FULLS_DIR, folder);
const thumbPath = path.join(THUMBS_DIR, folder);
const images = listImagesInDir(fullPath);
return {
name: folder,
image_count: images.length,
thumb_count: listImagesInDir(thumbPath).length,
sample: images[0] || null
};
}
function isSafePath(baseDir, requestedPath) {
const resolvedBase = path.resolve(baseDir);
const resolvedPath = path.resolve(resolvedBase, requestedPath);
@@ -138,12 +149,6 @@ function serveProtectedImage(req, res) {
});
}
function runDocker(args, callback) {
execFile('docker', args, { timeout: 5 * 60 * 1000 }, (err, stdout, stderr) => {
callback(err, `${stdout || ''}${stderr || ''}`.trim());
});
}
// ==================== AUTH API ====================
app.post('/api/auth/login', (req, res) => {
@@ -180,6 +185,116 @@ app.get('/api/auth/me', (req, res) => {
res.json(req.user);
});
// ==================== KEYCLOAK SSO API ====================
let oidcClient = null;
async function getOidcClient() {
if (oidcClient) return oidcClient;
const ssoAuthority = process.env.SSO_AUTHORITY || 'https://auth.mischlabs.de/realms/mischlabs';
const ssoClientId = process.env.SSO_CLIENT_ID || 'pocketbase';
const ssoClientSecret = process.env.SSO_CLIENT_SECRET;
const ssoRedirectUri = process.env.SSO_REDIRECT_URI || 'https://tom.mischlabs.de/api/auth/sso/callback';
if (!ssoClientSecret) {
throw new Error('SSO_CLIENT_SECRET is not configured');
}
const issuer = await Issuer.discover(ssoAuthority);
oidcClient = new issuer.Client({
client_id: ssoClientId,
client_secret: ssoClientSecret,
redirect_uris: [ssoRedirectUri],
response_types: ['code'],
});
return oidcClient;
}
app.get('/api/auth/sso/config', (req, res) => {
res.json({
enabled: !!(process.env.SSO_CLIENT_SECRET && process.env.SSO_CLIENT_ID && process.env.SSO_AUTHORITY),
});
});
app.get('/api/auth/sso/login', async (req, res) => {
try {
const client = await getOidcClient();
const authorizationUrl = client.authorizationUrl({
scope: 'openid email profile',
state: 'mischlabs-state',
});
res.redirect(authorizationUrl);
} catch (err) {
console.error('SSO Login Error:', err);
res.status(500).send('SSO Login Initialisierung fehlgeschlagen: ' + err.message);
}
});
app.get('/api/auth/sso/callback', async (req, res) => {
try {
const client = await getOidcClient();
const params = client.callbackParams(req);
const redirectUri = process.env.SSO_REDIRECT_URI || 'https://tom.mischlabs.de/api/auth/sso/callback';
const tokenSet = await client.callback(redirectUri, params, { state: 'mischlabs-state' });
const userinfo = await client.userinfo(tokenSet.access_token);
const username = userinfo.preferred_username || userinfo.name || userinfo.sub;
const email = userinfo.email;
if (!username) {
return res.status(400).send('Kein Benutzername im OIDC Token gefunden.');
}
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
if (err) return res.status(500).send('Datenbankfehler');
const handleUserLogin = (dbUser) => {
const token = jwt.sign(
{ id: dbUser.id, username: dbUser.username, role: dbUser.role, created_at: dbUser.created_at },
JWT_SECRET, { expiresIn: '7d' }
);
res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
res.redirect('/admin.html');
};
if (user) {
handleUserLogin(user);
} else {
const randomPassword = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(randomPassword, 10);
db.get('SELECT COUNT(*) as count FROM users', [], (countErr, row) => {
let role = 'client';
if ((row && row.count === 0) || username.toLowerCase() === 'mrdiderot') {
role = 'admin';
}
db.run(
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
[username, hash, role],
function (insertErr) {
if (insertErr) {
console.error('Failed JIT provisioning:', insertErr.message);
return res.status(500).send('Fehler bei der Benutzererstellung');
}
const newUserId = this.lastID;
db.get('SELECT * FROM users WHERE id = ?', [newUserId], (getErr, newUser) => {
if (getErr || !newUser) return res.status(500).send('Fehler beim Laden des neuen Benutzers');
handleUserLogin(newUser);
});
}
);
});
}
});
} catch (err) {
console.error('SSO Callback Error:', err);
res.status(500).send('SSO Authentifizierung fehlgeschlagen: ' + err.message);
}
});
app.post('/api/auth/change-password', requireAuth, (req, res) => {
const { old_password, new_password } = req.body;
if (!new_password || new_password.length < 4) {
@@ -205,29 +320,52 @@ app.get('/images/fulls/', (req, res) => {
const rootImages = listImagesInDir(FULLS_DIR).map(name => ({
name, type: 'file', folder: null
}));
const requestedFolder = req.query.folder;
if (requestedFolder) {
if (!isValidName(requestedFolder)) {
return res.status(400).json({ error: 'Ungueltiger Ordnername' });
}
if (!req.user) {
return res.status(401).json({ error: 'Nicht eingeloggt' });
}
const sendFolderImages = () => {
const folderPath = path.join(FULLS_DIR, requestedFolder);
if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
return res.status(404).json({ error: 'Ordner nicht gefunden' });
}
return res.json(listImagesInDir(folderPath).map(name => ({
name, type: 'file', folder: requestedFolder
})));
};
if (req.user.role === 'admin') {
return sendFolderImages();
}
return db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
[req.user.id, requestedFolder], (err, row) => {
if (err || !row) return res.status(403).json({ error: 'Kein Zugriff auf diesen Ordner' });
return sendFolderImages();
});
}
// Nicht eingeloggt: nur Root-Bilder
if (!req.user) return res.json(rootImages);
const folders = listSubfolders(FULLS_DIR);
// Admin: Nur Root-Bilder (damit die Startseite sauber bleibt)
if (req.user.role === 'admin') {
// Eingeloggt: Startseite bleibt die normale oeffentliche Galerie.
return res.json(rootImages);
}
});
// User/Client: NUR zugewiesene Ordner (keine Root-Bilder untermischen)
app.get('/api/folders/available', requireAuth, (req, res) => {
const folders = listSubfolders(FULLS_DIR);
if (req.user.role === 'admin') {
return res.json(folders.map(getFolderInfo));
}
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 = [];
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);
return res.json(assignedFolders.filter(folder => folders.includes(folder)).map(getFolderInfo));
});
});
@@ -235,7 +373,23 @@ app.get('/images/fulls/', (req, res) => {
app.get('/api/admin/users', requireAdmin, (req, res) => {
db.all('SELECT id, username, role, created_at FROM users', (err, rows) => {
if (err) return res.json([]);
if (err) {
console.error('User-Abfrage fehlgeschlagen:', err.message);
// Fallback: einfache Abfrage ohne neue Spalten
return db.all('SELECT * FROM users', (err2, rows2) => {
if (err2) return res.status(500).json({ error: 'DB Error: ' + err2.message });
res.json(rows2.map(u => ({ id: u.id, username: u.username, role: u.role || 'client', created_at: u.created_at || null })));
});
}
db.all('SELECT user_id, folder_name FROM user_folders ORDER BY folder_name', (folderErr, folderRows) => {
const foldersByUser = {};
if (!folderErr && folderRows) {
folderRows.forEach(row => {
if (!foldersByUser[row.user_id]) foldersByUser[row.user_id] = [];
foldersByUser[row.user_id].push(row.folder_name);
});
}
// Ablaufdatum berechnen fuer Clients
const result = rows.map(u => {
const out = { ...u };
@@ -245,10 +399,12 @@ app.get('/api/admin/users', requireAdmin, (req, res) => {
out.expires_at = expires.toISOString();
out.expired = new Date() > expires;
}
out.folders = foldersByUser[u.id] || [];
return out;
});
res.json(result);
});
});
});
app.post('/api/admin/users', requireAdmin, (req, res) => {
@@ -290,7 +446,49 @@ app.put('/api/admin/users/:id/password', requireAdmin, (req, res) => {
// Ordner-Verwaltung
app.get('/api/admin/folders', requireAdmin, (req, res) => {
res.json(listSubfolders(FULLS_DIR));
const folders = listSubfolders(FULLS_DIR).map(getFolderInfo);
db.all(
`SELECT uf.folder_name, u.id, u.username, u.role
FROM user_folders uf
JOIN users u ON u.id = uf.user_id
ORDER BY uf.folder_name, u.username`,
(err, rows) => {
const assignedByFolder = {};
if (!err && rows) {
rows.forEach(row => {
if (!assignedByFolder[row.folder_name]) assignedByFolder[row.folder_name] = [];
assignedByFolder[row.folder_name].push({
id: row.id,
username: row.username,
role: row.role || 'client'
});
});
}
res.json(folders.map(folder => ({
...folder,
assigned_users: assignedByFolder[folder.name] || [],
private: true
})));
}
);
});
app.get('/api/admin/folders/:folderName/images', requireAdmin, (req, res) => {
const folderName = req.params.folderName;
if (!isValidName(folderName)) {
return res.status(400).json({ error: 'Ungueltiger Ordnername' });
}
const folderPath = path.join(FULLS_DIR, folderName);
if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
return res.status(404).json({ error: 'Ordner nicht gefunden' });
}
res.json(listImagesInDir(folderPath).map(name => ({
name,
folder: folderName,
full_url: '/images/fulls/' + encodeURIComponent(folderName) + '/' + encodeURIComponent(name),
thumb_url: '/images/thumbs/' + encodeURIComponent(folderName) + '/' + encodeURIComponent(name)
})));
});
app.get('/api/admin/assign', requireAdmin, (req, res) => {
@@ -309,7 +507,10 @@ app.get('/api/admin/assign', requireAdmin, (req, res) => {
app.post('/api/admin/assign', requireAdmin, (req, res) => {
const { user_id, folders } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id erforderlich' });
const folderList = Array.isArray(folders) ? folders : [];
const existingFolders = listSubfolders(FULLS_DIR);
const folderList = Array.isArray(folders)
? folders.filter(name => isValidName(name) && existingFolders.includes(name))
: [];
db.serialize(() => {
db.run('DELETE FROM user_folders WHERE user_id = ?', [user_id]);
const stmt = db.prepare('INSERT INTO user_folders (user_id, folder_name) VALUES (?, ?)');
@@ -319,41 +520,132 @@ app.post('/api/admin/assign', requireAdmin, (req, res) => {
});
});
app.post('/api/admin/sync', requireAdmin, (req, res) => {
if (syncInProgress) {
return res.status(409).json({ error: 'Sync laeuft bereits' });
// ==================== DOWNLOAD API ====================
function isValidName(name) {
return name && !name.includes('/') && !name.includes('\\') && !name.includes('..');
}
app.post('/api/download/selected', (req, res) => {
const { images } = req.body;
if (!Array.isArray(images) || images.length === 0) {
return res.status(400).json({ error: 'Keine Bilder ausgewaehlt' });
}
syncInProgress = true;
runDocker(['pull', SYNC_IMAGE], (pullErr, pullOutput) => {
syncInProgress = false;
// Alle Bilder validieren und Zugriff pruefen
let pending = images.length;
const verified = [];
let responded = false;
if (pullErr) {
console.error('Sync pull failed:', pullOutput || pullErr.message);
return res.status(500).json({
error: 'Docker pull fehlgeschlagen',
details: pullOutput || pullErr.message
});
images.forEach((img, idx) => {
if (responded) return;
const name = img.name;
const folder = img.folder || null;
// Dateiname + Ordnername validieren
if (!isValidName(name) || !IMAGE_RE.test(name) || (folder && !isValidName(folder))) {
responded = true;
return res.status(400).json({ error: 'Ungueltiger Dateiname' });
}
res.json({
success: true,
message: 'Neues Image geladen. Container startet neu.',
output: pullOutput
const checkAccess = (ok) => {
if (responded) return;
if (!ok) {
responded = true;
return res.status(403).json({ error: 'Kein Zugriff auf: ' + name });
}
verified[idx] = img;
pending--;
if (pending === 0) sendFiles();
};
if (!folder) {
// Root-Bild: fuer alle zugaenglich
const filePath = path.join(FULLS_DIR, name);
checkAccess(fs.existsSync(filePath));
} else {
// Ordner-Bild: nur wenn User den Ordner zugewiesen hat
if (!req.user) return checkAccess(false);
if (req.user.role === 'admin') {
const filePath = path.join(FULLS_DIR, folder, name);
return checkAccess(fs.existsSync(filePath));
}
db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
[req.user.id, folder], (err, row) => {
if (err || !row) return checkAccess(false);
const filePath = path.join(FULLS_DIR, folder, name);
checkAccess(fs.existsSync(filePath));
});
}
});
setTimeout(() => {
runDocker(['restart', SYNC_CONTAINER], (restartErr, restartOutput) => {
if (restartErr) {
console.error('Sync restart failed:', restartOutput || restartErr.message);
return;
}
console.log('Sync restart complete:', restartOutput);
function sendFiles() {
if (verified.length <= 3) {
// Einzelne Downloads: URLs zurueckgeben
const urls = verified.map(img => {
const prefix = img.folder ? encodeURIComponent(img.folder) + '/' : '';
return '/images/fulls/' + prefix + encodeURIComponent(img.name);
});
}, 500);
return res.json({ mode: 'individual', urls });
}
// 4+ Bilder: ZIP streamen
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="mischkomposition-auswahl.zip"');
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', () => {
if (!res.headersSent) res.status(500).json({ error: 'ZIP-Fehler' });
});
archive.pipe(res);
verified.forEach(img => {
const filePath = img.folder
? path.join(FULLS_DIR, img.folder, img.name)
: path.join(FULLS_DIR, img.name);
archive.file(filePath, { name: img.name });
});
archive.finalize();
}
});
app.get('/api/download/folder/:folderName', (req, res) => {
const folderName = req.params.folderName;
if (!isValidName(folderName)) {
return res.status(400).json({ error: 'Ungueltiger Ordnername' });
}
if (!req.user) {
return res.status(401).json({ error: 'Nicht eingeloggt' });
}
if (req.user.role === 'admin') {
return sendFolderDownload(folderName, res);
}
db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
[req.user.id, folderName], (err, row) => {
if (err || !row) {
return res.status(403).json({ error: 'Kein Zugriff auf diesen Ordner' });
}
return sendFolderDownload(folderName, res);
});
});
function sendFolderDownload(folderName, res) {
const folderPath = path.join(FULLS_DIR, folderName);
const images = listImagesInDir(folderPath);
if (images.length === 0) {
return res.status(404).json({ error: 'Keine Bilder im Ordner' });
}
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(folderName) + '.zip"');
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', () => {
if (!res.headersSent) res.status(500).json({ error: 'ZIP-Fehler' });
});
archive.pipe(res);
images.forEach(name => {
archive.file(path.join(folderPath, name), { name });
});
archive.finalize();
}
// ==================== STATIC FILES ====================
app.get(/^\/images\/(fulls|thumbs)\/(.+)$/, serveProtectedImage);

210
brain.old Normal file
View File

@@ -0,0 +1,210 @@
# Brain.md - Projekt-Wissensdatenbank
## Projektziel
Eine eigene Fotografie-Portfolio-Website basierend auf dem Template von rampatra/photography.
Keine 1:1 Kopie, sondern eine individuell angepasste Version mit eigenen Anspruechen.
## Besitzer & Kontakt
- **Name**: Tom Misch
- **Marke**: Mischkomposition
- **Domain**: tom.mischlabs.de
- **E-Mail**: tom@mischlabs.de
- **Instagram**: @Mischkomposition
- **Gitea Repo**: https://git.mischlabs.de/MrDiderot/Photography.git
- **Lokaler Pfad**: d:\Vibecoding\Website\Photography
---
## Technologie-Stack
| Komponente | Technologie |
|---|---|
| Static Site Generator | **Jekyll** (Ruby-basiert) |
| Backend | **Node.js / Express** (Auth, API, Static Serving) |
| Datenbank | **SQLite** (Nutzerverwaltung, Bildzuweisung) |
| Templating | Liquid Templates |
| Styling | SCSS -> kompiliertes CSS |
| JS Libraries | jQuery, Poptrox (Lightbox), EXIF.js |
| Build-Tool | Gulp (SCSS kompilieren, JS minifizieren, Bilder resizen) |
| Hosting | **UGREEN NAS (Docker)** via Node.js Express |
| Containerisierung | Docker Multi-Stage (Jekyll Build + Node.js Backend) |
| Auto-Update | **Watchtower** (prueft alle 5 Min auf neue Images) |
| Fonts | FontAwesome, Google Fonts (Source Sans Pro) |
| Paketmanager | npm (Node), Bundler (Ruby) |
## Projektstruktur
```
Photography/
├── _config.yml # Zentrale Konfiguration (Titel, Social Links, EXIF etc.)
├── _layouts/
│ └── default.html # Basis-Layout (Head + Body + Scripts)
├── _includes/
│ ├── header.html # <head> Bereich: CSS, Fonts
│ └── footer.html # Script-Einbindungen (jQuery, Poptrox, EXIF, Main)
├── index.html # Hauptseite: Header, Galerie-Grid, Footer/About, Login-Panel
├── admin.html # Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
├── backend/
│ ├── server.js # Express Backend (Auth, API, Static Files, Port 8090)
│ └── database.js # SQLite Datenbank-Setup (Users, User-Images)
├── assets/
│ ├── css/ # Kompilierte CSS-Dateien (.min.css)
│ ├── sass/ # SCSS Quelldateien
│ │ ├── base/ # Reset, Typography, Page
│ │ ├── components/ # Buttons, Forms, Icons, Panels, Poptrox
│ │ ├── layout/ # Header, Footer, Main, Wrapper
│ │ └── libs/ # Breakpoints, Mixins, Vars, Functions
│ ├── js/ # JavaScript (jQuery, Poptrox, EXIF, Main, Admin)
│ ├── fonts/ # FontAwesome Fonts
│ └── webfonts/ # FA5 Webfonts
├── images/
│ ├── fulls/ # Vollaufloesung Bilder
│ └── thumbs/ # Thumbnails (512px)
├── gulpfile.mjs # Build: SCSS->CSS, JS minify, Image resize
├── package.json # Node Dependencies (Express, SQLite, JWT, bcrypt)
├── Gemfile # Ruby Dependencies (jekyll)
├── Dockerfile # Multi-Stage: Jekyll Build + Node.js Runtime
├── docker-compose.yml # Container + Watchtower
├── entrypoint.sh # Thumbnail-Generierung + Node.js Start
└── nginx.conf # (Legacy, nicht mehr aktiv - Express dient statische Dateien)
```
## Wie die Galerie funktioniert (Dynamisch)
1. **Bilder hochladen**: Fotos in Nextcloud-Ordner `Websitebilder` ablegen
2. **Volume-Mount**: Docker mountet den Nextcloud-Ordner als `/app/public/images/fulls/` (read-only)
3. **Thumbnails**: `entrypoint.sh` generiert automatisch 512px Thumbnails via ImageMagick
4. **Thumbnail-Check**: Alle 60 Sekunden prueft der Container auf neue Bilder
5. **Express API**: `/images/fulls/` liefert JSON-Liste der Bilddateien
6. **JavaScript (main.js)**: Fetcht die JSON-Liste und baut dynamisch `<article class="thumb">` Elemente
7. **Poptrox-Lightbox**: Klick oeffnet Vollbild + EXIF-Daten (clientseitig via exif.js)
8. **Download-Button**: In der Lightbox kann jedes Bild heruntergeladen werden
### Zugriffslogik
- **Nicht eingeloggt**: Nur Root-Bilder (direkt in Websitebilder/) sichtbar
- **Admin**: Alle Bilder (Root + alle Unterordner) sichtbar
- **User (permanent)**: Root-Bilder + zugewiesene Unterordner
- **Client (30 Tage)**: Root-Bilder + zugewiesene Unterordner, Account laeuft nach 30 Tagen ab
### Nextcloud-Integration
- **Nextcloud-Pfad**: `/volume1/nextcloud/data/Tom/files/Websitebilder`
- **Mount im Container**: `/app/public/images/fulls:ro`
- **Thumbnails-Volume**: Docker-Volume `thumbs` (persistent)
- **Datenbank-Volume**: Docker-Volume `database` (persistent, SQLite)
- **Workflow**: Bild in Nextcloud hochladen -> max. 60s warten -> Seite neu laden -> fertig
## Login-System & Nutzerverwaltung
### Architektur
- **Backend**: Node.js/Express auf Port 8090
- **Auth**: JWT-Token als httpOnly Cookie (7 Tage gueltig)
- **Datenbank**: SQLite unter `/app/data/database.sqlite`
- **Passwort-Hashing**: bcryptjs (10 Rounds)
### Rollen
| Rolle | Beschreibung | Ablauf |
|---|---|---|
| `admin` | Voller Zugriff, Nutzerverwaltung, alle Bilder | Nie |
| `user` | Root-Bilder + zugewiesene Ordner | Nie |
| `client` | Root-Bilder + zugewiesene Ordner | 30 Tage nach Erstellung |
### Ordner-basierte Bildzuweisung
- Bilder direkt in `Websitebilder/` = oeffentlich (fuer alle sichtbar)
- Bilder in Unterordnern (z.B. `Websitebilder/Hochzeit/`) = nur fuer zugewiesene Nutzer
- Zuweisung erfolgt auf Ordner-Ebene (nicht einzelne Bilder)
- Tabelle `user_folders` speichert Zuweisungen (user_id, folder_name)
### Initialer Admin
- Es wird kein festes Standardkonto mehr angelegt.
- Bestehende Installationen behalten Nutzer und Rollen im persistenten SQLite-Volume.
- Fuer frische Installationen kann einmalig `INITIAL_ADMIN_USERNAME=MrDiderot` zusammen mit `INITIAL_ADMIN_PASSWORD_HASH` gesetzt werden.
### API-Endpunkte
| Endpunkt | Methode | Beschreibung |
|---|---|---|
| `/api/auth/login` | POST | Login (username, password) |
| `/api/auth/logout` | POST | Logout (Cookie loeschen) |
| `/api/auth/me` | GET | Aktueller User |
| `/api/auth/change-password` | POST | Eigenes Passwort aendern |
| `/api/admin/users` | GET/POST | Nutzer auflisten / erstellen (mit Rolle) |
| `/api/admin/users/:id` | DELETE | Nutzer loeschen (nicht sich selbst) |
| `/api/admin/users/:id/password` | PUT | Admin aendert User-Passwort |
| `/api/admin/folders` | GET | Verfuegbare Unterordner auflisten |
| `/api/admin/assign` | GET/POST | Ordner-Zuweisungen anzeigen / setzen |
| `/images/fulls/` | GET | Bilderliste (gefiltert nach User-Rolle + Ordnern) |
## Wichtige Konfiguration (_config.yml)
- `baseurl`: URL der Website
- `title`, `subtitle`, `author`: Seitentitel und Autor
- `header.title/subtitle`: Header-Anzeige
- `footer.name/bio`: Footer-Infos
- `social_urls`: Links zu Social Media
- `exif`: Welche EXIF-Tags angezeigt werden (Model, FNumber, ExposureTime, ISO)
## Build-Befehle
| Befehl | Aktion |
|---|---|
| `bundle install` | Ruby Dependencies installieren |
| `bundle exec jekyll serve` | Lokaler Dev-Server (nur Frontend) |
| `npm install` | Node Dependencies installieren |
| `gulp build` | SCSS kompilieren + JS minifizieren |
| `gulp resize` | Bilder zu fulls (1024px) + thumbs (512px) |
| `gulp` | Alles (build + resize) |
## Docker / NAS Hosting
- **NAS**: UGREEN mit UGOS PRO
- **Port**: 8090 (intern und extern)
- **Domain**: tom.mischlabs.de (via Cloudflare Tunnel)
### Docker-Dateien
- `Dockerfile` - Multi-Stage: Ruby/Jekyll baut Site -> Node.js Express serviert alles
- `docker-compose.yml` - Photography Container + Watchtower, Volumes fuer Bilder/Thumbs/DB
- `entrypoint.sh` - Thumbnail-Generierung + Node.js Server-Start
- `.dockerignore` - Haelt Build-Context klein (kein .git, node_modules etc.)
### Docker-Befehle (auf NAS via SSH)
| Befehl | Aktion |
|---|---|
| `sudo docker compose up -d` | Container starten |
| `sudo docker compose down` | Container stoppen |
| `sudo docker compose logs -f photography` | Logs anzeigen |
| `sudo docker compose pull && sudo docker compose up -d` | Manuelles Update |
### Deployment-Workflow (automatisch!)
1. Aenderungen lokal machen (Code, Design)
2. `gulp build` ausfuehren (JS + CSS minifizieren)
3. Git commit & push nach master
4. Gitea Actions baut automatisch Docker Image -> lokales Gitea Container Registry
5. Watchtower auf NAS erkennt neues Image (alle 5 Min)
6. Watchtower aktualisiert Container automatisch
7. Fertig - Website aktualisiert ohne SSH!
## Anpassungs-Roadmap
- [x] Titel, Subtitle, Autor anpassen (Mischkomposition / Tom Misch)
- [x] Social Media Links aktualisieren (Instagram: @Mischkomposition)
- [x] Sponsor-Bereich entfernen
- [x] Kontaktformular auf tom@mischlabs.de konfiguriert (Formsubmit.co)
- [x] Google Analytics vom Originalautor entfernt
- [x] Custom Domain: tom.mischlabs.de eingetragen
- [x] Nextcloud-Integration (Bilder aus Websitebilder-Ordner)
- [x] Dynamische Galerie (JS statt Jekyll-Loop)
- [x] Automatische Thumbnail-Generierung
- [x] Login-System mit JWT + SQLite
- [x] Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
- [x] Kunden-Portal (zugewiesene Bilder + Download)
- [x] Watchtower fuer automatische Updates
- [x] 3-Rollen-System (admin/user/client mit 30-Tage-Ablauf)
- [x] Ordner-basierte Bildzuweisung (Unterordner = private Kundenbilder)
- [x] Passwort-Aenderung (Self-Service + Admin kann fuer andere aendern)
- [ ] Footer Bio-Text schreiben (optional)
- [ ] Farbschema/Design anpassen
- [ ] Eigene Kategorien/Alben erstellen (Feature-Erweiterung)
- [ ] Admin-Passwort aendern!
---
*Letzte Aktualisierung: 2026-04-12*

View File

@@ -6,12 +6,12 @@ services:
- "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
- SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest
- SYNC_CONTAINER=photography-website
- SSO_AUTHORITY=${SSO_AUTHORITY:-https://auth.mischlabs.de/realms/mischlabs}
- SSO_CLIENT_ID=${SSO_CLIENT_ID:-pocketbase}
- SSO_CLIENT_SECRET=${SSO_CLIENT_SECRET:-}
- SSO_REDIRECT_URI=${SSO_REDIRECT_URI:-https://tom.mischlabs.de/api/auth/sso/callback}
volumes:
# Nextcloud-Ordner mit Originalbildern (read-only)
- /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro
@@ -19,25 +19,19 @@ services:
- thumbs:/app/public/images/thumbs
# SQLite Database (persistent)
- database:/app/data
# Zugriff auf Docker & Credentials fuer den One-Click "Website Aktualisieren" Button
- /var/run/docker.sock:/var/run/docker.sock
- ~/.docker/config.json:/root/.docker/config.json:ro
restart: unless-stopped
# Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert den Container
# Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert Container
watchtower:
image: containrrr/watchtower
container_name: watchtower
# Nur unsere Webseite updaten, andere UGREEN Container ignorieren!
command: photography-website
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Registry Login von deinem NAS uebernehmen, um private Images lesen zu duerfen
- ~/.docker/config.json:/config.json:ro
- /volume2/docker/watchtower/config.json:/config.json:ro
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_POLL_INTERVAL=300
- WATCHTOWER_LABEL_ENABLE=false
restart: unless-stopped
volumes:

View File

@@ -3,10 +3,21 @@
FULLS_DIR="/app/public/images/fulls"
THUMBS_DIR="/app/public/images/thumbs"
# ImageMagick 7 (Alpine) nutzt "magick" statt "convert"
if command -v magick >/dev/null 2>&1; then
IM_CMD="magick"
elif command -v convert >/dev/null 2>&1; then
IM_CMD="convert"
else
echo "FEHLER: Weder magick noch convert gefunden! Thumbnails koennen nicht erzeugt werden."
IM_CMD=""
fi
# Thumbnails fuer einen Quell- und Zielordner generieren
generate_thumbs_for_dir() {
src_dir="$1"
dst_dir="$2"
[ -z "$IM_CMD" ] && return
mkdir -p "$dst_dir"
for img in "$src_dir"/*; do
[ -f "$img" ] || continue
@@ -21,7 +32,10 @@ generate_thumbs_for_dir() {
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
$IM_CMD "$img" -resize 512x512 -quality 80 "$dst_dir/$filename"
if [ $? -ne 0 ]; then
echo "WARNUNG: Thumbnail fehlgeschlagen fuer $filename"
fi
fi
done
}

View File

@@ -11,7 +11,6 @@ layout: default
<nav>
<ul>
<li><a href="#" id="theme-toggle" title="Theme wechseln"><i class="fa fa-sun"></i></a></li>
<li><a href="#galerie" id="galerie-link">Galerie</a></li>
<li><a href="#login-panel" id="login-nav">Login&nbsp;&nbsp;<i class="fa fa-user"></i></a></li>
<li><a href="#footer">About&nbsp;&nbsp;<i class="fa fa-info-circle text-fg"></i></a></li>
</ul>

1251
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
},
"repository": {
"type": "git",
"url": "git+https://github.com/Kroonk/Photography.git"
"url": "git+https://git.mischlabs.de/MrDiderot/Photography.git"
},
"keywords": [
"photography",
@@ -18,14 +18,16 @@
"author": "Tom Misch",
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/Kroonk/Photography/issues"
"url": "https://git.mischlabs.de/MrDiderot/Photography/issues"
},
"homepage": "https://github.com/Kroonk/Photography#readme",
"homepage": "https://tom.mischlabs.de",
"dependencies": {
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"openid-client": "^5.6.0",
"sqlite3": "^5.1.7"
},
"devDependencies": {