Compare commits

...

45 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
Tom Misch
0efe3066fc Reduce Docker image layer size
All checks were successful
Build & Push Docker Image / build-and-push (push) Successful in 1m52s
2026-05-11 11:17:24 +02:00
Tom Misch
6f879f4d48 Use existing registry user secret
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 6m45s
2026-05-11 11:08:36 +02:00
Tom Misch
9512650a83 Use registry token for image push
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 13s
2026-05-11 11:05:09 +02:00
Tom Misch
9a2a8dca9f Use sh shell for Gitea workflow
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 16s
2026-05-11 11:02:32 +02:00
Tom Misch
cb5c0f2581 Install Docker CLI in build workflow
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 27s
2026-05-11 11:00:00 +02:00
Tom Misch
e2c83f5fe4 Use Mischlabs container registry
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 13s
2026-05-11 10:43:49 +02:00
Tom Misch
55f201da40 Add admin deployment sync
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 14s
2026-05-11 10:39:02 +02:00
Tom Misch
a59457a8bb Secure image access and bootstrap admin
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 1m50s
2026-05-11 10:35:05 +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
Kroonk
d393630357 Update README to reflect Mischkomposition features and remove original creator info
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 1m52s
2026-04-12 21:41:31 +02:00
Kroonk
5c81e93455 Isolate client workspaces from root portfolio and restrict Admin view to root images
Some checks failed
Build & Push Docker Image / build-and-push (push) Has been cancelled
2026-04-12 20:47:24 +02:00
Kroonk
eaa5945611 Wrap background-image url string in quotes to support poorly encoded original filenames 2026-04-12 20:18:22 +02:00
Kroonk
33db10f32d Change lightbox caption background to beige with grey text for readability 2026-04-12 19:19:59 +02:00
Kroonk
ab06284beb Implement Light Mode (Beige) via alternate SASS compilation and runtime switching 2026-04-12 19:11:23 +02:00
Kroonk
67044add84 Commit multi-replace docker-compose.yml 2026-04-12 19:08:16 +02:00
Kroonk
61bd512cd8 Deploy user requested features and fixes, add Galerie menu link. 2026-04-12 18:53:59 +02:00
Kroonk
941d041b5d Nutzerverwaltung-Upgrade: 3 Rollen, Ordner-Zuweisung, Passwort-Aenderung
- 3 Rollen: admin (voll), user (permanent), client (30 Tage Ablauf)
- Ordner-basierte Bildzuweisung statt Einzelbilder (user_folders Tabelle)
- Root-Bilder oeffentlich, Unterordner nur fuer zugewiesene Nutzer
- Passwort-Aenderung fuer alle Nutzer (Self-Service + Admin)
- Admin kann beliebige Admins erstellen/loeschen
- Thumbnail-Generierung fuer Unterordner
- Admin-Dashboard komplett ueberarbeitet

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 17:53:46 +02:00
Kroonk
a4f3cf738c Fix: Backend-Bugs, fehlende Galerie, Watchtower statt Docker-Socket
- express.urlencoded() hinzugefuegt (Login war komplett kaputt)
- Oeffentliche Galerie zeigt jetzt alle Bilder (statt leere Galerie)
- loadGallery() Aufruf in main.js eingefuegt (fehlte komplett)
- main.min.js neu gebaut mit gulp (Galerie-Code fehlte in minified Version)
- Watchtower Container in docker-compose.yml (auto-updates alle 5 Min)
- Docker-Socket und docker-cli aus Photography-Container entfernt
- runs.json Muell-Datei geloescht
- Brain.md und Feature-Request.md aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 17:22:49 +02:00
Kroonk
15e74f42c2 Align all internal, exposed, and mapped ports to 8090 universally per user request. 2026-04-12 16:47:54 +02:00
Kroonk
7509253ee7 Revert server port to 80 to match untouched user docker-compose.yml mappings indefinitely 2026-04-12 16:41:05 +02:00
30 changed files with 3666 additions and 406 deletions

View File

@@ -2,6 +2,7 @@ _site/
.git/
.claude/
node_modules/
images/
.sass-cache/
.jekyll-metadata
*.md

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,38 +1,60 @@
name: Build & Push Docker Image
name: Build & Push Docker Image to Gitea Registry
on:
push:
branches: [master]
env:
REGISTRY: ghcr.io
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
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install Docker CLI
shell: sh
run: |
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
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache docker-cli
else
echo "Cannot install Docker CLI!"
exit 1
fi
fi
else
echo "Docker CLI already installed."
fi
docker --version
- name: Lowercase image name
run: echo "IMAGE_NAME=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log in to Gitea Package Registry
shell: sh
run: |
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 "${{ 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 }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ 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 }}

