Compare commits

...

10 Commits
v1.0.0 ... main

Author SHA1 Message Date
Weapie
e7d8d1a856 Release 1.0.1
All checks were successful
check / verify (push) Successful in 3m30s
check / publish (push) Successful in 3m4s
2026-10-08 15:41:59 +02:00
Weapie
77dffd46f0 Merge security, RBAC and production deployment improvements 2026-10-08 15:41:43 +02:00
Weapie
c28c7364cb feat: separate checklist editing permission from task updates
All checks were successful
check / verify (push) Successful in 3m3s
check / publish (push) Has been skipped
2026-10-08 15:36:54 +02:00
Weapie
238dc11848 deploy: run administrator seed after access and upload migrations
All checks were successful
check / verify (push) Successful in 3m8s
check / publish (push) Has been skipped
2026-10-08 14:48:49 +02:00
Weapie
9317a6131f deploy: add production Compose without Traefik
All checks were successful
check / verify (push) Successful in 3m2s
check / publish (push) Has been skipped
2026-10-08 13:42:27 +02:00
Weapie
dd79dde532 deploy: add standalone production Compose for Dokploy and existing data
Some checks failed
check / publish (push) Blocked by required conditions
check / verify (push) Has been cancelled
2026-10-08 13:40:05 +02:00
Weapie
90c85083bc docs: explain Cloudflare registry upload limit and direct TLS prerequisite
All checks were successful
check / verify (push) Successful in 3m0s
check / publish (push) Has been skipped
2026-10-08 11:33:20 +02:00
Weapie
6fd5e21008 Clarify forwarded protocol precedence in Gitea registry diagnostics
All checks were successful
check / verify (push) Successful in 3m1s
check / publish (push) Has been skipped
2026-10-08 09:00:49 +02:00
Weapie
7be231409f Fail early when Gitea advertises an insecure registry token endpoint
All checks were successful
check / verify (push) Successful in 3m10s
check / publish (push) Has been skipped
2026-10-08 08:45:52 +02:00
Weapie
74c1e7130c Document HTTPS registry authentication configuration [skip ci] 2026-10-07 10:57:26 +02:00
19 changed files with 553 additions and 15 deletions

31
.env.production.example Normal file
View File

@ -0,0 +1,31 @@
# Copy to .env.production or enter in Dokploy's Compose environment settings.
# Verify that both release images have been published before deployment.
TASKMANAGER_VERSION=1.0.1
POSTGRES_USER=taskmanager
POSTGRES_DB=taskmanager
POSTGRES_PASSWORD=
# Match the DB credentials above. URL-encode special characters in this URL.
DATABASE_URL=
NEXTAUTH_URL=
NEXTAUTH_SECRET=
# Required only when no system administrator exists. Remove after bootstrap.
BOOTSTRAP_EMAIL=
BOOTSTRAP_PASSWORD=
# Explicit target volumes: restore your backup here BEFORE deploying app/worker.
POSTGRES_VOLUME=
UPLOADS_VOLUME=
TRAEFIK_NETWORK=dokploy-network
# Only docker-compose.production.direct.yml (no Traefik):
APP_BIND_ADDRESS=127.0.0.1
APP_PORT=3000
APP_TIMEZONE=Europe/Berlin
TRUST_PROXY=false
GROUP_UPLOAD_QUOTA_MB=1024
ARCHIVE_RETENTION_DAYS=0
SMTP_URL=
MAIL_FROM=
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REQUIRED_ACR=
WEBHOOK_ALLOWED_HOSTS=

View File

@ -62,6 +62,8 @@ jobs:
RELEASE_REF: ${{ github.ref }}
run: |
node scripts/release-version.mjs >> "$GITHUB_OUTPUT"
- name: Verify HTTPS registry authentication endpoint
run: node scripts/check-registry.mjs
- uses: docker/login-action@v3
with:
registry: git.jfritzsche.de

96
PRODUCTION.md Normal file
View File

