diff --git a/Dockerfile b/Dockerfile index d585eea..37a8da4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine AS base +FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS base WORKDIR /app @@ -28,6 +28,10 @@ COPY . . RUN npm run build:prod +# Separate administrative image; never ship the migration CLI in the app runner. +FROM builder AS migrator +CMD ["npm", "run", "db:migrate:deploy"] + FROM base AS runner ENV NODE_ENV=production @@ -35,10 +39,11 @@ ENV PORT=3000 ENV HOSTNAME=0.0.0.0 ENV UPLOAD_DIR=/app/uploads/announcements -COPY --from=builder /app ./ - -RUN mkdir -p /app/uploads/announcements && chmod +x /app/scripts/start-production.sh - +RUN addgroup -S -g 1001 app && adduser -S -u 1001 -G app app +COPY --from=builder --chown=app:app /app/.next/standalone ./ +COPY --from=builder --chown=app:app /app/.next/static ./.next/static +COPY --from=builder --chown=app:app /app/public ./public +RUN mkdir -p /app/uploads/announcements/announcements /app/.next/cache && chown -R app:app /app/uploads /app/.next/cache +USER app EXPOSE 3000 - -CMD ["./scripts/start-production.sh"] +CMD ["node", "server.js"] diff --git a/deploy/README.md b/deploy/README.md index f84d1a2..827c526 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,55 +1,68 @@ -# Kita-Planer Deployment +# Sicherer Betrieb und Rollout -Dieses Verzeichnis enthaelt die ausfuellbaren Vorlagen fuer das Production-Deployment. +Die Anwendung startet ohne Schemaänderungen und ohne Demo-Seeding. Datenbankmigrationen laufen separat mit einer administrativen Rolle. Der App-Container verwendet ausschließlich `kita_runtime`, läuft als UID/GID 1001 und hat ein schreibgeschütztes Dateisystem mit persistenten Upload- und Cache-Volumes. -## Dateien +## Konfiguration -- `production.env.example`: Vorlage fuer alle benoetigten Environment Variables. -- `production-checklist.md`: Checkliste fuer Domain, Datenbank, Auth, Mail, Uploads und Rechtliches. -- `../Dockerfile`: Container-Build fuer Coolify oder andere Docker-Hosts. -- `../docker-compose.coolify.yml`: Compose-Vorlage fuer Coolify mit required Environment Variables. +Werte aus `production.env.example` als Hosting-Secrets hinterlegen. `APP_DATABASE_URL` enthält die eingeschränkte Rolle; `MIGRATION_DATABASE_URL` den Datenbankeigentümer. Für lokale CLI-Befehle heißt die jeweils benötigte URL `DATABASE_URL`. `APP_DATABASE_PASSWORD` ist das separate Runtime-Passwort (24–256 Zeichen); in URLs muss es URL-kodiert werden. Keine echten Secrets im Repository speichern. -## Empfohlener Ablauf mit Coolify und Gitea +`AUTH_URL`, `NEXTAUTH_URL` und `NEXT_PUBLIC_SITE_URL` müssen dieselbe kanonische HTTPS-Origin verwenden. Der Reverse Proxy terminiert TLS, routet intern auf App-Port 3000 und überschreibt weitergeleitete Host-/IP-Header. `TRUSTED_CLIENT_IP_HEADER` nur setzen, wenn der Proxy den gewählten Header (`x-real-ip` oder `cf-connecting-ip`) zuverlässig überschreibt. Ohne diese Einstellung gelten weiterhin gemeinsame globale und identitätsbezogene Datenbanklimits. Proxyseitig Body-Limit von höchstens 26 MiB, Timeouts und zusätzliche Request-Limits setzen. -1. Repository in deine Gitea-Instanz pushen. -2. In Coolify eine neue Application aus diesem Gitea-Repository anlegen. -3. Als Build Pack `Docker Compose` waehlen. -4. Als Compose-Datei `docker-compose.coolify.yml` eintragen. -5. Die bestehende Coolify-Postgres-Datenbank ueber ihre interne `DATABASE_URL` verbinden. -6. Environment Variables aus `production.env.example` in Coolify eintragen. -7. Domain auf den `app`-Service mit internem Port `3000` zeigen lassen. -8. Deploy starten. +Compose startet PostgreSQL standardmäßig intern. Bei einer bereits extern verwalteten Coolify-Datenbank den Stack entsprechend anpassen und deren interne URL verwenden; niemals versehentlich einen neuen leeren Datenbankdienst als Ersatz anschließen. pgAdmin ist nur im Profil `maintenance` und nur auf Loopback verfügbar; Zugriff über SSH-Tunnel. -Beim Container-Start fuehrt `scripts/start-production.sh` automatisch aus: +## Bestehende Installation aktualisieren -```bash -npx prisma db push +1. Datenbank **und** Upload-Volume sichern und Wiederherstellung überprüfen. Wartungsfenster ankündigen und alte App stoppen: das neue Schema und der neue Code gehören zusammen. +2. Den Zustand der bestehenden Datenbank mit `prisma/migrations/20261009000000_baseline/migration.sql` vergleichen. Die Baseline bildet das bisherige Schema ab. Bei Abweichungen zuerst die Unterschiede gezielt klären; eine Baseline-Markierung prüft das Schema nicht automatisch. +3. `scripts/check-tenant-integrity.sql` mit `psql "$MIGRATION_DATABASE_URL" -v ON_ERROR_STOP=1 -f scripts/check-tenant-integrity.sql` ausführen. **Keine Ergebniszeilen** bedeutet, dass keine der geprüften widersprüchlichen Tenant-Verknüpfungen vorliegt. Ergebnisse vor der Migration fachlich korrigieren; keine Daten automatisch einem anderen Mandanten zuweisen. +4. **Nur für eine bestehende, zur Baseline passende Datenbank** einmal die Baseline als bereits angewendet markieren. Bei einer leeren neuen Datenbank diesen Schritt auslassen. +5. Alle weiteren Migrationen anwenden, Runtime-Rolle bereitstellen und Größenmetadaten vorhandener Anhänge nachtragen. +6. Eigentümer bestehender Upload-/Cache-Volumes auf UID/GID 1001 vorbereiten, dann die neue App starten. Stichproben mit zwei verschiedenen Kitas und den echten E-Mail-Flows prüfen. + +Compose-Beispiele (Environment-Secrets vorher bereitstellen): + +```sh +docker compose -f docker-compose.coolify.yml build app migrate +# NUR bei bestehender, zuvor geprüfter Baseline-Datenbank: +docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npx prisma migrate resolve --applied 20261009000000_baseline +# Bei neuen und bestehenden Datenbanken: +docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npm run db:migrate:deploy +docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npm run db:provision-runtime +docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate npm run uploads:backfill +# Nur auf dem vorgesehenen App-Upload-Volume: +docker compose -f docker-compose.coolify.yml --profile maintenance run --rm migrate chown -R 1001:1001 /app/uploads +# Falls ein bereits vorhandenes Cache-Volume root gehört: dessen Eigentümer separat korrigieren. +docker compose -f docker-compose.coolify.yml up -d app ``` -Coolify erkennt Variablen aus `docker-compose.coolify.yml` und zeigt required Variablen -wie `DATABASE_URL`, `AUTH_SECRET`, `AUTH_URL`, `NEXTAUTH_URL`, `NEXT_PUBLIC_SITE_URL`, -`RESEND_API_KEY`, `EMAIL_FROM` und `ADMIN_EMAIL` in der UI an. +Bei einem Rollback nach Migration zuerst Kompatibilität prüfen; das alte Image allein zurückzusetzen reicht wegen neuer Pflichtfelder nicht aus. Im Zweifel das geprüfte Backup von Datenbank und Dateien gemeinsam wiederherstellen. -## Welche Coolify-Postgres-URL? +## Auswirkungen auf bestehende Konten -Wenn die App und die Postgres-DB im selben Coolify-Projekt/Netzwerk laufen, nimm -die interne Datenbank-URL aus Coolify, nicht die oeffentliche externe URL. Das ist -stabiler und vermeidet unnoetigen Traffic ueber den Host. +Alle JWT-Sitzungen vor diesem Release werden ungültig; erneute Anmeldung ist erforderlich. Administrative Sperren entwerten auch bei späterer Entsperrung dauerhaft die alten Sitzungen. Gründerkonten ohne Kita und ohne echte E-Mail-Prüfung registrieren sich mit derselben Adresse erneut und bestätigen den Link. Bereits bestehende Kita-Gründer behalten ihre bisherige Verifizierung; deren historische Adressen werden dadurch nicht nachträglich als echte Mailbox-Bestätigung nachgewiesen. -Das Format bleibt: +Neue Registrierungen bestätigen zuerst die E-Mail-Adresse, wählen anschließend ihr Passwort und erstellen erst danach eine Kita. Beim Ändern einer Elternadresse werden Passwort und bisherige Sitzungen entwertet; die neue Adresse erhält eine Einladung zum Festlegen eines neuen Passworts. Kita-Admins können die Einladung erneut senden. Neue Registrierungslinks gelten 30 Minuten, Einladungen sieben Tage und Notdienst-Bestätigungslinks höchstens 24 Stunden sowie nur für offene, noch relevante Einsätze. Bestehende UUID-Links bleiben innerhalb ihrer Laufzeit kompatibel. Zwei-Faktor-Anmeldung ist auf ausdrücklichen Wunsch vorerst nicht eingeführt. -```txt -postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public +## Dateien und Wartung + +`UPLOAD_DIR` bezeichnet die bisherige Basis; die Anwendung ergänzt `announcements`. Compose verwendet unverändert `/app/uploads/announcements/announcements`. Backfill muss genau dieselben Dateien sehen. Fehlende Dateien werden mit Größe 0 erfasst und als Anzahl gemeldet: Ursache und Datenverlust prüfen. Solange Größenwerte `NULL` sind, blockiert die Anwendung neue Anhänge, damit das Gesamtlimit nicht umgangen werden kann. + +Grenzen: 10 MiB pro Datei, fünf Dateien und 25 MiB pro Nachricht; standardmäßig 256 MiB pro Kita und 10 GiB insgesamt, konfigurierbar über die beiden Quota-Variablen. Erlaubt sind PDF, PNG und JPEG mit passenden Dateisignaturen und Endungen. Das ist keine Virenprüfung oder vollständige PDF-Bereinigung. Downloads erfordern Sitzung, Consent und die passende Kita; sie werden als Anhang ausgeliefert. + +`npm run uploads:cleanup` zeigt zunächst nur verwaiste Dateien mit mindestens 24 Stunden Alter. Nach Prüfung entfernt `npm run uploads:cleanup -- --delete` diese Dateien. Backups, Speicherüberwachung und eine Aufbewahrungs-/Löschstrategie für Backups separat einrichten. Dateien gelöschter Datensätze werden auch unmittelbar bestmöglich entfernt. + +Die SQL-Migrationen ergänzen **31 zusammengesetzte Tenant-Fremdschlüssel**, die Prisma wegen der vorhandenen optionalen Cascade-/SetNull-Beziehungen nicht vollständig abbildet. Zukünftige generierte Migrationen auf unbeabsichtigtes Entfernen dieser Constraints prüfen. Kein `prisma db push` auf verwalteten Datenbanken einsetzen. Runtime-Grants nach zukünftigen Migrationen erneut bereitstellen. + +## Verifikation und Updates + +```sh +npm ci +npm run build:prod +npx tsc --noEmit +npm run lint -- --quiet +npm audit --omit=dev ``` -## Wichtige Hinweise +Die Integrationstests setzen eine ausdrücklich isolierte lokale PostgreSQL-Datenbank namens `kita_security_test` voraus; `SECURITY_TEST_DATABASE_URL` darf keine Anwendungsdaten enthalten. `npm run test:security` prüft Aktionen und echte DB-Transaktionen. `npm run test:security:http` benötigt den Production-Server auf localhost:3057 mit derselben Testdatenbank, `UPLOAD_DIR` und dem im Test dokumentierten Test-Secret. Niemals dieses Test-Secret in Produktion verwenden. -- Das aktuelle Projekt nutzt noch `prisma db push` statt versionierter Prisma-Migrationen. - Fuer den ersten MVP-Deploy ist das praktikabel, fuer laengerfristigen Betrieb sollten - Migrationen eingefuehrt werden. -- `UPLOAD_DIR` muss persistent sein. Die Coolify-Compose-Datei mountet dafuer ein - Docker-Volume nach `/app/uploads`. -- `NEXT_PUBLIC_SITE_URL` muss die echte Domain enthalten, damit OpenGraph-Bilder in - WhatsApp, LinkedIn und Co. korrekt als absolute URL aufgeloest werden. -- `NEXT_PUBLIC_SITE_URL` wird auch als Build-Arg an Docker uebergeben. In Coolify sollte - diese Variable deshalb fuer Build und Runtime verfuegbar sein. +Docker-Images sind auf Digests fixiert. Neue Sicherheitsreleases regelmäßig prüfen und die Digests bewusst aktualisieren. Zum Prüfzeitpunkt verbleibt eine ungepatchte `braces`-Schwachstelle in der Entwicklungs-Lint-Kette; `npm audit --force` löst sie nicht sinnvoll. Der Runtime-Audit ist ohne bekannte Findings. NextAuth bleibt eine Beta-Abhängigkeit und benötigt weiterhin Update-Beobachtung. diff --git a/deploy/production-checklist.md b/deploy/production-checklist.md index e4c1910..b0de402 100644 --- a/deploy/production-checklist.md +++ b/deploy/production-checklist.md @@ -1,60 +1,37 @@ -# Production Checklist +# Production-Checkliste -## 1. Domain & Basis-URLs +## Vor dem Rollout -- [ ] Produktionsdomain festlegen: `https://[deine-domain.de]` -- [ ] `NEXT_PUBLIC_SITE_URL` auf die Produktionsdomain setzen -- [ ] `AUTH_URL` und `NEXTAUTH_URL` auf die Produktionsdomain setzen -- [ ] Nach Deployment die WhatsApp-/LinkedIn-Vorschau mit der finalen URL testen +- [ ] Datenbank und Uploads gesichert; Wiederherstellung geprüft. +- [ ] Alte App gestoppt; Wartungsfenster und erneute Anmeldung angekündigt. +- [ ] Bestehendes Schema mit Baseline verglichen; Tenant-Preflight ohne Befunde. +- [ ] Baseline nur bei bestehender passender DB markiert; anschließend alle Migrationen angewendet. +- [ ] Runtime-Rolle mit separatem Passwort bereitgestellt; App verwendet `APP_DATABASE_URL`. +- [ ] Upload-Größen nachgetragen; gemeldete fehlende Dateien geprüft. +- [ ] Upload- und Cache-Volumes für UID/GID 1001 beschreibbar; Upload-Pfad unverändert. -## 2. Datenbank +## Hosting und Konfiguration -- [ ] Interne `DATABASE_URL` der bestehenden Coolify-Postgres-DB kopieren -- [ ] `DATABASE_URL` in der Coolify-App eintragen -- [ ] Beim ersten App-Start wird das Schema automatisch mit `npx prisma db push --skip-generate` angewendet -- [ ] Fuer spaetere Releases idealerweise Prisma-Migrationen einfuehren +- [ ] Drei öffentliche Basis-URLs auf dieselbe kanonische HTTPS-Origin gesetzt. +- [ ] Starkes `AUTH_SECRET`; echte Secrets nur in Hosting-Konfiguration. +- [ ] TLS und Proxy-Header korrekt; kein öffentlicher DB-/App-/pgAdmin-Port. +- [ ] Request-Limits, Timeouts und Upload-Body-Limit am Proxy eingerichtet. +- [ ] Trusted-IP-Header nur bei garantierter Überschreibung aktiviert. +- [ ] Maildomain bei Resend verifiziert; Produktionsabsender gesetzt. +- [ ] Optionale Analytics nur auf öffentlichen Marketingseiten; keine Token-/Login-/Dashboard-Daten. +- [ ] Upload-Speicher und Backups überwacht; Löschfristen festgelegt. +- [ ] Impressum und Datenschutztexte an tatsächliche Betreiber/Anbieter angepasst. -## 3. Auth & Sicherheit +## Nach dem Rollout -- [ ] `AUTH_SECRET` neu generieren: `openssl rand -base64 32` -- [ ] Keine `.env`-Dateien mit echten Secrets committen -- [ ] Dashboard ist per Metadata auf `noindex,nofollow` gesetzt -- [ ] Produktionsdomain auf HTTPS betreiben +- [ ] Anmeldung und Registrierung mit Mailbox-Bestätigung getestet. +- [ ] Einladungen, Adressänderung und erneutes Senden getestet. +- [ ] Administrative Sperre entwertet bestehende Sitzung; Entsperrung verlangt neue Anmeldung. +- [ ] Zwei getrennte Kitas: Anhänge, Kalender und Mutationen bleiben isoliert. +- [ ] Anhänge heruntergeladen; Quotas und persistentes Volume geprüft. +- [ ] Privates Dashboard ohne Analytics und ohne Cache; CSP ohne Browserfehler. +- [ ] Kontakt-/Notdienst-Mailfluss und Fehlerbehandlung geprüft. +- [ ] Build, Lint, Typprüfung, Sicherheitstests und Runtime-Audit erfolgreich. +- [ ] Offenes Entwicklungs-Advisory und fixierte Image-Digests weiter beobachten. -## 4. Mail - -- [ ] Eigene Versanddomain bei Resend verifizieren -- [ ] `RESEND_API_KEY` fuer Produktion setzen -- [ ] `EMAIL_FROM` auf eine verifizierte Absenderadresse setzen -- [ ] `ADMIN_EMAIL` fuer Kontaktformular setzen -- [ ] Kontaktformular und Einladung/E-Mail-Flows einmal in Produktion testen - -## 5. Uploads - -- [ ] Coolify-Volume `kita_planer_uploads` fuer `/app/uploads` verwenden -- [ ] Sicherstellen, dass Uploads nicht aus Versehen oeffentlich ausgeliefert werden -- [ ] Backup-Strategie fuer Uploads klaeren - -## 6. Rechtliches - -- [ ] Platzhalter im Impressum ersetzen -- [ ] Platzhalter in der Datenschutzerklaerung ersetzen -- [ ] Hosting-Anbieter und Mail-Anbieter in Datenschutztext eintragen -- [ ] Rechtliche Texte vor Livegang pruefen lassen - -## 7. Build & Smoke Test - -- [ ] Dependencies installieren: `npm ci` -- [ ] Production Build erstellen: `npm run build:prod` -- [ ] App starten: `npm run start:prod` -- [ ] Startseite, Login, Dashboard und Kontaktformular testen - -## 8. Coolify - -- [ ] Code nach Gitea pushen -- [ ] In Coolify eine Application aus dem Gitea-Repository anlegen -- [ ] Build Pack `Docker Compose` waehlen -- [ ] Compose-Datei `docker-compose.coolify.yml` verwenden -- [ ] Environment Variables in Coolify eintragen, nicht ins Repo committen -- [ ] Domain auf den `app`-Service und Container-Port `3000` routen -- [ ] Upload-Volume `kita_planer_uploads` bleibt zwischen Deployments erhalten +Details und Befehle stehen in [README.md](README.md). Lokale Tests ersetzen nicht die Kontrolle der produktiven TLS-, Proxy-, Backup-, Mail- und Container-Konfiguration. diff --git a/deploy/production.env.example b/deploy/production.env.example index f3d8fb0..092491c 100644 --- a/deploy/production.env.example +++ b/deploy/production.env.example @@ -11,6 +11,11 @@ POSTGRES_DB="kita_planer" POSTGRES_USER="kita_planer" POSTGRES_PASSWORD="[GENERATE_ME]" +# Unterschiedliche DB-Rollen für Runtime und Migration. URL-Passwörter müssen URL-kodiert sein. +APP_DATABASE_PASSWORD="[GENERATE_ME]" +APP_DATABASE_URL="postgresql://kita_runtime:[URL_ENCODED_APP_PASSWORD]@postgres:5432/kita_planer?schema=public" +MIGRATION_DATABASE_URL="postgresql://kita_planer:[URL_ENCODED_OWNER_PASSWORD]@postgres:5432/kita_planer?schema=public" + # pgAdmin im Coolify-Compose-Stack PGADMIN_DEFAULT_EMAIL="[admin@deine-domain.de]" PGADMIN_DEFAULT_PASSWORD="[GENERATE_ME]" @@ -40,5 +45,14 @@ ADMIN_EMAIL="[kontakt@deine-domain.de]" # Muss auf dem Server dauerhaft erhalten bleiben und darf nicht im Repo liegen. UPLOAD_DIR="/var/lib/kita-planer/uploads/announcements" -# Optional: Nur bewusst fuer initiales Demo-Seeding in Produktion setzen. -# ALLOW_DATABASE_SEED="true" +# Standard: 256 MiB je Kita, 10 GiB insgesamt. Compose verwendet diese Werte. +UPLOAD_TENANT_QUOTA_BYTES="268435456" +UPLOAD_GLOBAL_QUOTA_BYTES="10737418240" + +# Compose setzt UPLOAD_DIR fest auf /app/uploads/announcements. +# Die Anwendung ergänzt /announcements: bestehende Dateien liegen dort unter +# /app/uploads/announcements/announcements. Diesen Pfad beim Backfill beibehalten. + +# Demo-Seeding ist in Produktion deaktiviert. +# Nur hinter einem Proxy setzen, der diesen Header überschreibt: +# TRUSTED_CLIENT_IP_HEADER="x-real-ip" diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 4fbc043..0381c2b 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:16-alpine + image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea environment: POSTGRES_DB: ${POSTGRES_DB:-kita_planer} POSTGRES_USER: ${POSTGRES_USER:-kita_planer} @@ -27,7 +27,7 @@ services: NODE_ENV: production PORT: 3000 HOSTNAME: 0.0.0.0 - DATABASE_URL: postgresql://${POSTGRES_USER:-kita_planer}:${POSTGRES_PASSWORD:?}@postgres:5432/${POSTGRES_DB:-kita_planer}?schema=public + DATABASE_URL: ${APP_DATABASE_URL:?} AUTH_SECRET: ${AUTH_SECRET:?} AUTH_URL: ${AUTH_URL:?} NEXTAUTH_URL: ${NEXTAUTH_URL:?} @@ -39,17 +39,52 @@ services: EMAIL_FROM: ${EMAIL_FROM:?} ADMIN_EMAIL: ${ADMIN_EMAIL:?} UPLOAD_DIR: /app/uploads/announcements + UPLOAD_TENANT_QUOTA_BYTES: ${UPLOAD_TENANT_QUOTA_BYTES:-268435456} + UPLOAD_GLOBAL_QUOTA_BYTES: ${UPLOAD_GLOBAL_QUOTA_BYTES:-10737418240} + TRUSTED_CLIENT_IP_HEADER: ${TRUSTED_CLIENT_IP_HEADER:-} + volumes: + - kita_planer_uploads:/app/uploads + - kita_planer_next_cache:/app/.next/cache + depends_on: + postgres: + condition: service_healthy + expose: + - "3000" + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 256 + mem_limit: 1g + cpus: "1.0" + restart: unless-stopped + + migrate: + profiles: ["maintenance"] + build: + context: . + dockerfile: Dockerfile + target: migrator + args: + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:?} + environment: + NODE_ENV: production + DATABASE_URL: ${MIGRATION_DATABASE_URL:?} + APP_DATABASE_PASSWORD: ${APP_DATABASE_PASSWORD:?} + UPLOAD_DIR: /app/uploads/announcements volumes: - kita_planer_uploads:/app/uploads depends_on: postgres: condition: service_healthy - ports: - - "3000" - restart: unless-stopped + restart: "no" pgadmin: - image: dpage/pgadmin4:latest + profiles: ["maintenance"] + image: dpage/pgadmin4@sha256:c332c5f6dfba995d9ebc4af261d93506d6876085d712eaaa3defc8dd1a3f26de environment: PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:?} PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:?} @@ -61,10 +96,11 @@ services: postgres: condition: service_healthy ports: - - "80" + - "127.0.0.1:5050:80" restart: unless-stopped volumes: kita_planer_postgres_data: kita_planer_uploads: + kita_planer_next_cache: kita_planer_pgadmin_data: diff --git a/docker-compose.yml b/docker-compose.yml index 39bf455..329e7db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,14 @@ version: '3.8' services: postgres: - image: postgres:16-alpine + image: postgres:16-alpine@sha256:721873c34ceb9f8d8fc265984940dc982404c105f19ad51be9fdc5970a6080ea container_name: kita_planer_db environment: POSTGRES_USER: kita POSTGRES_PASSWORD: kita POSTGRES_DB: kita_planer ports: - - "5433:5432" + - "127.0.0.1:5433:5432" volumes: - postgres_data:/var/lib/postgresql/data restart: unless-stopped diff --git a/next.config.ts b/next.config.ts index 886e38f..00dd27a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,12 +1,33 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", + experimental: { serverActions: { bodySizeLimit: "26mb" } }, + async headers() { + return [{ source: "/:path*", headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "no-referrer" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + ...(process.env.NODE_ENV === "production" ? [{ key: "Strict-Transport-Security", value: "max-age=31536000" }] : []), + ] }]; + }, images: { + localPatterns: [ + { pathname: "/landing/**", search: "" }, + { pathname: "/og-image.png", search: "" }, + { pathname: "/_next/static/media/**", search: "" }, + ], + maximumDiskCacheSize: 128 * 1024 * 1024, + maximumResponseBody: 10 * 1024 * 1024, + maximumRedirects: 0, remotePatterns: [ { protocol: "https", hostname: "images.unsplash.com", - pathname: "/**", + port: "", + pathname: "/photo-1503454537195-1dcabb73ffb9", + search: "?auto=format&fit=crop&w=2400&q=86", }, ], }, diff --git a/package-lock.json b/package-lock.json index ddb8798..5b1ebc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "kita-planer", "version": "0.1.0", "dependencies": { - "@auth/prisma-adapter": "^2.11.2", "@hookform/resolvers": "^5.2.2", "@prisma/client": "^6.19.3", "@radix-ui/react-checkbox": "^1.3.3", @@ -25,8 +24,8 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^1.14.0", - "next": "16.2.4", - "next-auth": "^5.0.0-beta.31", + "next": "^16.4.0", + "next-auth": "^5.0.0-beta.32", "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -46,7 +45,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.4", + "eslint-config-next": "^16.4.0", "prisma": "^6.19.3", "tailwindcss": "^4", "tsx": "^4.21.0", @@ -67,9 +66,9 @@ } }, "node_modules/@auth/core": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", - "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==", + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", "license": "ISC", "dependencies": { "@panva/hkdf": "^1.2.1", @@ -81,7 +80,7 @@ "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^7.0.7" + "nodemailer": "^7.0.7 || ^8.0.5" }, "peerDependenciesMeta": { "@simplewebauthn/browser": { @@ -95,26 +94,14 @@ } } }, - "node_modules/@auth/prisma-adapter": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.2.tgz", - "integrity": "sha512-GyNEUNtrPgDPs0M4xX6F5i7jTsCKwU6BXV9zutctcoo6K1Ud+juckrmQS11uyNgeWsw6sliextHbU/e+8lsizQ==", - "license": "ISC", - "dependencies": { - "@auth/core": "0.41.2" - }, - "peerDependencies": { - "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" - } - }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -123,9 +110,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -133,22 +120,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -165,14 +151,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -182,14 +168,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -199,9 +185,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -209,29 +195,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -241,9 +227,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -251,9 +237,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -261,9 +247,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -271,27 +257,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -301,33 +287,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -335,14 +321,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -361,9 +347,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -382,9 +368,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -399,9 +385,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -416,9 +402,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -433,9 +419,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -450,9 +436,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -467,9 +453,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -484,9 +470,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -501,9 +487,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -518,9 +504,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -535,9 +521,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -552,9 +538,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -569,9 +555,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -586,9 +572,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -603,9 +589,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -620,9 +606,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -637,9 +623,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -654,9 +640,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -671,9 +657,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -688,9 +674,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -705,9 +691,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -722,9 +708,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -739,9 +725,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -756,9 +742,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -773,9 +759,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -790,9 +776,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -807,9 +793,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1094,9 +1080,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -1106,19 +1092,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -1128,19 +1114,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -1154,9 +1159,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -1170,12 +1175,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1186,12 +1194,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1202,12 +1213,15 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1218,12 +1232,15 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1234,12 +1251,15 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1250,12 +1270,15 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1266,12 +1289,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1282,12 +1308,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1298,204 +1327,244 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -1505,16 +1574,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -1524,16 +1593,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -1543,7 +1612,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1613,25 +1682,26 @@ } }, "node_modules/@next/env": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", - "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz", + "integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", - "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.0.tgz", + "integrity": "sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==", "dev": true, "license": "MIT", "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", - "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz", + "integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==", "cpu": [ "arm64" ], @@ -1645,9 +1715,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", - "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz", + "integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==", "cpu": [ "x64" ], @@ -1661,12 +1731,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", - "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz", + "integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1677,12 +1750,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", - "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz", + "integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1693,12 +1769,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", - "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz", + "integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1709,12 +1788,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", - "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz", + "integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1725,9 +1807,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", - "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz", + "integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==", "cpu": [ "arm64" ], @@ -1741,9 +1823,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", - "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz", + "integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==", "cpu": [ "x64" ], @@ -1819,7 +1901,6 @@ "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -2717,7 +2798,6 @@ "integrity": "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -2731,7 +2811,6 @@ "integrity": "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -2745,7 +2824,6 @@ "integrity": "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "dependencies": { "prismjs": "^1.30.0" }, @@ -2762,7 +2840,6 @@ "integrity": "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -2841,7 +2918,6 @@ "integrity": "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -2881,7 +2957,6 @@ "integrity": "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -2895,7 +2970,6 @@ "integrity": "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -2922,7 +2996,6 @@ "integrity": "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -2936,7 +3009,6 @@ "integrity": "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -2966,7 +3038,6 @@ "integrity": "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -2979,7 +3050,6 @@ "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.8.tgz", "integrity": "sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ==", "license": "MIT", - "peer": true, "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" @@ -3083,7 +3153,6 @@ "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -3131,9 +3200,9 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -3505,7 +3574,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3516,7 +3584,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3572,7 +3639,6 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -3727,16 +3793,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -4104,7 +4170,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4415,9 +4480,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4436,9 +4501,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -4460,9 +4525,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -4479,13 +4544,12 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -4584,9 +4648,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "funding": [ { "type": "opencollective", @@ -4923,10 +4987,20 @@ } }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.0.tgz", + "integrity": "sha512-ICNjaP0ML+eSdEpJYQC46XiAn/UjAdwbEl0dE8p85ZTeNDinN4Kd4+9jS4OSAuH7st6eC7rQhsqTF5zIDaUm2g==", "devOptional": true, + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -5128,9 +5202,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.349", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", - "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "dev": true, "license": "ISC" }, @@ -5355,9 +5429,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5368,32 +5442,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -5425,7 +5499,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5481,21 +5554,21 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", - "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.0.tgz", + "integrity": "sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.4", + "@next/eslint-plugin-next": "16.4.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^7.0.0", + "eslint-plugin-react-hooks": "^7.1.0", "globals": "16.4.0", - "typescript-eslint": "^8.46.0" + "typescript-eslint": "^8.56.0" }, "peerDependencies": { "eslint": ">=9.0.0", @@ -5611,7 +5684,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5931,9 +6003,9 @@ "license": "Unlicense" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -7024,9 +7096,9 @@ } }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -7040,10 +7112,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -8435,9 +8517,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -8476,16 +8558,16 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", - "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz", + "integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==", "license": "MIT", "dependencies": { - "@next/env": "16.2.4", - "@swc/helpers": "0.5.15", + "@next/env": "16.4.0", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -8495,15 +8577,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.4", - "@next/swc-darwin-x64": "16.2.4", - "@next/swc-linux-arm64-gnu": "16.2.4", - "@next/swc-linux-arm64-musl": "16.2.4", - "@next/swc-linux-x64-gnu": "16.2.4", - "@next/swc-linux-x64-musl": "16.2.4", - "@next/swc-win32-arm64-msvc": "16.2.4", - "@next/swc-win32-x64-msvc": "16.2.4", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.4.0", + "@next/swc-darwin-x64": "16.4.0", + "@next/swc-linux-arm64-gnu": "16.4.0", + "@next/swc-linux-arm64-musl": "16.4.0", + "@next/swc-linux-x64-gnu": "16.4.0", + "@next/swc-linux-x64-musl": "16.4.0", + "@next/swc-win32-arm64-msvc": "16.4.0", + "@next/swc-win32-x64-msvc": "16.4.0", + "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -8529,18 +8611,18 @@ } }, "node_modules/next-auth": { - "version": "5.0.0-beta.31", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz", - "integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==", + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", "license": "ISC", "dependencies": { - "@auth/core": "0.41.2" + "@auth/core": "0.41.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", - "nodemailer": "^7.0.7", + "nodemailer": "^7.0.7 || ^8.0.5", "react": "^18.2.0 || ^19.0.0" }, "peerDependenciesMeta": { @@ -8565,34 +8647,6 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -8620,11 +8674,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nypm": { "version": "0.6.6", @@ -8652,9 +8709,9 @@ "license": "MIT" }, "node_modules/oauth4webapi": { - "version": "3.8.6", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", - "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -9007,10 +9064,9 @@ "license": "MIT-0" }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", - "dev": true, + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -9027,7 +9083,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9040,7 +9096,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -9087,7 +9142,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -9202,7 +9256,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9212,7 +9265,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9225,7 +9277,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz", "integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -9697,54 +9748,59 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -9864,9 +9920,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10153,8 +10209,7 @@ "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -10230,7 +10285,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10433,7 +10487,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10615,9 +10668,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -10866,7 +10919,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 9f4b2c2..c9824c3 100644 --- a/package.json +++ b/package.json @@ -3,23 +3,28 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "NODE_ENV=development prisma db push && NODE_ENV=development prisma db seed && next dev", - "build": "next build", - "build:prod": "prisma generate && next build", + "dev": "next dev", + "build": "next build && node scripts/prepare-standalone.mjs", + "build:prod": "prisma generate && next build && node scripts/prepare-standalone.mjs", "start": "next start", - "start:prod": "next start", + "start:prod": "node .next/standalone/server.js", "lint": "eslint", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", - "db:seed": "NODE_ENV=development prisma db seed", - "db:seed:reset": "NODE_ENV=development prisma db seed -- --reset" + "db:seed": "prisma db seed", + "db:seed:reset": "prisma db seed -- --reset", + "db:migrate:deploy": "prisma migrate deploy", + "test:security": "node --test tests/security.test.cjs", + "db:provision-runtime": "node scripts/provision-runtime-role.mjs", + "uploads:backfill": "tsx scripts/backfill-attachment-sizes.ts", + "uploads:cleanup": "tsx scripts/cleanup-attachments.ts", + "test:security:http": "node tests/security-http.cjs" }, "prisma": { "seed": "tsx prisma/seed.ts" }, "dependencies": { - "@auth/prisma-adapter": "^2.11.2", "@hookform/resolvers": "^5.2.2", "@prisma/client": "^6.19.3", "@radix-ui/react-checkbox": "^1.3.3", @@ -36,8 +41,8 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^1.14.0", - "next": "16.2.4", - "next-auth": "^5.0.0-beta.31", + "next": "^16.4.0", + "next-auth": "^5.0.0-beta.32", "next-themes": "^0.4.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -57,10 +62,18 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.4", + "eslint-config-next": "^16.4.0", "prisma": "^6.19.3", "tailwindcss": "^4", "tsx": "^4.21.0", "typescript": "^5" + }, + "overrides": { + "@prisma/config": { + "deepmerge-ts": "8.0.0" + }, + "tsx": { + "esbuild": "^0.28.1" + } } } diff --git a/prisma/migrations/20261009000000_baseline/migration.sql b/prisma/migrations/20261009000000_baseline/migration.sql new file mode 100644 index 0000000..7bac5a8 --- /dev/null +++ b/prisma/migrations/20261009000000_baseline/migration.sql @@ -0,0 +1,713 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('SUPERADMIN', 'ADMIN', 'KOORDINATOR', 'ERZIEHER', 'ELTERN'); + +-- CreateEnum +CREATE TYPE "InvitationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED'); + +-- CreateEnum +CREATE TYPE "TerminType" AS ENUM ('KITA_FEST', 'SCHLIESSTAG', 'TEAMTAG', 'MITMACH_TAG', 'ELTERNABEND', 'MITGLIEDERVERSAMMLUNG', 'ELTERNCAFE', 'GEBURTSTAG_INTERN', 'GEBURTSTAG_EXTERN', 'SONSTIGES'); + +-- CreateEnum +CREATE TYPE "TerminStatus" AS ENUM ('PENDING', 'CONFIRMED', 'REJECTED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "EventParticipationStatus" AS ENUM ('ATTENDING', 'NOT_ATTENDING'); + +-- CreateEnum +CREATE TYPE "AuditTargetType" AS ENUM ('KITA', 'USER'); + +-- CreateEnum +CREATE TYPE "NotdienstPlanStatus" AS ENUM ('DRAFT', 'PUBLISHED'); + +-- CreateEnum +CREATE TYPE "NotdienstAlertStatus" AS ENUM ('PENDING', 'CONFIRMED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "NotdienstSwapStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "DutyAssignmentStatus" AS ENUM ('PLANNED', 'DONE', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "AbsenceReason" AS ENUM ('ILLNESS', 'VACATION', 'OTHER'); + +-- CreateTable +CREATE TABLE "kitas" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "notdienstModuleEnabled" BOOLEAN NOT NULL DEFAULT true, + "terminModuleEnabled" BOOLEAN NOT NULL DEFAULT true, + "adressbuchModuleEnabled" BOOLEAN NOT NULL DEFAULT true, + "notdienstMinPerChildPerMonth" INTEGER NOT NULL DEFAULT 2, + "notdienstReminderDaysBefore" INTEGER NOT NULL DEFAULT 7, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "kitas_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "families" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "families_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "kitaId" TEXT, + "familyId" TEXT, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'ELTERN', + "phone" TEXT, + "street" TEXT, + "postalCode" TEXT, + "city" TEXT, + "privacyPolicyAcceptedAt" TIMESTAMP(3), + "privacyPolicyVersion" TEXT, + "directoryOptInAt" TIMESTAMP(3), + "emailVerifiedAt" TIMESTAMP(3), + "lastLoginAt" TIMESTAMP(3), + "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0, + "lockedUntil" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "children" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "familyId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "dateOfBirth" TIMESTAMP(3), + "notes" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "children_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "educators" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "educators_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "parent_duties" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "parent_duties_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "parent_duty_assignments" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "dutyId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "parent_duty_assignments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "duty_types" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "duty_types_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "duty_assignments" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "familyId" TEXT NOT NULL, + "dutyTypeId" TEXT NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE NOT NULL, + "status" "DutyAssignmentStatus" NOT NULL DEFAULT 'PLANNED', + "reminderSentAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "duty_assignments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "absences" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "childId" TEXT NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE NOT NULL, + "reason" "AbsenceReason" NOT NULL, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "absences_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "announcements" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "announcements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "attachments" ( + "id" TEXT NOT NULL, + "announcementId" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "fileUrl" TEXT NOT NULL, + "fileType" TEXT NOT NULL, + + CONSTRAINT "attachments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "announcement_reads" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "announcementId" TEXT NOT NULL, + "readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "announcement_reads_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "invitations" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'ELTERN', + "token" TEXT NOT NULL, + "status" "InvitationStatus" NOT NULL DEFAULT 'PENDING', + "expiresAt" TIMESTAMP(3) NOT NULL, + "acceptedAt" TIMESTAMP(3), + "invitedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "termine" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "type" "TerminType" NOT NULL, + "status" "TerminStatus" NOT NULL DEFAULT 'PENDING', + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "allDay" BOOLEAN NOT NULL DEFAULT false, + "mitbringselListEnabled" BOOLEAN NOT NULL DEFAULT false, + "requiresRsvp" BOOLEAN NOT NULL DEFAULT false, + "rsvpDeadline" TIMESTAMP(3), + "participantInfo" TEXT, + "createdById" TEXT, + "approvedById" TEXT, + "approvedAt" TIMESTAMP(3), + "rejectionReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "termine_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "event_participations" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "childId" TEXT NOT NULL, + "status" "EventParticipationStatus" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "event_participations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "mitbringsel_items" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "terminId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "mitbringsel_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notdienst_plans" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL, + "status" "NotdienstPlanStatus" NOT NULL DEFAULT 'DRAFT', + "createdById" TEXT, + "publishedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notdienst_plans_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notdienst_availabilities" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "childId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "date" DATE NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notdienst_availabilities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notdienst_assignments" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "childId" TEXT NOT NULL, + "date" DATE NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notdienst_assignments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notdienst_swap_requests" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "requesterUserId" TEXT NOT NULL, + "requesterFamilyId" TEXT NOT NULL, + "targetAssignmentId" TEXT NOT NULL, + "offeredAssignmentId" TEXT NOT NULL, + "status" "NotdienstSwapStatus" NOT NULL DEFAULT 'PENDING', + "message" TEXT, + "respondedById" TEXT, + "respondedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notdienst_swap_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notdienst_alerts" ( + "id" TEXT NOT NULL, + "kitaId" TEXT NOT NULL, + "assignmentId" TEXT NOT NULL, + "parentUserId" TEXT NOT NULL, + "triggeredById" TEXT, + "educatorId" TEXT, + "status" "NotdienstAlertStatus" NOT NULL DEFAULT 'PENDING', + "confirmationToken" TEXT NOT NULL, + "triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "confirmedAt" TIMESTAMP(3), + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notdienst_alerts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" TEXT NOT NULL, + "actorUserId" TEXT, + "actorEmail" TEXT NOT NULL, + "action" TEXT NOT NULL, + "targetType" "AuditTargetType" NOT NULL, + "targetId" TEXT NOT NULL, + "targetLabel" TEXT NOT NULL, + "targetKitaId" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification_tokens" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "kitas_slug_key" ON "kitas"("slug"); + +-- CreateIndex +CREATE INDEX "families_kitaId_idx" ON "families"("kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE INDEX "users_kitaId_idx" ON "users"("kitaId"); + +-- CreateIndex +CREATE INDEX "users_familyId_idx" ON "users"("familyId"); + +-- CreateIndex +CREATE INDEX "users_kitaId_role_idx" ON "users"("kitaId", "role"); + +-- CreateIndex +CREATE INDEX "children_kitaId_idx" ON "children"("kitaId"); + +-- CreateIndex +CREATE INDEX "children_familyId_idx" ON "children"("familyId"); + +-- CreateIndex +CREATE INDEX "educators_kitaId_idx" ON "educators"("kitaId"); + +-- CreateIndex +CREATE INDEX "parent_duties_kitaId_idx" ON "parent_duties"("kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "parent_duties_kitaId_name_key" ON "parent_duties"("kitaId", "name"); + +-- CreateIndex +CREATE INDEX "parent_duty_assignments_kitaId_idx" ON "parent_duty_assignments"("kitaId"); + +-- CreateIndex +CREATE INDEX "parent_duty_assignments_userId_idx" ON "parent_duty_assignments"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "parent_duty_assignments_dutyId_userId_key" ON "parent_duty_assignments"("dutyId", "userId"); + +-- CreateIndex +CREATE INDEX "duty_types_kitaId_idx" ON "duty_types"("kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "duty_types_kitaId_name_key" ON "duty_types"("kitaId", "name"); + +-- CreateIndex +CREATE INDEX "duty_assignments_kitaId_startDate_idx" ON "duty_assignments"("kitaId", "startDate"); + +-- CreateIndex +CREATE INDEX "duty_assignments_familyId_startDate_idx" ON "duty_assignments"("familyId", "startDate"); + +-- CreateIndex +CREATE UNIQUE INDEX "duty_assignments_kitaId_dutyTypeId_startDate_key" ON "duty_assignments"("kitaId", "dutyTypeId", "startDate"); + +-- CreateIndex +CREATE INDEX "absences_kitaId_startDate_endDate_idx" ON "absences"("kitaId", "startDate", "endDate"); + +-- CreateIndex +CREATE INDEX "absences_childId_startDate_idx" ON "absences"("childId", "startDate"); + +-- CreateIndex +CREATE INDEX "announcements_kitaId_createdAt_idx" ON "announcements"("kitaId", "createdAt"); + +-- CreateIndex +CREATE INDEX "announcements_authorId_idx" ON "announcements"("authorId"); + +-- CreateIndex +CREATE INDEX "attachments_announcementId_idx" ON "attachments"("announcementId"); + +-- CreateIndex +CREATE INDEX "announcement_reads_announcementId_idx" ON "announcement_reads"("announcementId"); + +-- CreateIndex +CREATE UNIQUE INDEX "announcement_reads_userId_announcementId_key" ON "announcement_reads"("userId", "announcementId"); + +-- CreateIndex +CREATE UNIQUE INDEX "invitations_token_key" ON "invitations"("token"); + +-- CreateIndex +CREATE INDEX "invitations_kitaId_idx" ON "invitations"("kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "invitations_kitaId_email_key" ON "invitations"("kitaId", "email"); + +-- CreateIndex +CREATE INDEX "termine_kitaId_startDate_idx" ON "termine"("kitaId", "startDate"); + +-- CreateIndex +CREATE INDEX "termine_kitaId_status_idx" ON "termine"("kitaId", "status"); + +-- CreateIndex +CREATE INDEX "event_participations_kitaId_idx" ON "event_participations"("kitaId"); + +-- CreateIndex +CREATE INDEX "event_participations_childId_idx" ON "event_participations"("childId"); + +-- CreateIndex +CREATE UNIQUE INDEX "event_participations_eventId_childId_key" ON "event_participations"("eventId", "childId"); + +-- CreateIndex +CREATE INDEX "mitbringsel_items_kitaId_idx" ON "mitbringsel_items"("kitaId"); + +-- CreateIndex +CREATE INDEX "mitbringsel_items_terminId_idx" ON "mitbringsel_items"("terminId"); + +-- CreateIndex +CREATE INDEX "notdienst_plans_kitaId_idx" ON "notdienst_plans"("kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_plans_kitaId_year_month_key" ON "notdienst_plans"("kitaId", "year", "month"); + +-- CreateIndex +CREATE INDEX "notdienst_availabilities_kitaId_date_idx" ON "notdienst_availabilities"("kitaId", "date"); + +-- CreateIndex +CREATE INDEX "notdienst_availabilities_userId_idx" ON "notdienst_availabilities"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_availabilities_childId_date_key" ON "notdienst_availabilities"("childId", "date"); + +-- CreateIndex +CREATE INDEX "notdienst_assignments_kitaId_date_idx" ON "notdienst_assignments"("kitaId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_assignments_planId_date_key" ON "notdienst_assignments"("planId", "date"); + +-- CreateIndex +CREATE INDEX "notdienst_swap_requests_kitaId_status_idx" ON "notdienst_swap_requests"("kitaId", "status"); + +-- CreateIndex +CREATE INDEX "notdienst_swap_requests_requesterFamilyId_status_idx" ON "notdienst_swap_requests"("requesterFamilyId", "status"); + +-- CreateIndex +CREATE INDEX "notdienst_swap_requests_targetAssignmentId_status_idx" ON "notdienst_swap_requests"("targetAssignmentId", "status"); + +-- CreateIndex +CREATE INDEX "notdienst_swap_requests_offeredAssignmentId_status_idx" ON "notdienst_swap_requests"("offeredAssignmentId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_alerts_confirmationToken_key" ON "notdienst_alerts"("confirmationToken"); + +-- CreateIndex +CREATE INDEX "notdienst_alerts_kitaId_idx" ON "notdienst_alerts"("kitaId"); + +-- CreateIndex +CREATE INDEX "notdienst_alerts_assignmentId_idx" ON "notdienst_alerts"("assignmentId"); + +-- CreateIndex +CREATE INDEX "notdienst_alerts_parentUserId_idx" ON "notdienst_alerts"("parentUserId"); + +-- CreateIndex +CREATE INDEX "audit_logs_actorUserId_idx" ON "audit_logs"("actorUserId"); + +-- CreateIndex +CREATE INDEX "audit_logs_targetType_targetId_idx" ON "audit_logs"("targetType", "targetId"); + +-- CreateIndex +CREATE INDEX "audit_logs_targetKitaId_idx" ON "audit_logs"("targetKitaId"); + +-- CreateIndex +CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "verification_tokens_token_key" ON "verification_tokens"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token"); + +-- AddForeignKey +ALTER TABLE "families" ADD CONSTRAINT "families_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "children" ADD CONSTRAINT "children_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "children" ADD CONSTRAINT "children_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "educators" ADD CONSTRAINT "educators_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "parent_duties" ADD CONSTRAINT "parent_duties_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_dutyId_fkey" FOREIGN KEY ("dutyId") REFERENCES "parent_duties"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "duty_types" ADD CONSTRAINT "duty_types_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_dutyTypeId_fkey" FOREIGN KEY ("dutyTypeId") REFERENCES "duty_types"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "absences" ADD CONSTRAINT "absences_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "absences" ADD CONSTRAINT "absences_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "announcements" ADD CONSTRAINT "announcements_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "announcements" ADD CONSTRAINT "announcements_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "attachments" ADD CONSTRAINT "attachments_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "announcement_reads" ADD CONSTRAINT "announcement_reads_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "announcement_reads" ADD CONSTRAINT "announcement_reads_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "termine" ADD CONSTRAINT "termine_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "termine" ADD CONSTRAINT "termine_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "termine" ADD CONSTRAINT "termine_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "termine"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_plans" ADD CONSTRAINT "notdienst_plans_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_plans" ADD CONSTRAINT "notdienst_plans_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_planId_fkey" FOREIGN KEY ("planId") REFERENCES "notdienst_plans"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_childId_fkey" FOREIGN KEY ("childId") REFERENCES "children"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterUserId_fkey" FOREIGN KEY ("requesterUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterFamilyId_fkey" FOREIGN KEY ("requesterFamilyId") REFERENCES "families"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_targetAssignmentId_fkey" FOREIGN KEY ("targetAssignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_offeredAssignmentId_fkey" FOREIGN KEY ("offeredAssignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_respondedById_fkey" FOREIGN KEY ("respondedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_kitaId_fkey" FOREIGN KEY ("kitaId") REFERENCES "kitas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "notdienst_assignments"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_parentUserId_fkey" FOREIGN KEY ("parentUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_triggeredById_fkey" FOREIGN KEY ("triggeredById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_educatorId_fkey" FOREIGN KEY ("educatorId") REFERENCES "educators"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20261009010000_security_hardening/migration.sql b/prisma/migrations/20261009010000_security_hardening/migration.sql new file mode 100644 index 0000000..25f3e16 --- /dev/null +++ b/prisma/migrations/20261009010000_security_hardening/migration.sql @@ -0,0 +1,102 @@ +BEGIN; +-- AlterTable +ALTER TABLE "users" ADD COLUMN "disabledAt" TIMESTAMP(3), +ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0; + +-- CreateTable +CREATE TABLE "rate_limit_buckets" ( + "key" TEXT NOT NULL, + "count" INTEGER NOT NULL, + "resetAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "rate_limit_buckets_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "pending_registrations" ( + "email" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "pending_registrations_pkey" PRIMARY KEY ("email") +); + +-- CreateIndex +CREATE INDEX "rate_limit_buckets_resetAt_idx" ON "rate_limit_buckets"("resetAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "pending_registrations_tokenHash_key" ON "pending_registrations"("tokenHash"); + +-- CreateIndex +CREATE INDEX "pending_registrations_expiresAt_idx" ON "pending_registrations"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "families_id_kitaId_key" ON "families"("id", "kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_id_kitaId_key" ON "users"("id", "kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "children_id_kitaId_key" ON "children"("id", "kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "educators_id_kitaId_key" ON "educators"("id", "kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "parent_duties_id_kitaId_key" ON "parent_duties"("id", "kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "duty_types_id_kitaId_key" ON "duty_types"("id", "kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "termine_id_kitaId_key" ON "termine"("id", "kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_plans_id_kitaId_key" ON "notdienst_plans"("id", "kitaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "notdienst_assignments_id_kitaId_key" ON "notdienst_assignments"("id", "kitaId"); + + +-- Additional composite tenant foreign keys deliberately supplement the Prisma single-ID relations. +-- NO ACTION preserves their existing Cascade/SetNull behavior; validation rejects inconsistent legacy rows. +ALTER TABLE "users" ADD CONSTRAINT "users_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "children" ADD CONSTRAINT "children_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_dutyId_tenant_fkey" FOREIGN KEY ("dutyId", "kitaId") REFERENCES "parent_duties"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "parent_duty_assignments" ADD CONSTRAINT "parent_duty_assignments_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_familyId_tenant_fkey" FOREIGN KEY ("familyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "duty_assignments" ADD CONSTRAINT "duty_assignments_dutyTypeId_tenant_fkey" FOREIGN KEY ("dutyTypeId", "kitaId") REFERENCES "duty_types"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "absences" ADD CONSTRAINT "absences_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "announcements" ADD CONSTRAINT "announcements_authorId_tenant_fkey" FOREIGN KEY ("authorId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invitedById_tenant_fkey" FOREIGN KEY ("invitedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "termine" ADD CONSTRAINT "termine_createdById_tenant_fkey" FOREIGN KEY ("createdById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "termine" ADD CONSTRAINT "termine_approvedById_tenant_fkey" FOREIGN KEY ("approvedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_eventId_tenant_fkey" FOREIGN KEY ("eventId", "kitaId") REFERENCES "termine"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "event_participations" ADD CONSTRAINT "event_participations_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_terminId_tenant_fkey" FOREIGN KEY ("terminId", "kitaId") REFERENCES "termine"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "mitbringsel_items" ADD CONSTRAINT "mitbringsel_items_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_plans" ADD CONSTRAINT "notdienst_plans_createdById_tenant_fkey" FOREIGN KEY ("createdById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_availabilities" ADD CONSTRAINT "notdienst_availabilities_userId_tenant_fkey" FOREIGN KEY ("userId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_planId_tenant_fkey" FOREIGN KEY ("planId", "kitaId") REFERENCES "notdienst_plans"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_assignments" ADD CONSTRAINT "notdienst_assignments_childId_tenant_fkey" FOREIGN KEY ("childId", "kitaId") REFERENCES "children"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterUserId_tenant_fkey" FOREIGN KEY ("requesterUserId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_requesterFamilyId_tenant_fkey" FOREIGN KEY ("requesterFamilyId", "kitaId") REFERENCES "families"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_targetAssignmentId_tenant_fkey" FOREIGN KEY ("targetAssignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_offeredAssignmentId_tenant_fkey" FOREIGN KEY ("offeredAssignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_swap_requests" ADD CONSTRAINT "notdienst_swap_requests_respondedById_tenant_fkey" FOREIGN KEY ("respondedById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_assignmentId_tenant_fkey" FOREIGN KEY ("assignmentId", "kitaId") REFERENCES "notdienst_assignments"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_parentUserId_tenant_fkey" FOREIGN KEY ("parentUserId", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_triggeredById_tenant_fkey" FOREIGN KEY ("triggeredById", "kitaId") REFERENCES "users"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE "notdienst_alerts" ADD CONSTRAINT "notdienst_alerts_educatorId_tenant_fkey" FOREIGN KEY ("educatorId", "kitaId") REFERENCES "educators"("id", "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- Existing founder registrations previously set this timestamp without verification. +UPDATE users SET "emailVerifiedAt" = NULL, "sessionVersion" = "sessionVersion" + 1 +WHERE role = 'ADMIN' AND "kitaId" IS NULL; +-- Preserve existing administrative account locks separately from temporary brute-force lockouts. +UPDATE users SET "disabledAt" = NOW(), "sessionVersion" = "sessionVersion" + 1 +WHERE "lockedUntil" >= TIMESTAMP '2099-01-01'; + +COMMIT; diff --git a/prisma/migrations/20261009020000_read_tenant_constraints/migration.sql b/prisma/migrations/20261009020000_read_tenant_constraints/migration.sql new file mode 100644 index 0000000..b7b01e3 --- /dev/null +++ b/prisma/migrations/20261009020000_read_tenant_constraints/migration.sql @@ -0,0 +1,20 @@ +BEGIN; +-- AlterTable +ALTER TABLE "announcement_reads" ADD COLUMN "kitaId" TEXT; +UPDATE announcement_reads AS r SET "kitaId" = a."kitaId" FROM announcements AS a WHERE a.id = r."announcementId"; +ALTER TABLE announcement_reads ALTER COLUMN "kitaId" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "announcements_id_kitaId_key" ON "announcements"("id", "kitaId"); + +-- CreateIndex +CREATE INDEX "announcement_reads_kitaId_idx" ON "announcement_reads"("kitaId"); + + +ALTER TABLE users ADD CONSTRAINT users_family_requires_tenant CHECK ("familyId" IS NULL OR "kitaId" IS NOT NULL); +ALTER TABLE announcement_reads ADD CONSTRAINT announcement_reads_user_tenant_fkey + FOREIGN KEY ("userId", "kitaId") REFERENCES users(id, "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; +ALTER TABLE announcement_reads ADD CONSTRAINT announcement_reads_announcement_tenant_fkey + FOREIGN KEY ("announcementId", "kitaId") REFERENCES announcements(id, "kitaId") ON DELETE NO ACTION ON UPDATE NO ACTION; + +COMMIT; diff --git a/prisma/migrations/20261009030000_attachment_quotas/migration.sql b/prisma/migrations/20261009030000_attachment_quotas/migration.sql new file mode 100644 index 0000000..d43faf7 --- /dev/null +++ b/prisma/migrations/20261009030000_attachment_quotas/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "attachments" ADD COLUMN "sizeBytes" INTEGER; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 44b650f..04dd041 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -170,6 +170,7 @@ model Family { @@index([kitaId]) @@map("families") + @@unique([id, kitaId]) } // ===================================================================== @@ -217,6 +218,8 @@ model User { lastLoginAt DateTime? failedLoginAttempts Int @default(0) lockedUntil DateTime? + disabledAt DateTime? + sessionVersion Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -244,6 +247,7 @@ model User { @@index([familyId]) @@index([kitaId, role]) @@map("users") + @@unique([id, kitaId]) } // ===================================================================== @@ -276,6 +280,7 @@ model Child { @@index([kitaId]) @@index([familyId]) @@map("children") + @@unique([id, kitaId]) } // ===================================================================== @@ -298,6 +303,7 @@ model Educator { @@index([kitaId]) @@map("educators") + @@unique([id, kitaId]) } // ===================================================================== @@ -321,6 +327,7 @@ model ParentDuty { @@unique([kitaId, name]) @@index([kitaId]) @@map("parent_duties") + @@unique([id, kitaId]) } model ParentDutyAssignment { @@ -360,6 +367,7 @@ model DutyType { @@unique([kitaId, name]) @@index([kitaId]) @@map("duty_types") + @@unique([id, kitaId]) } model DutyAssignment { @@ -435,6 +443,7 @@ model Announcement { @@index([kitaId, createdAt]) @@index([authorId]) + @@unique([id, kitaId]) @@map("announcements") } @@ -446,12 +455,14 @@ model Attachment { fileName String fileUrl String fileType String + sizeBytes Int? @@index([announcementId]) @@map("attachments") } model AnnouncementRead { + kitaId String id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -462,6 +473,7 @@ model AnnouncementRead { @@unique([userId, announcementId]) @@index([announcementId]) + @@index([kitaId]) @@map("announcement_reads") } @@ -536,6 +548,7 @@ model Termin { @@index([kitaId, startDate]) @@index([kitaId, status]) @@map("termine") + @@unique([id, kitaId]) } model EventParticipation { @@ -612,6 +625,7 @@ model NotdienstPlan { @@unique([kitaId, year, month]) @@index([kitaId]) @@map("notdienst_plans") + @@unique([id, kitaId]) } /// Verfügbarkeits-Eintrag eines Elternteils für ein Kind an einem Tag. @@ -664,6 +678,7 @@ model NotdienstAssignment { @@unique([planId, date]) @@index([kitaId, date]) @@map("notdienst_assignments") + @@unique([id, kitaId]) } /// Tauschanfrage zwischen Eltern fuer zwei bereits veroeffentlichte @@ -786,3 +801,21 @@ model VerificationToken { @@unique([identifier, token]) @@map("verification_tokens") } + +model RateLimitBucket { + key String @id + count Int + resetAt DateTime + @@index([resetAt]) + @@map("rate_limit_buckets") +} + +model PendingRegistration { + email String @id + firstName String + lastName String + tokenHash String @unique + expiresAt DateTime + @@index([expiresAt]) + @@map("pending_registrations") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 1ec38db..662b94b 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -19,7 +19,7 @@ import { hash } from "bcryptjs"; // noch keine Kita und keine User enthaelt. // // Reset: `npm run db:seed:reset` loescht gezielt die Demo-Daten und baut -// sie neu auf. Production wird nur mit ALLOW_DATABASE_SEED=true erlaubt. +// sie neu auf. Production-Seeding ist grundsätzlich deaktiviert. // ===================================================================== const prisma = new PrismaClient(); @@ -101,16 +101,8 @@ function getWorkingDays(year: number, month: number, limit?: number) { } function isSeedAllowed() { - if ( - process.env.NODE_ENV === "development" || - process.env.ALLOW_DATABASE_SEED === "true" - ) { - return true; - } - - console.log( - "Seed uebersprungen: NODE_ENV ist nicht development. Setze ALLOW_DATABASE_SEED=true, wenn das bewusst gewuenscht ist.", - ); + if (process.env.NODE_ENV !== "production" && process.env.ALLOW_DATABASE_SEED === "true") return true; + console.log("Seed deaktiviert. Nur außerhalb der Produktion mit ALLOW_DATABASE_SEED=true erlaubt."); return false; } @@ -688,10 +680,10 @@ async function createAnnouncements(context: SeedContext) { await prisma.announcementRead.createMany({ data: [ - { userId: koordinator.id, announcementId: sommerfest.id }, - { userId: elternSchmidt.id, announcementId: sommerfest.id }, - { userId: elternYilmaz.id, announcementId: sommerfest.id }, - { userId: elternFischer.id, announcementId: garderobe.id }, + { kitaId: kita.id, userId: koordinator.id, announcementId: sommerfest.id }, + { kitaId: kita.id, userId: elternSchmidt.id, announcementId: sommerfest.id }, + { kitaId: kita.id, userId: elternYilmaz.id, announcementId: sommerfest.id }, + { kitaId: kita.id, userId: elternFischer.id, announcementId: garderobe.id }, ], }); } diff --git a/scripts/backfill-attachment-sizes.ts b/scripts/backfill-attachment-sizes.ts new file mode 100644 index 0000000..995cae2 --- /dev/null +++ b/scripts/backfill-attachment-sizes.ts @@ -0,0 +1,23 @@ +import { stat } from "node:fs/promises"; +import { PrismaClient } from "@prisma/client"; +import { attachmentPath } from "../src/lib/attachments"; +const prisma = new PrismaClient(); +async function main() { + let count = 0, missing = 0; + while (true) { + const batch = await prisma.attachment.findMany({ where: { sizeBytes: null }, select: { id: true }, take: 100 }); + if (!batch.length) break; + for (const item of batch) { + let sizeBytes = 0; + try { sizeBytes = (await stat(attachmentPath(item.id))).size; } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + missing++; + } + await prisma.attachment.update({ where: { id: item.id }, data: { sizeBytes } }); + count++; + } + } + console.log(`Attachment sizes recorded: ${count}; missing files: ${missing}.`); +} +main().finally(() => prisma.$disconnect()).catch(() => { console.error("Attachment size backfill failed."); process.exitCode = 1; }); diff --git a/scripts/check-tenant-integrity.sql b/scripts/check-tenant-integrity.sql new file mode 100644 index 0000000..a7a125e --- /dev/null +++ b/scripts/check-tenant-integrity.sql @@ -0,0 +1,62 @@ +-- Read-only preflight for both legacy and upgraded schemas. Empty result means no violations. +SELECT * FROM ( +SELECT 'users.familyId' AS relation, count(*) AS invalid_rows FROM "users" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'children.familyId' AS relation, count(*) AS invalid_rows FROM "children" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'parent_duty_assignments.dutyId' AS relation, count(*) AS invalid_rows FROM "parent_duty_assignments" c JOIN "parent_duties" p ON c."dutyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'parent_duty_assignments.userId' AS relation, count(*) AS invalid_rows FROM "parent_duty_assignments" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'duty_assignments.familyId' AS relation, count(*) AS invalid_rows FROM "duty_assignments" c JOIN "families" p ON c."familyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'duty_assignments.dutyTypeId' AS relation, count(*) AS invalid_rows FROM "duty_assignments" c JOIN "duty_types" p ON c."dutyTypeId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'absences.childId' AS relation, count(*) AS invalid_rows FROM "absences" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'announcements.authorId' AS relation, count(*) AS invalid_rows FROM "announcements" c JOIN "users" p ON c."authorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'invitations.invitedById' AS relation, count(*) AS invalid_rows FROM "invitations" c JOIN "users" p ON c."invitedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'termine.createdById' AS relation, count(*) AS invalid_rows FROM "termine" c JOIN "users" p ON c."createdById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'termine.approvedById' AS relation, count(*) AS invalid_rows FROM "termine" c JOIN "users" p ON c."approvedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'event_participations.eventId' AS relation, count(*) AS invalid_rows FROM "event_participations" c JOIN "termine" p ON c."eventId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'event_participations.childId' AS relation, count(*) AS invalid_rows FROM "event_participations" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'mitbringsel_items.terminId' AS relation, count(*) AS invalid_rows FROM "mitbringsel_items" c JOIN "termine" p ON c."terminId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'mitbringsel_items.userId' AS relation, count(*) AS invalid_rows FROM "mitbringsel_items" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_plans.createdById' AS relation, count(*) AS invalid_rows FROM "notdienst_plans" c JOIN "users" p ON c."createdById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_availabilities.childId' AS relation, count(*) AS invalid_rows FROM "notdienst_availabilities" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_availabilities.userId' AS relation, count(*) AS invalid_rows FROM "notdienst_availabilities" c JOIN "users" p ON c."userId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_assignments.planId' AS relation, count(*) AS invalid_rows FROM "notdienst_assignments" c JOIN "notdienst_plans" p ON c."planId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_assignments.childId' AS relation, count(*) AS invalid_rows FROM "notdienst_assignments" c JOIN "children" p ON c."childId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_swap_requests.requesterUserId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "users" p ON c."requesterUserId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_swap_requests.requesterFamilyId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "families" p ON c."requesterFamilyId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_swap_requests.targetAssignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "notdienst_assignments" p ON c."targetAssignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_swap_requests.offeredAssignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "notdienst_assignments" p ON c."offeredAssignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_swap_requests.respondedById' AS relation, count(*) AS invalid_rows FROM "notdienst_swap_requests" c JOIN "users" p ON c."respondedById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_alerts.assignmentId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "notdienst_assignments" p ON c."assignmentId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_alerts.parentUserId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "users" p ON c."parentUserId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_alerts.triggeredById' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "users" p ON c."triggeredById" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'notdienst_alerts.educatorId' AS relation, count(*) AS invalid_rows FROM "notdienst_alerts" c JOIN "educators" p ON c."educatorId" = p.id WHERE c."kitaId" IS DISTINCT FROM p."kitaId" +UNION ALL +SELECT 'announcement_reads.userId' AS relation, count(*) AS invalid_rows FROM announcement_reads r JOIN announcements a ON r."announcementId" = a.id JOIN users u ON r."userId" = u.id WHERE u."kitaId" IS DISTINCT FROM a."kitaId" +) violations WHERE invalid_rows > 0; diff --git a/scripts/cleanup-attachments.ts b/scripts/cleanup-attachments.ts new file mode 100644 index 0000000..41a961d --- /dev/null +++ b/scripts/cleanup-attachments.ts @@ -0,0 +1,21 @@ +import { readdir, stat, rm } from "node:fs/promises"; +import { PrismaClient } from "@prisma/client"; +import { attachmentDirectory, attachmentPath } from "../src/lib/attachments"; + +// Dry run by default. A 24-hour grace period protects concurrent uploads. +const prisma = new PrismaClient(); +async function main() { + const remove = process.argv.includes("--delete"); + let candidates = 0; + for (const entry of await readdir(attachmentDirectory(), { withFileTypes: true })) { + if (!entry.isFile()) continue; + let file: string; + try { file = attachmentPath(entry.name); } catch { continue; } + if ((await stat(file)).mtimeMs > Date.now() - 24 * 60 * 60_000) continue; + if (await prisma.attachment.findUnique({ where: { id: entry.name }, select: { id: true } })) continue; + candidates++; + if (remove) await rm(file, { force: true }); + } + console.log(`${candidates} orphan attachments ${remove ? "removed" : "found (dry run)"}`); +} +main().finally(() => prisma.$disconnect()).catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/prepare-standalone.mjs b/scripts/prepare-standalone.mjs new file mode 100644 index 0000000..904a554 --- /dev/null +++ b/scripts/prepare-standalone.mjs @@ -0,0 +1,3 @@ +import { cpSync } from 'node:fs'; +cpSync('.next/static', '.next/standalone/.next/static', { recursive: true }); +cpSync('public', '.next/standalone/public', { recursive: true }); diff --git a/scripts/provision-runtime-role.mjs b/scripts/provision-runtime-role.mjs new file mode 100644 index 0000000..5ca9194 --- /dev/null +++ b/scripts/provision-runtime-role.mjs @@ -0,0 +1,34 @@ +import { PrismaClient } from '@prisma/client'; + +const password = process.env.APP_DATABASE_PASSWORD; +if (!password || password.length < 24 || password.length > 256) { + throw new Error('Set APP_DATABASE_PASSWORD to a dedicated password of 24–256 characters.'); +} +const prisma = new PrismaClient(); +async function main() { + const existing = await prisma.$queryRaw`SELECT 1 FROM pg_roles WHERE rolname = 'kita_runtime'`; + if (!existing.length) await prisma.$executeRawUnsafe('CREATE ROLE kita_runtime LOGIN'); + const owned = await prisma.$queryRaw`SELECT 1 FROM pg_class c JOIN pg_roles r ON c.relowner = r.oid WHERE r.rolname = 'kita_runtime' + UNION ALL SELECT 1 FROM pg_namespace n JOIN pg_roles r ON n.nspowner = r.oid WHERE r.rolname = 'kita_runtime'`; + if (owned.length) throw new Error('Runtime role must not own tables or schemas.'); + const memberships = await prisma.$queryRaw`SELECT quote_ident(r.rolname) AS role FROM pg_auth_members m JOIN pg_roles r ON m.roleid = r.oid + JOIN pg_roles member ON m.member = member.oid WHERE member.rolname = 'kita_runtime'`; + for (const membership of memberships) await prisma.$executeRawUnsafe(`REVOKE ${membership.role} FROM kita_runtime`); + // PostgreSQL quotes the secret. Only this server-produced SQL is executed. + const [quoted] = await prisma.$queryRaw`SELECT format('ALTER ROLE kita_runtime PASSWORD %L LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOREPLICATION', ${password}) AS statement`; + await prisma.$executeRawUnsafe(quoted.statement); + for (const statement of [ + 'REVOKE CREATE ON SCHEMA public FROM PUBLIC', + 'GRANT USAGE ON SCHEMA public TO kita_runtime', + 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO kita_runtime', + 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO kita_runtime', + 'REVOKE ALL ON TABLE _prisma_migrations FROM kita_runtime', + 'REVOKE UPDATE, DELETE, TRUNCATE ON TABLE audit_logs FROM kita_runtime', + ]) await prisma.$executeRawUnsafe(statement); + console.log('Runtime database grants configured. Re-run after future migrations.'); +} +main().finally(() => prisma.$disconnect()).catch(error => { + // Avoid putting a statement containing credentials into deployment logs. + console.error('Runtime role provisioning failed:', error?.code ?? error?.name ?? 'unknown'); + process.exitCode = 1; +}); diff --git a/scripts/start-production.sh b/scripts/start-production.sh index 0ee8b4a..c1d6edd 100755 --- a/scripts/start-production.sh +++ b/scripts/start-production.sh @@ -1,8 +1,4 @@ #!/bin/sh set -eu - -echo "Applying Prisma schema..." -npx prisma db push --skip-generate - -echo "Starting Kita-Planer..." -exec npm run start:prod +# Apply reviewed migrations separately before starting the runtime. +exec node server.js diff --git a/src/actions/absences.ts b/src/actions/absences.ts index aaa68f2..a02bab2 100644 --- a/src/actions/absences.ts +++ b/src/actions/absences.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { AbsenceReason, UserRole } from "@prisma/client"; +import { dateOnlySchema } from "@/lib/date-schema"; import { z } from "zod"; import { requireKitaSession, requireRole } from "@/lib/auth-utils"; @@ -9,14 +10,14 @@ import { prisma } from "@/lib/prisma"; const reportAbsenceSchema = z.object({ childId: z.string().min(1), - startDate: z.string().min(1), - endDate: z.string().min(1), + startDate: dateOnlySchema, + endDate: dateOnlySchema, reason: z.nativeEnum(AbsenceReason), note: z.string().trim().max(500).optional(), }); const dailyAbsenceSchema = z.object({ - date: z.string().min(1), + date: dateOnlySchema, }); export type DailyAbsenceDto = { diff --git a/src/actions/announcements.ts b/src/actions/announcements.ts index d781ef8..0abaff5 100644 --- a/src/actions/announcements.ts +++ b/src/actions/announcements.ts @@ -2,7 +2,9 @@ import { randomUUID } from "crypto"; import { mkdir, rm, writeFile } from "fs/promises"; -import path from "path"; +import { attachmentDirectory, attachmentPath, MAX_ATTACHMENT_SIZE, detectAttachmentType, attachmentExtensionMatches } from "@/lib/attachments"; +import { serializableTransaction } from "@/lib/transaction"; +import { rateLimit } from "@/lib/rate-limit"; import { createElement } from "react"; import { revalidatePath } from "next/cache"; import { Prisma, UserRole } from "@prisma/client"; @@ -13,15 +15,6 @@ import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; import { prisma } from "@/lib/prisma"; -const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; -const ALLOWED_TYPES = new Map([ - ["application/pdf", ".pdf"], - ["image/jpeg", ".jpg"], - ["image/png", ".png"], -]); - -const ALLOWED_EXTENSIONS = new Set([".pdf", ".jpg", ".jpeg", ".png"]); - const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]); const announcementSchema = z.object({ @@ -31,30 +24,10 @@ const announcementSchema = z.object({ audience: audienceSchema, }); -function getUploadDir() { - return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements"); -} - function getSafeFileName(name: string) { return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160); } -function validateAttachment(file: File) { - const extension = path.extname(file.name).toLowerCase(); - const typeAllowed = ALLOWED_TYPES.has(file.type); - const extensionAllowed = ALLOWED_EXTENSIONS.has(extension); - - if (!typeAllowed && !extensionAllowed) { - return "Nur PDF, JPG und PNG sind erlaubt."; - } - - if (file.size > MAX_ATTACHMENT_SIZE) { - return "Anhänge dürfen maximal 10 MB groß sein."; - } - - return null; -} - async function getRecipients(kitaId: string, audience: z.infer) { const where: Prisma.UserWhereInput = audience === "ELTERN" @@ -76,7 +49,7 @@ async function getRecipients(kitaId: string, audience: z.infer value instanceof File && value.size > 0); + if (attachmentFiles.length > 5 || attachmentFiles.some(file => file.size > MAX_ATTACHMENT_SIZE) || + attachmentFiles.reduce((total, file) => total + file.size, 0) > 25 * 1024 * 1024) { + return { error: "Maximal 5 Anhänge, je 10 MB und insgesamt 25 MB sind erlaubt." }; + } + if (!(await rateLimit(`announcements:${kitaId}:${session.user.id}`, 20, 3600)).allowed) { + return { error: "Zu viele Ankündigungen. Bitte versuche es später erneut." }; + } + const attachmentInputs: { id: string; bytes: Buffer; fileName: string; fileType: string; fileUrl: string }[] = []; for (const file of attachmentFiles) { - const error = validateAttachment(file); - if (error) { - return { error }; + const bytes = Buffer.from(await file.arrayBuffer()); + const fileType = detectAttachmentType(bytes); + if (!fileType || !attachmentExtensionMatches(file.name, fileType) || (file.type && file.type !== fileType)) { + return { error: "Nur PDF-, JPG- und PNG-Dateien mit passenden Dateiinhalten sind erlaubt." }; } + const id = randomUUID(); + attachmentInputs.push({ id, bytes, fileName: getSafeFileName(file.name), fileType, + fileUrl: `/api/announcements/attachments/${id}` }); } - const attachmentInputs = attachmentFiles.map((file) => { - const id = randomUUID(); - return { - id, - file, - fileName: getSafeFileName(file.name), - fileType: file.type || "application/octet-stream", - fileUrl: `/api/announcements/attachments/${id}`, - }; - }); - - const announcement = await prisma.announcement.create({ - data: { - kitaId, - title: parsed.data.title, - content: parsed.data.content, - authorId: session.user.id, - attachments: { - create: attachmentInputs.map((attachment) => ({ - id: attachment.id, - fileName: attachment.fileName, - fileType: attachment.fileType, - fileUrl: attachment.fileUrl, - })), + const announcement = await serializableTransaction(async tx => { + if (attachmentInputs.length) { + const pending = await tx.attachment.count({ where: { sizeBytes: null } }); + if (pending) return null; + const [tenantUsage, globalUsage] = await Promise.all([ + tx.attachment.aggregate({ where: { announcement: { kitaId } }, _sum: { sizeBytes: true } }), + tx.attachment.aggregate({ _sum: { sizeBytes: true } }), + ]); + const bytes = attachmentInputs.reduce((total, item) => total + item.bytes.length, 0); + const quota = (value: string | undefined, fallback: number) => { + const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; + }; + if ((tenantUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_TENANT_QUOTA_BYTES, 256 * 1024 ** 2) || + (globalUsage._sum.sizeBytes ?? 0) + bytes > quota(process.env.UPLOAD_GLOBAL_QUOTA_BYTES, 10 * 1024 ** 3)) return null; + } + return tx.announcement.create({ + data: { + kitaId, + title: parsed.data.title, + content: parsed.data.content, + authorId: session.user.id, + attachments: { + create: attachmentInputs.map((attachment) => ({ + id: attachment.id, + fileName: attachment.fileName, + fileType: attachment.fileType, + sizeBytes: attachment.bytes.length, + fileUrl: attachment.fileUrl, + })), + }, }, - }, - select: { - id: true, - title: true, - content: true, - }, + select: { + id: true, + title: true, + content: true, + }, + }); }); + if (!announcement) return { error: "Anhänge können momentan nicht gespeichert werden. Bitte wende dich an die Administration." }; if (attachmentInputs.length > 0) { - const uploadDir = getUploadDir(); + const uploadDir = attachmentDirectory(); try { await mkdir(uploadDir, { recursive: true }); await Promise.all( attachmentInputs.map(async (attachment) => { - const arrayBuffer = await attachment.file.arrayBuffer(); await writeFile( - path.join(uploadDir, attachment.id), - Buffer.from(arrayBuffer), + attachmentPath(attachment.id), + attachment.bytes, + { flag: "wx", mode: 0o600 }, ); }), ); @@ -163,7 +155,7 @@ export async function createAnnouncement(formData: FormData) { await prisma.announcement.delete({ where: { id: announcement.id } }); await Promise.all( attachmentInputs.map((attachment) => - rm(path.join(uploadDir, attachment.id), { force: true }), + rm(attachmentPath(attachment.id), { force: true }), ), ); console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error); @@ -232,6 +224,7 @@ export async function markAnnouncementRead(announcementId: string) { }, }, create: { + kitaId: session.user.kitaId, userId: session.user.id, announcementId: announcement.id, }, diff --git a/src/actions/contact.ts b/src/actions/contact.ts index a8c862b..f8f49ce 100644 --- a/src/actions/contact.ts +++ b/src/actions/contact.ts @@ -1,7 +1,7 @@ "use server"; import { createElement } from "react"; -import { headers } from "next/headers"; + import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail"; import { ContactRequestEmail } from "@/emails/ContactRequestEmail"; @@ -11,39 +11,17 @@ import { type ContactSubmissionPayload, } from "@/lib/contact-schema"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; -import { rateLimit } from "@/lib/rate-limit"; +import { limitPublicAction } from "@/lib/rate-limit"; const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local"; -const RATE_LIMIT_MAX = 5; -const RATE_LIMIT_WINDOW_SECONDS = 60 * 60; - -async function resolveClientIp(): Promise { - const h = await headers(); - const forwarded = h.get("x-forwarded-for"); - if (forwarded) { - return forwarded.split(",")[0]?.trim() || "unknown"; - } - return h.get("x-real-ip") ?? "unknown"; -} - export async function submitContactRequest(data: ContactSubmissionPayload) { // Honeypot: silently absorb bot submissions without informing them. - if (data.website && data.website.trim().length > 0) { + if (typeof data?.website === "string" && data.website.trim().length > 0) { return { success: true }; } - const ip = await resolveClientIp(); - const limit = rateLimit( - `contact:${ip}`, - RATE_LIMIT_MAX, - RATE_LIMIT_WINDOW_SECONDS, - ); - if (!limit.allowed) { - const minutes = Math.max(1, Math.ceil(limit.retryAfterSeconds / 60)); - return { - success: false, - error: `Zu viele Anfragen. Bitte versuche es in ${minutes} Minuten erneut.`, - }; + if (!(await limitPublicAction("contact", String(data.email ?? "").trim().toLowerCase()))) { + return { success: false, error: "Zu viele Anfragen. Bitte versuche es später erneut." }; } const parsed = contactRequestSchema.safeParse(data); diff --git a/src/app/admin/actions.ts b/src/app/admin/actions.ts index c344a86..93b2db0 100644 --- a/src/app/admin/actions.ts +++ b/src/app/admin/actions.ts @@ -1,5 +1,7 @@ "use server"; +import { removeAttachmentFiles } from "@/lib/attachments"; +import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { AuditTargetType, UserRole } from "@prisma/client"; import { z } from "zod"; @@ -26,6 +28,7 @@ type ActionResult = { async function requireSuperadminActor() { const session = await requireRole([UserRole.SUPERADMIN]); + if (Date.now() - session.user.authenticatedAt > 15 * 60_000) redirect("/login?reauth=1&callbackUrl=/admin"); return { id: session.user.id, email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin", @@ -92,7 +95,8 @@ export async function deleteKita( const counts = await getKitaDeleteCounts(kita.id); - await prisma.$transaction(async (tx) => { + const attachmentIds = await prisma.$transaction(async (tx) => { + const attachments = await tx.attachment.findMany({ where: { announcement: { kitaId: kita.id } }, select: { id: true } }); await tx.auditLog.create({ data: { actorUserId: actor.id, @@ -107,8 +111,10 @@ export async function deleteKita( }); await tx.kita.delete({ where: { id: kita.id } }); + return attachments.map(item => item.id); }); + await removeAttachmentFiles(attachmentIds); revalidatePath("/admin"); revalidatePath("/admin/audit"); return { ok: true, redirectTo: "/admin" }; @@ -182,7 +188,8 @@ export async function deleteUser(rawPayload: unknown): Promise { }; } - await prisma.$transaction(async (tx) => { + const attachmentIds = await prisma.$transaction(async (tx) => { + const attachments = await tx.attachment.findMany({ where: { announcement: { authorId: user.id } }, select: { id: true } }); await tx.verificationToken.deleteMany({ where: { identifier: user.id }, }); @@ -206,8 +213,10 @@ export async function deleteUser(rawPayload: unknown): Promise { }, }); await tx.user.delete({ where: { id: user.id } }); + return attachments.map(item => item.id); }); + await removeAttachmentFiles(attachmentIds); revalidatePath("/admin"); revalidatePath(`/admin/kitas/${user.kitaId}`); revalidatePath("/admin/audit"); @@ -219,6 +228,7 @@ async function updateUserSupportState( action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET", data: { lockedUntil?: Date | null; + disabledAt?: Date | null; failedLoginAttempts?: number; }, ): Promise { @@ -238,7 +248,7 @@ async function updateUserSupportState( await prisma.$transaction(async (tx) => { await tx.user.update({ where: { id: user.id }, - data, + data: { ...data, ...(action === "USER_LOCK" ? { sessionVersion: { increment: 1 } } : {}) }, }); await tx.auditLog.create({ data: { @@ -266,12 +276,14 @@ async function updateUserSupportState( export async function lockUser(userId: string): Promise { return updateUserSupportState(userId, "USER_LOCK", { + disabledAt: new Date(), lockedUntil: new Date("2099-12-31T23:59:59.000Z"), }); } export async function unlockUser(userId: string): Promise { return updateUserSupportState(userId, "USER_UNLOCK", { + disabledAt: null, lockedUntil: null, failedLoginAttempts: 0, }); diff --git a/src/app/alert/[token]/actions.ts b/src/app/alert/[token]/actions.ts index 6b5536d..ea8bd7d 100644 --- a/src/app/alert/[token]/actions.ts +++ b/src/app/alert/[token]/actions.ts @@ -1,34 +1,18 @@ "use server"; - import { revalidatePath } from "next/cache"; import { NotdienstAlertStatus } from "@prisma/client"; import { prisma } from "@/lib/prisma"; - +import { activeAlertTokenWhere } from "@/lib/alert-tokens"; +import { hashToken, isPublicToken } from "@/lib/tokens"; +import { limitPublicAction } from "@/lib/rate-limit"; export async function confirmAlertAction(token: string) { - try { - const alert = await prisma.notdienstAlert.findUnique({ - where: { confirmationToken: token }, - }); - - if (!alert) { - return { error: "Ungültiger Bestätigungs-Link." }; - } - - if (alert.status !== NotdienstAlertStatus.PENDING) { - return { error: "Dieser Alarm wurde bereits bestätigt oder abgebrochen." }; - } - - await prisma.notdienstAlert.update({ - where: { id: alert.id }, - data: { - status: NotdienstAlertStatus.CONFIRMED, - confirmedAt: new Date(), - }, - }); - - revalidatePath("/dashboard"); - return { success: true }; - } catch { - return { error: "Fehler bei der Bestätigung." }; - } + if (!isPublicToken(token)) return { error: "Ungültiger Bestätigungs-Link." }; + if (!(await limitPublicAction("alert-confirm", hashToken(token), 10, 900))) return { error: "Bitte versuche es später erneut." }; + const updated = await prisma.notdienstAlert.updateMany({ + where: activeAlertTokenWhere(token), + data: { status: NotdienstAlertStatus.CONFIRMED, confirmedAt: new Date() }, + }); + if (updated.count !== 1) return { error: "Dieser Link ist abgelaufen oder wurde bereits verwendet." }; + revalidatePath("/dashboard"); + return { success: true }; } diff --git a/src/app/alert/[token]/page.tsx b/src/app/alert/[token]/page.tsx index 174a015..903c4f8 100644 --- a/src/app/alert/[token]/page.tsx +++ b/src/app/alert/[token]/page.tsx @@ -4,6 +4,8 @@ import { de } from "date-fns/locale"; import { BellRing, ShieldCheck, XCircle } from "lucide-react"; import { NotdienstAlertStatus } from "@prisma/client"; +import { activeAlertTokenWhere } from "@/lib/alert-tokens"; +import { isPublicToken } from "@/lib/tokens"; import { prisma } from "@/lib/prisma"; import { Card, @@ -14,7 +16,8 @@ import { } from "@/components/ui/card"; import { AlertConfirmForm } from "./alert-confirm-form"; -export const metadata = { title: "Notdienst Alarm · Kita-Planer" }; +export const dynamic = "force-dynamic"; +export const metadata = { title: "Notdienst Alarm · Kita-Planer", robots: { index: false, follow: false } }; export default async function AlertPage({ params, @@ -23,8 +26,9 @@ export default async function AlertPage({ }) { const { token } = await params; - const alert = await prisma.notdienstAlert.findUnique({ - where: { confirmationToken: token }, + if (!isPublicToken(token)) notFound(); + const alert = await prisma.notdienstAlert.findFirst({ + where: activeAlertTokenWhere(token), select: { status: true, parentUser: { diff --git a/src/app/api/announcements/attachments/[id]/route.ts b/src/app/api/announcements/attachments/[id]/route.ts index 5ed780f..bf9d061 100644 --- a/src/app/api/announcements/attachments/[id]/route.ts +++ b/src/app/api/announcements/attachments/[id]/route.ts @@ -1,30 +1,18 @@ -import { readFile } from "fs/promises"; -import path from "path"; +import { open } from "node:fs/promises"; +import { constants } from "node:fs"; +import { attachmentPath, attachmentDisposition, detectAttachmentType, MAX_ATTACHMENT_SIZE } from "@/lib/attachments"; import { NextResponse } from "next/server"; -import { auth } from "@/auth"; +import { getKitaSession } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; export const runtime = "nodejs"; -function getUploadPath(attachmentId: string) { - const uploadDir = path.resolve( - process.env.UPLOAD_DIR ?? "uploads", - "announcements", - ); - return path.join(uploadDir, attachmentId); -} - -function contentDisposition(fileName: string) { - const fallback = fileName.replace(/[^\w.\-]/g, "_"); - return `inline; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`; -} - export async function GET( _request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const session = await auth(); + const session = await getKitaSession(); const user = session?.user; if (!user?.id || !user.kitaId) { @@ -32,6 +20,7 @@ export async function GET( } const { id } = await params; + try { attachmentPath(id); } catch { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const attachment = await prisma.attachment.findFirst({ where: { id, @@ -51,12 +40,22 @@ export async function GET( } try { - const file = await readFile(getUploadPath(attachment.id)); + const filePath = attachmentPath(attachment.id); + const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW); + let file: Buffer; + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > MAX_ATTACHMENT_SIZE) throw new Error("Invalid attachment"); + file = await handle.readFile(); + } finally { await handle.close(); } return new Response(new Uint8Array(file), { headers: { - "Content-Type": attachment.fileType, - "Content-Disposition": contentDisposition(attachment.fileName), - "Cache-Control": "private, max-age=300", + "Content-Type": detectAttachmentType(file) ?? "application/octet-stream", + "Content-Disposition": attachmentDisposition(attachment.fileName), + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Referrer-Policy": "no-referrer", }, }); } catch (error) { diff --git a/src/app/dashboard/actions.ts b/src/app/dashboard/actions.ts index 1557dc8..785012b 100644 --- a/src/app/dashboard/actions.ts +++ b/src/app/dashboard/actions.ts @@ -1,6 +1,9 @@ "use server"; -import crypto from "crypto"; +import { serializableTransaction } from "@/lib/transaction"; +import { UserFacingError, publicActionError } from "@/lib/action-error"; +import { hashToken, newToken } from "@/lib/tokens"; +import { rateLimit } from "@/lib/rate-limit"; import { createElement } from "react"; import { format } from "date-fns"; import { de } from "date-fns/locale"; @@ -19,6 +22,7 @@ export async function triggerAlertAction(assignmentId: string) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId!; + if (!(await rateLimit(`alerts:${kitaId}:${session.user.id}`, 50, 3600)).allowed) return { error: "Zu viele Alarme. Bitte versuche es später erneut." }; try { const [existing, assignment] = await Promise.all([ // Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt @@ -26,17 +30,18 @@ export async function triggerAlertAction(assignmentId: string) { where: { assignmentId, kitaId }, }), prisma.notdienstAssignment.findFirst({ - where: { id: assignmentId, kitaId }, + where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } }, include: { child: { select: { id: true, + familyId: true, firstName: true, lastName: true, family: { select: { users: { - where: { role: UserRole.ELTERN }, + where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { id: true, email: true, @@ -75,24 +80,22 @@ export async function triggerAlertAction(assignmentId: string) { locale: de, }); const childName = `${assignment.child.firstName} ${assignment.child.lastName}`; - const alertJobs: { email: string; token: string }[] = []; - - await prisma.$transaction( - parents.map((parent) => { - const token = crypto.randomUUID(); - alertJobs.push({ email: parent.email, token }); - return prisma.notdienstAlert.create({ - data: { - kitaId, - assignmentId, - parentUserId: parent.id, - triggeredById: session.user.id, - status: NotdienstAlertStatus.PENDING, - confirmationToken: token, - }, - }); - }), - ); + const alertJobs = parents.map(parent => ({ email: parent.email, token: newToken(), parentId: parent.id })); + await serializableTransaction(async tx => { + if (await tx.notdienstAlert.findFirst({ where: { assignmentId, kitaId }, select: { id: true } })) { + throw new UserFacingError("Für diese Einteilung wurde bereits ein Alarm ausgelöst."); + } + const current = await tx.notdienstAssignment.findFirst({ where: { id: assignmentId, kitaId, plan: { status: "PUBLISHED" } }, select: { childId: true, date: true } }); + const today = new Date(); today.setUTCHours(0, 0, 0, 0); + if (!current || current.childId !== assignment.child.id || current.date < today) throw new UserFacingError("Die Einteilung hat sich geändert oder liegt in der Vergangenheit. Bitte lade die Seite neu."); + for (const job of alertJobs) { + const parent = await tx.user.findFirst({ where: { id: job.parentId, kitaId, familyId: assignment.child.familyId, + email: job.email, disabledAt: null, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } }, select: { id: true } }); + if (!parent) throw new UserFacingError("Die Empfänger haben sich geändert. Bitte lade die Seite neu."); + await tx.notdienstAlert.create({ data: { kitaId, assignmentId, parentUserId: parent.id, + triggeredById: session.user.id, status: NotdienstAlertStatus.PENDING, confirmationToken: hashToken(job.token) } }); + } + }); for (const job of alertJobs) { const emailResult = await sendAppEmail({ @@ -118,6 +121,6 @@ export async function triggerAlertAction(assignmentId: string) { return { success: true }; } catch (error) { console.error(error); - return { error: "Fehler beim Auslösen des Alarms." }; + return { error: publicActionError(error, "Fehler beim Auslösen des Alarms.") }; } } diff --git a/src/app/dashboard/admin/dienste/actions.ts b/src/app/dashboard/admin/dienste/actions.ts index ba37640..e1a3dd4 100644 --- a/src/app/dashboard/admin/dienste/actions.ts +++ b/src/app/dashboard/admin/dienste/actions.ts @@ -5,11 +5,13 @@ import { revalidatePath } from "next/cache"; import { DutyAssignmentStatus, UserRole } from "@prisma/client"; import { endOfWeek, format, startOfDay, startOfWeek } from "date-fns"; import { de } from "date-fns/locale"; +import { dateOnlySchema } from "@/lib/date-schema"; import { z } from "zod"; import { DutyReminderEmail } from "@/emails/DutyReminderEmail"; import { requireRole } from "@/lib/auth-utils"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; +import { rateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/prisma"; const dutyTypeSchema = z.object({ @@ -20,8 +22,8 @@ const dutyTypeSchema = z.object({ const assignmentSchema = z.object({ dutyTypeId: z.string().min(1), familyId: z.string().nullable(), - startDate: z.string().min(1), - endDate: z.string().min(1), + startDate: dateOnlySchema, + endDate: dateOnlySchema, }); async function requireDutyAdmin() { @@ -160,6 +162,7 @@ export async function upsertDutyAssignment(rawPayload: unknown) { export async function checkAndSendDutyReminders() { const { kitaId } = await requireDutyAdmin(); + if (!(await rateLimit(`duty-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." }; const configError = getAppEmailConfigError(); if (configError) { @@ -195,6 +198,8 @@ export async function checkAndSendDutyReminders() { users: { where: { emailVerifiedAt: { not: null }, + disabledAt: null, + privacyPolicyAcceptedAt: { not: null }, }, select: { email: true, diff --git a/src/app/dashboard/admin/news/news-form.tsx b/src/app/dashboard/admin/news/news-form.tsx index b822620..4f85420 100644 --- a/src/app/dashboard/admin/news/news-form.tsx +++ b/src/app/dashboard/admin/news/news-form.tsx @@ -93,7 +93,7 @@ export function NewsForm() { />

