Compare commits
11 Commits
5266f36820
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea02bab3c | ||
|
|
75e4fbaa19 | ||
|
|
163d8a20f1 | ||
|
|
d9334dae00 | ||
|
|
3ad76e841d | ||
|
|
64fe667240 | ||
|
|
e6a11f2eed | ||
|
|
c211d2bf9d | ||
|
|
beb123b144 | ||
|
|
a588bd36d6 | ||
|
|
caf56b4117 |
21
.env.example
Normal file
21
.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# JWT Secret used for admin/user authentication tokens (change this to a secure random value)
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
|
||||
# SQLite Database path inside container (normally persistent volume, no need to change)
|
||||
# FULLS_DIR=/app/public/images/fulls
|
||||
# THUMBS_DIR=/app/public/images/thumbs
|
||||
|
||||
# ==========================================
|
||||
# Keycloak SSO (OIDC) Configuration
|
||||
# ==========================================
|
||||
# Base Keycloak URL Realm Authority
|
||||
SSO_AUTHORITY=https://auth.mischlabs.de/realms/mischlabs
|
||||
|
||||
# Client ID configured in Keycloak for Photography
|
||||
SSO_CLIENT_ID=pocketbase
|
||||
|
||||
# Client Secret retrieved from Keycloak (Credentials tab)
|
||||
SSO_CLIENT_SECRET=
|
||||
|
||||
# Public callback URI registered in Keycloak for the Photography app
|
||||
SSO_REDIRECT_URI=https://tom.mischlabs.de/api/auth/sso/callback
|
||||
67
.github/workflows/docker-build.yml
vendored
67
.github/workflows/docker-build.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build & Push Docker Image
|
||||
name: Build & Push Docker Image to Gitea Registry
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,66 +6,55 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: git.mischlabs.de
|
||||
REGISTRY_USER: MrDiderot
|
||||
REGISTRY_IMAGE: git.mischlabs.de/mrdiderot/photography
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
if: ${{ github.server_url == 'https://git.mischlabs.de' }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Docker CLI
|
||||
shell: sh
|
||||
run: |
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
docker --version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Docker CLI not found. Installing static binary..."
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-26.1.4.tgz | tar -xz -C /usr/local/bin --strip-components=1 docker/docker
|
||||
else
|
||||
echo "Neither curl nor wget found! Attempting package manager install..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
apt-get install -y docker.io
|
||||
apt-get update && apt-get install -y docker.io
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache docker-cli
|
||||
else
|
||||
echo "No supported package manager found to install Docker CLI"
|
||||
echo "Cannot install Docker CLI!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fi
|
||||
else
|
||||
echo "Docker CLI already installed."
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Check Docker daemon
|
||||
run: docker version
|
||||
|
||||
- name: Log in to package registry
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Log in to Gitea Package Registry
|
||||
shell: sh
|
||||
run: |
|
||||
if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_TOKEN" ]; then
|
||||
echo "Missing registry credentials."
|
||||
echo "Create repository secrets REGISTRY_USER and REGISTRY_TOKEN."
|
||||
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||
echo "Missing REGISTRY_TOKEN secret."
|
||||
echo "REGISTRY_TOKEN must be a Gitea access token with package write permission."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u "${{ env.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY_IMAGE }}:latest
|
||||
${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
|
||||
shell: sh
|
||||
run: |
|
||||
docker build -t ${{ env.REGISTRY_IMAGE }}:latest -t ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} .
|
||||
docker push ${{ env.REGISTRY_IMAGE }}:latest
|
||||
docker push ${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
|
||||
|
||||
12
Brain.md
12
Brain.md
@@ -10,7 +10,7 @@ Keine 1:1 Kopie, sondern eine individuell angepasste Version mit eigenen Ansprue
|
||||
- **Domain**: tom.mischlabs.de
|
||||
- **E-Mail**: tom@mischlabs.de
|
||||
- **Instagram**: @Mischkomposition
|
||||
- **GitHub Repo**: https://github.com/Kroonk/Photography.git
|
||||
- **Gitea Repo**: https://git.mischlabs.de/MrDiderot/Photography.git
|
||||
- **Lokaler Pfad**: d:\Vibecoding\Website\Photography
|
||||
|
||||
---
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
18
README.md
18
README.md
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
304
admin.html
304
admin.html
@@ -6,23 +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; }
|
||||
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; }
|
||||
.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">
|
||||
@@ -30,87 +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>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>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">
|
||||
<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>
|
||||
|
||||
2
assets/css/custom.min.css
vendored
2
assets/css/custom.min.css
vendored
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
210
brain.old
Normal file
@@ -0,0 +1,210 @@
|
||||
# Brain.md - Projekt-Wissensdatenbank
|
||||
|
||||
## Projektziel
|
||||
Eine eigene Fotografie-Portfolio-Website basierend auf dem Template von rampatra/photography.
|
||||
Keine 1:1 Kopie, sondern eine individuell angepasste Version mit eigenen Anspruechen.
|
||||
|
||||
## Besitzer & Kontakt
|
||||
- **Name**: Tom Misch
|
||||
- **Marke**: Mischkomposition
|
||||
- **Domain**: tom.mischlabs.de
|
||||
- **E-Mail**: tom@mischlabs.de
|
||||
- **Instagram**: @Mischkomposition
|
||||
- **Gitea Repo**: https://git.mischlabs.de/MrDiderot/Photography.git
|
||||
- **Lokaler Pfad**: d:\Vibecoding\Website\Photography
|
||||
|
||||
---
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
| Komponente | Technologie |
|
||||
|---|---|
|
||||
| Static Site Generator | **Jekyll** (Ruby-basiert) |
|
||||
| Backend | **Node.js / Express** (Auth, API, Static Serving) |
|
||||
| Datenbank | **SQLite** (Nutzerverwaltung, Bildzuweisung) |
|
||||
| Templating | Liquid Templates |
|
||||
| Styling | SCSS -> kompiliertes CSS |
|
||||
| JS Libraries | jQuery, Poptrox (Lightbox), EXIF.js |
|
||||
| Build-Tool | Gulp (SCSS kompilieren, JS minifizieren, Bilder resizen) |
|
||||
| Hosting | **UGREEN NAS (Docker)** via Node.js Express |
|
||||
| Containerisierung | Docker Multi-Stage (Jekyll Build + Node.js Backend) |
|
||||
| Auto-Update | **Watchtower** (prueft alle 5 Min auf neue Images) |
|
||||
| Fonts | FontAwesome, Google Fonts (Source Sans Pro) |
|
||||
| Paketmanager | npm (Node), Bundler (Ruby) |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
Photography/
|
||||
├── _config.yml # Zentrale Konfiguration (Titel, Social Links, EXIF etc.)
|
||||
├── _layouts/
|
||||
│ └── default.html # Basis-Layout (Head + Body + Scripts)
|
||||
├── _includes/
|
||||
│ ├── header.html # <head> Bereich: CSS, Fonts
|
||||
│ └── footer.html # Script-Einbindungen (jQuery, Poptrox, EXIF, Main)
|
||||
├── index.html # Hauptseite: Header, Galerie-Grid, Footer/About, Login-Panel
|
||||
├── admin.html # Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
|
||||
├── backend/
|
||||
│ ├── server.js # Express Backend (Auth, API, Static Files, Port 8090)
|
||||
│ └── database.js # SQLite Datenbank-Setup (Users, User-Images)
|
||||
├── assets/
|
||||
│ ├── css/ # Kompilierte CSS-Dateien (.min.css)
|
||||
│ ├── sass/ # SCSS Quelldateien
|
||||
│ │ ├── base/ # Reset, Typography, Page
|
||||
│ │ ├── components/ # Buttons, Forms, Icons, Panels, Poptrox
|
||||
│ │ ├── layout/ # Header, Footer, Main, Wrapper
|
||||
│ │ └── libs/ # Breakpoints, Mixins, Vars, Functions
|
||||
│ ├── js/ # JavaScript (jQuery, Poptrox, EXIF, Main, Admin)
|
||||
│ ├── fonts/ # FontAwesome Fonts
|
||||
│ └── webfonts/ # FA5 Webfonts
|
||||
├── images/
|
||||
│ ├── fulls/ # Vollaufloesung Bilder
|
||||
│ └── thumbs/ # Thumbnails (512px)
|
||||
├── gulpfile.mjs # Build: SCSS->CSS, JS minify, Image resize
|
||||
├── package.json # Node Dependencies (Express, SQLite, JWT, bcrypt)
|
||||
├── Gemfile # Ruby Dependencies (jekyll)
|
||||
├── Dockerfile # Multi-Stage: Jekyll Build + Node.js Runtime
|
||||
├── docker-compose.yml # Container + Watchtower
|
||||
├── entrypoint.sh # Thumbnail-Generierung + Node.js Start
|
||||
└── nginx.conf # (Legacy, nicht mehr aktiv - Express dient statische Dateien)
|
||||
```
|
||||
|
||||
## Wie die Galerie funktioniert (Dynamisch)
|
||||
|
||||
1. **Bilder hochladen**: Fotos in Nextcloud-Ordner `Websitebilder` ablegen
|
||||
2. **Volume-Mount**: Docker mountet den Nextcloud-Ordner als `/app/public/images/fulls/` (read-only)
|
||||
3. **Thumbnails**: `entrypoint.sh` generiert automatisch 512px Thumbnails via ImageMagick
|
||||
4. **Thumbnail-Check**: Alle 60 Sekunden prueft der Container auf neue Bilder
|
||||
5. **Express API**: `/images/fulls/` liefert JSON-Liste der Bilddateien
|
||||
6. **JavaScript (main.js)**: Fetcht die JSON-Liste und baut dynamisch `<article class="thumb">` Elemente
|
||||
7. **Poptrox-Lightbox**: Klick oeffnet Vollbild + EXIF-Daten (clientseitig via exif.js)
|
||||
8. **Download-Button**: In der Lightbox kann jedes Bild heruntergeladen werden
|
||||
|
||||
### Zugriffslogik
|
||||
- **Nicht eingeloggt**: Nur Root-Bilder (direkt in Websitebilder/) sichtbar
|
||||
- **Admin**: Alle Bilder (Root + alle Unterordner) sichtbar
|
||||
- **User (permanent)**: Root-Bilder + zugewiesene Unterordner
|
||||
- **Client (30 Tage)**: Root-Bilder + zugewiesene Unterordner, Account laeuft nach 30 Tagen ab
|
||||
|
||||
### Nextcloud-Integration
|
||||
- **Nextcloud-Pfad**: `/volume1/nextcloud/data/Tom/files/Websitebilder`
|
||||
- **Mount im Container**: `/app/public/images/fulls:ro`
|
||||
- **Thumbnails-Volume**: Docker-Volume `thumbs` (persistent)
|
||||
- **Datenbank-Volume**: Docker-Volume `database` (persistent, SQLite)
|
||||
- **Workflow**: Bild in Nextcloud hochladen -> max. 60s warten -> Seite neu laden -> fertig
|
||||
|
||||
## Login-System & Nutzerverwaltung
|
||||
|
||||
### Architektur
|
||||
- **Backend**: Node.js/Express auf Port 8090
|
||||
- **Auth**: JWT-Token als httpOnly Cookie (7 Tage gueltig)
|
||||
- **Datenbank**: SQLite unter `/app/data/database.sqlite`
|
||||
- **Passwort-Hashing**: bcryptjs (10 Rounds)
|
||||
|
||||
### Rollen
|
||||
| Rolle | Beschreibung | Ablauf |
|
||||
|---|---|---|
|
||||
| `admin` | Voller Zugriff, Nutzerverwaltung, alle Bilder | Nie |
|
||||
| `user` | Root-Bilder + zugewiesene Ordner | Nie |
|
||||
| `client` | Root-Bilder + zugewiesene Ordner | 30 Tage nach Erstellung |
|
||||
|
||||
### Ordner-basierte Bildzuweisung
|
||||
- Bilder direkt in `Websitebilder/` = oeffentlich (fuer alle sichtbar)
|
||||
- Bilder in Unterordnern (z.B. `Websitebilder/Hochzeit/`) = nur fuer zugewiesene Nutzer
|
||||
- Zuweisung erfolgt auf Ordner-Ebene (nicht einzelne Bilder)
|
||||
- Tabelle `user_folders` speichert Zuweisungen (user_id, folder_name)
|
||||
|
||||
### Initialer Admin
|
||||
- Es wird kein festes Standardkonto mehr angelegt.
|
||||
- Bestehende Installationen behalten Nutzer und Rollen im persistenten SQLite-Volume.
|
||||
- Fuer frische Installationen kann einmalig `INITIAL_ADMIN_USERNAME=MrDiderot` zusammen mit `INITIAL_ADMIN_PASSWORD_HASH` gesetzt werden.
|
||||
|
||||
### API-Endpunkte
|
||||
| Endpunkt | Methode | Beschreibung |
|
||||
|---|---|---|
|
||||
| `/api/auth/login` | POST | Login (username, password) |
|
||||
| `/api/auth/logout` | POST | Logout (Cookie loeschen) |
|
||||
| `/api/auth/me` | GET | Aktueller User |
|
||||
| `/api/auth/change-password` | POST | Eigenes Passwort aendern |
|
||||
| `/api/admin/users` | GET/POST | Nutzer auflisten / erstellen (mit Rolle) |
|
||||
| `/api/admin/users/:id` | DELETE | Nutzer loeschen (nicht sich selbst) |
|
||||
| `/api/admin/users/:id/password` | PUT | Admin aendert User-Passwort |
|
||||
| `/api/admin/folders` | GET | Verfuegbare Unterordner auflisten |
|
||||
| `/api/admin/assign` | GET/POST | Ordner-Zuweisungen anzeigen / setzen |
|
||||
| `/images/fulls/` | GET | Bilderliste (gefiltert nach User-Rolle + Ordnern) |
|
||||
|
||||
## Wichtige Konfiguration (_config.yml)
|
||||
|
||||
- `baseurl`: URL der Website
|
||||
- `title`, `subtitle`, `author`: Seitentitel und Autor
|
||||
- `header.title/subtitle`: Header-Anzeige
|
||||
- `footer.name/bio`: Footer-Infos
|
||||
- `social_urls`: Links zu Social Media
|
||||
- `exif`: Welche EXIF-Tags angezeigt werden (Model, FNumber, ExposureTime, ISO)
|
||||
|
||||
## Build-Befehle
|
||||
|
||||
| Befehl | Aktion |
|
||||
|---|---|
|
||||
| `bundle install` | Ruby Dependencies installieren |
|
||||
| `bundle exec jekyll serve` | Lokaler Dev-Server (nur Frontend) |
|
||||
| `npm install` | Node Dependencies installieren |
|
||||
| `gulp build` | SCSS kompilieren + JS minifizieren |
|
||||
| `gulp resize` | Bilder zu fulls (1024px) + thumbs (512px) |
|
||||
| `gulp` | Alles (build + resize) |
|
||||
|
||||
## Docker / NAS Hosting
|
||||
|
||||
- **NAS**: UGREEN mit UGOS PRO
|
||||
- **Port**: 8090 (intern und extern)
|
||||
- **Domain**: tom.mischlabs.de (via Cloudflare Tunnel)
|
||||
|
||||
### Docker-Dateien
|
||||
- `Dockerfile` - Multi-Stage: Ruby/Jekyll baut Site -> Node.js Express serviert alles
|
||||
- `docker-compose.yml` - Photography Container + Watchtower, Volumes fuer Bilder/Thumbs/DB
|
||||
- `entrypoint.sh` - Thumbnail-Generierung + Node.js Server-Start
|
||||
- `.dockerignore` - Haelt Build-Context klein (kein .git, node_modules etc.)
|
||||
|
||||
### Docker-Befehle (auf NAS via SSH)
|
||||
| Befehl | Aktion |
|
||||
|---|---|
|
||||
| `sudo docker compose up -d` | Container starten |
|
||||
| `sudo docker compose down` | Container stoppen |
|
||||
| `sudo docker compose logs -f photography` | Logs anzeigen |
|
||||
| `sudo docker compose pull && sudo docker compose up -d` | Manuelles Update |
|
||||
|
||||
### Deployment-Workflow (automatisch!)
|
||||
1. Aenderungen lokal machen (Code, Design)
|
||||
2. `gulp build` ausfuehren (JS + CSS minifizieren)
|
||||
3. Git commit & push nach master
|
||||
4. Gitea Actions baut automatisch Docker Image -> lokales Gitea Container Registry
|
||||
5. Watchtower auf NAS erkennt neues Image (alle 5 Min)
|
||||
6. Watchtower aktualisiert Container automatisch
|
||||
7. Fertig - Website aktualisiert ohne SSH!
|
||||
|
||||
## Anpassungs-Roadmap
|
||||
|
||||
- [x] Titel, Subtitle, Autor anpassen (Mischkomposition / Tom Misch)
|
||||
- [x] Social Media Links aktualisieren (Instagram: @Mischkomposition)
|
||||
- [x] Sponsor-Bereich entfernen
|
||||
- [x] Kontaktformular auf tom@mischlabs.de konfiguriert (Formsubmit.co)
|
||||
- [x] Google Analytics vom Originalautor entfernt
|
||||
- [x] Custom Domain: tom.mischlabs.de eingetragen
|
||||
- [x] Nextcloud-Integration (Bilder aus Websitebilder-Ordner)
|
||||
- [x] Dynamische Galerie (JS statt Jekyll-Loop)
|
||||
- [x] Automatische Thumbnail-Generierung
|
||||
- [x] Login-System mit JWT + SQLite
|
||||
- [x] Admin-Dashboard (Nutzerverwaltung, Bildzuweisung)
|
||||
- [x] Kunden-Portal (zugewiesene Bilder + Download)
|
||||
- [x] Watchtower fuer automatische Updates
|
||||
- [x] 3-Rollen-System (admin/user/client mit 30-Tage-Ablauf)
|
||||
- [x] Ordner-basierte Bildzuweisung (Unterordner = private Kundenbilder)
|
||||
- [x] Passwort-Aenderung (Self-Service + Admin kann fuer andere aendern)
|
||||
- [ ] Footer Bio-Text schreiben (optional)
|
||||
- [ ] Farbschema/Design anpassen
|
||||
- [ ] Eigene Kategorien/Alben erstellen (Feature-Erweiterung)
|
||||
- [ ] Admin-Passwort aendern!
|
||||
|
||||
---
|
||||
|
||||
*Letzte Aktualisierung: 2026-04-12*
|
||||
@@ -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
61
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user