134
Brain.md
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
---
@@ -20,12 +20,15 @@ Keine 1:1 Kopie, sondern eine individuell angepasste Version mit eigenen Ansprue
| 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 nginx |
| Containerisierung | Docker Multi-Stage (Jekyll Build + nginx) |
| 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) |
@@ -37,9 +40,13 @@ Photography/
├── _layouts/
│ └── default.html # Basis-Layout (Head + Body + Scripts)
├── _includes/
│ ├── header.html # <head> Bereich: CSS, Fonts, Google Analytics
│ ├── header.html # <head> Bereich: CSS, Fonts
│ └── footer.html # Script-Einbindungen (jQuery, Poptrox, EXIF, Main)
├── index.html # Hauptseite: Header, Galerie-Grid, Footer/About
├── 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
@@ -47,50 +54,100 @@ Photography/
│ │ ├── components/ # Buttons, Forms, Icons, Panels, Poptrox
│ │ ├── layout/ # Header, Footer, Main, Wrapper
│ │ └── libs/ # Breakpoints, Mixins, Vars, Functions
│ ├── js/ # JavaScript (jQuery, Poptrox, EXIF, Main)
│ ├── 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
├── package.json # Node Dependencies (Express, SQLite, JWT, bcrypt)
├── Gemfile # Ruby Dependencies (jekyll)
── CNAME # Custom Domain Config
── 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 `/images/fulls/` (read-only)
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. **nginx autoindex**: Liefert JSON-Liste aller Dateien in `/images/fulls/`
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**: `/usr/share/nginx/html/images/fulls:ro`
- **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 (leer fuer lokal)
- `baseurl`: URL der Website
- `title`, `subtitle`, `author`: Seitentitel und Autor
- `header.title/subtitle`: Header-Anzeige
- `footer.name/bio/github`: Footer-Infos
- `footer.name/bio`: Footer-Infos
- `social_urls`: Links zu Social Media
- `exif`: Welche EXIF-Tags angezeigt werden (Model, FNumber, ExposureTime, ISO)
- `google_analytics`: Tracking-ID
## Build-Befehle
| Befehl | Aktion |
|---|---|
| `bundle install` | Ruby Dependencies installieren |
| `bundle exec jekyll serve` | Lokaler Dev-Server |
| `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) |
@@ -99,45 +156,54 @@ Photography/
## Docker / NAS Hosting
- **NAS**: UGREEN mit UGOS PRO
- **Port**: 8090 (Port 80 ist belegt)
- **Zugriff**: `http://NAS-IP:8090` -> spaeter `https://tom.mischlabs.de`
- **Domain**: tom.mischlabs.de (Reverse Proxy auf NAS einrichten)
- **Port**: 8090 (intern und extern)
- **Domain**: tom.mischlabs.de (via Cloudflare Tunnel)
### Docker-Dateien
- `Dockerfile` - Multi-Stage: Ruby/Jekyll baut Site -> nginx serviert statische Dateien
- `docker-compose.yml` - Container-Definition, Port 8090:80, Nextcloud-Volume, auto-restart
- `entrypoint.sh` - Thumbnail-Generierung + periodischer Check (alle 60s)
- `nginx.conf` - Webserver-Config mit Caching und Sicherheit
- `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
### Docker-Befehle (auf NAS via SSH)
| Befehl | Aktion |
|---|---|
| `docker compose up -d --build` | Container bauen und starten |
| `docker compose down` | Container stoppen |
| `docker compose logs -f` | Logs anzeigen |
| `docker compose up -d --build --force-recreate` | Neu bauen nach Aenderungen |
| `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
1. Aenderungen lokal machen (Bilder, Config, Design)
2. Git commit & push
3. Auf NAS: `git pull && docker compose up -d --build`
4. Fertig - Website aktualisiert
### 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 (TODO - mit Besitzer besprechen)
## 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
- [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!
---

View File

@@ -12,15 +12,24 @@ COPY . .
RUN bundle exec jekyll build
# === Serve-Phase: node.js backend + ImageMagick ===
FROM node:20-alpine
# === Node-Dependencies: native sqlite3 mit Build-Tools bauen ===
FROM node:20-alpine AS node-deps
# Install build dependencies for sqlite3 native bindings on Alpine
RUN apk add --no-cache imagemagick docker-cli python3 make g++ build-base sqlite-dev
RUN apk add --no-cache python3 make g++ sqlite-dev
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
RUN npm ci --omit=dev && npm cache clean --force
# === Serve-Phase: node.js backend + Runtime-Tools ===
FROM node:20-alpine
RUN apk add --no-cache imagemagick libjpeg-turbo libpng libwebp
RUN apk add --no-cache sqlite-libs
WORKDIR /app
COPY package*.json ./
COPY --from=node-deps /app/node_modules ./node_modules
COPY backend ./backend
COPY --from=builder /site/_site ./public
@@ -33,6 +42,6 @@ RUN chmod +x /entrypoint.sh
# Verzeichnisse anlegen
RUN mkdir -p /app/public/images/fulls /app/public/images/thumbs /app/data
EXPOSE 8080
EXPOSE 8090
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -3,7 +3,7 @@
## Nutzerverwaltung & Login-System
### Beschreibung
Die Website soll um ein Login-System mit Nutzerverwaltung erweitert werden.
Die Website wurde um ein Login-System mit Nutzerverwaltung erweitert.
### Anforderungen
@@ -17,23 +17,48 @@ Die Website soll um ein Login-System mit Nutzerverwaltung erweitert werden.
- Nutzer koennen ihre zugewiesenen Bilder anschauen (Galerie + Lightbox)
- Nutzer koennen ihre zugewiesenen Bilder herunterladen
### Status: Geplant (noch nicht umgesetzt)
### Status: Umgesetzt
---
## Admin-Dashboard: One-Click Update
## Rollen-System & Ordner-Zuweisung
### Beschreibung
Im Admin-Bereich soll ein Button vorhanden sein, der per Klick die neueste Version aus dem Git-Repository zieht und den Container automatisch aktualisiert. Kein manuelles SSH oder Terminal noetig.
Erweitertes Nutzermanagement mit 3 Rollen und ordnerbasierter Bildzuweisung.
### Anforderungen
- Button im Admin-Dashboard: "Website aktualisieren"
- Zieht automatisch die neueste Version aus GitHub (git pull oder neues Image)
- Baut den Container neu / startet ihn neu
- Zeigt Status-Feedback (Erfolg/Fehler)
### Status: Geplant (noch nicht umgesetzt)
**3 Rollen:**
- Admin: Voller Zugriff auf alle Bilder und Nutzerverwaltung
- User: Permanenter Account, sieht Root-Bilder + zugewiesene Ordner
- Client: Temporaerer Account (30 Tage), sieht Root-Bilder + zugewiesene Ordner
**Ordner-basierte Zuweisung:**
- Bilder direkt im Websitebilder-Ordner = oeffentlich fuer alle
- Unterordner (z.B. Hochzeit, Portrait) = nur fuer zugewiesene Nutzer
- Admin weist ganze Ordner einem Nutzer zu (nicht einzelne Bilder)
**Passwort-Aenderung:**
- Jeder Nutzer kann sein eigenes Passwort aendern
- Admin kann Passwoerter beliebiger Nutzer aendern
- Neue Admins erstellen und bestehende loeschen moeglich
### Status: Umgesetzt
---
*Erstellt: 2026-04-12*
## Auto-Update via Watchtower
### Beschreibung
Watchtower laeuft als separater Docker-Container und prueft alle 5 Minuten auf neue Images.
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
- Alte Images werden automatisch aufgeraeumt (WATCHTOWER_CLEANUP)
### Status: Umgesetzt
---
*Aktualisiert: 2026-04-12*

113
README.md
View File

@@ -1,85 +1,50 @@
<img src="https://i.imgur.com/ZG4gSnb.jpeg" alt="demo"/>
# Mischkomposition Photography Portfolio
# Photography ![OpenCollective](https://opencollective.com/photography/tiers/backer/badge.svg?label=backer&color=brightgreen)
A jekyll website for photographers
This is the code repository for the photography portfolio of Tom Misch ("Mischkomposition").
The project features a fast static frontend (based on HTML5 UP Multiverse) powered by a robust **Node.js + SQLite backend** and **Nextcloud integration**.
## Highlights
1. Easy setup and you get a website of your own for __free__. No web hosting charges too.
2. To add new pictures, you need to just upload them. __No code__ changes required.
3. And, my favorite, you get to see EXIF data like __aperture, shutter speed, iso__ etc. when you click on any image, automagically. Moreover, you can customize this as per your needs.
## Features
- **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, roles, private folders, access rights, account security, and deployment status.
- **Light/Dark Mode:** Dynamic theme toggle saving preferences in the browser.
## Quick Start
If you know a tad about tech and love taking pictures then this open-source project may help you setup a website to showcase
all your creations without effort. And not just that, with this you need not pay a single dime to host your website as
it's hosted by GitHub for __free__.
## Deployment Setup (Docker)
This repository is automatically built into a Docker container via Gitea Actions and pushed to the local Gitea container registry.
**Just follow the below steps and your website would be live in no time:**
To deploy on a NAS or Linux server, adjust the `docker-compose.yml` to match your Nextcloud mount:
1. Fork this repo by hitting the `Fork` button at the top right corner.
2. Enable github pages from the repo settings.
3. Upload your pictures to `images/fulls` and `images/thumbs` directory. _You can do that on github.com itself or you can clone and push the images to your repo._
4. Add your own custom domain in `CNAME` file or just remove the file if you don't own a domain and use the default domain that github provides ([yourusername].github.io/photography).
5. Update `baseurl` field in `_config.yml` file with whatever domain you used in step 4.
6. And that's it, your website is set. To view, go to [photography.rampatra.com](http://photography.rampatra.com) (or whatever you have in the CNAME file) and if you don't have one, you can go to [[yourusername].github.io/photography](http://yourusername.github.io/photography)
```yaml
services:
photography:
image: git.mischlabs.de/mrdiderot/photography:latest
container_name: photography-website
ports:
- "8090:8090"
environment:
- JWT_SECRET=replace-with-a-long-random-secret
- FULLS_DIR=/app/public/images/fulls
- THUMBS_DIR=/app/public/images/thumbs
volumes:
- /path/to/nextcloud/data:/app/public/images/fulls:ro
- thumbs:/app/public/images/thumbs
- database:/app/data
```
And, of course, you don't want my name at the bottom to show up. You can change it in `_config.yml` file as well as a few other settings like your social links, google analytics, etc. Just do not forget to [build the website](#build-the-website) after you make the changes.
Run `docker compose up -d` to launch the site.
## Run the website locally to test
1. `$ cd photography` - go to the project directory
2. `$ bundle install` - install gems
3. Change the `baseurl` in `_config.yml` to an empty value
4. `$ bundle exec jekyll serve` - start/run the website
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`:
### Build the website
1. `$ cd photography` - go to the project directory
2. `$ npm install` - install all npm dependencies
3. `$ gulp` - minify css, js, resize images, etc.
```bash
node -e "const bcrypt = require('bcryptjs'); console.log(bcrypt.hashSync('your-admin-password', 10));"
```
Note: You only need to build the website if you make changes such as replacing the images, modifying the css styles, etc.
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.
## ProTips
Container updates are handled by Watchtower, which pulls from `git.mischlabs.de/mrdiderot/photography:latest`.
### Resize Images
I have made this as a [npm](https://www.npmjs.com) package with [gulp](http://gulpjs.com/) to __automate image resizing
and thumbnail generation__. So if you're lazy like me then you can just do the following before you push your images to github.
1. Fork and clone the project to your computer
2. Go inside the project `$ cd photography`
3. Install all dependencies by `$ npm install`
4. Copy all your pictures (possibly jpg, the largest size available, straight from your camera) and put it inside `images` directory
5. Run `$ gulp resize` to resize the images and to generate thumbnails automatically
6. Push your changes to github.com by `$ git add --all` and `$ git commit -m "a nice commit message"` and then finally `$ git push origin master`
### Contact Form
You can make the contact form work without the need of any server-side code. Just follow this [article on github](https://github.com/dwyl/html-form-send-email-via-google-script-without-server) which uses a simple google script to send emails or to upload to a google spreadsheet when someone submits the form.
## Credits
Thanks to [AJ](https://twitter.com/ajlkn) for the website template which I enhanced for [jekyll](http://jekyllrb.com/).
## Sponsors
Proudly sponsored by these awesome apps. Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://x.com/rampatra_)]
<table>
<tr>
<td>
<a href="https://presentifyapp.com/" target="_blank"><img src="https://raw.githubusercontent.com/rampatra/assets/refs/heads/main/Presentify/Icons/icon_512.png" width="150" height="150"></a>
</td>
<td>
<a href="https://facescreenapp.com/" target="_blank"><img src="https://github.com/user-attachments/assets/b251b413-ccc4-48f1-a316-c2c2a71f959e" width="150" height="150"></a>
</td>
<td>
<a href="https://keyscreenapp.com" target="_blank"><img src="https://github.com/user-attachments/assets/4b75a739-b4b5-432c-a03c-a9bdd8309934" width="150" height="150"></a>
</td>
<td>
<a href="https://todobarapp.com/" target="_blank"><img src="https://todobarapp.com/assets/img/todobar/app-icon-512.png" width="150" height="150"></a>
</td>
<td>
<a href="https://simplefillapp.com/" target="_blank"><img src="https://github.com/user-attachments/assets/6c575d9c-b65b-4ce7-a468-30f74cfedf18" width="150" height="150"></a>
</td>
</tr>
</table>
---
_P.S. For any queries or concerns, you can reach out to me on [Twitter](https://twitter.com/rampatra_). I'll try my best to help 🙏._
## Credits & License
- Originally based on a Jekyll template.
- UI Design by [AJ / HTML5 UP](https://html5up.net).
- Enhanced with Node.js authentication, SQLite database management, and Docker integrations by Tom Misch.

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

@@ -3,8 +3,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<!-- Load resources -->
<link rel="stylesheet" href="assets/css/main.min.css" />
<noscript><link rel="stylesheet" href="assets/css/noscript.min.css" /></noscript>
<link rel="stylesheet" id="theme-style" href="assets/css/main.min.css" />
<script>
var theme = localStorage.getItem('mischkomposition_theme');
if (theme === 'light') {
document.getElementById('theme-style').href = 'assets/css/light.min.css';
}
</script>
<noscript><link rel="stylesheet" href="assets/css/main.min.css" /></noscript>
<link rel="stylesheet" href="assets/css/custom.min.css" />
<link rel="stylesheet" href="/assets/css/fontawesome-all.min.css">

View File

@@ -6,54 +6,291 @@
<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; }
.img-check { display: inline-block; width: 100px; margin: 5px; text-align: center; }
.img-check img { width: 100px; height: 100px; object-fit: cover; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.2); }
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; }
.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">
<div id="wrapper">
<header id="header">
<h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1>
<nav><ul><li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li></ul></nav>
<nav><ul>
<li><a href="/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: 900px; 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>System</h2>
<p>Aktualisiert den Docker-Container aus dem GitHub Repository. Container startet danach neu.</p>
<button id="update-btn" class="primary">Website Aktualisieren (Docker Pull)</button>
<p id="update-status" style="margin-top: 10px;"></p>
<div class="admin-content">
<section class="admin-view active" data-view="overview">
<div class="view-head">
<div>
<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 class="field-inline">
<label>Passwort</label>
<input type="password" id="new-password" placeholder="Passwort" required />
</div>
<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>
</form>
</div>
<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>Status</th><th>Ordnerzugriff</th><th>Aktionen</th></tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
</div>
</div>
</section>
<section class="admin-view" data-view="folders">
<div class="view-head">
<div>
<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 class="field-inline">
<label>Neues Passwort</label>
<input type="password" id="admin-pw-new" required />
</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>
<div class="admin-section">
<h2>Nutzerverwaltung</h2>
<form id="create-user-form" style="display:flex; gap:10px; margin-bottom:1em;">
<input type="text" id="new-username" placeholder="Nutzername" required />
<input type="password" id="new-password" placeholder="Passwort" required />
<button type="submit">Erstellen</button>
</form>
<table>
<thead><tr><th>ID</th><th>Username</th><th>Aktion</th></tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
<div class="admin-section">
<h2>BildZuweisung</h2>
<select id="user-select" style="margin-bottom: 1em;">
<option value="">Nutzer wählen...</option>
</select>
<div id="image-checkboxes" style="display:flex; flex-wrap:wrap; gap:10px;"></div>
<button id="save-assignments" style="margin-top:1em;">Zuweisung Speichern</button>
</div>
</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}

1
assets/css/light.min.css vendored Normal file

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,101 +1,441 @@
$(document).ready(function() {
// Check if admin
var currentAdmin = null;
var users = [];
var folders = [];
var assignedFolders = [];
var selectedUserId = "";
$.get('/api/auth/me')
.done(function(user) {
if(user.role !== 'admin') {
alert("Kein Zugriff!");
if (user.role !== 'admin') {
alert('Kein Zugriff!');
window.location.href = '/';
} else {
loadUsers();
loadAssignmentImages();
$('body').removeClass('is-preload');
return;
}
currentAdmin = user;
$('#admin-user-label').text(user.username || 'Admin');
refreshDashboard();
$('body').removeClass('is-preload');
})
.fail(function() {
window.location.href = '/';
});
.fail(function() { window.location.href = '/'; });
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 = '/'; });
});
function loadUsers() {
$.get('/api/admin/users').done(function(users) {
$('#users-tbody').empty();
$('#user-select').html('<option value="">Nutzer wählen...</option>');
users.forEach(function(u) {
$('#users-tbody').append(
'<tr>' +
'<td>' + u.id + '</td>' +
'<td>' + u.username + ' ' + (u.role === 'admin' ? '(Admin)' : '') + '</td>' +
'<td>' + (u.id !== 1 ? '<button onclick="deleteUser(' + u.id + ')">Löschen</button>' : '') + '</td>' +
'</tr>'
);
if(u.role !== 'admin') {
$('#user-select').append('<option value="' + u.id + '">' + u.username + '</option>');
}
});
$('#refresh-dashboard').click(refreshDashboard);
function refreshDashboard() {
$.when(loadUsers(), loadFolders()).done(function() {
renderStats();
renderUsers();
renderUserSelect();
renderFolders();
if (selectedUserId) {
loadAssignments(selectedUserId);
} else {
renderSelectedUserSummary(null);
}
});
}
window.deleteUser = function(id) {
if(confirm("Nutzer loeschen?")) {
$.ajax({url: '/api/admin/users/'+id, type: 'DELETE'}).done(loadUsers);
function loadUsers() {
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>'
);
});
}
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>');
});
}
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 {
$foldersCell.append($('<span></span>').addClass('muted').text('Keine privaten Ordner'));
}
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', 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(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);
});
}
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: 'user' })
.done(function() {
$('#new-username').val('');
$('#new-password').val('');
loadUsers();
}).fail(function(err) { alert("Fehler: " + err.responseJSON.error); });
});
function loadAssignmentImages() {
$.get('/images/fulls/').done(function(files) {
$('#image-checkboxes').empty();
files.forEach(function(f) {
$('#image-checkboxes').append(
'<label class="img-check">' +
'<img src="/images/thumbs/' + encodeURIComponent(f.name) + '" />' +
'<br/><input type="checkbox" value="' + f.name + '" class="img-cb"/>' +
'</label>'
);
});
});
}
$('#user-select').change(function() {
var uid = $(this).val();
$('.img-cb').prop('checked', false);
if(!uid) return;
$.get('/api/admin/assign').done(function(assignments) {
assignments.filter(function(a) { return a.user_id == uid; }).forEach(function(a) {
$('.img-cb[value="' + a.image_name + '"]').prop('checked', true);
});
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, #new-password').val('');
refreshDashboard();
}).fail(function(xhr) {
var msg = (xhr.responseJSON && xhr.responseJSON.error) || 'Unbekannter Fehler';
alert('Fehler beim Erstellen: ' + msg);
});
});
$('#save-assignments').click(function() {
var uid = $('#user-select').val();
if(!uid) return alert('Bitte Nutzer waehlen');
var selected = [];
$('.img-cb:checked').each(function() { selected.push($(this).val()); });
$.post('/api/admin/assign', { user_id: uid, image_names: selected })
.done(function() { alert("Zuweisung gespeichert!"); });
$('#users-tbody').on('click', '.user-delete', function() {
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'); });
});
$('#update-btn').click(function() {
if(!confirm("Achtung: Dies startet den Server auf dem NAS neu. Fortfahren?")) return;
$('#update-status').text('Update laeuft...');
$.post('/api/admin/update').done(function(res) {
$('#update-status').text(res.message);
setTimeout(function() { window.location.reload(); }, 5000);
$('#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({
url: '/api/admin/users/' + id + '/password',
type: 'PUT',
contentType: 'application/json',
data: JSON.stringify({ new_password: newPw })
}).done(function() {
alert('Passwort geaendert.');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
});
});
$('#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(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 wählen...</option>'),e.forEach(function(e){$("#users-tbody").append("<tr><td>"+e.id+"</td><td>"+e.username+" "+("admin"===e.role?"(Admin)":"")+"</td><td>"+(1!==e.id?'<button onclick="deleteUser('+e.id+')">Löschen</button>':"")+"</td></tr>"),"admin"!==e.role&&$("#user-select").append('<option value="'+e.id+'">'+e.username+"</option>")})})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/images/fulls/").done(function(e){$("#image-checkboxes").empty(),e.forEach(function(e){$("#image-checkboxes").append('<label class="img-check"><img src="/images/thumbs/'+encodeURIComponent(e.name)+'" /><br/><input type="checkbox" value="'+e.name+'" class="img-cb"/></label>')})}),$("body").removeClass("is-preload"))}).fail(function(){window.location.href="/"}),$("#logout-btn").click(function(e){e.preventDefault(),$.post("/api/auth/logout").done(function(){window.location.href="/"})}),window.deleteUser=function(n){confirm("Nutzer loeschen?")&&$.ajax({url:"/api/admin/users/"+n,type:"DELETE"}).done(e)},$("#create-user-form").submit(function(n){n.preventDefault(),$.post("/api/admin/users",{username:$("#new-username").val(),password:$("#new-password").val(),role:"user"}).done(function(){$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){alert("Fehler: "+e.responseJSON.error)})}),$("#user-select").change(function(){var e=$(this).val();$(".img-cb").prop("checked",!1),e&&$.get("/api/admin/assign").done(function(n){n.filter(function(n){return n.user_id==e}).forEach(function(e){$('.img-cb[value="'+e.image_name+'"]').prop("checked",!0)})})}),$("#save-assignments").click(function(){var e=$("#user-select").val();if(!e)return alert("Bitte Nutzer waehlen");var n=[];$(".img-cb:checked").each(function(){n.push($(this).val())}),$.post("/api/admin/assign",{user_id:e,image_names:n}).done(function(){alert("Zuweisung gespeichert!")})}),$("#update-btn").click(function(){confirm("Achtung: Dies startet den Server auf dem NAS neu. Fortfahren?")&&($("#update-status").text("Update laeuft..."),$.post("/api/admin/update").done(function(e){$("#update-status").text(e.message),setTimeout(function(){window.location.reload()},5e3)}))})});
$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer waehlen...</option>'),e&&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,15 +153,22 @@
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) {
if(files.length === 0) {
$main.html('<p style="text-align:center;color:#fff;padding:2em;width:100%">Bitte logge dich über den Login-Button unten links ein, um deine zugewiesenen Bilder zu sehen.</p>');
$main.html('<p style="text-align:center;color:#fff;padding:2em;width:100%">Noch keine Bilder vorhanden.</p>');
return;
}
@@ -182,14 +189,20 @@
images.forEach(function(file) {
var article = $('<article class="thumb"></article>');
var link = $('<a class="image" href="/images/fulls/' + encodeURIComponent(file.name) + '"></a>');
var img = $('<img src="/images/thumbs/' + encodeURIComponent(file.name) + '" alt="" data-name="' + file.name.replace(/"/g, '&quot;') + '" />');
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);
var link = $('<a class="image" href="' + fullPath + '"></a>');
var img = $('<img src="' + thumbPath + '" alt="" data-name="' + file.name.replace(/"/g, '&quot;') + '" />');
link.append(img);
article.append(link);
$main.append(article);
});
initThumbs();
initSelectionMode(images);
initPoptrox();
},
error: function() {
@@ -207,10 +220,18 @@
if ($image.length == 0) return;
$image.css("background-image", "url(" + $image_img.attr("src") + ")");
$image.css("background-image", "url('" + $image_img.attr("src") + "')");
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);
@@ -279,10 +300,260 @@
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();
// Theme Toggle
var currentTheme = localStorage.getItem('mischkomposition_theme') || 'main';
updateThemeIcon(currentTheme);
$('#theme-toggle').click(function(e) {
e.preventDefault();
currentTheme = currentTheme === 'main' ? 'light' : 'main';
localStorage.setItem('mischkomposition_theme', currentTheme);
$('#theme-style').attr('href', 'assets/css/' + currentTheme + '.min.css');
updateThemeIcon(currentTheme);
});
function updateThemeIcon(theme) {
if (theme === 'light') {
$('#theme-toggle i').removeClass('fa-sun').addClass('fa-moon');
} else {
$('#theme-toggle i').removeClass('fa-moon').addClass('fa-sun');
}
}
// 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>');
}
@@ -293,7 +564,13 @@
e.preventDefault();
$.post('/api/auth/login', { username: $('#login-username').val(), password: $('#login-password').val() })
.done(function() { window.location.reload(); })
.fail(function(err) { alert("Login fehlgeschlagen. Bitte Zugangsdaten ueberpruefen."); });
.fail(function(xhr) {
var msg = 'Login fehlgeschlagen.';
if (xhr.responseJSON && xhr.responseJSON.error === 'Account abgelaufen') {
msg = 'Dein Account ist abgelaufen. Bitte kontaktiere den Administrator.';
}
alert(msg);
});
});
$('#logout-btn').click(function(e) {
@@ -301,4 +578,14 @@
$.post('/api/auth/logout').done(function() { window.location.reload(); });
});
$('#pw-change-form').submit(function(e) {
e.preventDefault();
var newPw = $('#pw-new').val();
var confirmPw = $('#pw-confirm').val();
if (newPw !== confirmPw) { alert('Passwoerter stimmen nicht ueberein.'); return; }
$.post('/api/auth/change-password', { old_password: $('#pw-old').val(), new_password: newPw })
.done(function() { alert('Passwort geaendert!'); $('#pw-old, #pw-new, #pw-confirm').val(''); })
.fail(function(xhr) { alert(xhr.responseJSON ? xhr.responseJSON.error : 'Fehler'); });
});
})(jQuery);

File diff suppressed because one or more lines are too long

View File

@@ -75,7 +75,7 @@
.caption {
@include padding(2em, 2em);
@include vendor('background-image', 'linear-gradient(to top, rgba(16,16,16,0.45) 25%, rgba(16,16,16,0) 100%)');
background: rgba(246, 240, 228, 0.95); /* freundliches Beige transparent */
bottom: 0;
cursor: default;
left: 0;
@@ -86,10 +86,11 @@
h2, h3, h4, h5, h6 {
margin: 0 0 (_size(element-margin) * 0.25) 0;
color: #4a4a4a !important; /* Grau */
}
p {
color: _palette(fg-bold);
p, span, i, strong, em {
color: #5c554e !important; /* Grau */
}
}

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
)
)
);

54
assets/sass/light.scss Normal file
View File

@@ -0,0 +1,54 @@
@import 'libs/vars';
$palette: (
bg: #f9f6f0,
bg-alt: #eee8dd,
bg-overlay: transparentize(#f9f6f0, 0.15),
bg-overlay-alt: transparentize(#f9f6f0, 0.05),
bg-ie-overlay: transparentize(#f9f6f0, 0.1),
bg-ie-overlay-alt: transparentize(#f9f6f0, 0.05),
fg: #5c554e,
fg-bold: #38332f,
fg-medium: #7a746e,
fg-light: #9e9790,
border: #e6dccc,
border-bg: #f0e5d6,
border-bg-alt: #e1d1ba,
accent1: #e48c71
) !global;
@import 'libs/functions';
@import 'libs/mixins';
@import 'libs/vendor';
@import 'libs/breakpoints';
@include breakpoints((
xlarge: ( 1281px, 1680px ),
large: ( 981px, 1280px ),
medium: ( 737px, 980px ),
small: ( 481px, 736px ),
xsmall: ( null, 480px )
));
@include keyframes(spinner) {
0% { @include vendor('transform', 'rotate(0deg)'); }
100% { @include vendor('transform', 'rotate(359deg)'); }
}
@import 'base/reset';
@import 'base/page';
@import 'base/typography';
@import 'components/button';
@import 'components/form';
@import 'components/icon';
@import 'components/list';
@import 'components/actions';
@import 'components/icons';
@import 'components/table';
@import 'components/panel';
@import 'components/poptrox-popup';
@import 'layout/wrapper';
@import 'layout/header';
@import 'layout/main';
@import 'layout/footer';
@import 'custom';

View File

@@ -7,14 +7,125 @@ const db = new sqlite3.Database(dbPath);
console.log('Using database at', dbPath);
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 together with INITIAL_ADMIN_PASSWORD_HASH to bootstrap an admin.');
return;
}
db.get('SELECT id FROM users WHERE username = ?', [username], (err, row) => {
if (err || row) return;
db.run(
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
[username, passwordHash, 'admin'],
(insertErr) => {
if (insertErr) {
console.error('Failed to create initial admin:', insertErr.message);
return;
}
console.log(`Initial admin created: ${username}`);
}
);
});
db.get('SELECT id, role FROM users WHERE username = ?', [username], (err, row) => {
if (err || !row || row.role === 'admin') return;
db.run('UPDATE users SET role = ? WHERE id = ?', ['admin', row.id], (updateErr) => {
if (updateErr) {
console.error('Failed to promote initial admin:', updateErr.message);
return;
}
console.log(`Initial admin promoted: ${username}`);
});
});
}
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
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,
@@ -22,14 +133,6 @@ db.serialize(() => {
UNIQUE(user_id, image_name)
)`);
// Create default admin user if none exists
db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => {
if (!row) {
const hash = bcrypt.hashSync('admin123', 10);
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']);
console.log('Default admin created with credentials: admin / admin123');
}
});
});
module.exports = db;

