Compare commits

..

9 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
18 changed files with 1419 additions and 511 deletions

21
.env.example Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,29 +6,89 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="/assets/css/main.min.css" />
<style>
.admin-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.25em; align-items: start; }
.admin-section { background: rgba(0,0,0,0.5); padding: 1.5em; border-radius: 4px; min-width: 0; }
.admin-section h2 { margin-top: 0; }
.admin-section.wide { grid-column: 1 / -1; }
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-row { cursor: pointer; transition: background 0.2s; }
.folder-row:hover { background: rgba(255,255,255,0.1); }
.folder-row.assigned { background: rgba(76,175,80,0.25); }
.folder-row.assigned:hover { background: rgba(76,175,80,0.35); }
.folder-status { font-weight: bold; }
.folder-status.yes { color: #4CAF50; }
.folder-status.no { color: #888; }
#folder-table-wrap { display: none; margin-top: 1em; }
@media screen and (max-width: 900px) {
.admin-grid { grid-template-columns: 1fr; }
.admin-section { padding: 1.25em; }
.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>
@@ -37,89 +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: 1180px; margin: 0 auto; width: 100%;">
<div class="admin-grid">
<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>Website aktualisieren</h2>
<p>Laedt das neueste Docker-Image und startet den Website-Container neu.</p>
<button id="sync-site">Sync starten</button>
<span id="sync-status" style="margin-left:10px;"></span>
</div>
<div class="admin-section wide">
<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 wide">
<h2>Ordner-Zuweisung</h2>
<p>Waehle einen Nutzer und klicke auf Ordner um sie zuzuweisen oder zu entfernen.</p>
<select id="user-select">
<option value="">Nutzer waehlen...</option>
</select>
<div id="folder-table-wrap">
<table id="folder-table">
<thead><tr><th><i class="fa fa-folder"></i> Ordner</th><th style="width:120px; text-align:center;">Status</th></tr></thead>
<tbody id="folder-tbody"></tbody>
</table>
<span id="assign-status" style="display:inline-block; margin-top: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}#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}
.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}

View File

@@ -1,83 +1,376 @@
$(document).ready(function() {
// Auth check
var currentAdmin = null;
var users = [];
var folders = [];
var assignedFolders = [];
var selectedUserId = "";
$.get('/api/auth/me')
.done(function(user) {
if(user.role !== 'admin') {
if (user.role !== 'admin') {
alert('Kein Zugriff!');
window.location.href = '/';
} else {
loadUsers();
loadFolders();
$('body').removeClass('is-preload');
return;
}
currentAdmin = user;
$('#admin-user-label').text(user.username || 'Admin');
refreshDashboard();
$('body').removeClass('is-preload');
})
.fail(function() { window.location.href = '/'; });
// Logout
function showView(name) {
$('.admin-view').removeClass('active').filter('[data-view="' + name + '"]').addClass('active');
$('.nav-item').removeClass('active').filter('[data-view="' + name + '"]').addClass('active');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
$('.admin-nav').on('click', '.nav-item', function() {
showView($(this).data('view'));
});
$('#logout-btn').click(function(e) {
e.preventDefault();
$.post('/api/auth/logout').done(function() { window.location.href = '/'; });
});
// ==================== NUTZER ====================
$('#refresh-dashboard').click(refreshDashboard);
function refreshDashboard() {
$.when(loadUsers(), loadFolders()).done(function() {
renderStats();
renderUsers();
renderUserSelect();
renderFolders();
if (selectedUserId) {
loadAssignments(selectedUserId);
} else {
renderSelectedUserSummary(null);
}
});
}
function loadUsers() {
$.get('/api/admin/users').done(function(users) {
$('#users-tbody').empty();
$('#user-select').html('<option value="">Nutzer waehlen...</option>');
return $.get('/api/admin/users').done(function(data) {
users = data || [];
}).fail(function(xhr) {
$('#users-tbody').html(
'<tr><td colspan="6" style="color:#e44d26;">Fehler beim Laden: HTTP ' +
xhr.status + '</td></tr>'
);
});
}
if (!users || users.length === 0) {
$('#users-tbody').html('<tr><td colspan="5" style="text-align:center;">Keine Nutzer gefunden</td></tr>');
function loadFolders() {
return $.get('/api/admin/folders').done(function(data) {
folders = data || [];
}).fail(function() {
$('#folders-overview').html('<p style="color:#e44d26;">Ordner konnten nicht geladen werden.</p>');
});
}
function roleCounts() {
return users.reduce(function(acc, user) {
var role = user.role || 'client';
acc[role] = (acc[role] || 0) + 1;
return acc;
}, { admin: 0, user: 0, client: 0 });
}
function totalAssignments() {
return users.reduce(function(total, user) {
return total + ((user.folders && user.folders.length) || 0);
}, 0);
}
function totalImages() {
return folders.reduce(function(total, folder) {
return total + (folder.image_count || 0);
}, 0);
}
function renderStats() {
var roles = roleCounts();
var expiredClients = users.filter(function(user) { return user.expired; }).length;
var unassignedUsers = users.filter(function(user) {
return (user.role || 'client') !== 'admin' && (!user.folders || !user.folders.length);
}).length;
var unsharedFolders = folders.filter(function(folder) {
return !folder.assigned_users || !folder.assigned_users.length;
}).length;
$('#stat-users').text(users.length);
$('#stat-users-sub').text(roles.admin + ' Admins, ' + roles.user + ' User');
$('#stat-folders').text(folders.length);
$('#stat-folders-sub').text(totalImages() + ' Bilder in privaten Ordnern');
$('#stat-access').text(totalAssignments());
$('#stat-access-sub').text(unassignedUsers + ' Nutzer ohne Ordner');
$('#stat-clients').text(roles.client);
$('#stat-clients-sub').text(expiredClients + ' abgelaufen');
renderAttentionList(unassignedUsers, unsharedFolders, expiredClients);
renderRoleList(roles);
}
function appendSignal($target, label, value, muted) {
var $item = $('<div></div>').addClass('signal-item');
$('<strong></strong>').text(label).appendTo($item);
$('<span></span>').toggleClass('muted', !!muted).text(value).appendTo($item);
$target.append($item);
}
function renderAttentionList(unassignedUsers, unsharedFolders, expiredClients) {
var $list = $('#attention-list').empty();
appendSignal($list, 'Nutzer ohne Ordner', unassignedUsers, unassignedUsers === 0);
appendSignal($list, 'Ordner ohne Freigabe', unsharedFolders, unsharedFolders === 0);
appendSignal($list, 'Abgelaufene Clients', expiredClients, expiredClients === 0);
appendSignal($list, 'Gesamtbilder privat', totalImages(), totalImages() === 0);
}
function renderRoleList(roles) {
var $list = $('#role-list').empty();
appendSignal($list, 'Admins', roles.admin || 0);
appendSignal($list, 'Permanente User', roles.user || 0);
appendSignal($list, 'Clients', roles.client || 0);
appendSignal($list, 'Freigaben', totalAssignments());
}
function getRoleBadge(role, expired) {
if (expired) return $('<span></span>').addClass('badge badge-expired').text('abgelaufen');
return $('<span></span>').addClass('badge badge-' + (role || 'client')).text(role || 'client');
}
function renderUsers() {
var $tbody = $('#users-tbody').empty();
if (!users.length) {
$tbody.html('<tr><td colspan="6" style="text-align:center;">Keine Nutzer gefunden</td></tr>');
return;
}
users.forEach(function(u) {
var role = u.role || 'unbekannt';
var badge = $('<span></span>').addClass('badge badge-' + role).text(role);
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-';
var info = created;
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');
}
if (role === 'client' && u.expires_at) {
var expDate = new Date(u.expires_at).toLocaleDateString('de-DE');
if (u.expired) {
badge = $('<span></span>').addClass('badge badge-expired').text('abgelaufen');
info = 'Abgelaufen: ' + expDate;
var $row = $('<tr></tr>');
var $userCell = $('<td></td>').text(user.username + ' ').append(getRoleBadge(role, user.expired));
var $foldersCell = $('<td></td>');
if (user.folders && user.folders.length) {
user.folders.forEach(function(folder) {
$('<span></span>').addClass('badge folder-badge').text(folder).appendTo($foldersCell);
});
} else {
info = 'Bis: ' + expDate;
}
$foldersCell.append($('<span></span>').addClass('muted').text('Keine privaten Ordner'));
}
var row = $('<tr></tr>');
var userCell = $('<td></td>').text(u.username + ' ').append(badge);
var actions = $('<td></td>');
var $actions = $('<td></td>');
$('<button type="button" class="btn-small user-access">Zugriff</button>')
.data('user-id', user.id)
.appendTo($actions);
$('<button type="button" class="btn-small user-password">PW</button>')
.data('user-id', u.id)
.data('username', u.username)
.appendTo(actions);
$('<button type="button" class="btn-small user-delete">X</button>')
.data('user-id', u.id)
.data('username', u.username)
.appendTo(actions);
.data('user-id', user.id)
.data('username', user.username)
.appendTo($actions);
$('<button type="button" class="btn-small btn-danger user-delete">Loeschen</button>')
.data('user-id', user.id)
.data('username', user.username)
.appendTo($actions);
row.append($('<td></td>').text(u.id));
row.append(userCell);
row.append($('<td></td>').text(role));
row.append($('<td></td>').text(info));
row.append(actions);
$('#users-tbody').append(row);
// Dropdown fuer Ordner-Zuweisung (nur non-admin)
if (role !== 'admin') {
$('#user-select').append($('<option></option>').val(u.id).text(u.username + ' (' + role + ')'));
$row.append($('<td></td>').text(user.id));
$row.append($userCell);
$row.append($('<td></td>').text(role));
$row.append($('<td></td>').text(status));
$row.append($foldersCell);
$row.append($actions);
$tbody.append($row);
});
}
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) {
var msg = 'HTTP ' + xhr.status + ': ' + xhr.statusText;
$('#users-tbody').html('<tr><td colspan="5" style="color:#e44d26;">Fehler beim Laden: ' + msg + '</td></tr>');
$('#folder-preview-note').text((xhr.responseJSON && xhr.responseJSON.error) || 'Ordner konnte nicht geladen werden.');
});
});
}
// Nutzer erstellen
$('#create-user-form').submit(function(e) {
e.preventDefault();
var username = $('#new-username').val().trim();
@@ -92,34 +385,33 @@ $(document).ready(function() {
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ username: username, password: password, role: role })
}).done(function(data) {
alert('Nutzer "' + username + '" erfolgreich erstellt!');
$('#new-username').val('');
$('#new-password').val('');
loadUsers();
}).done(function() {
$('#new-username, #new-password').val('');
refreshDashboard();
}).fail(function(xhr) {
var msg = 'Unbekannter Fehler';
if (xhr.responseJSON && xhr.responseJSON.error) msg = xhr.responseJSON.error;
else if (xhr.status) msg = 'HTTP ' + xhr.status + ': ' + xhr.statusText;
var msg = (xhr.responseJSON && xhr.responseJSON.error) || 'Unbekannter Fehler';
alert('Fehler beim Erstellen: ' + msg);
});
});
// Nutzer loeschen
function deleteUser(id, name) {
if (confirm('Nutzer "' + name + '" wirklich loeschen?')) {
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(loadUsers)
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
}
}
$('#users-tbody').on('click', '.user-delete', function() {
deleteUser($(this).data('user-id'), $(this).data('username'));
var id = $(this).data('user-id');
var name = $(this).data('username');
if (!confirm('Nutzer "' + name + '" wirklich loeschen?')) return;
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(function() {
if (String(selectedUserId) === String(id)) {
selectedUserId = "";
assignedFolders = [];
}
refreshDashboard();
})
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
});
// Passwort aendern (Admin setzt fuer anderen User)
function changePassword(id, name) {
$('#users-tbody').on('click', '.user-password', function() {
var id = $(this).data('user-id');
var name = $(this).data('username');
var newPw = prompt('Neues Passwort fuer "' + name + '":');
if (!newPw) return;
$.ajax({
@@ -128,142 +420,22 @@ $(document).ready(function() {
contentType: 'application/json',
data: JSON.stringify({ new_password: newPw })
}).done(function() {
alert('Passwort geaendert!');
alert('Passwort geaendert.');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
});
}
$('#users-tbody').on('click', '.user-password', function() {
changePassword($(this).data('user-id'), $(this).data('username'));
});
// ==================== ORDNER-ZUWEISUNG ====================
var allFolders = [];
var assignedFolders = [];
function loadFolders() {
$.get('/api/admin/folders').done(function(folders) {
allFolders = folders || [];
renderFolderTable();
});
}
function renderFolderTable() {
var $tbody = $('#folder-tbody');
$tbody.empty();
if (allFolders.length === 0) {
$tbody.html('<tr><td colspan="2" style="text-align:center;">Keine Unterordner gefunden. Erstelle Ordner in Nextcloud.</td></tr>');
return;
}
allFolders.forEach(function(f) {
var isAssigned = assignedFolders.indexOf(f) !== -1;
var cls = isAssigned ? 'folder-row assigned' : 'folder-row';
var statusCls = isAssigned ? 'folder-status yes' : 'folder-status no';
var statusText = isAssigned ? 'Zugewiesen' : '—';
$tbody.append(
'<tr class="' + cls + '" data-folder="' + f + '">' +
'<td><i class="fa fa-folder"></i> ' + f + '</td>' +
'<td style="text-align:center;"><span class="' + statusCls + '">' + statusText + '</span></td>' +
'</tr>'
);
});
}
// Bei User-Auswahl: zugewiesene Ordner laden und Tabelle anzeigen
$('#user-select').change(function() {
var uid = $(this).val();
if (!uid) {
$('#folder-table-wrap').hide();
assignedFolders = [];
return;
}
// Ordner neu laden falls noch leer, dann Zuweisung anzeigen
var showTable = function() {
$.get('/api/admin/assign?user_id=' + uid).done(function(folders) {
assignedFolders = folders || [];
renderFolderTable();
$('#folder-table-wrap').show();
}).fail(function() {
assignedFolders = [];
renderFolderTable();
$('#folder-table-wrap').show();
});
};
if (allFolders.length === 0) {
$.get('/api/admin/folders').done(function(folders) {
allFolders = folders || [];
showTable();
}).fail(function() {
allFolders = [];
showTable();
});
} else {
showTable();
}
});
// Klick auf Ordner-Zeile: Toggle + sofort speichern
$(document).on('click', '.folder-row', function() {
var uid = $('#user-select').val();
if (!uid) return;
var folder = $(this).data('folder');
var idx = assignedFolders.indexOf(folder);
if (idx !== -1) {
assignedFolders.splice(idx, 1);
} else {
assignedFolders.push(folder);
}
renderFolderTable();
$.ajax({
url: '/api/admin/assign',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ user_id: uid, folders: assignedFolders })
}).done(function() {
$('#assign-status').text('Gespeichert!').show().delay(1500).fadeOut();
}).fail(function() {
alert('Fehler beim Speichern');
});
});
// ==================== WEBSITE SYNC ====================
$('#sync-site').click(function() {
if (!confirm('Neueste Version laden und Website neu starten?')) return;
var $button = $(this);
var $status = $('#sync-status');
$button.prop('disabled', true);
$status.text('Sync laeuft...');
$.post('/api/admin/sync')
.done(function(res) {
$status.text(res.message || 'Sync gestartet. Website startet neu...');
window.setTimeout(function() {
window.location.reload();
}, 12000);
})
.fail(function(err) {
var msg = err.responseJSON ? err.responseJSON.error : 'Sync fehlgeschlagen';
$status.text(msg);
$button.prop('disabled', false);
});
});
// ==================== EIGENES PASSWORT ====================
$('#admin-pw-form').submit(function(e) {
e.preventDefault();
$.post('/api/auth/change-password', {
old_password: $('#admin-pw-old').val(),
new_password: $('#admin-pw-new').val()
}).done(function() {
alert('Passwort geaendert!');
alert('Passwort geaendert.');
$('#admin-pw-old, #admin-pw-new').val('');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
}).fail(function(xhr) {
alert(xhr.responseJSON ? xhr.responseJSON.error : 'Fehler');
});
});
});

View File

@@ -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) {
@@ -296,6 +303,10 @@
// ==================== 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;
@@ -481,6 +492,32 @@
});
}
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();
@@ -505,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>');
}