@ -0,0 +1,96 @@
# Produktion mit Dokploy und Registry-Images
`docker-compose.production.yml` ist eine eigenständige Compose-Datei. Sie benötigt keine andere Compose-Datei und keinen lokalen Build. App, Migration und Worker verwenden dieselbe explizite Release-Version. Ein erfolgreicher Registry-Publish beider Images ist Voraussetzung; ein Git-Tag allein bedeutet noch nicht, dass die Images verfuegbar sind. Siehe [REGISTRY.md](REGISTRY.md).
## Konfiguration
### Variante ohne Traefik
`docker-compose.production.direct.yml` ist ebenfalls eigenständig und benötigt weder Dokploy noch ein externes Proxy-Netz. Sie veröffentlicht die App standardmäßig auf `127.0.0.1:3000` des Docker-Hosts. Die übrigen Dienste, Daten-Volumes und Migrationen entsprechen der Traefik-Variante. Nur **eine** der beiden Compose-Dateien verwenden.
`APP_PORT` bestimmt den Host-Port. `APP_BIND_ADDRESS=127.0.0.1` eignet sich für einen auf demselben Host laufenden HTTPS-Proxy, beispielsweise Nginx oder Caddy. Bei einem separaten vorgeschalteten Proxy die erreichbare private Host-IP einstellen und den Port entsprechend auf diesen Proxy begrenzen. `0.0.0.0` bindet an alle IPv4-Schnittstellen. `TRAEFIK_NETWORK` wird in dieser Variante nicht verwendet.
Die App selbst stellt HTTP bereit. Die öffentliche Produktionsadresse in `NEXTAUTH_URL` bleibt HTTPS; TLS muss deshalb ein vorhandener HTTPS-Proxy oder Load Balancer übernehmen. Die Datei stellt keinen eigenen TLS-Dienst bereit und deaktiviert die HTTPS-Prüfung der Anwendung nicht. Eine Nginx-Vorlage liegt unter [deploy/nginx.conf.example](deploy/nginx.conf.example).
Für alle nachfolgenden CLI-Schritte und die Datenübernahme diese Funktion verwenden:
```bash
dc() { docker compose --env-file .env.production -p taskmanager-next -f docker-compose.production.direct.yml "$@"; }
dc config --quiet
```
Den Projektnamen konsistent auf den Namen der vorbereiteten Zielinstallation setzen. Die spätere Funktionsdefinition für die Dokploy-Variante überspringen. Nach Restore, Migration und Start lässt sich die App lokal prüfen:
```bash
curl --fail http://127.0.0.1:3000/api/health
```
Bei abweichendem `APP_PORT` oder `APP_BIND_ADDRESS` die Prüfadresse entsprechend anpassen.
### Variante mit Dokploy/Traefik
In Dokploy ein Compose-Projekt mit `docker-compose.production.yml` konfigurieren. Die Werte aus `.env.production.example` in die Compose-Umgebung übernehmen. Alternativ auf dem Server:
```bash
cp .env.production.example .env.production
chmod 600 .env.production
openssl rand -hex 32 # NEXTAUTH_SECRET
openssl rand -hex 24 # Passwort für eine neue Ziel-Datenbank
```
Pflichtwerte:
| Variable | Wert |
| --- | --- |
| `TASKMANAGER_VERSION` | Tatsächlich veröffentlichte Version, z.B. `1.0.1` |
| `POSTGRES_PASSWORD` | Passwort der Ziel-Datenbank |
| `DATABASE_URL` | `postgresql://taskmanager:PASSWORT@db:5432/taskmanager` (Sonderzeichen URL-kodieren) |
| `NEXTAUTH_URL` | Öffentliche HTTPS-Adresse der Aufgabenverwaltung |
| `NEXTAUTH_SECRET` | Zufälliges Secret mit mindestens 32 Zeichen |
| `POSTGRES_VOLUME` | Exakter Name des vorbereiteten Ziel-Volumes für PostgreSQL 16 |
| `UPLOADS_VOLUME` | Exakter Name des Ziel-Volumes mit wiederhergestellten Anhängen |
| `TRAEFIK_NETWORK` | Vorhandenes Traefik-Netz, standardmäßig `dokploy-network` |
`POSTGRES_USER` und `POSTGRES_DB` sind standardmäßig `taskmanager`; bei Änderungen die DATABASE_URL entsprechend anpassen. Bei einem bereits initialisierten Daten-Volume ändern diese Variablen weder vorhandene Benutzer noch Passwörter. Keine PostgreSQL-Daten einer anderen Hauptversion direkt einhängen.
Die App hängt am privaten Compose-Netz und am Proxy-Netz. DB, Migrator und Worker hängen nur am Compose-Netz; kein Dienst veröffentlicht Host-Ports. In Dokploy unter Domains die gewünschte Domain dem Dienst **app**, Container-Port **3000**, zuweisen und HTTPS mit Zertifikat aktivieren. Dokploy verwaltet dafür die Traefik-Router. Die Compose-Datei allein legt keinen Domain-Router an. Keine alten statischen `/uploads`-Freigaben übernehmen. `TRUST_PROXY=false` belassen, bis der Proxy eingehendes `X-Real-IP` nachweislich zuverlässig ersetzt.
## Bestehende Daten übernehmen
Vor dem ersten vollständigen Deployment [UPGRADE.md](UPGRADE.md) ausführen: Altanwendung stoppen, zusammengehöriges Datenbank-/Dateibackup erstellen, in separate Ziel-Volumes wiederherstellen und abnehmen. Die Compose-Datei kopiert keine Daten aus einer anderen Installation. Externe Volumes werden absichtlich nicht automatisch angelegt: ein falscher Name soll zum Fehler führen, statt unbemerkt eine leere Datenbank zu starten.
Nur für eine **neue, separate Zielinstallation** zwei noch nicht vorhandene Volume-Namen wählen, deren Nichtexistenz prüfen und sie dann mit `docker volume create NAME` anlegen. Namen in der Umgebung hinterlegen. Alte Volumes unangetastet lassen. Für die Befehle aus UPGRADE.md stets denselben Projektnamen und diese Compose-Datei verwenden. In Bash hilft folgende Funktion (auch für Restore und Abnahme):
```bash
dc() { docker compose --env-file .env.production -p taskmanager-next -f docker-compose.production.yml "$@"; }
dc config --quiet
docker login git.jfritzsche.de --username jf
dc pull
dc up -d --wait db
# Jetzt DB-Dump und Anhänge gemäß UPGRADE.md wiederherstellen.
# Dort "docker compose -p taskmanager-next" jeweils durch "dc" ersetzen.
```
In Dokploy stattdessen dessen tatsächlichen Projektnamen und dieselbe Umgebung verwenden; nicht parallel ein zweites CLI-Projekt auf denselben DB-Volumes starten. Bei privaten Images die Registry-Anmeldung auch für Dokploy hinterlegen. Zum Wiederherstellen der Dateien nutzt UPGRADE.md kurzzeitig UID 0; die regulären Anwendungsdienste laufen als UID 1001. Anhänge müssen dieser UID gehören. Eine gegebenenfalls erforderliche Prisma-Baseline nach UPGRADE.md prüfen, nicht automatisch setzen.
Nach Restore und Prüfung der Migrationshistorie:
```bash
dc run --rm migrate
# Nur bei Erfolg fortfahren; Bestandsmengen gemäß UPGRADE.md vergleichen.
dc up -d --wait app
# Login, Bestandsdaten und Downloads prüfen, noch im Wartungsmodus.
dc up -d --wait worker
dc ps -a
dc logs --tail=100 migrate app worker
```
Beim regulären vollständigen Compose-Start erzwingt die Abhängigkeitskette: gesunde DB → erfolgreiche Schema-, RBAC- und Datei-Migration → gesunde App → Worker. `migrate-access.ts` führt den RBAC-Seed aus; anschließend an die Datei-Migration läuft `prisma/seed.ts` (einschließlich idempotentem RBAC-Seed). Ist bereits ein Systemadministrator vorhanden, wird dessen Anlage übersprungen. Andernfalls sind `BOOTSTRAP_EMAIL` und `BOOTSTRAP_PASSWORD` erforderlich; ohne diese Werte bricht der Start ab. Bestehende Konten und Passwörter werden nicht überschrieben. Bei einem Upgrade ohne Administrator zuerst die wiederhergestellten Bestandsdaten prüfen, bevor ein neues Administratorkonto angelegt wird. Ein fehlgeschlagener Migrator blockiert den Start abhängiger Dienste, stoppt aber keine bereits laufende alte App: deshalb vor Updates immer App und Worker stoppen.
## Weitere Updates
Zusammengehöriges Backup erstellen, Schreibzugriffe verhindern und `dc stop app worker` ausführen. Dann `TASKMANAGER_VERSION` auf die nächste geprüfte Version setzen, `dc pull`, `dc run --rm migrate` und erst nach erfolgreicher Migration `dc up -d --wait app worker` ausführen. Keine gleichzeitigen Deployments oder Migratoren. `ARCHIVE_RETENTION_DAYS=0` verhindert automatische dauerhafte Archivbereinigung. Rollback und Backups stehen in UPGRADE.md und README.md.
### Erstes Administratorkonto
Bei einer bestätigten Neuinstallation `BOOTSTRAP_EMAIL` und `BOOTSTRAP_PASSWORD` setzen (Passwort mindestens 12 Zeichen, maximal 72 UTF-8-Bytes). Diese Werte erhält in den Produktionsdateien ausschließlich der Migrator. Nach erfolgreichem Bootstrap aus der Umgebung entfernen und den beendeten Migrationscontainer entfernen, damit dessen alte Umgebung nicht gespeichert bleibt (`dc rm -f migrate`). Bei der ersten Anmeldung ist eine Passwortänderung erforderlich. Bei vorhandenen Administratoren können beide Werte leer bleiben. `scripts/seed-demo.ts` wird niemals beim Produktionsstart ausgeführt.