View File

@@ -4,29 +4,59 @@ const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const fs = require('fs');
const path = require('path');
const { exec } = 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 || 'mischkomposition-super-secret-key-change-me';
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET must be set before starting the server');
}
const PUBLIC_DIR = path.join(__dirname, '../public');
const DEFAULT_FULLS_DIR = path.join(PUBLIC_DIR, 'images/fulls');
const DEFAULT_THUMBS_DIR = path.join(PUBLIC_DIR, 'images/thumbs');
const DEV_FULLS_DIR = path.join(__dirname, '../images/fulls');
const DEV_THUMBS_DIR = path.join(__dirname, '../images/thumbs');
const FULLS_DIR = process.env.FULLS_DIR || (fs.existsSync(DEFAULT_FULLS_DIR) ? DEFAULT_FULLS_DIR : DEV_FULLS_DIR);
const THUMBS_DIR = process.env.THUMBS_DIR || (fs.existsSync(DEFAULT_THUMBS_DIR) ? DEFAULT_THUMBS_DIR : DEV_THUMBS_DIR);
const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i;
console.log('Using full-size images at', FULLS_DIR);
console.log('Using thumbnails at', THUMBS_DIR);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// Middleware to verify JWT cookie
// ==================== MIDDLEWARE ====================
function authMiddleware(req, res, next) {
const token = req.cookies.token;
if (!token) {
req.user = null;
return next();
}
if (!token) { req.user = null; return next(); }
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) req.user = null;
else req.user = decoded;
if (err) { req.user = null; return next(); }
// Client-Ablauf pruefen (30 Tage)
if (decoded.role === 'client' && decoded.created_at) {
const expires = new Date(decoded.created_at);
expires.setDate(expires.getDate() + 30);
if (new Date() > expires) {
res.clearCookie('token');
req.user = null;
return next();
}
}
req.user = decoded;
next();
});
}
function requireAuth(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'Not logged in' });
next();
}
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
@@ -36,6 +66,89 @@ function requireAdmin(req, res, next) {
app.use(authMiddleware);
// ==================== HELPER ====================
function listImagesInDir(dir) {
try {
const files = fs.readdirSync(dir);
return files.filter(f => IMAGE_RE.test(f) && fs.statSync(path.join(dir, f)).isFile());
} catch (e) {
return [];
}
}
function listSubfolders(dir) {
try {
return fs.readdirSync(dir).filter(f => {
try { return fs.statSync(path.join(dir, f)).isDirectory(); }
catch (e) { return false; }
});
} catch (e) {
return [];
}
}
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);
return resolvedPath === resolvedBase || resolvedPath.startsWith(resolvedBase + path.sep);
}
function hasAssignedFolder(userId, folder, callback) {
db.get(
'SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
[userId, folder],
(err, row) => callback(!err && !!row)
);
}
function serveProtectedImage(req, res) {
const kind = req.params[0];
const relativePath = req.params[1] || '';
const baseDir = kind === 'thumbs' ? THUMBS_DIR : FULLS_DIR;
const segments = relativePath.split('/').filter(Boolean);
const fileName = segments[segments.length - 1];
if (!fileName || !IMAGE_RE.test(fileName) || !isSafePath(baseDir, relativePath)) {
return res.status(404).end();
}
const absolutePath = path.resolve(baseDir, relativePath);
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
return res.status(404).end();
}
// Bilder direkt im Root-Verzeichnis sind oeffentlich. Kundenordner sind geschuetzt.
if (segments.length === 1) {
return res.sendFile(absolutePath);
}
if (!req.user) {
return res.status(401).json({ error: 'Not logged in' });
}
if (req.user.role === 'admin') {
return res.sendFile(absolutePath);
}
hasAssignedFolder(req.user.id, segments[0], (allowed) => {
if (!allowed) return res.status(403).json({ error: 'Forbidden' });
res.sendFile(absolutePath);
});
}
// ==================== AUTH API ====================
app.post('/api/auth/login', (req, res) => {
@@ -45,7 +158,18 @@ app.post('/api/auth/login', (req, res) => {
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '7d' });
// Client-Ablauf pruefen
if (user.role === 'client' && user.created_at) {
const expires = new Date(user.created_at);
expires.setDate(expires.getDate() + 30);
if (new Date() > expires) {
return res.status(401).json({ error: 'Account abgelaufen' });
}
}
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role, created_at: user.created_at },
JWT_SECRET, { expiresIn: '7d' }
);
res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
res.json({ success: true, role: user.role });
});
@@ -61,123 +185,478 @@ app.get('/api/auth/me', (req, res) => {
res.json(req.user);
});
// ==================== IMAGE PROXY ====================
// ==================== KEYCLOAK SSO API ====================
// We must override the nginx `/images/fulls/` JSON behavior here:
app.get('/images/fulls/', (req, res) => {
if (!req.user) return res.json([]); // Not logged in -> empty gallery
let oidcClient = null;
const fullsDir = path.join(__dirname, '../public/images/fulls');
async function getOidcClient() {
if (oidcClient) return oidcClient;
fs.readdir(fullsDir, (err, files) => {
if (err) return res.json([]);
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';
let imageFiles = files.filter(f => f.match(/\.(jpg|jpeg|png|gif|webp)$/i));
if (!ssoClientSecret) {
throw new Error('SSO_CLIENT_SECRET is not configured');
}
if (req.user.role === 'admin') {
// Admin sees all
const result = imageFiles.map(name => {
const stats = fs.statSync(path.join(fullsDir, name));
return { name, type: 'file', mtime: stats.mtime.toUTCString(), size: stats.size };
});
return res.json(result);
} else {
// Normal user sees only assigned
db.all('SELECT image_name FROM user_images WHERE user_id = ?', [req.user.id], (err, rows) => {
if (err) return res.json([]);
const assignedNames = rows.map(r => r.image_name);
const assignedFiles = imageFiles.filter(name => assignedNames.includes(name));
const result = assignedFiles.map(name => {
const stats = fs.statSync(path.join(fullsDir, name));
return { name, type: 'file', mtime: stats.mtime.toUTCString(), size: stats.size };
});
return res.json(result);
});
}
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),
});
});
// Proxy image files directly, but we let Express serve static later.
// BUT we should restrict access to physical image files?
// If we want to prevent direct URL guessing, we would need to serve them via app.get('/images/fulls/:name').
// For now, let's just use express.static to serve the actual binary files.
// Nextcloud folder names are hard to guess anyway.
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) {
return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' });
}
db.get('SELECT * FROM users WHERE id = ?', [req.user.id], (err, user) => {
if (err || !user) return res.status(500).json({ error: 'DB Error' });
if (!bcrypt.compareSync(old_password, user.password_hash)) {
return res.status(401).json({ error: 'Altes Passwort ist falsch' });
}
const hash = bcrypt.hashSync(new_password, 10);
db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, user.id], (err) => {
if (err) return res.status(500).json({ error: 'DB Error' });
res.json({ success: true });
});
});
});
// ==================== IMAGE LISTING ====================
app.get('/images/fulls/', (req, res) => {
// Root-Bilder sind immer oeffentlich
const rootImages = listImagesInDir(FULLS_DIR).map(name => ({
name, type: 'file', folder: null
}));
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);
// Eingeloggt: Startseite bleibt die normale oeffentliche Galerie.
return res.json(rootImages);
});
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);
return res.json(assignedFolders.filter(folder => folders.includes(folder)).map(getFolderInfo));
});
});
// ==================== ADMIN API ====================
app.get('/api/admin/users', requireAdmin, (req, res) => {
db.all('SELECT id, username, role FROM users', (err, rows) => {
res.json(rows);
db.all('SELECT id, username, role, created_at FROM users', (err, rows) => {
if (err) {
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 };
if (u.role === 'client' && u.created_at) {
const expires = new Date(u.created_at);
expires.setDate(expires.getDate() + 30);
out.expires_at = expires.toISOString();
out.expired = new Date() > expires;
}
out.folders = foldersByUser[u.id] || [];
return out;
});
res.json(result);
});
});
});
app.post('/api/admin/users', requireAdmin, (req, res) => {
const { username, password, role } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username und Passwort erforderlich' });
const validRoles = ['admin', 'user', 'client'];
const userRole = validRoles.includes(role) ? role : 'client';
const hash = bcrypt.hashSync(password, 10);
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, role || 'user'], function(err) {
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', [username, hash, userRole], function(err) {
if (err) return res.status(400).json({ error: err.message });
res.json({ success: true, id: this.lastID });
});
});
app.delete('/api/admin/users/:id', requireAdmin, (req, res) => {
if (req.params.id == 1) return res.status(400).json({ error: 'Cannot delete primary admin' });
db.run('DELETE FROM users WHERE id = ?', [req.params.id], err => {
db.run('DELETE FROM user_images WHERE user_id = ?', [req.params.id]);
const userId = parseInt(req.params.id);
// Sich selbst loeschen verhindern
if (userId === req.user.id) {
return res.status(400).json({ error: 'Du kannst dich nicht selbst loeschen' });
}
db.run('DELETE FROM users WHERE id = ?', [userId], (err) => {
if (err) return res.status(500).json({ error: 'DB Error' });
db.run('DELETE FROM user_folders WHERE user_id = ?', [userId]);
res.json({ success: true });
});
});
app.put('/api/admin/users/:id/password', requireAdmin, (req, res) => {
const { new_password } = req.body;
if (!new_password || new_password.length < 4) {
return res.status(400).json({ error: 'Passwort muss mindestens 4 Zeichen lang sein' });
}
const hash = bcrypt.hashSync(new_password, 10);
db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, parseInt(req.params.id)], (err) => {
if (err) return res.status(500).json({ error: 'DB Error' });
res.json({ success: true });
});
});
// Ordner-Verwaltung
app.get('/api/admin/folders', requireAdmin, (req, res) => {
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) => {
db.all('SELECT * FROM user_images', (err, rows) => {
res.json(rows);
});
const userId = req.query.user_id;
if (userId) {
db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [userId], (err, rows) => {
res.json(err ? [] : rows.map(r => r.folder_name));
});
} else {
db.all('SELECT * FROM user_folders', (err, rows) => {
res.json(err ? [] : rows);
});
}
});
app.post('/api/admin/assign', requireAdmin, (req, res) => {
const { user_id, image_names } = req.body; // image_names is array of strings
const { user_id, folders } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id erforderlich' });
const existingFolders = listSubfolders(FULLS_DIR);
const folderList = Array.isArray(folders)
? folders.filter(name => isValidName(name) && existingFolders.includes(name))
: [];
db.serialize(() => {
db.run('BEGIN TRANSACTION');
db.run('DELETE FROM user_images WHERE user_id = ?', [user_id]);
const stmt = db.prepare('INSERT INTO user_images (user_id, image_name) VALUES (?, ?)');
image_names.forEach(name => {
stmt.run(user_id, name);
});
db.run('DELETE FROM user_folders WHERE user_id = ?', [user_id]);
const stmt = db.prepare('INSERT INTO user_folders (user_id, folder_name) VALUES (?, ?)');
folderList.forEach(name => stmt.run(user_id, name));
stmt.finalize();
db.run('COMMIT');
res.json({ success: true });
});
});
app.post('/api/admin/update', requireAdmin, (req, res) => {
console.log("Triggering Docker Compose Update...");
// ==================== DOWNLOAD API ====================
// Wir fuehren den Docker compose pull/down/up aus.
// ACHTUNG: Der Server stuertzt danach ohnehin im Container ab (da er neu gestartet wird).
const cmd = "docker compose pull && docker compose down && docker compose up -d";
function isValidName(name) {
return name && !name.includes('/') && !name.includes('\\') && !name.includes('..');
}
// Return early to ensure client gets success BEFORE container dies
res.json({ success: true, message: 'Update gestartet. Container wird neu gestartet...' });
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' });
}
setTimeout(() => {
exec(cmd, { cwd: '/app' }, (err, stdout, stderr) => {
if (err) console.error("Update failed:", err);
else console.log("Update success:", stdout);
// Alle Bilder validieren und Zugriff pruefen
let pending = images.length;
const verified = [];
let responded = false;
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' });
}
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));
});
}
});
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);
});
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' });
});
}, 1000);
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.use(express.static(path.join(__dirname, '../public')));
app.get(/^\/images\/(fulls|thumbs)\/(.+)$/, serveProtectedImage);
app.use(express.static(PUBLIC_DIR));
// Catch all - send index.html
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
const PORT = 8080;
const PORT = 8090;
app.listen(PORT, () => {
console.log(`Backend server listening on port ${PORT}`);
console.log(`Mischkomposition Backend on port ${PORT}`);
});

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