- PDF, JPG und PNG bis 10 MB pro Datei. + PDF, JPG und PNG: maximal 5 Dateien, je 10 MB und insgesamt 25 MB.

diff --git a/src/app/dashboard/families/_components/families-list-client.tsx b/src/app/dashboard/families/_components/families-list-client.tsx index 5c1ac8e..d77215d 100644 --- a/src/app/dashboard/families/_components/families-list-client.tsx +++ b/src/app/dashboard/families/_components/families-list-client.tsx @@ -1,7 +1,9 @@ "use client"; -import { useState } from "react"; +import { useState, useTransition } from "react"; import { LayoutGrid, Mail, Search, Table, Users } from "lucide-react"; +import { toast } from "sonner"; +import { resendParentInvite } from "../actions"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -245,6 +247,7 @@ export function FamiliesListClient({ {parent.email} + {!parent.emailVerifiedAt && } @@ -351,6 +354,7 @@ export function FamiliesListClient({ {parent.email} + {!parent.emailVerifiedAt && } @@ -416,3 +420,11 @@ export function FamiliesListClient({ ); } + +function ResendInviteButton({ userId }: { userId: string }) { + const [pending, startTransition] = useTransition(); + return ; +} diff --git a/src/app/dashboard/families/actions.ts b/src/app/dashboard/families/actions.ts index 6a1793c..4249c70 100644 --- a/src/app/dashboard/families/actions.ts +++ b/src/app/dashboard/families/actions.ts @@ -1,6 +1,6 @@ "use server"; -import crypto from "crypto"; +import { hashToken, newToken } from "@/lib/tokens"; import { createElement } from "react"; import { revalidatePath } from "next/cache"; import { Prisma, UserRole } from "@prisma/client"; @@ -9,6 +9,7 @@ import { z } from "zod"; import { InviteEmail } from "@/emails/InviteEmail"; import { requireRole } from "@/lib/auth-utils"; import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail"; +import { rateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/prisma"; const INVITE_TOKEN_TTL_DAYS = 7; @@ -115,7 +116,7 @@ function optionalParentFromForm(data: z.infer) { } function createInviteToken() { - const token = crypto.randomUUID(); + const token = newToken(); const expires = new Date( Date.now() + INVITE_TOKEN_TTL_DAYS * 24 * 60 * 60_000, ); @@ -153,6 +154,8 @@ export async function addFamilyAction( return { errors: { _form: ["Kein Mandant zugeordnet."] } }; } + if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { errors: { _form: ["Zu viele Einladungen. Bitte versuche es später erneut."] } }; + const parsedFamily = addFamilySchema.safeParse(Object.fromEntries(formData)); if (!parsedFamily.success) { return { errors: parsedFamily.error.flatten().fieldErrors }; @@ -245,7 +248,7 @@ export async function addFamilyAction( await tx.verificationToken.create({ data: { identifier: createdParent.id, - token: invite.token, + token: hashToken(invite.token), expires: invite.expires, }, }); @@ -320,6 +323,7 @@ export async function updateFamilyAction(rawPayload: unknown) { const removedChildIdSet = new Set(removedChildIds); const activeChildren = children.filter((child) => !removedChildIdSet.has(child.id)); + if (activeChildren.length + newChildren.length > 20) return { error: "Maximal 20 Kinder pro Familie." }; if (activeChildren.length + newChildren.length === 0) { return { error: "Eine Familie benötigt mindestens ein Kind." }; } @@ -347,7 +351,7 @@ export async function updateFamilyAction(rawPayload: unknown) { where: { id: familyId, kitaId }, select: { id: true, - users: { select: { id: true } }, + users: { select: { id: true, email: true } }, children: { select: { id: true } }, }, }); @@ -356,6 +360,13 @@ export async function updateFamilyAction(rawPayload: unknown) { return { error: "Familie wurde nicht gefunden." }; } + const needsEmail = newParents.length > 0 || parents.some(parent => existingFamily.users.find(user => user.id === parent.id)?.email !== parent.email); + if (needsEmail) { + const configError = getAppEmailConfigError(); + if (configError) return { error: configError }; + if (!(await rateLimit(`family-invites:${kitaId}`, 30, 3600)).allowed) return { error: "Zu viele Einladungen. Bitte versuche es später erneut." }; + } + const existingParentIds = new Set(existingFamily.users.map((user) => user.id)); const existingChildIds = new Set(existingFamily.children.map((child) => child.id)); @@ -382,14 +393,20 @@ export async function updateFamilyAction(rawPayload: unknown) { }); for (const parent of parents) { + const current = await tx.user.findFirst({ where: { id: parent.id, kitaId, familyId }, select: { email: true } }); + if (!current) throw new Error("Parent assignment changed"); + const emailChanged = current.email !== parent.email; await tx.user.update({ - where: { id: parent.id }, - data: { - firstName: parent.firstName, - lastName: parent.lastName, - email: parent.email, - }, + where: { id: parent.id, kitaId, familyId }, + data: { firstName: parent.firstName, lastName: parent.lastName, email: parent.email, + ...(emailChanged ? { passwordHash: "", emailVerifiedAt: null, sessionVersion: { increment: 1 } } : {}) }, }); + if (emailChanged) { + const invite = createInviteToken(); + await tx.verificationToken.deleteMany({ where: { identifier: parent.id } }); + await tx.verificationToken.create({ data: { identifier: parent.id, token: hashToken(invite.token), expires: invite.expires } }); + inviteJobs.push({ to: parent.email, parentName: `${parent.firstName} ${parent.lastName}`, inviteUrl: invite.inviteUrl }); + } } for (const parent of newParents) { @@ -413,7 +430,7 @@ export async function updateFamilyAction(rawPayload: unknown) { await tx.verificationToken.create({ data: { identifier: createdParent.id, - token: invite.token, + token: hashToken(invite.token), expires: invite.expires, }, }); @@ -421,7 +438,7 @@ export async function updateFamilyAction(rawPayload: unknown) { for (const child of activeChildren) { await tx.child.update({ - where: { id: child.id }, + where: { id: child.id, kitaId, familyId }, data: { firstName: child.firstName, lastName: child.lastName, @@ -430,7 +447,7 @@ export async function updateFamilyAction(rawPayload: unknown) { } for (const childId of removedChildIds) { - await tx.child.delete({ where: { id: childId } }); + await tx.child.delete({ where: { id: childId, kitaId, familyId } }); } for (const child of newChildren) { @@ -469,3 +486,28 @@ export async function updateFamilyAction(rawPayload: unknown) { revalidatePath("/dashboard"); return { success: true }; } + +export async function resendParentInvite(userId: string) { + const session = await requireRole([UserRole.ADMIN]); + if (typeof userId !== "string" || !userId || userId.length > 64) return { error: "Ungültige Eingabedaten." }; + if (!(await rateLimit(`family-invites:${session.user.kitaId}`, 30, 3600)).allowed) return { error: "Bitte versuche es später erneut." }; + if (!(await rateLimit(`resend-invite:${session.user.kitaId}:${userId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." }; + const user = await prisma.user.findFirst({ where: { id: userId, kitaId: session.user.kitaId, emailVerifiedAt: null, passwordHash: "", disabledAt: null }, + select: { id: true, email: true, firstName: true, lastName: true, kita: { select: { name: true } } } }); + if (!user?.kita) return { error: "Keine offene Einladung gefunden." }; + const configError = getAppEmailConfigError(); if (configError) return { error: configError }; + const invite = createInviteToken(); + const renewed = await prisma.$transaction(async tx => { + // Lock and re-check the account before issuing a link for the selected mailbox. + // A concurrent address change/activation must invalidate this resend. + const eligible = await tx.user.updateMany({ where: { id: user.id, email: user.email, kitaId: session.user.kitaId, + emailVerifiedAt: null, passwordHash: "", disabledAt: null }, data: { sessionVersion: { increment: 1 } } }); + if (eligible.count !== 1) return false; + await tx.verificationToken.deleteMany({ where: { identifier: user.id } }); + await tx.verificationToken.create({ data: { identifier: user.id, token: hashToken(invite.token), expires: invite.expires } }); + return true; + }); + if (!renewed) return { error: "Die Einladung hat sich geändert. Bitte lade die Seite neu." }; + const error = await sendInviteJobs(user.kita.name, [{ to: user.email, parentName: `${user.firstName} ${user.lastName}`, inviteUrl: invite.inviteUrl }]); + return error ? { error } : { success: true }; +} diff --git a/src/app/dashboard/families/duty-actions.ts b/src/app/dashboard/families/duty-actions.ts index 24f1159..6207169 100644 --- a/src/app/dashboard/families/duty-actions.ts +++ b/src/app/dashboard/families/duty-actions.ts @@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma"; const dutySchema = z.object({ name: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein").max(50), - description: z.string().optional(), + description: z.string().trim().max(2000).optional(), }); export async function createDuty(rawPayload: unknown) { diff --git a/src/app/dashboard/families/edit-family-dialog.tsx b/src/app/dashboard/families/edit-family-dialog.tsx index a01a4f0..fd20b17 100644 --- a/src/app/dashboard/families/edit-family-dialog.tsx +++ b/src/app/dashboard/families/edit-family-dialog.tsx @@ -154,7 +154,7 @@ export function EditFamilyDialog({ family }: { family: EditableFamily }) { return; } - toast.success("Familie aktualisiert."); + toast.success("Familie aktualisiert. Geänderte E-Mail-Adressen müssen über einen neuen Einladungslink bestätigt werden."); setRemovedChildIds([]); }); } diff --git a/src/app/dashboard/kalender/actions.ts b/src/app/dashboard/kalender/actions.ts index a4b18e9..7520763 100644 --- a/src/app/dashboard/kalender/actions.ts +++ b/src/app/dashboard/kalender/actions.ts @@ -1,8 +1,10 @@ "use server"; +import { UserFacingError, publicActionError } from "@/lib/action-error"; import { createElement } from "react"; import { revalidatePath } from "next/cache"; import { + Prisma, NotdienstAlertStatus, NotdienstPlanStatus, NotdienstSwapStatus, @@ -17,6 +19,8 @@ import { z } from "zod"; import { NotdienstSwapEmail } from "@/emails/NotdienstSwapEmail"; import { requireKitaSession, requireRole } from "@/lib/auth-utils"; import { sendAppEmail } from "@/lib/mail"; +import { serializableTransaction } from "@/lib/transaction"; +import { rateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/prisma"; // ===================================================================== @@ -24,8 +28,8 @@ import { prisma } from "@/lib/prisma"; // ===================================================================== const terminSchema = z.object({ - title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein"), - description: z.string().trim().optional(), + title: z.string().trim().min(2, "Titel muss mindestens 2 Zeichen lang sein").max(160), + description: z.string().trim().max(20000).optional(), type: z.nativeEnum(TerminType), startDate: z.string().datetime(), endDate: z.string().datetime(), @@ -99,8 +103,9 @@ type SwapAssignmentWithFamily = { async function getSwapAssignment( assignmentId: string, kitaId: string, + db: Prisma.TransactionClient = prisma, ): Promise { - return prisma.notdienstAssignment.findFirst({ + return db.notdienstAssignment.findFirst({ where: { id: assignmentId, kitaId }, select: { id: true, @@ -114,7 +119,7 @@ async function getSwapAssignment( select: { name: true, users: { - where: { role: UserRole.ELTERN }, + where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, @@ -295,6 +300,7 @@ function buildTerminData(data: z.infer) { export async function createNotdienstSwapRequest(rawPayload: unknown) { const session = await requireKitaSession(); + if (!(await rateLimit(`createNotdienstSwapRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." }; if (session.user.role !== UserRole.ELTERN) { return { error: "Nur Eltern können Notdienst-Tausche anfragen." }; } @@ -311,54 +317,58 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) { const today = getTodayUtcDateOnly(); try { - const [targetAssignment, offeredAssignment, kita, existingRequest] = - await Promise.all([ - getSwapAssignment(targetAssignmentId, session.user.kitaId), - getSwapAssignment(offeredAssignmentId, session.user.kitaId), - prisma.kita.findUnique({ - where: { id: session.user.kitaId }, - select: { name: true }, - }), - prisma.notdienstSwapRequest.findFirst({ - where: { - kitaId: session.user.kitaId, - status: NotdienstSwapStatus.PENDING, - OR: [ - { targetAssignmentId }, - { offeredAssignmentId }, - { targetAssignmentId: offeredAssignmentId }, - { offeredAssignmentId: targetAssignmentId }, - ], - }, - select: { id: true }, - }), - ]); + const { targetAssignment, offeredAssignment, kita } = await serializableTransaction(async tx => { + const [targetAssignment, offeredAssignment, kita, existingRequest] = + await Promise.all([ + getSwapAssignment(targetAssignmentId, session.user.kitaId, tx), + getSwapAssignment(offeredAssignmentId, session.user.kitaId, tx), + tx.kita.findUnique({ + where: { id: session.user.kitaId }, + select: { name: true }, + }), + tx.notdienstSwapRequest.findFirst({ + where: { + kitaId: session.user.kitaId, + status: NotdienstSwapStatus.PENDING, + OR: [ + { targetAssignmentId }, + { offeredAssignmentId }, + { targetAssignmentId: offeredAssignmentId }, + { offeredAssignmentId: targetAssignmentId }, + ], + }, + select: { id: true }, + }), + ]); - const validationError = validateSwapAssignmentPair({ - targetAssignment, - offeredAssignment, - requesterFamilyId: session.user.familyId, - today, - }); - if (validationError) { - return { error: validationError }; - } - if (!targetAssignment || !offeredAssignment || !kita) { - return { error: "Notdienst nicht gefunden." }; - } - if (existingRequest) { - return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." }; - } + const validationError = validateSwapAssignmentPair({ + targetAssignment, + offeredAssignment, + requesterFamilyId: session.user.familyId!, + today, + }); + if (validationError) { + throw new UserFacingError(validationError); + } + if (!targetAssignment || !offeredAssignment || !kita) { + throw new UserFacingError("Notdienst nicht gefunden."); + } + if (existingRequest) { + throw new UserFacingError("Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage."); + } - await prisma.notdienstSwapRequest.create({ - data: { - kitaId: session.user.kitaId, - requesterUserId: session.user.id, - requesterFamilyId: session.user.familyId, - targetAssignmentId, - offeredAssignmentId, - message: message || null, - }, + await tx.notdienstSwapRequest.create({ + data: { + kitaId: session.user.kitaId, + requesterUserId: session.user.id, + requesterFamilyId: session.user.familyId!, + targetAssignmentId, + offeredAssignmentId, + message: message || null, + }, + }); + + return { targetAssignment, offeredAssignment, kita }; }); await notifyFamilyAboutSwap({ @@ -375,7 +385,9 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) { revalidateCalendarAndNotdienst(); return { success: true }; } catch (error) { - console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error); + if (!(error instanceof Error && error.message === "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.")) { + console.error("Fehler beim Erstellen der Notdienst-Tauschanfrage:", error); + } return { error: "Tauschanfrage konnte nicht erstellt werden." }; } } @@ -394,7 +406,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { const today = getTodayUtcDateOnly(); try { - const result = await prisma.$transaction(async (tx) => { + const result = await serializableTransaction(async (tx) => { const request = await tx.notdienstSwapRequest.findFirst({ where: { id: parsed.data, @@ -419,7 +431,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { select: { name: true, users: { - where: { role: UserRole.ELTERN }, + where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, @@ -445,7 +457,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { select: { name: true, users: { - where: { role: UserRole.ELTERN }, + where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, @@ -462,11 +474,11 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { }); if (!request) { - throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen."); + throw new UserFacingError("Tauschanfrage nicht gefunden oder nicht mehr offen."); } if (request.targetAssignment.child.familyId !== session.user.familyId) { - throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen."); + throw new UserFacingError("Nur der angefragte Haushalt kann diese Anfrage annehmen."); } const validationError = validateSwapAssignmentPair({ @@ -476,7 +488,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { today, }); if (validationError) { - throw new Error(validationError); + throw new UserFacingError(validationError); } await tx.notdienstAssignment.update({ @@ -487,14 +499,15 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { where: { id: request.offeredAssignmentId }, data: { childId: request.targetAssignment.childId }, }); - await tx.notdienstSwapRequest.update({ - where: { id: request.id }, + const responded = await tx.notdienstSwapRequest.updateMany({ + where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING }, data: { status: NotdienstSwapStatus.ACCEPTED, respondedById: session.user.id, respondedAt: new Date(), }, }); + if (responded.count !== 1) throw new UserFacingError("Diese Anfrage ist nicht mehr offen."); await tx.notdienstSwapRequest.updateMany({ where: { id: { not: request.id }, @@ -550,7 +563,7 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) { return { success: true }; } catch (error) { return { - error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.", + error: publicActionError(error, "Tauschanfrage konnte nicht angenommen werden."), }; } } @@ -585,7 +598,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) { select: { name: true, users: { - where: { role: UserRole.ELTERN }, + where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, @@ -603,7 +616,7 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) { select: { name: true, users: { - where: { role: UserRole.ELTERN }, + where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, @@ -622,14 +635,15 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) { return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." }; } - await prisma.notdienstSwapRequest.update({ - where: { id: request.id }, + const responded = await prisma.notdienstSwapRequest.updateMany({ + where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING }, data: { status: NotdienstSwapStatus.DECLINED, respondedById: session.user.id, respondedAt: new Date(), }, }); + if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." }; const kita = await prisma.kita.findUnique({ where: { id: session.user.kitaId }, @@ -694,14 +708,15 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) { return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." }; } - await prisma.notdienstSwapRequest.update({ - where: { id: request.id }, + const responded = await prisma.notdienstSwapRequest.updateMany({ + where: { id: request.id, kitaId: session.user.kitaId, status: NotdienstSwapStatus.PENDING }, data: { status: NotdienstSwapStatus.CANCELLED, respondedById: session.user.id, respondedAt: new Date(), }, }); + if (responded.count !== 1) return { error: "Diese Anfrage ist nicht mehr offen." }; revalidateCalendarAndNotdienst(); return { success: true }; @@ -713,6 +728,7 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) { export async function createTerminRequest(rawPayload: unknown) { const session = await requireKitaSession(); + if (!(await rateLimit(`createTerminRequest:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." }; const parsed = terminSchema.safeParse(rawPayload); if (!parsed.success) { @@ -914,11 +930,12 @@ export async function toggleMitbringselList(terminId: string, enabled: boolean) const mitbringselSchema = z.object({ terminId: z.string(), - content: z.string().min(1, "Eintrag darf nicht leer sein"), + content: z.string().trim().min(1, "Eintrag darf nicht leer sein").max(500), }); export async function addMitbringsel(rawPayload: unknown) { const session = await requireKitaSession(); + if (!(await rateLimit(`addMitbringsel:${session.user.kitaId}:${session.user.id}`, 30, 3600)).allowed) return { error: "Zu viele Anfragen. Bitte versuche es später erneut." }; const parsed = mitbringselSchema.safeParse(rawPayload); if (!parsed.success) { diff --git a/src/app/dashboard/kalender/page.tsx b/src/app/dashboard/kalender/page.tsx index ad21750..445116a 100644 --- a/src/app/dashboard/kalender/page.tsx +++ b/src/app/dashboard/kalender/page.tsx @@ -239,6 +239,8 @@ export default async function KalenderPage({ const allUserTermine: CalendarListItemDto[] = [ ...confirmedTermineRows.map((termin) => ({ ...termin, + participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING) + ? termin.participantInfo : null, kind: "termin" as const, showParticipantInfo: isAdmin || @@ -249,6 +251,8 @@ export default async function KalenderPage({ })), ...myPendingTermineRows.map((termin) => ({ ...termin, + participantInfo: isAdmin || termin.participations.some(p => p.status === EventParticipationStatus.ATTENDING) + ? termin.participantInfo : null, kind: "termin" as const, showParticipantInfo: isAdmin || diff --git a/src/app/dashboard/notdienst/actions.ts b/src/app/dashboard/notdienst/actions.ts index 8c53ee0..afcf259 100644 --- a/src/app/dashboard/notdienst/actions.ts +++ b/src/app/dashboard/notdienst/actions.ts @@ -18,13 +18,13 @@ import { getTargetMonthData } from "@/lib/date-utils"; const dateKeyPattern = /^\d{4}-\d{2}-\d{2}$/; const payloadSchema = z.object({ - childrenIds: z.array(z.string()).min(1), + childrenIds: z.array(z.string().min(1).max(64)).min(1).max(20), dates: z.array( z.string().regex(dateKeyPattern).refine((value) => { const date = dateKeyToUtcDate(value); - return value === date.toISOString().slice(0, 10); + return Number.isFinite(date.getTime()) && value === date.toISOString().slice(0, 10); }), - ), + ).max(31), }); function dateKeyToUtcDate(dateKey: string) { diff --git a/src/app/dashboard/notdienst/plan/actions.ts b/src/app/dashboard/notdienst/plan/actions.ts index 7a92654..d70678d 100644 --- a/src/app/dashboard/notdienst/plan/actions.ts +++ b/src/app/dashboard/notdienst/plan/actions.ts @@ -1,11 +1,14 @@ "use server"; +import { UserFacingError, publicActionError } from "@/lib/action-error"; import { createElement } from "react"; import { revalidatePath } from "next/cache"; import { NotdienstPlanStatus, UserRole } from "@prisma/client"; import { format } from "date-fns"; import { de } from "date-fns/locale"; +import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema"; +import { rateLimit } from "@/lib/rate-limit"; import { requireRole } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils"; @@ -32,6 +35,8 @@ export async function generatePlanAction(year?: number, month?: number) { targetMonth = data.targetMonth; } + if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." }; + // Prüfen, ob schon ein Plan existiert const existingPlan = await prisma.notdienstPlan.findUnique({ where: { @@ -156,6 +161,7 @@ export async function updateAssignmentAction( const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId!; + if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." }; const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number); const parsedDate = new Date(Date.UTC(yr, mo - 1, dy)); @@ -166,21 +172,21 @@ export async function updateAssignmentAction( }); if (!plan) { - throw new Error("Plan nicht gefunden."); + throw new UserFacingError("Plan nicht gefunden."); } const isDraft = plan.status === NotdienstPlanStatus.DRAFT; const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED; if (!isDraft && !isPublished) { - throw new Error("Plan kann nicht bearbeitet werden."); + throw new UserFacingError("Plan kann nicht bearbeitet werden."); } if (isPublished) { const today = new Date(); const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate())); if (parsedDate < todayUtc) { - throw new Error("Vergangene Notdienste können nicht geändert werden."); + throw new UserFacingError("Vergangene Notdienste können nicht geändert werden."); } } @@ -192,7 +198,7 @@ export async function updateAssignmentAction( family: { include: { users: { - where: { role: UserRole.ELTERN }, + where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, @@ -222,7 +228,7 @@ export async function updateAssignmentAction( family: { include: { users: { - where: { role: UserRole.ELTERN }, + where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, select: { email: true, firstName: true, lastName: true }, }, }, @@ -231,7 +237,7 @@ export async function updateAssignmentAction( }); if (!newChild) { - throw new Error("Kind gehört nicht zu dieser Kita."); + throw new UserFacingError("Kind gehört nicht zu dieser Kita."); } } @@ -364,7 +370,7 @@ export async function updateAssignmentAction( return { success: true }; } catch (error) { return { - error: error instanceof Error ? error.message : "Update fehlgeschlagen.", + error: publicActionError(error, "Update fehlgeschlagen."), }; } } @@ -379,6 +385,8 @@ export async function updateKitaNotdienstSettingsAction( return { error: "Kein Mandant zugeordnet." }; } + if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || + !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." }; try { await prisma.kita.update({ where: { id: kitaId }, @@ -398,6 +406,8 @@ export async function updateKitaNotdienstSettingsAction( export async function sendNotdienstRemindersAction(year: number, month: number) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); + if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." }; + if (!(await rateLimit(`notdienst-reminders:${session.user.kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." }; const kitaId = session.user.kitaId; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; @@ -426,7 +436,7 @@ export async function sendNotdienstRemindersAction(year: number, month: number) }, include: { users: { - where: { role: UserRole.ELTERN }, + where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } }, }, }, }); @@ -513,11 +523,11 @@ export async function resetPlanAction(planId: string) { }); if (!plan) { - throw new Error("Plan nicht gefunden."); + throw new UserFacingError("Plan nicht gefunden."); } if (plan.status !== NotdienstPlanStatus.DRAFT) { - throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden."); + throw new UserFacingError("Nur Pläne im Entwurfsstatus können zurückgesetzt werden."); } // Delete all assignments @@ -535,6 +545,6 @@ export async function resetPlanAction(planId: string) { return { success: true }; } catch (error) { console.error("Fehler beim Zurücksetzen des Plans:", error); - return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." }; + return { error: publicActionError(error, "Fehler beim Zurücksetzen des Plans.") }; } } diff --git a/src/app/dashboard/profil/actions.ts b/src/app/dashboard/profil/actions.ts index 7b865c4..5dd5d40 100644 --- a/src/app/dashboard/profil/actions.ts +++ b/src/app/dashboard/profil/actions.ts @@ -10,6 +10,7 @@ import { getChildBirthdateRange, parseDateInput, } from "@/lib/child-birthdate"; +import { serializableTransaction } from "@/lib/transaction"; import { prisma } from "@/lib/prisma"; // Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche, @@ -118,14 +119,18 @@ export async function createMyChild(rawPayload: unknown) { } try { - await prisma.child.create({ - data: { - kitaId: session.user.kitaId, - familyId: session.user.familyId, - firstName: parsed.data.firstName, - lastName: parsed.data.lastName, - dateOfBirth: parseDateInput(parsed.data.dateOfBirth), - }, + await serializableTransaction(async tx => { + const count = await tx.child.count({ where: { kitaId: session.user.kitaId, familyId: session.user.familyId! } }); + if (count >= 20) throw new Error("Household child limit reached"); + await tx.child.create({ + data: { + kitaId: session.user.kitaId, + familyId: session.user.familyId!, + firstName: parsed.data.firstName, + lastName: parsed.data.lastName, + dateOfBirth: parseDateInput(parsed.data.dateOfBirth), + }, + }); }); revalidatePath("/dashboard/profil"); @@ -158,10 +163,11 @@ export async function createMyFamily(rawPayload: unknown) { }, }); - await tx.user.update({ - where: { id: session.user.id }, + const attached = await tx.user.updateMany({ + where: { id: session.user.id, kitaId: session.user.kitaId, familyId: null, disabledAt: null, sessionVersion: session.user.sessionVersion }, data: { familyId: family.id }, }); + if (attached.count !== 1) throw new Error("Account already assigned or revoked"); }); revalidatePath("/dashboard/profil"); @@ -170,7 +176,9 @@ export async function createMyFamily(rawPayload: unknown) { revalidatePath("/dashboard"); return { success: true }; } catch (error) { - console.error("Fehler beim Anlegen des eigenen Haushalts:", error); + if (!(error instanceof Error && error.message === "Account already assigned or revoked")) { + console.error("Fehler beim Anlegen des eigenen Haushalts:", error); + } return { error: "Der Haushalt konnte nicht angelegt werden." }; } } @@ -239,7 +247,7 @@ export async function updateMyChild(childId: string, rawPayload: unknown) { } await prisma.child.update({ - where: { id: childId }, + where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! }, data: { firstName: parsed.data.firstName, lastName: parsed.data.lastName, @@ -271,7 +279,7 @@ export async function deleteMyChild(childId: string) { } await prisma.child.delete({ - where: { id: childId }, + where: { id: childId, kitaId: session.user.kitaId, familyId: session.user.familyId! }, }); revalidatePath("/dashboard/profil"); diff --git a/src/app/invite/[token]/actions.ts b/src/app/invite/[token]/actions.ts index b8efb0f..554fcc1 100644 --- a/src/app/invite/[token]/actions.ts +++ b/src/app/invite/[token]/actions.ts @@ -1,9 +1,12 @@ "use server"; +import { passwordSchema } from "@/lib/password"; +import { limitPublicAction } from "@/lib/rate-limit"; import { hash } from "bcryptjs"; import { z } from "zod"; import { signIn } from "@/auth"; +import { hashToken, isPublicToken } from "@/lib/tokens"; import { prisma } from "@/lib/prisma"; // ===================================================================== @@ -18,8 +21,8 @@ import { prisma } from "@/lib/prisma"; const inviteSchema = z .object({ - token: z.string().min(1), - password: z.string().min(8, "Mindestens 8 Zeichen.").max(72, "Maximal 72 Zeichen."), + token: z.string().refine(isPublicToken), + password: passwordSchema, confirmPassword: z.string().min(1, "Passwort-Bestätigung fehlt."), acceptPrivacyPolicy: z.literal("on", { error: "Bitte die Datenschutzerklärung akzeptieren.", @@ -80,11 +83,12 @@ export async function acceptInviteAction( } const { token, password, directoryOptIn } = parsed.data; + if (!(await limitPublicAction("invite", hashToken(token), 10, 900))) return { attempt, values, errors: { _form: ["Bitte versuche es später erneut."] } }; const now = new Date(); // ── 1. Token erneut DB-seitig validieren ────────────────────────── - const verificationToken = await prisma.verificationToken.findUnique({ - where: { token }, + const verificationToken = await prisma.verificationToken.findFirst({ + where: { token: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] }, expires: { gt: new Date() } }, }); if (!verificationToken) { @@ -111,10 +115,10 @@ export async function acceptInviteAction( // ── 2. User validieren (existiert und hat noch kein Passwort) ────── const user = await prisma.user.findUnique({ where: { id: userId }, - select: { id: true, email: true, passwordHash: true }, + select: { id: true, email: true, passwordHash: true, disabledAt: true }, }); - if (!user) { + if (!user || user.disabledAt) { return { attempt, values, @@ -138,22 +142,22 @@ export async function acceptInviteAction( const passwordHash = await hash(password, 12); // ── 4. User updaten + Token löschen (atomar) ────────────────────── - await prisma.$transaction([ - prisma.user.update({ - where: { id: userId }, - data: { - passwordHash, - privacyPolicyAcceptedAt: now, - privacyPolicyVersion: PRIVACY_POLICY_VERSION, - directoryOptInAt: directoryOptIn === "on" ? now : null, - emailVerifiedAt: now, - lastLoginAt: now, - }, - }), - prisma.verificationToken.delete({ - where: { token }, - }), - ]); + try { + await prisma.$transaction(async tx => { + const consumed = await tx.verificationToken.deleteMany({ where: { token: verificationToken.token, expires: { gt: new Date() } } }); + if (consumed.count !== 1) throw new Error("INVITE_USED"); + const activated = await tx.user.updateMany({ + where: { id: userId, passwordHash: "", disabledAt: null }, + data: { passwordHash, privacyPolicyAcceptedAt: now, privacyPolicyVersion: PRIVACY_POLICY_VERSION, + directoryOptInAt: directoryOptIn === "on" ? now : null, emailVerifiedAt: now, lastLoginAt: now, + sessionVersion: { increment: 1 } }, + }); + if (activated.count !== 1) throw new Error("INVITE_USED"); + }); + } catch (error) { + if (error instanceof Error && error.message === "INVITE_USED") return { attempt, values, errors: { _form: ["Dieser Link kann nicht mehr verwendet werden."] } }; + throw error; + } // ── 5. Direkt einloggen → wirft NEXT_REDIRECT ───────────────────── await signIn("credentials", { diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx index eadbfa7..97afa56 100644 --- a/src/app/invite/[token]/page.tsx +++ b/src/app/invite/[token]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { Baby } from "lucide-react"; +import { hashToken, isPublicToken } from "@/lib/tokens"; import { prisma } from "@/lib/prisma"; import { Card, @@ -12,7 +13,8 @@ import { } from "@/components/ui/card"; import { InviteForm } from "./invite-form"; -export const metadata = { title: "Konto aktivieren · Kita-Planer" }; +export const dynamic = "force-dynamic"; +export const metadata = { title: "Konto aktivieren · Kita-Planer", robots: { index: false, follow: false } }; // ===================================================================== // /invite/[token] · Öffentliche Seite (kein Auth erforderlich) @@ -30,9 +32,11 @@ export default async function InvitePage({ }) { const { token } = await params; + if (!isPublicToken(token)) notFound(); + // Token in DB nachschlagen - const verificationToken = await prisma.verificationToken.findUnique({ - where: { token }, + const verificationToken = await prisma.verificationToken.findFirst({ + where: { token: { in: [hashToken(token), ...(token.length === 36 ? [token] : [])] }, expires: { gt: new Date() } }, }); // Unbekannter Token → 404 @@ -46,6 +50,7 @@ export default async function InvitePage({ const user = await prisma.user.findUnique({ where: { id: verificationToken.identifier }, select: { + disabledAt: true, firstName: true, lastName: true, emailVerifiedAt: true, @@ -53,7 +58,7 @@ export default async function InvitePage({ }, }); - if (!user) notFound(); + if (!user || user.disabledAt) notFound(); const userName = `${user.firstName} ${user.lastName}`; const alreadyAccepted = !!user.emailVerifiedAt; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 93f1a6a..2d6303d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,5 @@ +import { getSession } from "@/lib/auth-utils"; +import { headers } from "next/headers"; import type { Metadata } from "next"; import { Manrope } from "next/font/google"; import "./globals.css"; @@ -44,11 +46,13 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const [requestHeaders, session] = await Promise.all([headers(), getSession()]); + const nonce = requestHeaders.get("x-nonce") ?? undefined; return ( {children} - + diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts index 98f5804..c1fd7bb 100644 --- a/src/app/login/actions.ts +++ b/src/app/login/actions.ts @@ -3,6 +3,7 @@ import { AuthError } from "next-auth"; import { z } from "zod"; +import { safeCallbackUrl } from "@/lib/safe-redirect"; import { signIn } from "@/auth"; // ===================================================================== @@ -17,7 +18,7 @@ import { signIn } from "@/auth"; const loginSchema = z.object({ email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(), - password: z.string().min(1, "Passwort fehlt."), + password: z.string().min(1, "Passwort fehlt.").max(72), callbackUrl: z.string().optional(), }); @@ -29,12 +30,6 @@ export type LoginState = { }; }; -function safeCallbackUrl(value: string | undefined) { - if (!value) return "/dashboard"; - if (!value.startsWith("/") || value.startsWith("//")) return "/dashboard"; - return value; -} - export async function loginAction( _prev: LoginState, formData: FormData, diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 0fac909..fee4869 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; +import { safeCallbackUrl } from "@/lib/safe-redirect"; import { auth } from "@/auth"; import { getPostLoginRedirect } from "@/lib/post-login-redirect"; import { @@ -16,25 +17,17 @@ import { LoginForm } from "./login-form"; export const metadata = { title: "Anmelden · Kita-Planer" }; type LoginPageProps = { - searchParams?: Promise<{ callbackUrl?: string | string[] }>; + searchParams?: Promise<{ callbackUrl?: string | string[]; reauth?: string }>; }; -function getSafeCallbackUrl(value: string | string[] | undefined) { - const callbackUrl = Array.isArray(value) ? value[0] : value; - if (!callbackUrl) return "/dashboard"; - if (!callbackUrl.startsWith("/") || callbackUrl.startsWith("//")) { - return "/dashboard"; - } - return callbackUrl; -} - export default async function LoginPage({ searchParams }: LoginPageProps) { + const resolvedSearchParams = await searchParams; const session = await auth(); - if (session?.user?.id) { + if (session?.user?.id && resolvedSearchParams?.reauth !== "1") { redirect(getPostLoginRedirect(session.user)); } - const resolvedSearchParams = await searchParams; - const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl); + const raw = resolvedSearchParams?.callbackUrl; + const callbackUrl = safeCallbackUrl(Array.isArray(raw) ? raw[0] : raw); const isDev = process.env.NODE_ENV === "development"; const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined; diff --git a/src/app/onboarding/actions.ts b/src/app/onboarding/actions.ts index 86a6648..66db5d1 100644 --- a/src/app/onboarding/actions.ts +++ b/src/app/onboarding/actions.ts @@ -4,7 +4,7 @@ import { redirect } from "next/navigation"; import { Prisma, UserRole } from "@prisma/client"; import { z } from "zod"; -import { auth } from "@/auth"; +import { requireSession } from "@/lib/auth-utils"; import { prisma } from "@/lib/prisma"; // ===================================================================== @@ -23,9 +23,9 @@ const onboardingSchema = z .trim() .min(2, "Bitte mindestens 2 Zeichen angeben.") .max(120, "Maximal 120 Zeichen."), - notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), - terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), - adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), + notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false), + terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false), + adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()).default(false), notdienstMinPerChildPerMonth: z.coerce .number() .int("Bitte eine ganze Zahl angeben.") @@ -87,10 +87,10 @@ export async function completeOnboardingAction( formData: FormData, ): Promise { const attempt = prev.attempt + 1; - const session = await auth(); - if (!session?.user) { - redirect("/login"); - } + const session = await requireSession(); + if (session.user.role !== UserRole.ADMIN) redirect("/forbidden"); + const founder = await prisma.user.findUnique({ where: { id: session.user.id } }); + if (!founder?.emailVerifiedAt || !founder.privacyPolicyAcceptedAt) redirect("/register"); if (session.user.kitaId) { // Onboarding bereits abgeschlossen — verhindert doppeltes Einrichten // (z.B. durch zurück-navigieren oder Replay des Formulars). @@ -124,14 +124,15 @@ export async function completeOnboardingAction( // tatsächlich noch keine Kita hat (verhindert Race-Conditions bei // parallelen Onboarding-Submits). const updated = await tx.user.updateMany({ - where: { id: session.user.id, kitaId: null }, + where: { id: session.user.id, kitaId: null, role: UserRole.ADMIN, disabledAt: null, + sessionVersion: session.user.sessionVersion, emailVerifiedAt: { not: null }, privacyPolicyAcceptedAt: { not: null } }, data: { kitaId: kita.id, role: UserRole.ADMIN, }, }); - if (updated.count === 0) { + if (updated.count !== 1) { throw new Error("USER_ALREADY_HAS_KITA"); } }); diff --git a/src/app/register/actions.ts b/src/app/register/actions.ts index 80fe2c2..310dbf6 100644 --- a/src/app/register/actions.ts +++ b/src/app/register/actions.ts @@ -1,26 +1,23 @@ "use server"; -import { hash } from "bcryptjs"; -import { Prisma, UserRole } from "@prisma/client"; +import { createElement } from "react"; + import { z } from "zod"; -import { signIn } from "@/auth"; +import { hashToken, newToken } from "@/lib/tokens"; +import { limitPublicAction } from "@/lib/rate-limit"; +import { sendAppEmail } from "@/lib/mail"; import { prisma } from "@/lib/prisma"; // ===================================================================== // /register · Server Action // --------------------------------------------------------------------- -// Legt einen "heimatlosen" Admin-Account an (kitaId === null) und loggt -// ihn direkt ein. Die Tenant-Erstellung folgt im Onboarding-Wizard. +// Speichert eine kurzlebige Registrierung und versendet einen Bestätigungslink. +// Konto und Passwort entstehen erst nach dem Nachweis des Postfachbesitzes. // ===================================================================== const registerSchema = z.object({ - email: z.string().email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase().trim(), - // bcrypt akzeptiert max. 72 Bytes — wir capen davor zur Sicherheit - password: z - .string() - .min(8, "Mindestens 8 Zeichen.") - .max(72, "Maximal 72 Zeichen."), + email: z.string().trim().max(254).email("Bitte eine gültige E-Mail-Adresse angeben.").toLowerCase(), firstName: z.string().min(1, "Pflichtfeld.").max(100).trim(), lastName: z.string().min(1, "Pflichtfeld.").max(100).trim(), // HTML-Checkboxen senden "on" wenn aktiviert, sonst nichts @@ -29,12 +26,12 @@ const registerSchema = z.object({ }), }); -const PRIVACY_POLICY_VERSION = "2026-05-01"; export type RegisterState = { // Bumped after every action call so the client can key uncontrolled inputs // and force a remount with the echoed values from a failed submit. attempt: number; + message?: string; // Echoed inputs (sans password) so failed submits don't wipe the form. values?: { firstName?: string; @@ -54,9 +51,9 @@ export type RegisterState = { function echoValues(formData: FormData): RegisterState["values"] { return { - firstName: String(formData.get("firstName") ?? ""), - lastName: String(formData.get("lastName") ?? ""), - email: String(formData.get("email") ?? ""), + firstName: String(formData.get("firstName") ?? "").slice(0, 100), + lastName: String(formData.get("lastName") ?? "").slice(0, 100), + email: String(formData.get("email") ?? "").slice(0, 254), acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on", }; } @@ -74,50 +71,28 @@ export async function registerAction( errors: parsed.error.flatten().fieldErrors, }; } - const { email, password, firstName, lastName } = parsed.data; - - const passwordHash = await hash(password, 12); - const now = new Date(); - - try { - await prisma.user.create({ - data: { - email, - passwordHash, - firstName, - lastName, - // Gründer wird "heimatloser" Admin — beim Onboarding-Abschluss - // wird die Rolle bestätigt und der User mit einer Kita verknüpft. - role: UserRole.ADMIN, - kitaId: null, - privacyPolicyAcceptedAt: now, - privacyPolicyVersion: PRIVACY_POLICY_VERSION, - emailVerifiedAt: now, - }, - }); - } catch (err) { - if ( - err instanceof Prisma.PrismaClientKnownRequestError && - err.code === "P2002" - ) { - return { - attempt, - values: echoValues(formData), - errors: { - email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."], - }, - }; - } - throw err; + const { email, firstName, lastName } = parsed.data; + const message = "Falls die Adresse registriert werden kann, erhältst du eine E-Mail. Öffne den Link, um dein Passwort zu wählen und deine Kita einzurichten. Der Link ist 30 Minuten gültig."; + if (!(await limitPublicAction("register", email))) { + return { attempt, errors: { _form: ["Zu viele Anfragen. Bitte versuche es später erneut."] } }; } - - // Direkt einloggen → wirft NEXT_REDIRECT, das wir nicht abfangen. - await signIn("credentials", { - email, - password, - redirectTo: "/onboarding", + const existing = await prisma.user.findUnique({ where: { email }, select: { role: true, kitaId: true, emailVerifiedAt: true, disabledAt: true } }); + if (existing && (existing.role !== "ADMIN" || existing.kitaId || existing.emailVerifiedAt || existing.disabledAt)) return { attempt, message }; + const token = newToken(); + await prisma.pendingRegistration.deleteMany({ where: { expiresAt: { lt: new Date() } } }); + await prisma.pendingRegistration.upsert({ + where: { email }, + create: { email, firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) }, + update: { firstName, lastName, tokenHash: hashToken(token), expiresAt: new Date(Date.now() + 30 * 60_000) }, }); - - // Unreachable – signIn redirected. - return { attempt }; + const base = process.env.AUTH_URL ?? process.env.NEXTAUTH_URL ?? "http://localhost:3000"; + const result = await sendAppEmail({ + to: email, + subject: "E-Mail bestätigen · Kita-Planer", + react: createElement("div", null, + createElement("p", null, "Bestätige deine E-Mail-Adresse, um dein Konto einzurichten. Falls du dich nicht registriert hast, kannst du diese Nachricht ignorieren."), + createElement("a", { href: `${base}/register/verify/${token}` }, "Registrierung abschließen")), + }); + if (!result.success) return { attempt, errors: { _form: ["Die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte versuche es später erneut."] } }; + return { attempt, message }; } diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 9690be2..1e5f5fb 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -27,7 +27,7 @@ export default async function RegisterPage() { Kita kostenlos registrieren - Lege deinen Account an. Im nächsten Schritt richtest du deine Kita ein. + Bestätige deine E-Mail-Adresse. Danach wählst du dein Passwort und richtest deine Kita ein. diff --git a/src/app/register/register-form.tsx b/src/app/register/register-form.tsx index f1b5c9c..391b2ea 100644 --- a/src/app/register/register-form.tsx +++ b/src/app/register/register-form.tsx @@ -22,6 +22,8 @@ export function RegisterForm() { // semantics would wipe them after a failed submit. const attemptKey = state.attempt; + if (state.message) return

{state.message}

; + return (
@@ -59,18 +61,6 @@ export function RegisterForm() { error={state.errors?.email?.[0]} /> - -
- {pending ? "Wird erstellt…" : "Account erstellen"} + {pending ? "Wird gesendet…" : "Bestätigungs-E-Mail anfordern"} ); diff --git a/src/app/register/verify/[token]/actions.ts b/src/app/register/verify/[token]/actions.ts new file mode 100644 index 0000000..d795a0e --- /dev/null +++ b/src/app/register/verify/[token]/actions.ts @@ -0,0 +1,45 @@ +"use server"; +import { hash } from "bcryptjs"; +import { z } from "zod"; +import { Prisma, UserRole } from "@prisma/client"; +import { signIn } from "@/auth"; +import { prisma } from "@/lib/prisma"; +import { passwordSchema } from "@/lib/password"; +import { hashToken, isPublicToken } from "@/lib/tokens"; +import { limitPublicAction } from "@/lib/rate-limit"; + +const schema = z.object({ token: z.string().refine(isPublicToken), password: passwordSchema, + confirmPassword: z.string().max(72), acceptPrivacyPolicy: z.literal("on") }) + .refine(d => d.password === d.confirmPassword, { message: "Passwörter stimmen nicht überein.", path: ["confirmPassword"] }); +export type VerificationState = { error?: string }; +export async function verifyRegistrationAction(_state: VerificationState, form: FormData): Promise { + const parsed = schema.safeParse(Object.fromEntries(form)); + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "Bitte Eingaben prüfen." }; + const { token, password } = parsed.data; + if (!(await limitPublicAction("verify-registration", hashToken(token), 10, 900))) return { error: "Bitte versuche es später erneut." }; + const draft = await prisma.pendingRegistration.findUnique({ where: { tokenHash: hashToken(token) } }); + if (!draft || draft.expiresAt <= new Date()) return { error: "Der Link ist ungültig oder abgelaufen. Bitte fordere eine neue E-Mail an." }; + const passwordHash = await hash(password, 12); + try { + await prisma.$transaction(async tx => { + const used = await tx.pendingRegistration.deleteMany({ where: { tokenHash: hashToken(token), expiresAt: { gt: new Date() } } }); + if (used.count !== 1) throw new Error("TOKEN_USED"); + const existing = await tx.user.findUnique({ where: { email: draft.email }, select: { id: true } }); + const data = { firstName: draft.firstName, lastName: draft.lastName, passwordHash, + emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date(), privacyPolicyVersion: "2026-05-01" }; + if (existing) { + const changed = await tx.user.updateMany({ where: { id: existing.id, role: UserRole.ADMIN, kitaId: null, + emailVerifiedAt: null, disabledAt: null }, data: { ...data, sessionVersion: { increment: 1 } } }); + if (changed.count !== 1) throw new Error("TOKEN_USED"); + } else { + await tx.user.create({ data: { ...data, email: draft.email, role: UserRole.ADMIN } }); + } + }); + } catch (error) { + if ((error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") || + (error instanceof Error && error.message === "TOKEN_USED")) return { error: "Der Link wurde bereits verwendet. Bitte melde dich an." }; + throw error; + } + await signIn("credentials", { email: draft.email, password, redirectTo: "/onboarding" }); + return {}; +} diff --git a/src/app/register/verify/[token]/page.tsx b/src/app/register/verify/[token]/page.tsx new file mode 100644 index 0000000..a0d6987 --- /dev/null +++ b/src/app/register/verify/[token]/page.tsx @@ -0,0 +1,17 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { hashToken, isPublicToken } from "@/lib/tokens"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { VerifyForm } from "./verify-form"; +export const dynamic = "force-dynamic"; +export const metadata = { title: "E-Mail bestätigen · Kita-Planer", robots: { index: false, follow: false } }; +export default async function VerifyPage({ params }: { params: Promise<{ token: string }> }) { + const { token } = await params; + if (!isPublicToken(token)) notFound(); + const draft = await prisma.pendingRegistration.findUnique({ where: { tokenHash: hashToken(token) }, select: { expiresAt: true } }); + if (!draft || draft.expiresAt <= new Date()) return
Dieser Link ist ungültig oder abgelaufen. Neue Bestätigungs-E-Mail anfordern
; + return
+ Registrierung abschließen + +
; +} diff --git a/src/app/register/verify/[token]/verify-form.tsx b/src/app/register/verify/[token]/verify-form.tsx new file mode 100644 index 0000000..c630485 --- /dev/null +++ b/src/app/register/verify/[token]/verify-form.tsx @@ -0,0 +1,18 @@ +"use client"; +import { useActionState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { verifyRegistrationAction, type VerificationState } from "./actions"; +export function VerifyForm({ token }: { token: string }) { + const [state, action, pending] = useActionState(verifyRegistrationAction, {} as VerificationState); + return
+ +
+
+ + {state.error &&

{state.error}

} + +
; +} diff --git a/src/auth.ts b/src/auth.ts index 7efde29..f0cb653 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,6 +1,7 @@ import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { compare } from "bcryptjs"; +import { limitPublicAction } from "@/lib/rate-limit"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; @@ -14,12 +15,12 @@ import { prisma } from "@/lib/prisma"; // ===================================================================== const credentialsSchema = z.object({ - email: z.string().email().toLowerCase().trim(), - password: z.string().min(1), + email: z.string().trim().max(254).email().toLowerCase(), + password: z.string().min(1).refine(value => Buffer.byteLength(value, "utf8") <= 72), }); // Brute-Force-Schutz auf Account-Ebene (planung.md §5.5). -// Globales Rate-Limiting ergänzen wir später per Middleware. +// Zusätzlich greifen atomare, instanzübergreifende DB-Limits vor bcrypt. const MAX_FAILED_ATTEMPTS = 10; const LOCKOUT_MINUTES = 15; @@ -40,8 +41,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (!parsed.success) return null; const { email, password } = parsed.data; + if (!(await limitPublicAction("login", email, 20, 900))) return null; const user = await prisma.user.findUnique({ where: { email } }); - if (!user) return null; + if (!user) { + // Match the cost for nonexistent accounts without exposing account existence. + await compare(password, "$2b$12$KbQiLuL6QbybTw/WNx4Ige2yCZcjVccoruuDx3vmkJt8YYUpphdIu"); + return null; + } + if (user.disabledAt || !user.emailVerifiedAt) return null; // Account temporär gesperrt → Login pauschal ablehnen. if (user.lockedUntil && user.lockedUntil > new Date()) { @@ -50,23 +57,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ const passwordOk = await compare(password, user.passwordHash); if (!passwordOk) { - const nextAttempts = user.failedLoginAttempts + 1; - await prisma.user.update({ - where: { id: user.id }, - data: { - failedLoginAttempts: nextAttempts, - lockedUntil: - nextAttempts >= MAX_FAILED_ATTEMPTS - ? new Date(Date.now() + LOCKOUT_MINUTES * 60_000) - : user.lockedUntil, - }, - }); + await prisma.$executeRaw` + UPDATE users SET "failedLoginAttempts" = "failedLoginAttempts" + 1, + "lockedUntil" = CASE WHEN "failedLoginAttempts" + 1 >= ${MAX_FAILED_ATTEMPTS} + THEN NOW() + make_interval(mins => ${LOCKOUT_MINUTES}::int) ELSE "lockedUntil" END + WHERE id = ${user.id}`; return null; } // Erfolgreicher Login → Counter zurücksetzen, lastLogin aktualisieren. - await prisma.user.update({ - where: { id: user.id }, + const admitted = await prisma.user.updateMany({ + where: { id: user.id, disabledAt: null, sessionVersion: user.sessionVersion, passwordHash: user.passwordHash, emailVerifiedAt: { not: null }, + OR: [{ lockedUntil: null }, { lockedUntil: { lte: new Date() } }] }, data: { failedLoginAttempts: 0, lockedUntil: null, @@ -74,7 +76,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }); + if (admitted.count !== 1) return null; return { + sessionVersion: user.sessionVersion, + authenticatedAt: Date.now(), id: user.id, email: user.email, name: `${user.firstName} ${user.lastName}`.trim(), @@ -86,6 +91,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }), ], callbacks: { + async redirect({ url, baseUrl }) { + if (/[\\\x00-\x20]/.test(url)) return baseUrl; + try { + const destination = new URL(url, baseUrl); + return destination.origin === new URL(baseUrl).origin ? destination.href : baseUrl; + } catch { return baseUrl; } + }, /** * Wird beim Login (mit `user`) und bei jedem Token-Refresh (ohne `user`) * aufgerufen. Beim Login schreiben wir die mandantenrelevanten Felder @@ -96,31 +108,20 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ async jwt({ token, user }) { if (user) { token.id = user.id; - token.role = user.role; - token.kitaId = user.kitaId; - token.familyId = user.familyId; - return token; + token.sessionVersion = user.sessionVersion; + token.authenticatedAt = user.authenticatedAt; } - - const tokenUserId = token.id ?? token.sub; - if (tokenUserId) { - const fresh = await prisma.user.findUnique({ - where: { id: tokenUserId }, - select: { role: true, kitaId: true, familyId: true }, - }); - if (!fresh) { - // User wurde gelöscht → Token entwerten. - // (Auth.js erkennt den fehlenden `sub`/`id` und meldet ab.) - delete (token as Partial).id; - delete (token as Partial).sub; - return token; - } - token.id = tokenUserId; - token.role = fresh.role; - token.kitaId = fresh.kitaId; - token.familyId = fresh.familyId; - } - + const id = token.id ?? token.sub; + if (typeof id !== "string" || !id || id.length > 64 || !Number.isSafeInteger(token.sessionVersion) || !Number.isFinite(token.authenticatedAt) || token.authenticatedAt > Date.now() + 60_000) return null; + const fresh = await prisma.user.findUnique({ + where: { id }, + select: { role: true, kitaId: true, familyId: true, disabledAt: true, sessionVersion: true, emailVerifiedAt: true }, + }); + if (!fresh || fresh.disabledAt || !fresh.emailVerifiedAt || fresh.sessionVersion !== token.sessionVersion) return null; + token.id = id; + token.role = fresh.role; + token.kitaId = fresh.kitaId; + token.familyId = fresh.familyId; return token; }, @@ -131,7 +132,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ */ async session({ session, token }) { if (token && session.user) { - session.user.id = token.id ?? token.sub; + session.user.id = token.id; + session.user.sessionVersion = token.sessionVersion; + session.user.authenticatedAt = token.authenticatedAt; session.user.role = token.role; session.user.kitaId = token.kitaId; session.user.familyId = token.familyId; diff --git a/src/components/analytics/umami-analytics.tsx b/src/components/analytics/umami-analytics.tsx index 9b6f3d1..16b4e97 100644 --- a/src/components/analytics/umami-analytics.tsx +++ b/src/components/analytics/umami-analytics.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; import Script from "next/script"; import { marketingFeatureSlugs } from "@/lib/marketing/features"; @@ -6,30 +10,55 @@ const publicAnalyticsPaths = new Set([ "/", "/impressum", "/datenschutz", - "/login", - "/register", ...marketingFeatureSlugs.map((slug) => `/features/${slug}`), ]); -export function UmamiAnalytics() { +declare global { interface Window { kitaPlanerAnalyticsLoaded?: boolean } } + +export function UmamiAnalytics({ nonce, enabled }: { nonce?: string; enabled: boolean }) { + const pathname = usePathname(); + useEffect(() => { + // A full document change discards an SDK already loaded on a public page. + if ((!enabled || !publicAnalyticsPaths.has(pathname)) && window.kitaPlanerAnalyticsLoaded) { + window.location.replace(window.location.href); + return; + } + if (!enabled || !publicAnalyticsPaths.has(pathname)) return; + // Auth and token pages get a fresh document before any private data is fetched. + const navigate = (event: MouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const link = event.target instanceof Element ? event.target.closest("a[href]") : null; + if (!(link instanceof HTMLAnchorElement)) return; + const destination = new URL(link.href, window.location.origin); + if (destination.origin === window.location.origin && !publicAnalyticsPaths.has(destination.pathname)) { + event.preventDefault(); event.stopPropagation(); + window.location.assign(destination.href); + } + }; + document.addEventListener("click", navigate, true); + return () => document.removeEventListener("click", navigate, true); + }, [pathname, enabled]); const scriptUrl = process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL; const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID; const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS; - if (!scriptUrl || !websiteId) { + if (!enabled || !scriptUrl || !websiteId || !publicAnalyticsPaths.has(pathname)) { return null; } return ( <> - '); + const attachment = cookie => fetch(origin + '/api/announcements/attachments/' + attachmentId, { headers: { Cookie: cookie } }); + assert.equal((await attachment(cookieA)).status, 404); checked(); + const own = await attachment(cookieB); assert.equal(own.status,200); checked(); + assert.equal(own.headers.get('content-type'),'application/octet-stream'); checked(); + assert.match(own.headers.get('content-disposition'),/^attachment;/); checked(); + assert.equal(own.headers.get('content-security-policy'),"default-src 'none'; sandbox"); checked(); + await prisma.user.update({ where: { id: userB.id }, data: { privacyPolicyAcceptedAt: null } }); + assert.equal((await attachment(cookieB)).status,401); checked(); + await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: new Date(), sessionVersion: { increment: 1 } } }); + assert.equal((await getSession(cookieA))?.user,undefined); checked(); + await prisma.user.update({ where: { id: userA.id }, data: { disabledAt: null } }); + assert.equal((await getSession(cookieA))?.user,undefined); checked(); + const newCookie = await login(userA.email,password); + assert.equal((await getSession(newCookie))?.user.id,userA.id); checked(); + await prisma.user.delete({ where: { id: userA.id } }); + assert.equal((await getSession(newCookie))?.user,undefined); checked(); + // Auth.js must reject trusted-but-obsolete token payloads rather than constructing a ghost session. + const { encode } = await import('@auth/core/jwt'); + for (const payload of [{ role: 'ADMIN', kitaId: kitaA.id }, { id: userB.id, role: 'ADMIN', kitaId: kitaB.id }, + { id: 123, sessionVersion: 0, authenticatedAt: Date.now() }, + { id: 'deleted-' + prefix, role: 'ADMIN', kitaId: kitaA.id, sessionVersion: 0, authenticatedAt: Date.now() }]) { + const jwt = await encode({ token: payload, secret: 'security-test-only-not-a-production-secret', salt: 'authjs.session-token' }); + assert.equal((await getSession('authjs.session-token=' + jwt))?.user,undefined); checked(); + } + console.log(`${checks} HTTP security checks passed against the production build.`); +} +main().finally(async () => { + if (attachmentId) await fs.rm(path.join(upload,attachmentId),{ force: true }); + await prisma.kita.deleteMany({ where: { slug: { startsWith: prefix } } }); + await prisma.$disconnect(); +}).catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/security.test.cjs b/tests/security.test.cjs new file mode 100644 index 0000000..8d9d673 --- /dev/null +++ b/tests/security.test.cjs @@ -0,0 +1,378 @@ +/* eslint-disable @typescript-eslint/no-require-imports -- CommonJS is required by the isolated TypeScript module loader. */ +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +const { PrismaClient, UserRole } = require('@prisma/client'); +const { hash, compare } = require('bcryptjs'); +const { randomUUID } = require('node:crypto'); + +// Execute the actual application code. Only the Next request/UI boundary and +// outgoing email transport are replaced. Database queries and bcrypt are real. +function loader(overrides = {}) { + const cache = new Map(); + function load(file) { + file = path.resolve(file); + if (cache.has(file)) return cache.get(file).exports; + const mod = { exports: {} }; cache.set(file, mod); + const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true }, + fileName: file, + }).outputText; + const req = spec => { + if (Object.hasOwn(overrides, spec)) return overrides[spec]; + if (spec === '@/lib/prisma') return { prisma }; + if (spec === 'next/headers') return { headers: async () => new Headers() }; + if (spec === 'next/cache') return { revalidatePath() {} }; + if (spec === 'next/navigation') return { redirect(url) { throw new Error(`REDIRECT:${url}`); }, notFound() { throw new Error('NOT_FOUND'); } }; + if (spec.startsWith('@/') || spec.startsWith('.')) { + const target = spec.startsWith('@/') ? path.resolve('src', spec.slice(2)) : path.resolve(path.dirname(file), spec); + for (const suffix of ['.ts', '.tsx', '/index.ts']) if (fs.existsSync(target + suffix)) return load(target + suffix); + } + return require(spec); + }; + vm.runInThisContext(`(function(require,module,exports){${code}\n})`, { filename: file })(req, mod, mod.exports); + return mod.exports; + } + return load; +} +let prisma, kitaA, kitaB, familyA, familyB, parentA, parentB, adminA, founder, superadmin, childA, childB, passHash; +const prefix = `security-${randomUUID()}`; +const emails = []; +const session = user => ({ user: { id: user.id, kitaId: user.kitaId, familyId: user.familyId, role: user.role, email: user.email, sessionVersion: user.sessionVersion, authenticatedAt: Date.now() } }); +const fields = data => { const form = new FormData(); for (const [key, value] of Object.entries(data)) form.set(key, value); return form; }; +const emailMock = { getAppEmailConfigError: () => null, sendAppEmail: async input => { emails.push(input); return { success: true }; } }; +const noAuth = { auth: async () => null, signIn: async () => {} }; +let authConfig; +before(async () => { + const url = process.env.SECURITY_TEST_DATABASE_URL; + if (!url || !['127.0.0.1','localhost'].includes(new URL(url).hostname) || !new URL(url).pathname.endsWith('/kita_security_test')) { + throw new Error('Set SECURITY_TEST_DATABASE_URL to the dedicated local kita_security_test database; never use application data.'); + } + prisma = new PrismaClient({ datasourceUrl: url }); + passHash = await hash('Safe-test-password', 12); + kitaA = await prisma.kita.create({ data: { name: prefix + '-A', slug: prefix + '-a' } }); + kitaB = await prisma.kita.create({ data: { name: prefix + '-B', slug: prefix + '-b' } }); + familyA = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'A' } }); + familyB = await prisma.family.create({ data: { kitaId: kitaB.id, name: 'B' } }); + const makeUser = (label, role, kitaId, familyId) => prisma.user.create({ data: { email: `${label}.${prefix}@example.invalid`, firstName: label, lastName: 'Test', role, kitaId, familyId, passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } }); + parentA = await makeUser('parent-a', UserRole.ELTERN, kitaA.id, familyA.id); + parentB = await makeUser('parent-b', UserRole.ELTERN, kitaB.id, familyB.id); + adminA = await makeUser('admin-a', UserRole.ADMIN, kitaA.id, null); + founder = await makeUser('founder', UserRole.ADMIN, null, null); + superadmin = await makeUser('superadmin', UserRole.SUPERADMIN, null, null); + childA = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: familyA.id, firstName: 'A', lastName: 'Test' } }); + childB = await prisma.child.create({ data: { kitaId: kitaB.id, familyId: familyB.id, firstName: 'B', lastName: 'Test' } }); + loader({ 'next-auth': config => { authConfig = config; return {}; }, 'next-auth/providers/credentials': options => options })('src/auth.ts'); +}); +after(async () => { + if (!prisma) return; + await prisma.verificationToken.deleteMany({ where: { identifier: { in: [parentA?.id, parentB?.id].filter(Boolean) } } }); + await prisma.pendingRegistration.deleteMany({ where: { email: { contains: prefix } } }); + await prisma.kita.deleteMany({ where: { slug: { startsWith: prefix } } }); + await prisma.user.deleteMany({ where: { email: { contains: prefix } } }); + await prisma.auditLog.deleteMany({ where: { actorEmail: { contains: prefix } } }); + await prisma.$disconnect(); +}); + +test('bcrypt limit counts UTF-8 bytes, preventing equivalent long passwords', async () => { + const { passwordSchema } = loader()('src/lib/password.ts'); + assert.equal(passwordSchema.safeParse('😀'.repeat(18)).success, true); + assert.equal(passwordSchema.safeParse('😀'.repeat(18) + 'A').success, false); + assert.equal(passwordSchema.safeParse('a'.repeat(73)).success, false); + assert.equal(await compare('😀'.repeat(18) + 'B', await hash('😀'.repeat(18) + 'A', 4)), true); +}); +test('redirects reject external, backslash and control-character destinations', () => { + const { safeCallbackUrl } = loader()('src/lib/safe-redirect.ts'); + for (const bad of ['//evil.invalid','/\\evil.invalid','https://evil.invalid','/\nevil']) assert.equal(safeCallbackUrl(bad), '/dashboard'); + assert.equal(safeCallbackUrl('/admin?view=1'), '/admin?view=1'); +}); +test('invalid calendar dates and planning months are rejected', () => { + const { dateOnlySchema, validPlanMonth } = loader()('src/lib/date-schema.ts'); + for (const bad of ['2026-02-31','2026-13-01','9999-99-99','garbage']) assert.equal(dateOnlySchema.safeParse(bad).success, false); + assert.equal(dateOnlySchema.safeParse('2028-02-29').success, true); + assert.equal(validPlanMonth(2026, 13), false); assert.equal(validPlanMonth(2026.5, 1), false); +}); +test('upload checks reject HTML disguised as JPG and path traversal', () => { + const files = loader()('src/lib/attachments.ts'); + assert.equal(files.detectAttachmentType(Buffer.from('')), null); + assert.throws(() => files.attachmentPath('../../etc/passwd')); + assert.equal(files.attachmentExtensionMatches('attack.jpg', 'application/pdf'), false); + assert.match(files.attachmentDisposition('a\r\nX-Injected: yes.pdf'), /^attachment;/); + assert.doesNotMatch(files.attachmentDisposition('a\r\nX-Injected: yes.pdf'), /[\r\n]/); +}); +test('rate limiting is atomic under concurrent requests and expires correctly', async () => { + const { rateLimit } = loader()('src/lib/rate-limit.ts'); + const results = await Promise.all(Array.from({ length: 20 }, () => rateLimit(prefix + ':parallel', 5, 60))); + assert.equal(results.filter(result => result.allowed).length, 5); + assert.equal((await rateLimit(prefix + ':parallel', 5, 60)).allowed, false); + const { createHash } = require('node:crypto'); + await prisma.rateLimitBucket.update({ where: { key: createHash('sha256').update(prefix + ':parallel').digest('hex') }, data: { resetAt: new Date(0) } }); + assert.equal((await rateLimit(prefix + ':parallel', 5, 60)).allowed, true); +}); +test('JWT callback rejects deleted, malformed and old-format sessions', async () => { + assert.equal(await authConfig.callbacks.jwt({ token: {} }), null); + assert.equal(await authConfig.callbacks.jwt({ token: { id: parentA.id } }), null); + assert.equal(await authConfig.callbacks.jwt({ token: { id: 'missing-' + prefix, sessionVersion: 0, authenticatedAt: Date.now() } }), null); +}); +test('administrative lock and unlock permanently revoke existing sessions', async () => { + const admitted = await authConfig.providers[0].authorize({ email: parentA.email, password: 'Safe-test-password' }); + assert.equal(admitted.id, parentA.id); + const token = await authConfig.callbacks.jwt({ token: {}, user: admitted }); + const admin = loader({ '@/auth': { auth: async () => session(superadmin) } })('src/app/admin/actions.ts'); + assert.equal((await admin.lockUser(parentA.id)).ok, true); + assert.equal(await authConfig.callbacks.jwt({ token: { ...token } }), null); + assert.equal(await authConfig.providers[0].authorize({ email: parentA.email, password: 'Safe-test-password' }), null); + assert.equal((await admin.unlockUser(parentA.id)).ok, true); + assert.equal(await authConfig.callbacks.jwt({ token: { ...token } }), null); + const relogin = await authConfig.providers[0].authorize({ email: parentA.email, password: 'Safe-test-password' }); + assert.equal(relogin.sessionVersion, token.sessionVersion + 1); + parentA = await prisma.user.findUnique({ where: { id: parentA.id } }); +}); +test('parallel incorrect passwords cannot lose failed-login increments', async () => { + await Promise.all(Array.from({ length: 8 }, () => authConfig.providers[0].authorize({ email: parentB.email, password: 'wrong' }))); + const user = await prisma.user.findUnique({ where: { id: parentB.id } }); + assert.equal(user.failedLoginAttempts, 8); + await prisma.user.update({ where: { id: parentB.id }, data: { failedLoginAttempts: 0 } }); +}); +test('normal roles require consent and tenant; TEST_MODE never creates a fake identity', async () => { + await prisma.user.update({ where: { id: adminA.id }, data: { privacyPolicyAcceptedAt: null } }); + const util = loader({ '@/auth': { auth: async () => session(adminA) } })('src/lib/auth-utils.ts'); + await assert.rejects(util.requireRole([UserRole.ADMIN]), /REDIRECT:\/onboarding\/consent/); + assert.equal(await util.getKitaSession(), null); + await prisma.user.update({ where: { id: adminA.id }, data: { privacyPolicyAcceptedAt: new Date() } }); + const old = process.env.TEST_MODE; process.env.TEST_MODE = 'true'; + try { await assert.rejects(loader({ '@/auth': noAuth })('src/lib/auth-utils.ts').requireRole([UserRole.SUPERADMIN]), /REDIRECT:\/login/); } + finally { if (old === undefined) delete process.env.TEST_MODE; else process.env.TEST_MODE = old; } +}); +test('onboarding denies missing IDs, global admins and unverified founders', async () => { + const payload = fields({ kitaName: 'Test', notdienstMinPerChildPerMonth: '1' }); + for (const bad of [null, { user: { role: UserRole.ADMIN } }, session(superadmin)]) { + const action = loader({ '@/auth': { auth: async () => bad } })('src/app/onboarding/actions.ts').completeOnboardingAction; + await assert.rejects(action({ attempt: 0 }, payload), /REDIRECT:/); + } + await prisma.user.update({ where: { id: founder.id }, data: { emailVerifiedAt: null } }); + await assert.rejects(loader({ '@/auth': { auth: async () => session(founder) } })('src/app/onboarding/actions.ts').completeOnboardingAction({ attempt: 0 }, payload), /REDIRECT:\/register/); + await prisma.user.update({ where: { id: founder.id }, data: { emailVerifiedAt: new Date() } }); +}); +test('database blocks cross-tenant child, user, RSVP and announcement links', async () => { + await assert.rejects(prisma.child.create({ data: { firstName: 'Wrong', lastName: 'Tenant', kitaId: kitaA.id, familyId: familyB.id } }), error => error.code === 'P2003'); + await assert.rejects(prisma.user.update({ where: { id: parentA.id }, data: { familyId: familyB.id } }), error => error.code === 'P2003'); + await assert.rejects(prisma.announcement.create({ data: { kitaId: kitaA.id, authorId: parentB.id, title: 'Wrong', content: 'Wrong' } }), error => error.code === 'P2003'); + const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Event', type: 'SONSTIGES', status: 'CONFIRMED', startDate: new Date(), endDate: new Date() } }); + await assert.rejects(prisma.eventParticipation.create({ data: { kitaId: kitaA.id, eventId: event.id, childId: childB.id, status: 'ATTENDING' } }), error => error.code === 'P2003'); +}); +test('download route rejects foreign attachments and requires consent', async () => { + const { writeFile, mkdir } = require('node:fs/promises'); + const files = loader()('src/lib/attachments.ts'); + const id = randomUUID(); + const announcement = await prisma.announcement.create({ data: { kitaId: kitaB.id, authorId: parentB.id, title: 'Private', content: 'Private', attachments: { create: { id, fileName: 'unsafe.html', fileType: 'text/html', fileUrl: '/api/' + id } } } }); + await mkdir(files.attachmentDirectory(), { recursive: true }); + await writeFile(files.attachmentPath(id), ''); + try { + const load = user => loader({ '@/auth': { auth: async () => session(user) } })('src/app/api/announcements/attachments/[id]/route.ts').GET(new Request('http://localhost'), { params: Promise.resolve({ id }) }); + assert.equal((await load(parentA)).status, 404); + const response = await load(parentB); + assert.equal(response.status, 200); assert.equal(response.headers.get('content-type'), 'application/octet-stream'); + assert.match(response.headers.get('content-disposition'), /^attachment;/); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); assert.equal(response.headers.get('cache-control'), 'private, no-store'); + await prisma.user.update({ where: { id: parentB.id }, data: { privacyPolicyAcceptedAt: null } }); + assert.equal((await load(parentB)).status, 401); + } finally { + await files.removeAttachmentFiles([id]); + await prisma.user.update({ where: { id: parentB.id }, data: { privacyPolicyAcceptedAt: new Date() } }); + await prisma.announcement.delete({ where: { id: announcement.id } }); + } +}); +test('registration has no user before mail verification and token can be consumed once', async () => { + const email = `registration.${prefix}@example.invalid`; + const load = loader({ '@/auth': noAuth, '@/lib/mail': emailMock }); + const registration = load('src/app/register/actions.ts'); + const result = await registration.registerAction({ attempt: 0 }, fields({ email, firstName: 'Founder', lastName: 'Test', acceptPrivacyPolicy: 'on' })); + assert.ok(result.message); assert.equal(await prisma.user.findUnique({ where: { email } }), null); + const message = emails.at(-1); const link = message.react.props.children[1].props.href; const token = link.split('/').at(-1); + const draft = await prisma.pendingRegistration.findUnique({ where: { email } }); + assert.notEqual(draft.tokenHash, token); + const verify = load('src/app/register/verify/[token]/actions.ts').verifyRegistrationAction; + const form = () => fields({ token, password: 'Owner-chosen-password', confirmPassword: 'Owner-chosen-password', acceptPrivacyPolicy: 'on' }); + const results = await Promise.all([verify({}, form()), verify({}, form())]); + assert.equal(results.filter(result => !result.error).length, 1); + const user = await prisma.user.findUnique({ where: { email } }); assert.ok(user.emailVerifiedAt); assert.equal(user.kitaId, null); + assert.equal(await compare('Owner-chosen-password', user.passwordHash), true); + assert.ok((await verify({}, form())).error); +}); +test('invite activation uses a hashed expiring token and rejects concurrent reuse', async () => { + const load = loader({ '@/auth': noAuth }); const { newToken, hashToken } = load('src/lib/tokens.ts'); + const token = newToken(); const email = `invite.${prefix}@example.invalid`; + const user = await prisma.user.create({ data: { email, firstName: 'Invited', lastName: 'Test', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '' } }); + await prisma.verificationToken.create({ data: { token: hashToken(token), identifier: user.id, expires: new Date(Date.now() + 60_000) } }); + const accept = load('src/app/invite/[token]/actions.ts').acceptInviteAction; + const form = () => fields({ token, password: 'Invite-test-password', confirmPassword: 'Invite-test-password', acceptPrivacyPolicy: 'on' }); + const results = await Promise.all([accept({ attempt: 0 }, form()), accept({ attempt: 0 }, form())]); + assert.equal(results.filter(result => !result.errors).length, 1); + assert.equal(await prisma.verificationToken.findUnique({ where: { token: hashToken(token) } }), null); +}); +test('household creation is atomic for parallel submissions', async () => { + const action = loader({ '@/auth': { auth: async () => session(adminA) } })('src/app/dashboard/profil/actions.ts').createMyFamily; + const before = await prisma.family.count({ where: { kitaId: kitaA.id } }); + const results = await Promise.all([action({ familyName: 'Test household' }), action({ familyName: 'Test household' })]); + assert.equal(results.filter(result => result.success).length, 1); + assert.equal(await prisma.family.count({ where: { kitaId: kitaA.id } }), before + 1); +}); +test('bulk email transport never exposes another recipient in To', async () => { + const sent = []; + const oldKey = process.env.RESEND_API_KEY; const oldFrom = process.env.EMAIL_FROM; + process.env.RESEND_API_KEY = 'test-only'; process.env.EMAIL_FROM = 'test@example.invalid'; + try { + const mail = loader({ resend: { Resend: class { emails = { send: async input => { sent.push(input); return { data: { id: 'test' } }; } }; } } })('src/lib/mail.ts'); + assert.equal((await mail.sendAppEmail({ to: ['a@example.invalid','b@example.invalid','a@example.invalid'], subject: 'Test', react: null })).success, true); + assert.deepEqual(sent.map(input => input.to), ['a@example.invalid','b@example.invalid']); + } finally { + if (oldKey === undefined) delete process.env.RESEND_API_KEY; else process.env.RESEND_API_KEY = oldKey; + if (oldFrom === undefined) delete process.env.EMAIL_FROM; else process.env.EMAIL_FROM = oldFrom; + } +}); + +test('read receipts cannot link users and announcements across tenants', async () => { + const announcement = await prisma.announcement.create({ data: { kitaId: kitaA.id, authorId: parentA.id, title: 'Tenant A', content: 'Private' } }); + await assert.rejects(prisma.announcementRead.create({ data: { kitaId: kitaA.id, userId: parentB.id, announcementId: announcement.id } }), error => error.code === 'P2003'); + await assert.rejects(prisma.user.update({ where: { id: parentA.id }, data: { kitaId: null } }), error => error.message.includes('users_family_requires_tenant')); +}); +test('participant secrets are removed from props delivered to unauthorized browsers', async () => { + const secret = 'CONFIDENTIAL-PARTICIPANT-' + prefix; + const event = await prisma.termin.create({ data: { kitaId: kitaA.id, title: 'Private details', type: 'SONSTIGES', status: 'CONFIRMED', requiresRsvp: true, + participantInfo: secret, startDate: new Date(Date.now() + 86400000), endDate: new Date(Date.now() + 86400000 + 3600000) } }); + const mockComponents = { './_components/admin-termin-modal': { AdminTerminModal: () => null }, + './_components/pending-anfragen': { PendingAnfragen: () => null }, + './_components/termin-request-modal': { TerminRequestModal: () => null }, + './_components/calendar-overview-client': { CalendarOverviewClient: () => null } }; + const page = user => loader({ ...mockComponents, '@/auth': { auth: async () => session(user) } })('src/app/dashboard/kalender/page.tsx').default({ searchParams: Promise.resolve({}) }); + assert.equal(JSON.stringify(await page(parentA)).includes(secret), false); + await prisma.eventParticipation.create({ data: { kitaId: kitaA.id, childId: childA.id, eventId: event.id, status: 'ATTENDING' } }); + assert.equal(JSON.stringify(await page(parentA)).includes(secret), true); + assert.equal(JSON.stringify(await page(parentB)).includes(secret), false); +}); +test('alarm links expire and concurrent confirmations consume the token only once', async () => { + const load = loader(); const { newToken, hashToken } = load('src/lib/tokens.ts'); + const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 1, status: 'PUBLISHED' } }); + const assignment = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 86400000) } }); + const token = newToken(); + const alert = await prisma.notdienstAlert.create({ data: { kitaId: kitaA.id, assignmentId: assignment.id, parentUserId: parentA.id, confirmationToken: hashToken(token), triggeredAt: new Date(Date.now() - 48 * 3600000) } }); + const confirm = load('src/app/alert/[token]/actions.ts').confirmAlertAction; + assert.ok((await confirm(token)).error); + await prisma.notdienstAlert.update({ where: { id: alert.id }, data: { triggeredAt: new Date() } }); + const results = await Promise.all([confirm(token), confirm(token)]); + assert.equal(results.filter(result => result.success).length, 1); + assert.ok((await confirm(token)).error); +}); +test('parallel swap creation and acceptance cannot duplicate or undo a swap', async () => { + const otherFamily = await prisma.family.create({ data: { kitaId: kitaA.id, name: 'Swap partner' } }); + const partner = await prisma.user.create({ data: { email: `partner.${prefix}@example.invalid`, firstName: 'Partner', lastName: 'Test', kitaId: kitaA.id, + familyId: otherFamily.id, role: 'ELTERN', passwordHash: passHash, emailVerifiedAt: new Date(), privacyPolicyAcceptedAt: new Date() } }); + const partnerChild = await prisma.child.create({ data: { kitaId: kitaA.id, familyId: otherFamily.id, firstName: 'Partner', lastName: 'Test' } }); + const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 2, status: 'PUBLISHED' } }); + const offered = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 2 * 86400000) } }); + const target = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: partnerChild.id, date: new Date(Date.now() + 3 * 86400000) } }); + const actions = user => loader({ '@/lib/mail': emailMock, '@/auth': { auth: async () => session(user) } })('src/app/dashboard/kalender/actions.ts'); + const requester = actions(parentA); const recipient = actions(partner); + const payload = { targetAssignmentId: target.id, offeredAssignmentId: offered.id }; + const created = await Promise.all([requester.createNotdienstSwapRequest(payload), requester.createNotdienstSwapRequest(payload)]); + assert.equal(created.filter(result => result.success).length, 1); + const request = await prisma.notdienstSwapRequest.findFirst({ where: { kitaId: kitaA.id, targetAssignmentId: target.id } }); + assert.ok((await actions(parentB).acceptNotdienstSwapRequest(request.id)).error); + const accepted = await Promise.all([recipient.acceptNotdienstSwapRequest(request.id), recipient.acceptNotdienstSwapRequest(request.id)]); + assert.equal(accepted.filter(result => result.success).length, 1); + assert.equal((await prisma.notdienstAssignment.findUnique({ where: { id: target.id } })).childId, childA.id); + assert.equal((await prisma.notdienstAssignment.findUnique({ where: { id: offered.id } })).childId, partnerChild.id); + assert.ok((await requester.cancelNotdienstSwapRequest(request.id)).error); +}); + +test('changing a parent email revokes sessions and requires fresh mailbox activation', async () => { + const newEmail = `changed.${prefix}@example.invalid`; + const load = loader({ '@/auth': { auth: async () => session(adminA), signIn: async () => {} }, '@/lib/mail': emailMock }); + const actions = load('src/app/dashboard/families/actions.ts'); + const oldToken = await authConfig.callbacks.jwt({ token: { id: parentA.id, sessionVersion: parentA.sessionVersion, authenticatedAt: Date.now() } }); + assert.equal((await actions.updateFamilyAction({ familyId: familyA.id, familyName: 'Family A', + parents: [{ id: parentA.id, firstName: 'Parent', lastName: 'Test', email: newEmail }], + children: [{ id: childA.id, firstName: 'A', lastName: 'Test' }] })).success, true); + const changed = await prisma.user.findUnique({ where: { id: parentA.id } }); + assert.equal(changed.emailVerifiedAt,null); assert.equal(changed.passwordHash,''); + assert.equal(await authConfig.callbacks.jwt({ token: oldToken }),null); + assert.equal(await authConfig.providers[0].authorize({ email: newEmail, password: 'Safe-test-password' }),null); + const initialInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1); + assert.equal((await actions.resendParentInvite(parentA.id)).success,true); + const renewedInvite = emails.at(-1).react.props.inviteLink.split('/').at(-1); + assert.notEqual(initialInvite,renewedInvite); + const accept = load('src/app/invite/[token]/actions.ts').acceptInviteAction; + const form = token => fields({ token, password: 'New-owner-password', confirmPassword: 'New-owner-password', acceptPrivacyPolicy: 'on' }); + assert.ok((await accept({ attempt: 0 }, form(initialInvite))).errors); + assert.equal((await accept({ attempt: 0 }, form(renewedInvite))).errors,undefined); + assert.ok((await prisma.user.findUnique({ where: { id: parentA.id } })).emailVerifiedAt); +}); +test('resending an invite cannot issue an active link to an obsolete mailbox', async () => { + const user = await prisma.user.create({ data: { email: `resend-race.${prefix}@example.invalid`, firstName: 'Race', lastName: 'Test', + role: 'ELTERN', kitaId: kitaA.id, familyId: familyA.id, passwordHash: '' } }); + // Interleave a committed address change after the resend's initial lookup. + const guardedPrisma = new Proxy(prisma, { get(target, property) { + if (property === '$transaction') return async operation => { + await prisma.user.update({ where: { id: user.id }, data: { email: `new-resend-race.${prefix}@example.invalid` } }); + return prisma.$transaction(operation); + }; + const value = target[property]; return typeof value === 'function' ? value.bind(target) : value; + } }); + const load = loader({ '@/auth': { auth: async () => session(adminA) }, '@/lib/mail': emailMock, '@/lib/prisma': { prisma: guardedPrisma } }); + const before = emails.length; + assert.ok((await load('src/app/dashboard/families/actions.ts').resendParentInvite(user.id)).error); + assert.equal(emails.length,before); + assert.equal(await prisma.verificationToken.count({ where: { identifier: user.id } }),0); +}); +test('upload quota holds under concurrent writes and accepted MIME comes from file bytes', async () => { + const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXZQAAAAASUVORK5CYII=', 'base64'); + const oldQuota = process.env.UPLOAD_TENANT_QUOTA_BYTES; + process.env.UPLOAD_TENANT_QUOTA_BYTES = String(bytes.length * 2); + const load = loader({ '@/auth': { auth: async () => session(adminA) }, '@/lib/mail': emailMock }); + const create = load('src/actions/announcements.ts').createAnnouncement; + const form = () => { const result = fields({ title: 'Quota test', content: 'Safe contents', audience: 'ALLE' }); result.append('attachments',new File([bytes],'test.png',{ type: 'image/png' })); return result; }; + try { + const results = await Promise.all([create(form()), create(form()), create(form())]); + assert.equal(results.filter(result => result.success).length,2); + const used = await prisma.attachment.aggregate({ where: { announcement: { kitaId: kitaA.id } }, _sum: { sizeBytes: true } }); + assert.equal(used._sum.sizeBytes,bytes.length * 2); + const bad = fields({ title: 'Quota bad', content: 'Unsafe contents' }); + bad.append('attachments',new File(['bad'],'bad.jpg',{ type: 'text/html' })); + assert.ok((await create(bad)).error); + } finally { + const files = await prisma.attachment.findMany({ where: { announcement: { kitaId: kitaA.id, title: 'Quota test' } }, select: { id: true } }); + await load('src/lib/attachments.ts').removeAttachmentFiles(files.map(file => file.id)); + await prisma.announcement.deleteMany({ where: { kitaId: kitaA.id, title: 'Quota test' } }); + if (oldQuota === undefined) delete process.env.UPLOAD_TENANT_QUOTA_BYTES; else process.env.UPLOAD_TENANT_QUOTA_BYTES = oldQuota; + } +}); + +test('all supplemental database tenant constraints are present', async () => { + const [row] = await prisma.$queryRaw`SELECT count(*)::int AS count FROM pg_constraint WHERE conname LIKE '%_tenant_fkey'`; + assert.equal(row.count,31); +}); +test('role guard authenticates once and keeps role validation on that session', async () => { + let calls = 0; + const util = loader({ '@/auth': { auth: async () => { calls++; return session(calls === 1 ? adminA : parentB); } } })('src/lib/auth-utils.ts'); + assert.equal((await util.requireRole([UserRole.ADMIN])).user.id,adminA.id); + assert.equal(calls,1); +}); +test('parallel alarm triggers send one set of messages and skip unverified recipients', async () => { + const unverified = await prisma.user.create({ data: { email: `unverified.${prefix}@example.invalid`, firstName: 'Unverified', lastName: 'Test', role: 'ELTERN', + kitaId: kitaA.id, familyId: familyA.id, passwordHash: '', emailVerifiedAt: null } }); + const plan = await prisma.notdienstPlan.create({ data: { kitaId: kitaA.id, year: 2029, month: 3, status: 'PUBLISHED' } }); + const assignment = await prisma.notdienstAssignment.create({ data: { kitaId: kitaA.id, planId: plan.id, childId: childA.id, date: new Date(Date.now() + 86400000) } }); + const trigger = loader({ '@/auth': { auth: async () => session(adminA) }, '@/lib/mail': emailMock })('src/app/dashboard/actions.ts').triggerAlertAction; + const before = emails.length; + const results = await Promise.all([trigger(assignment.id),trigger(assignment.id)]); + assert.equal(results.filter(result => result.success).length,1); + const alerts = await prisma.notdienstAlert.findMany({ where: { assignmentId: assignment.id } }); + assert.ok(alerts.length > 0); + assert.equal(alerts.some(alert => alert.parentUserId === unverified.id),false); + assert.equal(emails.slice(before).some(email => email.to === unverified.email),false); + assert.equal(emails.length - before,alerts.length); +});