Compare commits

..

35 Commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:40:18 +02:00
Kroonk
d393630357 Update README to reflect Mischkomposition features and remove original creator info
Some checks failed
Build & Push Docker Image / build-and-push (push) Failing after 1m52s
2026-04-12 21:41:31 +02:00
27 changed files with 3193 additions and 381 deletions

View File

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

21
.env.example Normal file
View File

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

View File

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

View File

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

View File

@@ -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
# ImageMagick fuer Thumbnails, Build-Tools fuer sqlite3 native bindings
RUN apk add --no-cache imagemagick 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

View File

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

113
README.md
View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,90 +1,417 @@
$(document).ready(function() {
// Auth check
var currentAdmin = null;
var users = [];
var folders = [];
var assignedFolders = [];
var selectedUserId = "";
$.get('/api/auth/me')
.done(function(user) {
if (user.role !== 'admin') {
alert('Kein Zugriff!');
window.location.href = '/';
} else {
loadUsers();
loadFolders();
$('body').removeClass('is-preload');
return;
}
currentAdmin = user;
$('#admin-user-label').text(user.username || 'Admin');
refreshDashboard();
$('body').removeClass('is-preload');
})
.fail(function() { window.location.href = '/'; });
// Logout
function showView(name) {
$('.admin-view').removeClass('active').filter('[data-view="' + name + '"]').addClass('active');
$('.nav-item').removeClass('active').filter('[data-view="' + name + '"]').addClass('active');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
$('.admin-nav').on('click', '.nav-item', function() {
showView($(this).data('view'));
});
$('#logout-btn').click(function(e) {
e.preventDefault();
$.post('/api/auth/logout').done(function() { window.location.href = '/'; });
});
// ==================== NUTZER ====================
$('#refresh-dashboard').click(refreshDashboard);
function refreshDashboard() {
$.when(loadUsers(), loadFolders()).done(function() {
renderStats();
renderUsers();
renderUserSelect();
renderFolders();
if (selectedUserId) {
loadAssignments(selectedUserId);
} else {
renderSelectedUserSummary(null);
}
});
}
function loadUsers() {
$.get('/api/admin/users').done(function(users) {
$('#users-tbody').empty();
$('#user-select').html('<option value="">Nutzer waehlen...</option>');
users.forEach(function(u) {
var badge = '<span class="badge badge-' + u.role + '">' + u.role + '</span>';
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-';
var info = created;
if (u.role === 'client' && u.expires_at) {
var expDate = new Date(u.expires_at).toLocaleDateString('de-DE');
if (u.expired) {
badge = '<span class="badge badge-expired">abgelaufen</span>';
info = 'Abgelaufen: ' + expDate;
} else {
info = 'Bis: ' + expDate;
}
}
var actions = '<button class="btn-small" onclick="changePassword(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">PW</button>';
actions += '<button class="btn-small" onclick="deleteUser(' + u.id + ', \'' + u.username.replace(/'/g, "\\'") + '\')">X</button>';
$('#users-tbody').append(
'<tr><td>' + u.id + '</td><td>' + u.username + ' ' + badge +
'</td><td>' + u.role + '</td><td>' + info +
'</td><td>' + actions + '</td></tr>'
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>'
);
// Dropdown fuer Ordner-Zuweisung (nur non-admin)
if (u.role !== 'admin') {
$('#user-select').append('<option value="' + u.id + '">' + u.username + ' (' + u.role + ')</option>');
}
});
});
}
// Nutzer erstellen
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: $('#new-role').val()
}).done(function() {
$('#new-username').val('');
$('#new-password').val('');
loadUsers();
}).fail(function(err) {
alert('Fehler: ' + (err.responseJSON ? err.responseJSON.error : 'Unbekannt'));
});
});
// Nutzer loeschen
window.deleteUser = function(id, name) {
if (confirm('Nutzer "' + name + '" wirklich loeschen?')) {
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(loadUsers)
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
var username = $('#new-username').val().trim();
var password = $('#new-password').val();
var role = $('#new-role').val();
if (!username || !password) {
alert('Bitte Username und Passwort eingeben.');
return;
}
};
$.ajax({
url: '/api/admin/users',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ username: username, password: password, role: role })
}).done(function() {
$('#new-username, #new-password').val('');
refreshDashboard();
}).fail(function(xhr) {
var msg = (xhr.responseJSON && xhr.responseJSON.error) || 'Unbekannter Fehler';
alert('Fehler beim Erstellen: ' + msg);
});
});
// Passwort aendern (Admin setzt fuer anderen User)
window.changePassword = function(id, name) {
$('#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'); });
});
$('#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({
@@ -93,64 +420,11 @@ $(document).ready(function() {
contentType: 'application/json',
data: JSON.stringify({ new_password: newPw })
}).done(function() {
alert('Passwort geaendert!');
alert('Passwort geaendert.');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
});
};
// ==================== ORDNER-ZUWEISUNG ====================
function loadFolders() {
$.get('/api/admin/folders').done(function(folders) {
$('#folder-checkboxes').empty();
if (folders.length === 0) {
$('#folder-checkboxes').html('<p>Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.</p>');
return;
}
folders.forEach(function(f) {
$('#folder-checkboxes').append(
'<div class="folder-check"><label>' +
'<input type="checkbox" value="' + f + '" class="folder-cb"/> ' +
'<i class="fa fa-folder"></i> ' + f +
'</label></div>'
);
});
});
}
// Bei User-Auswahl: zugewiesene Ordner laden
$('#user-select').change(function() {
var uid = $(this).val();
$('.folder-cb').prop('checked', false);
if (!uid) return;
$.get('/api/admin/assign?user_id=' + uid).done(function(assignedFolders) {
assignedFolders.forEach(function(f) {
$('.folder-cb[value="' + f + '"]').prop('checked', true);
});
});
});
// Zuweisung speichern
$('#save-assignments').click(function() {
var uid = $('#user-select').val();
if (!uid) return alert('Bitte Nutzer waehlen');
var selected = [];
$('.folder-cb:checked').each(function() { selected.push($(this).val()); });
$.ajax({
url: '/api/admin/assign',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ user_id: uid, folders: selected })
}).done(function() {
$('#assign-status').text('Gespeichert!').fadeIn().delay(2000).fadeOut();
}).fail(function() {
alert('Fehler beim Speichern');
});
});
// ==================== EIGENES PASSWORT ====================
$('#admin-pw-form').submit(function(e) {
e.preventDefault();
@@ -158,10 +432,10 @@ $(document).ready(function() {
old_password: $('#admin-pw-old').val(),
new_password: $('#admin-pw-new').val()
}).done(function() {
alert('Passwort geaendert!');
alert('Passwort geaendert.');
$('#admin-pw-old, #admin-pw-new').val('');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
}).fail(function(xhr) {
alert(xhr.responseJSON ? xhr.responseJSON.error : 'Fehler');
});
});
});