@@ -1,18 +1,37 @@
services:
photography:
image: ghcr.io/kroonk/photography:latest
image: git.mischlabs.de/mrdiderot/photography:latest
container_name: photography-website
ports:
- "8090:8080"
- "8090:8090"
environment:
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment}
- FULLS_DIR=/app/public/images/fulls
- THUMBS_DIR=/app/public/images/thumbs
- 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
# Thumbnails werden automatisch generiert und hier gespeichert
- thumbs:/app/public/images/thumbs
# SQLite Database Volume
# SQLite Database (persistent)
- database:/app/data
# Docker Socket fuer One-Click Update
restart: unless-stopped
# Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert Container
watchtower:
image: containrrr/watchtower
container_name: watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Registry Login von deinem NAS uebernehmen, um private Images lesen zu duerfen
- /volume2/docker/watchtower/config.json:/config.json:ro
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_POLL_INTERVAL=300
restart: unless-stopped
volumes:

View File

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

View File

@@ -10,6 +10,7 @@ layout: default
<h1><a href="index.html"><strong>{{ site.header.title }}</strong> {{ site.header.subtitle }}</a></h1>
<nav>
<ul>
<li><a href="#" id="theme-toggle" title="Theme wechseln"><i class="fa fa-sun"></i></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>
@@ -130,6 +131,25 @@ layout: default
<li id="admin-link"></li>
</ul>
</form>
<div id="pw-change-section" style="display:none; margin-top:1.5em; border-top:1px solid rgba(255,255,255,0.2); padding-top:1.5em;">
<h3>Passwort aendern</h3>
<form id="pw-change-form">
<div class="fields">
<div class="field">
<input type="password" id="pw-old" placeholder="Altes Passwort" required />
</div>
<div class="field half">
<input type="password" id="pw-new" placeholder="Neues Passwort" required />
</div>
<div class="field half">
<input type="password" id="pw-confirm" placeholder="Passwort bestaetigen" required />
</div>
</div>
<ul class="actions">
<li><input type="submit" value="Passwort aendern" class="primary" /></li>
</ul>
</form>
</div>
</section>
</div>
</footer>

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": {