View File

@ -6,6 +6,7 @@ Aufgabenverwaltung mit Next.js, PostgreSQL, privaten Anhängen und RBAC aus Benu
## Inhalt
- [Produktions-Compose für Dokploy/Traefik](PRODUCTION.md)
- [Fertige Docker-Images, Versionen und Registry-Deployment](REGISTRY.md)
- [Bestehende Installation übernehmen](UPGRADE.md)
- [Voraussetzungen](#voraussetzungen)
@ -37,7 +38,7 @@ Die folgenden Serverbefehle verwenden Bash. Alle Befehle im Checkout ausführen.
```bash
git clone https://git.jfritzsche.de/jf/taskmanager.git
cd taskmanager
git switch feature/security-rbac
git switch main
cp .env.example .env
chmod 600 .env
openssl rand -hex 32 # als NEXTAUTH_SECRET eintragen
@ -64,10 +65,9 @@ Passwörter in `DATABASE_URL` müssen URL-kodiert sein; die oben generierten Hex
docker compose -p taskmanager build
docker compose -p taskmanager up -d --wait db
docker compose -p taskmanager run --rm migrate
docker compose -p taskmanager run --rm --no-deps migrate npm run prisma:seed
```
Bootstrap-Werte danach aus `.env` entfernen. Seed legt ausschließlich bei fehlendem Administrator einen neuen an; keine Standardzugänge. Das Startpasswort muss bei der ersten Anmeldung geändert werden.
Der Migrationsdienst führt auch die Seeds aus: RBAC über `migrate-access.ts`, danach Administrator-Bootstrap über `prisma/seed.ts`. Bootstrap-Werte danach aus `.env` entfernen. Seed legt ausschließlich bei fehlendem Administrator einen neuen an; keine Standardzugänge. Das Startpasswort muss bei der ersten Anmeldung geändert werden.
```bash
docker compose -p taskmanager up -d --wait app worker

View File

@ -28,6 +28,51 @@ Ein bereits vorhandenes Secret bleibt unverändert. Der Workflow kann alternativ
Gitea-Pakete gehören einem Benutzer/einer Organisation. Sichtbarkeit nach dem ersten Publish prüfen und Pakete bei Bedarf mit `jf/taskmanager` verknüpfen. Private Images erfordern beim Deployment einen separaten Token mit `read:package`. Dokumentation: [Gitea Container Registry](https://docs.gitea.com/usage/packages/container/).
### Gitea hinter einem HTTPS-Proxy
Gitea muss seine **externe** URL kennen. In `app.ini`:
```ini
[server]
ROOT_URL = https://git.jfritzsche.de/
```
Alternativ im Gitea-Container `GITEA__server__ROOT_URL=https://git.jfritzsche.de/` setzen und Gitea neu starten. Der interne Listener darf weiterhin HTTP auf Port 3000 verwenden. HTTPS am Proxy allein korrigiert nicht automatisch die Registry-Token-URL.
Bei einer geänderten Compose-Umgebungsvariable reicht `docker restart` nicht: den **Gitea-Dienst** aus dessen eigenem Compose-Projekt neu erstellen (`docker compose up -d --force-recreate DIENSTNAME`). Das ist nicht der Taskmanager-Dienst. In der Gitea-Administrationsansicht die tatsächlich geladene Serverkonfiguration prüfen; eine Umgebungsvariable kann eine manuell bearbeitete `app.ini` beim Start wieder überschreiben. Keine Secrets oder vollständigen Umgebungsvariablen in Fehlerlogs veröffentlichen.
Neue Release-Workflows prüfen den HTTPS-Realm bereits vor Login und Image-Build mit `node scripts/check-registry.mjs`. Ein erneuter Lauf des alten Tags `v1.0.0` verwendet weiterhin den Workflow dieses Tags; die Serverkorrektur ist auch dafür erforderlich.
**Gitea 1.22.3: Proxy-Header haben Vorrang.** Der Registry-Code verwendet `GuessCurrentHostURL`; ein vom Proxy angeliefertes `X-Forwarded-Proto: http` übersteuert eine korrekt gesetzte HTTPS-`ROOT_URL`. Danach berücksichtigt Gitea auch `X-Forwarded-Protocol`, `X-Url-Scheme`, `Front-End-Https` und `X-Forwarded-Ssl`. Siehe [Registry-Code](https://github.com/go-gitea/gitea/blob/v1.22.3/routers/api/packages/container/container.go) und [URL-Ermittlung](https://github.com/go-gitea/gitea/blob/v1.22.3/modules/httplib/url.go).
Wenn Umgebungsvariable und `app.ini` bereits HTTPS enthalten, nicht wiederholt ROOT_URL ändern. Im Gitea-Container ohne Proxy prüfen:
```sh
wget -S -O /dev/null http://127.0.0.1:3000/v2/ 2>&1
```
HTTP 401 ist dabei erwartbar. Liefert der direkte Aufruf einen HTTPS-Realm, der öffentliche Aufruf aber HTTP, liegt der Unterschied in der Proxy-Kette. Der vertrauenswürdige Proxy muss für externe HTTPS-Aufrufe das ursprüngliche Protokoll korrekt an Gitea weiterreichen. Bei Cloudflare auch SSL/TLS-Modus und die Verbindung zum Origin prüfen; `Full (strict)` benötigt ein gültiges Origin-Zertifikat. Keine ungeprüften Client-Header pauschal vertrauen. Die konkrete Korrektur hängt von Nginx, Traefik, Caddy bzw. Cloudflare Tunnel ab.
```bash
curl -sS -D - -o /dev/null https://git.jfritzsche.de/v2/
```
Ohne Anmeldung ist HTTP 401 normal. Der `WWW-Authenticate`-Header muss als Bearer-Realm **`https://git.jfritzsche.de/v2/token`** nennen. Ein HTTP-Realm kann den Upload mit `authorization server did not include a token in the response` scheitern lassen, obwohl `docker login` erfolgreich erschien. Keine HTTP-/TLS-Ausnahmen als Ersatz einrichten. Nach Korrektur den fehlgeschlagenen `publish`-Job erneut starten; dafür weder einen Release-Tag verschieben noch die Anwendung neu versionieren.
### Upload scheitert mit HTTP 413 von Cloudflare
Enthält die Fehlerantwort beim Push einer Image-Schicht `413 Payload Too Large` und den HTML-Absender `cloudflare`, blockiert Cloudflare den Upload. Eine funktionierende Registry-Anmeldung und ein korrekter HTTPS-Realm schließen diesen Fehler nicht aus. Cloudflare begrenzt einzelne Upload-Anfragen je Tarif, bei Free/Pro auf 100 MB; kleinere konfigurierte Grenzwerte sind ebenfalls möglich. Siehe [Cloudflare: Error 413](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-413/).
Für große Registry-Uploads kann `git.jfritzsche.de` als **DNS only** (graue Wolke) betrieben werden. **Vor der Umstellung** muss Dokploy/Traefik die Domain selbst auf Port 443 mit einem öffentlich vertrauenswürdigen Zertifikat, etwa von Let's Encrypt, bedienen. Ein reiner HTTP-Router auf `web` reicht nicht. Ein nur von Cloudflare vertrautes Origin-CA-Zertifikat reicht für direkte Docker-Clients ebenfalls nicht. Die direkte Erreichbarkeit des Origins muss zur Firewall-Konfiguration passen. Die Umstellung betrifft auch die Gitea-Weboberfläche auf derselben Domain.
Vorher von außerhalb des Servers mit dessen tatsächlicher öffentlicher IP prüfen:
```bash
curl --resolve git.jfritzsche.de:443:SERVER_IP -sS -D - -o /dev/null https://git.jfritzsche.de/v2/
```
Erwartet: erfolgreiche TLS-Prüfung ohne `-k`, HTTP 401 und HTTPS-Bearer-Realm. Erst danach den DNS-Proxy abschalten, die DNS-Auflösung abwarten und den fehlgeschlagenen Publish-Job erneut starten. Keine Änderung von ROOT_URL, Tokens oder Release-Tags erforderlich. Falls der Cloudflare-Schutz für die Domain erhalten bleiben soll, benötigt der Publisher stattdessen einen gezielt eingerichteten direkten HTTPS-Zugang zum Origin; auch der BuildKit-Container muss diesen verwenden. Ein erneuter Lauf über dieselbe begrenzte Verbindung behebt HTTP 413 nicht.
### Neues Release erstellen
```bash
@ -53,7 +98,8 @@ export TASKMANAGER_VERSION=1.0.0
docker compose -p taskmanager -f docker-compose.yml -f docker-compose.registry.yml pull
docker compose -p taskmanager -f docker-compose.yml -f docker-compose.registry.yml up -d --wait db
docker compose -p taskmanager -f docker-compose.yml -f docker-compose.registry.yml run --rm migrate
# Nur leere Neuinstallation: einmaliges Bootstrap nach README.
# Bei Neuinstallation vorher Bootstrap-Werte gemäß README setzen;
# der Migrationsdienst führt den Seed mit aus.
docker compose -p taskmanager -f docker-compose.yml -f docker-compose.registry.yml up -d --wait app worker
```

View File

@ -21,7 +21,7 @@ Festhalten: laufender Git-Commit/Image-ID, Compose-Konfiguration, verschlüsselt
Neue Version in **separatem** Verzeichnis bauen:
```bash
git clone --branch feature/security-rbac https://git.jfritzsche.de/jf/taskmanager.git taskmanager-next
git clone --branch main https://git.jfritzsche.de/jf/taskmanager.git taskmanager-next
cd taskmanager-next
git rev-parse HEAD # Release-Commit im Änderungsprotokoll festhalten
cp .env.example .env
@ -115,6 +115,8 @@ Der Befehl führt nacheinander aus:
2. `scripts/migrate-access.ts`: eindeutige E-Mail-Normalisierung, Bestandsmitgliedschaften, alte Rollen nach RBAC, Entwertung alter Sitzungen. Transaktional und über einen Migrationsmarker wiederholbar.
3. `scripts/migrate-uploads.ts`: **alle** referenzierten Dateien auf Vorhandensein, Größe und zugelassenen Inhalt prüfen; alte `/uploads/...`-Pfade in zufällige private Speicherschlüssel umstellen und kopierte Inhalte per SHA-256 vergleichen. Originaldateien bleiben als Rückfallkopie im privaten Volume erhalten. Bereits migrierte Dateien werden beim erneuten Lauf geprüft, nicht erneut kopiert.
4. `prisma/seed.ts`: RBAC-Stammdaten idempotent prüfen und Administrator-Bootstrap ausführen. Ein vorhandener Systemadministrator wird erkannt; kein Konto und kein Passwort werden überschrieben. Fehlt ein Administrator, stoppt der Lauf ohne gültige `BOOTSTRAP_EMAIL`/`BOOTSTRAP_PASSWORD`. In diesem Fall zuerst den Restore und die alten Rollen prüfen; erst danach gegebenenfalls bewusst ein neues Administratorkonto anlegen.
App hängt vom erfolgreichen Migrationsdienst ab. Kein Start bei Fehlern. Die Dateiübernahme ist pro Datei wiederaufnehmbar, nicht eine globale Dateisystemtransaktion. Nach einem Abbruch Ursache beheben und erneut ausführen. Keine parallelen Migratoren und keine schreibenden alten/neuen Apps währenddessen betreiben.
### Welche Daten bleiben erhalten?
@ -134,6 +136,8 @@ Fehlende oder ungültige Anhänge werden **nicht** übersprungen oder gelöscht.
### Alte Rollen werden übernommen
Die Migration `20261008150000_checklist_permission` trennt „Checkliste bearbeiten“ (`tasks.checklist.update`) von „Aufgaben bearbeiten“. Rollen mit bisherigem `tasks.update` erhalten das neue Recht einmalig, mit ihren bestehenden Zuweisungsbereichen. Anschließend lässt es sich unabhängig entziehen oder vergeben; spätere Seeds stellen entzogene Rechte bei diesen Rollen nicht wieder her. Die geschützte Systemadministrator-Rolle besitzt weiterhin alle Rechte. Das neue Recht umfasst Hinzufügen, Ändern, Entfernen und Abhaken von Punkten bestehender Aufgaben. Eine Checkliste beim Erstellen einer neuen Aufgabe bleibt Teil von `tasks.create`.
| Alte Rolle | Neue direkte Rollen |
| --- | --- |
| ADMIN | Systemadministrator / GLOBAL |

View File

@ -0,0 +1,123 @@
# Standalone production deployment with a configurable host port; no Traefik required.
# Read PRODUCTION.md before deploying an existing installation.
x-app-environment: &app-environment
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL with host db and URL-encoded credentials}
NEXTAUTH_URL: ${NEXTAUTH_URL:?Set the public HTTPS URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?Set NEXTAUTH_SECRET}
ALLOW_LOCAL_HTTP: "false"
TRUST_PROXY: ${TRUST_PROXY:-false}
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/Berlin}
UPLOAD_DIR: /app/private-uploads
GROUP_UPLOAD_QUOTA_MB: ${GROUP_UPLOAD_QUOTA_MB:-1024}
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-0}
SMTP_URL: ${SMTP_URL:-}
MAIL_FROM: ${MAIL_FROM:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REQUIRED_ACR: ${OIDC_REQUIRED_ACR:-}
WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-}
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-taskmanager}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-taskmanager}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
stop_grace_period: 60s
logging: *logging
migrate:
image: git.jfritzsche.de/jf/taskmanager-operations:${TASKMANAGER_VERSION:?Set a published release version}
restart: "no"
init: true
user: "1001:1001"
security_opt: ["no-new-privileges:true"]
environment:
<<: *app-environment
LEGACY_UPLOAD_DIR: /app/private-uploads
BOOTSTRAP_EMAIL: ${BOOTSTRAP_EMAIL:-}
BOOTSTRAP_PASSWORD: ${BOOTSTRAP_PASSWORD:-}
command: ["sh", "-c", "npx prisma migrate deploy && npx tsx scripts/migrate-access.ts && npx tsx scripts/migrate-uploads.ts && npx tsx prisma/seed.ts"]
volumes:
- uploads:/app/private-uploads
depends_on:
db:
condition: service_healthy
logging: *logging
app:
image: git.jfritzsche.de/jf/taskmanager:${TASKMANAGER_VERSION:?Set a published release version}
restart: unless-stopped
init: true
user: "1001:1001"
security_opt: ["no-new-privileges:true"]
cap_drop: ["ALL"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=64m"]
environment: *app-environment
ports:
- "${APP_BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-3000}:3000"
volumes:
- uploads:/app/private-uploads
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
depends_on:
migrate:
condition: service_completed_successfully
stop_grace_period: 30s
logging: *logging
worker:
image: git.jfritzsche.de/jf/taskmanager-operations:${TASKMANAGER_VERSION:?Set a published release version}
restart: unless-stopped
init: true
user: "1001:1001"
security_opt: ["no-new-privileges:true"]
cap_drop: ["ALL"]
environment:
<<: *app-environment
WORKER_HEARTBEAT_FILE: /tmp/taskmanager-worker-heartbeat
command: ["node", "--import", "tsx", "scripts/worker.ts"]
volumes:
- uploads:/app/private-uploads
healthcheck:
test: ["CMD", "node", "-e", "const fs=require('fs');process.exit(Date.now()-fs.statSync('/tmp/taskmanager-worker-heartbeat').mtimeMs<180000?0:1)"]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
depends_on:
app:
condition: service_healthy
stop_grace_period: 30s
logging: *logging
volumes:
postgres_data:
external: true
name: ${POSTGRES_VOLUME:?Set the prepared PostgreSQL target volume name}
uploads:
external: true
name: ${UPLOADS_VOLUME:?Set the prepared uploads target volume name}

View File

@ -0,0 +1,128 @@
# Standalone production deployment for Dokploy/Traefik.
# Read PRODUCTION.md before deploying an existing installation.
x-app-environment: &app-environment
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL with host db and URL-encoded credentials}
NEXTAUTH_URL: ${NEXTAUTH_URL:?Set the public HTTPS URL}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?Set NEXTAUTH_SECRET}
ALLOW_LOCAL_HTTP: "false"
TRUST_PROXY: ${TRUST_PROXY:-false}
APP_TIMEZONE: ${APP_TIMEZONE:-Europe/Berlin}
UPLOAD_DIR: /app/private-uploads
GROUP_UPLOAD_QUOTA_MB: ${GROUP_UPLOAD_QUOTA_MB:-1024}
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-0}
SMTP_URL: ${SMTP_URL:-}
MAIL_FROM: ${MAIL_FROM:-}
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REQUIRED_ACR: ${OIDC_REQUIRED_ACR:-}
WEBHOOK_ALLOWED_HOSTS: ${WEBHOOK_ALLOWED_HOSTS:-}
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-taskmanager}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-taskmanager}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
stop_grace_period: 60s
logging: *logging
migrate:
image: git.jfritzsche.de/jf/taskmanager-operations:${TASKMANAGER_VERSION:?Set a published release version}
restart: "no"
init: true
user: "1001:1001"
security_opt: ["no-new-privileges:true"]
environment:
<<: *app-environment
LEGACY_UPLOAD_DIR: /app/private-uploads
BOOTSTRAP_EMAIL: ${BOOTSTRAP_EMAIL:-}
BOOTSTRAP_PASSWORD: ${BOOTSTRAP_PASSWORD:-}
command: ["sh", "-c", "npx prisma migrate deploy && npx tsx scripts/migrate-access.ts && npx tsx scripts/migrate-uploads.ts && npx tsx prisma/seed.ts"]
volumes:
- uploads:/app/private-uploads
depends_on:
db:
condition: service_healthy
logging: *logging
app:
image: git.jfritzsche.de/jf/taskmanager:${TASKMANAGER_VERSION:?Set a published release version}
restart: unless-stopped
init: true
user: "1001:1001"
security_opt: ["no-new-privileges:true"]
cap_drop: ["ALL"]
read_only: true
tmpfs: ["/tmp:rw,noexec,nosuid,size=64m"]
environment: *app-environment
expose: ["3000"]
volumes:
- uploads:/app/private-uploads
networks: ["default", "proxy"]
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health',{signal:AbortSignal.timeout(4000)}).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
depends_on:
migrate:
condition: service_completed_successfully
stop_grace_period: 30s
logging: *logging
worker:
image: git.jfritzsche.de/jf/taskmanager-operations:${TASKMANAGER_VERSION:?Set a published release version}
restart: unless-stopped
init: true
user: "1001:1001"
security_opt: ["no-new-privileges:true"]
cap_drop: ["ALL"]
environment:
<<: *app-environment
WORKER_HEARTBEAT_FILE: /tmp/taskmanager-worker-heartbeat
command: ["node", "--import", "tsx", "scripts/worker.ts"]
volumes:
- uploads:/app/private-uploads
healthcheck:
test: ["CMD", "node", "-e", "const fs=require('fs');process.exit(Date.now()-fs.statSync('/tmp/taskmanager-worker-heartbeat').mtimeMs<180000?0:1)"]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
depends_on:
app:
condition: service_healthy
stop_grace_period: 30s
logging: *logging
networks:
proxy:
external: true
name: ${TRAEFIK_NETWORK:-dokploy-network}
volumes:
postgres_data:
external: true
name: ${POSTGRES_VOLUME:?Set the prepared PostgreSQL target volume name}
uploads:
external: true
name: ${UPLOADS_VOLUME:?Set the prepared uploads target volume name}

View File

@ -17,7 +17,7 @@ services:
context: .
target: operations
env_file: ${TASKMANAGER_ENV_FILE:-.env}
command: ["sh", "-c", "npx prisma migrate deploy && npx tsx scripts/migrate-access.ts && npx tsx scripts/migrate-uploads.ts"]
command: ["sh", "-c", "npx prisma migrate deploy && npx tsx scripts/migrate-access.ts && npx tsx scripts/migrate-uploads.ts && npx tsx prisma/seed.ts"]
environment:
UPLOAD_DIR: /app/private-uploads
LEGACY_UPLOAD_DIR: /app/private-uploads

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "taskmanager",
"version": "1.0.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "taskmanager",
"version": "1.0.0",
"version": "1.0.1",
"license": "ISC",
"dependencies": {
"@hookform/resolvers": "^5.2.2",

View File

@ -1,6 +1,6 @@
{
"name": "taskmanager",
"version": "1.0.0",
"version": "1.0.1",
"description": "",
"main": "index.js",
"scripts": {

View File

@ -0,0 +1,10 @@
-- Preserve existing checklist access once. Subsequent seeds must not restore
-- permissions deliberately revoked by an administrator.
INSERT INTO "Permission" ("key") VALUES ('tasks.checklist.update')
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionKey")
SELECT "roleId", 'tasks.checklist.update'
FROM "RolePermission"
WHERE "permissionKey" = 'tasks.update'
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,21 @@
import { execFileSync } from "node:child_process";
// Inspect the unauthenticated challenge before sending credentials or building images.
const headers = execFileSync("curl", [
"--silent", "--show-error", "--max-time", "20",
"--header", "Cache-Control: no-cache", "--dump-header", "-",
"--output", process.platform === "win32" ? "NUL" : "/dev/null",
"https://git.jfritzsche.de/v2/",
], { encoding: "utf8" });
const realm = headers.match(/^www-authenticate:\s*Bearer[^\r\n]*realm="([^"]+)"/im)?.[1];
if (realm !== "https://git.jfritzsche.de/v2/token") {
throw new Error(
`Registry advertises an unexpected authentication realm: ${realm ?? "missing"}. ` +
"Set Gitea [server] ROOT_URL=https://git.jfritzsche.de/ " +
"(Docker: GITEA__server__ROOT_URL), recreate the Gitea container when changing its environment, " +
"and check the proxy: in Gitea 1.22.3 forwarded scheme headers override ROOT_URL. " +
"The trusted proxy must pass X-Forwarded-Proto=https for the public HTTPS request. " +
"Verify the live /v2/ challenge. No credentials were sent by this check.",
);
}
console.log("Registry HTTPS authentication endpoint verified.");

View File

@ -66,6 +66,7 @@ export const permissionLabels: Record<string, string> = {
"tasks.read": "Aufgaben ansehen",
"tasks.create": "Aufgaben erstellen",
"tasks.update": "Aufgaben bearbeiten",
"tasks.checklist.update": "Checkliste bearbeiten",
"tasks.assign": "Aufgaben zuweisen",
"tasks.claim": "Aufgaben übernehmen",
"tasks.change_status": "Status ändern",

View File

@ -98,6 +98,7 @@ export function TaskDetail({ id }: { id: string }) {
const qc = useQueryClient();
const router = useRouter();
const [edit, setEdit] = useState(false);
const [checklistDraft, setChecklistDraft] = useState<string | null>(null);
const [template, setTemplate] = useState(false);
const [error, setError] = useState<unknown>();
const [busy, setBusy] = useState(false);
@ -282,19 +283,35 @@ export function TaskDetail({ id }: { id: string }) {
{t.checklist?.filter((c) => c.done).length || 0} von{" "}
{t.checklist?.length || 0} erledigt
</span>
{can("tasks.update") && !t.archivedAt && (
<Button size="sm" variant="ghost" onClick={() => setEdit(true)}>
{can("tasks.checklist.update") && !t.archivedAt && (
<Button size="sm" variant="ghost" onClick={() => setChecklistDraft(t.checklist?.map(c => c.text).join("\n") || "")}>
Bearbeiten
</Button>
)}
</div>
{checklistDraft !== null && can("tasks.checklist.update") && !t.archivedAt && (
<div className="mb-4 space-y-3">
<label className="text-sm" htmlFor="checklist-draft">Checkliste (ein Punkt pro Zeile)</label>
<Textarea id="checklist-draft" value={checklistDraft} disabled={busy}
onChange={e => setChecklistDraft(e.target.value)} />
<div className="flex gap-2">
<Button size="sm" disabled={busy} onClick={() => run(async () => {
await update({ checklist: checklistDraft.split("\n").map(text => text.trim()).filter(Boolean).map(text => ({
text, done: t.checklist?.find(c => c.text === text)?.done || false,
})) });
setChecklistDraft(null);
})}>Checkliste speichern</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => setChecklistDraft(null)}>Abbrechen</Button>
</div>
</div>
)}
{t.checklist?.length ? (
<div className="space-y-3">
{t.checklist.map((c, i) => (
<label key={i} className="flex items-start gap-3 text-sm">
<Checkbox
checked={c.done}
disabled={busy || !can("tasks.update") || !!t.archivedAt}
disabled={busy || checklistDraft !== null || !can("tasks.checklist.update") || !!t.archivedAt}
onCheckedChange={() =>
run(() =>
update({

View File

@ -49,6 +49,7 @@ export function TaskEditor({
recurrenceDays: task?.recurrenceDays || 0,
checklist: task?.checklist?.map((c) => c.text).join("\n") || "",
});
const editChecklist = !task || allowed(me, "tasks.checklist.update", task);
const [error, setError] = useState<unknown>();
const [busy, setBusy] = useState(false);
const [templateId, setTemplateId] = useState("");
@ -88,13 +89,13 @@ export function TaskEditor({
.split(",")
.map((t) => t.trim())
.filter(Boolean),
checklist: form.checklist
checklist: editChecklist ? form.checklist
.split("\n")
.filter((t) => t.trim())
.map((text) => ({
text,
done: task?.checklist?.find((c) => c.text === text)?.done || false,
})),
})) : undefined,
};
await request(
task ? `/api/tasks/${task.id}` : "/api/tasks",
@ -264,6 +265,7 @@ export function TaskEditor({
<Field label="Checkliste (ein Punkt pro Zeile)">
<Textarea
className={inputClass}
disabled={!editChecklist}
value={form.checklist}
onChange={(e) => change("checklist", e.target.value)}
/>

View File

@ -2,6 +2,7 @@ export const PERMISSIONS = [
"tasks.read",
"tasks.create",
"tasks.update",
"tasks.checklist.update",
"tasks.assign",
"tasks.claim",
"tasks.change_status",
@ -95,6 +96,7 @@ export const LEADER = [
...COORDINATOR,
"tasks.change_status",
"tasks.update",
"tasks.checklist.update",
"tasks.reopen",
"tasks.archive",
"tasks.delete",

View File

@ -194,8 +194,10 @@ export async function updateTask(user: Actor, id: string, raw: unknown) {
if (old.version !== version)
throw new HttpError(409, "Die Aufgabe wurde geändert. Bitte neu laden.");
const fields = Object.keys(data);
if (fields.some((k) => k !== "status"))
if (fields.some((k) => k !== "status" && k !== "checklist"))
requirePermission(user, "tasks.update", old);
if (data.checklist !== undefined)
requirePermission(user, "tasks.checklist.update", old);
if (data.status) {
requirePermission(user, "tasks.change_status", old);
if (old.status === "ERLEDIGT" && data.status !== "ERLEDIGT")

View File

@ -26,6 +26,59 @@ async function login(user: string) {
return api;
}
test.afterAll(async () => db.$disconnect());
test("checklist permission is independent and scoped, including its editor", async ({ page }) => {
test.setTimeout(60000);
const roleId = "test-checklist-role";
const taskId = "test-checklist-task";
await db.task.deleteMany({ where: { id: taskId } });
await db.accessRole.deleteMany({ where: { id: roleId } });
await db.accessRole.create({ data: { id: roleId, name: roleId, permissions: { create: [
{ permissionKey: "tasks.read" }, { permissionKey: "tasks.checklist.update" },
] } } });
await db.userRole.create({ data: { userId: "test-worker", roleId, scope: "ASSIGNED" } });
await db.task.create({ data: { id: taskId, wo: "Test", was: "Checklist permissions", bisWann: new Date("2026-12-01"), creatorId: "test-admin", owningGroupId: "test-a", assigneeId: "test-worker" } });
const api = await login("test-worker");
try {
const patch = async (data: object) => {
const task = await db.task.findUniqueOrThrow({ where: { id: taskId } });
return api.patch(`/api/tasks/${taskId}`, { data: { version: task.version, ...data } });
};
expect((await patch({ checklist: [{ text: "Check", done: false }] })).status()).toBe(200);
expect((await patch({ was: "Forbidden", checklist: [] })).status()).toBe(403);
expect((await db.task.findUniqueOrThrow({ where: { id: taskId } })).was).toBe("Checklist permissions");
await page.context().addCookies((await api.storageState()).cookies);
await page.goto(`/dashboard/tasks/${taskId}`);
await page.getByRole("button", { name: "Bearbeiten", exact: true }).click();
await page.getByLabel("Checkliste (ein Punkt pro Zeile)").fill("Check\nSecond");
await page.getByRole("button", { name: "Checkliste speichern" }).click();
await expect(page.getByText("Second", { exact: true })).toBeVisible();
await page.getByRole("checkbox").first().click();
await expect.poll(async () => ((await db.task.findUniqueOrThrow({ where: { id: taskId } })).checklist as { done: boolean }[])[0].done).toBe(true);
await db.task.update({ where: { id: taskId }, data: { assigneeId: "test-other" } });
expect((await patch({ checklist: [] })).status()).toBe(404);
await db.task.update({ where: { id: taskId }, data: { assigneeId: "test-worker" } });
await db.rolePermission.delete({ where: { roleId_permissionKey: { roleId, permissionKey: "tasks.checklist.update" } } });
await db.rolePermission.create({ data: { roleId, permissionKey: "tasks.update" } });
expect((await patch({ was: "Allowed" })).status()).toBe(200);
expect((await patch({ checklist: [] })).status()).toBe(403);
await page.reload();
await expect(page.getByRole("checkbox").first()).toBeDisabled();
await page.getByRole("button", { name: "Bearbeiten", exact: true }).click();
const editor = page.getByRole("dialog");
await editor.getByText("Weitere Angaben", { exact: false }).click();
await expect(editor.getByLabel("Checkliste (ein Punkt pro Zeile)")).toBeDisabled();
await editor.getByLabel(/^Beschreibung/).fill("Editor without checklist access");
await editor.getByRole("button", { name: "Speichern", exact: true }).click();
await expect(editor).not.toBeVisible();
expect((await db.task.findUniqueOrThrow({ where: { id: taskId } })).checklist).toEqual([
{ text: "Check", done: true }, { text: "Second", done: false },
]);
} finally {
await api.dispose();
await db.task.delete({ where: { id: taskId } });
await db.accessRole.delete({ where: { id: roleId } });
}
});
test("unauthenticated APIs and public upload paths are denied", async ({
request,
}) => {