View File

@@ -6,6 +6,40 @@
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 {

View File

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

View File

@@ -4,9 +4,10 @@ const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const archiver = require('archiver');
const db = require('./database');
const crypto = require('crypto');
const { Issuer } = require('openid-client');
const app = express();
const JWT_SECRET = process.env.JWT_SECRET;
@@ -20,10 +21,7 @@ const DEV_FULLS_DIR = path.join(__dirname, '../images/fulls');
const DEV_THUMBS_DIR = path.join(__dirname, '../images/thumbs');
const FULLS_DIR = process.env.FULLS_DIR || (fs.existsSync(DEFAULT_FULLS_DIR) ? DEFAULT_FULLS_DIR : DEV_FULLS_DIR);
const THUMBS_DIR = process.env.THUMBS_DIR || (fs.existsSync(DEFAULT_THUMBS_DIR) ? DEFAULT_THUMBS_DIR : DEV_THUMBS_DIR);
const SYNC_IMAGE = process.env.SYNC_IMAGE || 'git.mischlabs.de/mrdiderot/photography:latest';
const SYNC_CONTAINER = process.env.SYNC_CONTAINER || 'photography-website';
const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i;
let syncInProgress = false;
console.log('Using full-size images at', FULLS_DIR);
console.log('Using thumbnails at', THUMBS_DIR);
@@ -90,6 +88,18 @@ function listSubfolders(dir) {
}
}
function getFolderInfo(folder) {
const fullPath = path.join(FULLS_DIR, folder);
const thumbPath = path.join(THUMBS_DIR, folder);
const images = listImagesInDir(fullPath);
return {
name: folder,
image_count: images.length,
thumb_count: listImagesInDir(thumbPath).length,
sample: images[0] || null
};
}
function isSafePath(baseDir, requestedPath) {
const resolvedBase = path.resolve(baseDir);
const resolvedPath = path.resolve(resolvedBase, requestedPath);
@@ -139,12 +149,6 @@ function serveProtectedImage(req, res) {
});
}
function runDocker(args, callback) {
execFile('docker', args, { timeout: 5 * 60 * 1000 }, (err, stdout, stderr) => {
callback(err, `${stdout || ''}${stderr || ''}`.trim());
});
}
// ==================== AUTH API ====================
app.post('/api/auth/login', (req, res) => {
@@ -181,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) {
@@ -206,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));
});
});
@@ -244,6 +381,15 @@ app.get('/api/admin/users', requireAdmin, (req, res) => {
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 };
@@ -253,10 +399,12 @@ app.get('/api/admin/users', requireAdmin, (req, res) => {
out.expires_at = expires.toISOString();
out.expired = new Date() > expires;
}
out.folders = foldersByUser[u.id] || [];
return out;
});
res.json(result);
});
});
});
app.post('/api/admin/users', requireAdmin, (req, res) => {
@@ -298,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) => {
@@ -317,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 (?, ?)');
@@ -327,41 +520,6 @@ app.post('/api/admin/assign', requireAdmin, (req, res) => {
});
});
app.post('/api/admin/sync', requireAdmin, (req, res) => {
if (syncInProgress) {
return res.status(409).json({ error: 'Sync laeuft bereits' });
}
syncInProgress = true;
runDocker(['pull', SYNC_IMAGE], (pullErr, pullOutput) => {
syncInProgress = false;
if (pullErr) {
console.error('Sync pull failed:', pullOutput || pullErr.message);
return res.status(500).json({
error: 'Docker pull fehlgeschlagen',
details: pullOutput || pullErr.message
});
}
res.json({
success: true,
message: 'Neues Image geladen. Container startet neu.',
output: pullOutput
});
setTimeout(() => {
runDocker(['restart', SYNC_CONTAINER], (restartErr, restartOutput) => {
if (restartErr) {
console.error('Sync restart failed:', restartOutput || restartErr.message);
return;
}
console.log('Sync restart complete:', restartOutput);
});
}, 500);
});
});
// ==================== DOWNLOAD API ====================
function isValidName(name) {
@@ -408,6 +566,10 @@ app.post('/api/download/selected', (req, res) => {
} 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);
@@ -453,11 +615,19 @@ app.get('/api/download/folder/:folderName', (req, res) => {
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) {
@@ -474,8 +644,7 @@ app.get('/api/download/folder/:folderName', (req, res) => {
archive.file(path.join(folderPath, name), { name });
});
archive.finalize();
});
});
}
// ==================== STATIC FILES ====================

210
brain.old Normal file
View File

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

View File

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

61
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"cookie-parser": "^1.4.6",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"openid-client": "^5.6.0",
"sqlite3": "^5.1.7"
},
"devDependencies": {
@@ -4389,6 +4390,15 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -5379,6 +5389,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5483,6 +5502,15 @@
"node": ">=0.10.0"
}
},
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -5503,6 +5531,39 @@
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/ordered-read-streams": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz",

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,15 +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": {