Compare commits
45 Commits
567cb67763
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea02bab3c | ||
|
|
75e4fbaa19 | ||
|
|
163d8a20f1 | ||
|
|
d9334dae00 | ||
|
|
3ad76e841d | ||
|
|
64fe667240 | ||
|
|
e6a11f2eed | ||
|
|
c211d2bf9d | ||
|
|
beb123b144 | ||
|
|
a588bd36d6 | ||
|
|
caf56b4117 | ||
|
|
5266f36820 | ||
|
|
0efe3066fc | ||
|
|
6f879f4d48 | ||
|
|
9512650a83 | ||
|
|
9a2a8dca9f | ||
|
|
cb5c0f2581 | ||
|
|
e2c83f5fe4 | ||
|
|
55f201da40 | ||
|
|
a59457a8bb | ||
|
|
95f2847155 | ||
|
|
b65ade8381 | ||
|
|
096d766358 | ||
|
|
75a5a596c5 | ||
|
|
f4c106bd19 | ||
|
|
74f1da13a3 | ||
|
|
159071a814 | ||
|
|
43539fa1a3 | ||
|
|
2c040b651d | ||
|
|
2dcd559501 | ||
|
|
c37aa3261c | ||
|
|
e5e313924d | ||
|
|
83058f80c6 | ||
|
|
ea15be0368 | ||
|
|
d393630357 | ||
|
|
5c81e93455 | ||
|
|
eaa5945611 | ||
|
|
33db10f32d | ||
|
|
ab06284beb | ||
|
|
67044add84 | ||
|
|
61bd512cd8 | ||
|
|
941d041b5d | ||
|
|
a4f3cf738c | ||
|
|
15e74f42c2 | ||
|
|
7509253ee7 |
@@ -2,6 +2,7 @@ _site/
|
||||
.git/
|
||||
.claude/
|
||||
node_modules/
|
||||
images/
|
||||
.sass-cache/
|
||||
.jekyll-metadata
|
||||
*.md
|
||||
|
||||
21
.env.example
Normal file
21
.env.example
Normal 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
|
||||
64
.github/workflows/docker-build.yml
vendored
64
.github/workflows/docker-build.yml
vendored
@@ -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
134
Brain.md
@@ -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!
|
||||
|
||||
---
|
||||
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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
113
README.md
@@ -1,85 +1,50 @@
|
||||
<img src="https://i.imgur.com/ZG4gSnb.jpeg" alt="demo"/>
|
||||
# Mischkomposition Photography Portfolio
|
||||
|
||||
# Photography 
|
||||
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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
293
admin.html
293
admin.html
@@ -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%;">
|
||||
|
||||
<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>
|
||||
<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>Nutzerverwaltung</h2>
|
||||
<form id="create-user-form" style="display:flex; gap:10px; margin-bottom:1em;">
|
||||
<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 />
|
||||
<button type="submit">Erstellen</button>
|
||||
</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>Aktion</th></tr></thead>
|
||||
<thead><tr><th>ID</th><th>Username</th><th>Rolle</th><th>Status</th><th>Ordnerzugriff</th><th>Aktionen</th></tr></thead>
|
||||
<tbody id="users-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h2>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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
2
assets/css/custom.min.css
vendored
2
assets/css/custom.min.css
vendored
@@ -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
1
assets/css/light.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
assets/css/main.min.css
vendored
2
assets/css/main.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,18 +1,33 @@
|
||||
$(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!");
|
||||
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) {
|
||||
@@ -20,82 +35,407 @@ $(document).ready(function() {
|
||||
$.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>'
|
||||
);
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
$('#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);
|
||||
});
|
||||
$.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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
2
assets/js/admin.min.js
vendored
2
assets/js/admin.min.js
vendored
@@ -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")})})});
|
||||
@@ -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, '"') + '" />');
|
||||
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, '"') + '" />');
|
||||
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);
|
||||
|
||||
2
assets/js/main.min.js
vendored
2
assets/js/main.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
54
assets/sass/light.scss
Normal 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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
if (!token) { req.user = null; return next(); }
|
||||
jwt.verify(token, JWT_SECRET, (err, 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();
|
||||
}
|
||||
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||
if (err) req.user = null;
|
||||
else req.user = decoded;
|
||||
}
|
||||
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 };
|
||||
const issuer = await Issuer.discover(ssoAuthority);
|
||||
oidcClient = new issuer.Client({
|
||||
client_id: ssoClientId,
|
||||
client_secret: ssoClientSecret,
|
||||
redirect_uris: [ssoRedirectUri],
|
||||
response_types: ['code'],
|
||||
});
|
||||
return res.json(result);
|
||||
|
||||
return oidcClient;
|
||||
}
|
||||
|
||||
app.get('/api/auth/sso/config', (req, res) => {
|
||||
res.json({
|
||||
enabled: !!(process.env.SSO_CLIENT_SECRET && process.env.SSO_CLIENT_ID && process.env.SSO_AUTHORITY),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/sso/login', async (req, res) => {
|
||||
try {
|
||||
const client = await getOidcClient();
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
scope: 'openid email profile',
|
||||
state: 'mischlabs-state',
|
||||
});
|
||||
res.redirect(authorizationUrl);
|
||||
} catch (err) {
|
||||
console.error('SSO Login Error:', err);
|
||||
res.status(500).send('SSO Login Initialisierung fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/sso/callback', async (req, res) => {
|
||||
try {
|
||||
const client = await getOidcClient();
|
||||
const params = client.callbackParams(req);
|
||||
const redirectUri = process.env.SSO_REDIRECT_URI || 'https://tom.mischlabs.de/api/auth/sso/callback';
|
||||
const tokenSet = await client.callback(redirectUri, params, { state: 'mischlabs-state' });
|
||||
const userinfo = await client.userinfo(tokenSet.access_token);
|
||||
|
||||
const username = userinfo.preferred_username || userinfo.name || userinfo.sub;
|
||||
const email = userinfo.email;
|
||||
|
||||
if (!username) {
|
||||
return res.status(400).send('Kein Benutzername im OIDC Token gefunden.');
|
||||
}
|
||||
|
||||
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
|
||||
if (err) return res.status(500).send('Datenbankfehler');
|
||||
|
||||
const handleUserLogin = (dbUser) => {
|
||||
const token = jwt.sign(
|
||||
{ id: dbUser.id, username: dbUser.username, role: dbUser.role, created_at: dbUser.created_at },
|
||||
JWT_SECRET, { expiresIn: '7d' }
|
||||
);
|
||||
res.cookie('token', token, { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 });
|
||||
res.redirect('/admin.html');
|
||||
};
|
||||
|
||||
if (user) {
|
||||
handleUserLogin(user);
|
||||
} else {
|
||||
// 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 };
|
||||
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);
|
||||
});
|
||||
return res.json(result);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('SSO Callback Error:', err);
|
||||
res.status(500).send('SSO Authentifizierung fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 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.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));
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
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
210
brain.old
Normal 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*
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
20
index.html
20
index.html
@@ -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 <i class="fa fa-user"></i></a></li>
|
||||
<li><a href="#footer">About <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
1251
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user