View File

@@ -1 +1 @@
$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer waehlen...</option>'),e.forEach(function(e){var n='<span class="badge badge-'+e.role+'">'+e.role+"</span>",a=e.created_at?new Date(e.created_at).toLocaleDateString("de-DE"):"-";if("client"===e.role&&e.expires_at){var t=new Date(e.expires_at).toLocaleDateString("de-DE");e.expired?(n='<span class="badge badge-expired">abgelaufen</span>',a="Abgelaufen: "+t):a="Bis: "+t}var r='<button class="btn-small" onclick="changePassword('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">PW</button>";r+='<button class="btn-small" onclick="deleteUser('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">X</button>",$("#users-tbody").append("<tr><td>"+e.id+"</td><td>"+e.username+" "+n+"</td><td>"+e.role+"</td><td>"+a+"</td><td>"+r+"</td></tr>"),"admin"!==e.role&&$("#user-select").append('<option value="'+e.id+'">'+e.username+" ("+e.role+")</option>")})})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/api/admin/folders").done(function(e){$("#folder-checkboxes").empty(),0!==e.length?e.forEach(function(e){$("#folder-checkboxes").append('<div class="folder-check"><label><input type="checkbox" value="'+e+'" class="folder-cb"/> <i class="fa fa-folder"></i> '+e+"</label></div>")}):$("#folder-checkboxes").html("<p>Keine Unterordner in Websitebilder gefunden. Erstelle Ordner in Nextcloud.</p>")}),$("body").removeClass("is-preload"))}).fail(function(){window.location.href="/"}),$("#logout-btn").click(function(e){e.preventDefault(),$.post("/api/auth/logout").done(function(){window.location.href="/"})}),$("#create-user-form").submit(function(n){n.preventDefault(),$.post("/api/admin/users",{username:$("#new-username").val(),password:$("#new-password").val(),role:$("#new-role").val()}).done(function(){$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){alert("Fehler: "+(e.responseJSON?e.responseJSON.error:"Unbekannt"))})}),window.deleteUser=function(n,a){confirm('Nutzer "'+a+'" wirklich loeschen?')&&$.ajax({url:"/api/admin/users/"+n,type:"DELETE"}).done(e).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},window.changePassword=function(e,n){var a=prompt('Neues Passwort fuer "'+n+'":');a&&$.ajax({url:"/api/admin/users/"+e+"/password",type:"PUT",contentType:"application/json",data:JSON.stringify({new_password:a})}).done(function(){alert("Passwort geaendert!")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},$("#user-select").change(function(){var e=$(this).val();$(".folder-cb").prop("checked",!1),e&&$.get("/api/admin/assign?user_id="+e).done(function(e){e.forEach(function(e){$('.folder-cb[value="'+e+'"]').prop("checked",!0)})})}),$("#save-assignments").click(function(){var e=$("#user-select").val();if(!e)return alert("Bitte Nutzer waehlen");var n=[];$(".folder-cb:checked").each(function(){n.push($(this).val())}),$.ajax({url:"/api/admin/assign",type:"POST",contentType:"application/json",data:JSON.stringify({user_id:e,folders:n})}).done(function(){$("#assign-status").text("Gespeichert!").fadeIn().delay(2e3).fadeOut()}).fail(function(){alert("Fehler beim Speichern")})}),$("#admin-pw-form").submit(function(e){e.preventDefault(),$.post("/api/auth/change-password",{old_password:$("#admin-pw-old").val(),new_password:$("#admin-pw-new").val()}).done(function(){alert("Passwort geaendert!"),$("#admin-pw-old, #admin-pw-new").val("")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})})});
$(document).ready(function(){function e(){$.get("/api/admin/users").done(function(e){$("#users-tbody").empty(),$("#user-select").html('<option value="">Nutzer waehlen...</option>'),e&&0!==e.length?e.forEach(function(e){var t=e.role||"unbekannt",n='<span class="badge badge-'+t+'">'+t+"</span>",a=e.created_at?new Date(e.created_at).toLocaleDateString("de-DE"):"-";if("client"===t&&e.expires_at){var r=new Date(e.expires_at).toLocaleDateString("de-DE");e.expired?(n='<span class="badge badge-expired">abgelaufen</span>',a="Abgelaufen: "+r):a="Bis: "+r}var o='<button class="btn-small" onclick="changePassword('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">PW</button>";o+='<button class="btn-small" onclick="deleteUser('+e.id+", '"+e.username.replace(/'/g,"\\'")+"')\">X</button>",$("#users-tbody").append("<tr><td>"+e.id+"</td><td>"+e.username+" "+n+"</td><td>"+t+"</td><td>"+a+"</td><td>"+o+"</td></tr>"),"admin"!==t&&$("#user-select").append('<option value="'+e.id+'">'+e.username+" ("+t+")</option>")}):$("#users-tbody").html('<tr><td colspan="5" style="text-align:center;">Keine Nutzer gefunden</td></tr>')}).fail(function(e){var t="HTTP "+e.status+": "+e.statusText;$("#users-tbody").html('<tr><td colspan="5" style="color:#e44d26;">Fehler beim Laden: '+t+"</td></tr>")})}$.get("/api/auth/me").done(function(n){"admin"!==n.role?(alert("Kein Zugriff!"),window.location.href="/"):(e(),$.get("/api/admin/folders").done(function(e){t=e}),$("body").removeClass("is-preload"))}).fail(function(){window.location.href="/"}),$("#logout-btn").click(function(e){e.preventDefault(),$.post("/api/auth/logout").done(function(){window.location.href="/"})}),$("#create-user-form").submit(function(t){t.preventDefault();var n=$("#new-username").val().trim(),a=$("#new-password").val(),r=$("#new-role").val();n&&a?$.ajax({url:"/api/admin/users",type:"POST",contentType:"application/json",data:JSON.stringify({username:n,password:a,role:r})}).done(function(t){alert('Nutzer "'+n+'" erfolgreich erstellt!'),$("#new-username").val(""),$("#new-password").val(""),e()}).fail(function(e){var t="Unbekannter Fehler";e.responseJSON&&e.responseJSON.error?t=e.responseJSON.error:e.status&&(t="HTTP "+e.status+": "+e.statusText),alert("Fehler beim Erstellen: "+t)}):alert("Bitte Username und Passwort eingeben.")}),window.deleteUser=function(t,n){confirm('Nutzer "'+n+'" wirklich loeschen?')&&$.ajax({url:"/api/admin/users/"+t,type:"DELETE"}).done(e).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})},window.changePassword=function(e,t){var n=prompt('Neues Passwort fuer "'+t+'":');n&&$.ajax({url:"/api/admin/users/"+e+"/password",type:"PUT",contentType:"application/json",data:JSON.stringify({new_password:n})}).done(function(){alert("Passwort geaendert!")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})};var t=[],n=[];function a(){var e=$("#folder-tbody");e.empty(),0!==t.length?t.forEach(function(t){var a=-1!==n.indexOf(t),r=a?"folder-row assigned":"folder-row",o=a?"folder-status yes":"folder-status no",s=a?"Zugewiesen":"—";e.append('<tr class="'+r+'" data-folder="'+t+'"><td><i class="fa fa-folder"></i> '+t+'</td><td style="text-align:center;"><span class="'+o+'">'+s+"</span></td></tr>")}):e.html('<tr><td colspan="2" style="text-align:center;">Keine Unterordner gefunden. Erstelle Ordner in Nextcloud.</td></tr>')}$("#user-select").change(function(){var e=$(this).val();if(!e)return $("#folder-table-wrap").hide(),void(n=[]);var r=function(){$.get("/api/admin/assign?user_id="+e).done(function(e){n=e||[],a(),$("#folder-table-wrap").show()}).fail(function(){n=[],a(),$("#folder-table-wrap").show()})};0===t.length?$.get("/api/admin/folders").done(function(e){t=e||[],r()}).fail(function(){t=[],r()}):r()}),$(document).on("click",".folder-row",function(){var e=$("#user-select").val();if(e){var t=$(this).data("folder"),r=n.indexOf(t);-1!==r?n.splice(r,1):n.push(t),a(),$.ajax({url:"/api/admin/assign",type:"POST",contentType:"application/json",data:JSON.stringify({user_id:e,folders:n})}).done(function(){$("#assign-status").text("Gespeichert!").show().delay(1500).fadeOut()}).fail(function(){alert("Fehler beim Speichern")})}}),$("#admin-pw-form").submit(function(e){e.preventDefault(),$.post("/api/auth/change-password",{old_password:$("#admin-pw-old").val(),new_password:$("#admin-pw-new").val()}).done(function(){alert("Passwort geaendert!"),$("#admin-pw-old, #admin-pw-new").val("")}).fail(function(e){alert(e.responseJSON?e.responseJSON.error:"Fehler")})})});

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,57 @@ 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 (
@@ -17,9 +68,53 @@ db.serialize(() => {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Migration: created_at hinzufuegen falls Tabelle schon existiert
db.run(`ALTER TABLE users ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP`, (err) => {
// Ignoriere Fehler wenn Spalte schon existiert
// 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)
@@ -38,14 +133,6 @@ db.serialize(() => {
UNIQUE(user_id, image_name)
)`);
// Default-Admin erstellen wenn keiner existiert
db.get('SELECT * FROM users WHERE username = ?', ['admin'], (err, row) => {
if (!row) {
const hash = bcrypt.hashSync('admin123', 10);
db.run('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']);
console.log('Default admin created: admin / admin123');
}
});
});
module.exports = db;

View File

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

210
brain.old Normal file
View File

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

View File

@@ -1,9 +1,17 @@
services:
photography:
image: ghcr.io/kroonk/photography:latest
image: git.mischlabs.de/mrdiderot/photography:latest
container_name: photography-website
ports:
- "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
@@ -11,25 +19,19 @@ services:
- thumbs:/app/public/images/thumbs
# SQLite Database (persistent)
- database:/app/data
# Zugriff auf Docker & Credentials fuer den One-Click "Website Aktualisieren" Button
- /var/run/docker.sock:/var/run/docker.sock
- ~/.docker/config.json:/root/.docker/config.json:ro
restart: unless-stopped
# Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert den Container
# Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert Container
watchtower:
image: containrrr/watchtower
container_name: watchtower
# Nur unsere Webseite updaten, andere UGREEN Container ignorieren!
command: photography-website
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# GitHub Login von deinem NAS uebernehmen, um ghcr.io lesen zu duerfen
- ~/.docker/config.json:/config.json:ro
# 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
- WATCHTOWER_LABEL_ENABLE=false
restart: unless-stopped
volumes:

View File

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

View File

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

1251
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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