Compare commits
16
Commits
5c2878875b
...
main
No files matched your search
+3
-1
@@ -26,7 +26,9 @@ BETTER_AUTH_SECRET=<anderer-zufaelliger-wert-mindestens-32-zeichen>
|
|||||||
# sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich)
|
# sk_test_… = Testmodus (Checkout ohne Live-Freigabe moeglich)
|
||||||
# sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben)
|
# sk_live_… = Livemodus (nur mit APP_ENV=production UND allen Freigaben)
|
||||||
# Empfohlen: eingeschraenkter Schluessel (rk_…) mit Schreibrechten fuer
|
# Empfohlen: eingeschraenkter Schluessel (rk_…) mit Schreibrechten fuer
|
||||||
# Customers, Checkout Sessions, PaymentIntents, PaymentMethods, Refunds.
|
# Customers, Checkout Sessions, PaymentIntents, PaymentMethods, Refunds und
|
||||||
|
# fuer die Folgeraten (Stripe Billing) zusaetzlich Products, Subscription
|
||||||
|
# Schedules, Subscriptions, Invoices (docs/BETRIEB.md, Abschnitt 4).
|
||||||
STRIPE_SECRET_KEY=<sk_test_…>
|
STRIPE_SECRET_KEY=<sk_test_…>
|
||||||
# Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md.
|
# Signaturgeheimnis des Webhook-Endpunkts (whsec_…), siehe docs/BETRIEB.md.
|
||||||
STRIPE_WEBHOOK_SECRET=<whsec_…>
|
STRIPE_WEBHOOK_SECRET=<whsec_…>
|
||||||
|
|||||||
@@ -7,14 +7,101 @@ name: Docker-Images bauen und veroeffentlichen
|
|||||||
#
|
#
|
||||||
# Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3"
|
# Ausloeser: Push auf main (":latest" + ":sha-<kurz>"), Versions-Tags "v1.2.3"
|
||||||
# (zusaetzlich ":1.2.3"), manuell ueber "Run workflow".
|
# (zusaetzlich ":1.2.3"), manuell ueber "Run workflow".
|
||||||
|
#
|
||||||
|
# Qualitaets-Gate: Images entstehen nur, wenn lint (inkl. audit), typecheck
|
||||||
|
# und test gruen sind. Die Pruefungen laufen auf "ci-isolated" (Jobs ohne
|
||||||
|
# Docker-Socket und ohne Clusternetz, Runner-Doku im VyCept-Repo,
|
||||||
|
# docs/BETRIEB.md 11b); nur der Image-Build braucht "docker-builder".
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Ein neuer Push bricht den ueberholten Lauf ab, statt ihn zu Ende zu rechnen.
|
||||||
|
concurrency:
|
||||||
|
group: docker-build-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm lint
|
||||||
|
# Bekannte Luecken in Laufzeit-Abhaengigkeiten (ab "high") brechen den Lauf ab.
|
||||||
|
- run: pnpm audit --prod --audit-level high
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ci-isolated
|
||||||
|
timeout-minutes: 15
|
||||||
|
# embedded-postgres startet nicht als root; deshalb ein Datenbank-Service
|
||||||
|
# (gleiche Version wie im Cluster), genutzt ueber TEST_DATABASE_URL
|
||||||
|
# (tests/support/global-setup.ts).
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.6-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: artista_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U test" --health-interval 5s --health-retries 10
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: postgresql://test:test@postgres:5432/artista_test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Runner-Isolation pruefen
|
||||||
|
run: bash scripts/ci-runner-isolation.sh
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with: { node-version: "22" }
|
||||||
|
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pnpm
|
||||||
|
~/.cache/pnpm-store
|
||||||
|
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
restore-keys: pnpm-
|
||||||
|
- run: export pnpm_config_store_dir="$HOME/.cache/pnpm-store" pnpm_config_cache_dir="$HOME/.cache/pnpm" && corepack enable && pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm test
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
runs-on: docker-builder
|
runs-on: docker-builder
|
||||||
env:
|
env:
|
||||||
# Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels).
|
# Oeffentlicher Registry-Host (Pull durch Kubernetes, OCI-Labels).
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
# ARTISTA MODELCAMP 2027 – Buchungswebsite
|
# ARTISTA MODELCAMP 2027 – Buchungswebsite
|
||||||
|
|
||||||
Deutschsprachige Onepage mit echter Buchungsstrecke: Stripe-Zahlung (einmalig oder in bis zu drei Raten), persistente Bestell- und Gutscheinverwaltung, geschützter Adminbereich mit Zwei-Faktor-Anmeldung, Docker- und RKE2-Deployment.
|
Deutschsprachige Onepage mit echter Buchungsstrecke: Stripe-Zahlung (einmalig oder in bis zu drei Raten), persistente Bestell- und Gutscheinverwaltung, Kontaktformular für ein Erstgespräch, geschützter Adminbereich mit Zwei-Faktor-Anmeldung, Docker- und RKE2-Deployment.
|
||||||
|
|
||||||
**Stand:** technisch vollständig, im Stripe-Testmodus buchbar. Der Live-Verkauf ist serverseitig gesperrt, bis alle Inhalte und Freigaben aus [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) im Adminbereich bestätigt sind.
|
**Stand:** technisch vollständig, im Stripe-Testmodus buchbar. Die Seite läuft im Cluster unter https://modelcamp.artistasphotography.de (Namespace `artista`). Der Live-Verkauf ist serverseitig gesperrt, bis alle Inhalte und Freigaben aus [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) im Adminbereich bestätigt sind.
|
||||||
|
|
||||||
|
## Projektstand
|
||||||
|
|
||||||
|
Was gebaut, geprüft und ausgerollt ist und was noch offen ist, steht im Übersichts-Issue [#33 „Projektstand und Übersicht“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/33). Aufgaben, die nur der Inhaber erledigen kann (Freigaben, Rechtstexte, Secrets, Rollouts), sammelt [#32 „Inhaber-Aufgaben“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/32). Jedes Arbeitspaket hat ein eigenes Issue.
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Design |
|
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Entscheidungen: Stack, Preisbindung, Ratenzahlung, Kapazität, Webhooks, Kontaktformular, Design |
|
||||||
| [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung |
|
| [docs/BETRIEB.md](docs/BETRIEB.md) | RKE2-Deployment, CI, Secrets, Stripe-Webhooks, E-Mail, Sicherung, Storno/Erstattung |
|
||||||
|
| [k8s/README.md](k8s/README.md) | Manifeste Schritt für Schritt anwenden, neues Image ausrollen, Fehlersuche |
|
||||||
|
| [docs/MEDIEN.md](docs/MEDIEN.md) | Dateinamen für Logo, Headerbild, Intro-Video, Porträts und Reels; Medien aufbereiten |
|
||||||
| [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) | Noch benötigte Inhalte und Freigaben |
|
| [docs/OFFENE-PUNKTE.md](docs/OFFENE-PUNKTE.md) | Noch benötigte Inhalte und Freigaben |
|
||||||
| [docs/TESTS.md](docs/TESTS.md) | Ausgeführte Tests und verbleibende Einschränkungen |
|
| [docs/TESTS.md](docs/TESTS.md) | Ausgeführte Tests und verbleibende Einschränkungen |
|
||||||
|
|
||||||
@@ -46,9 +52,14 @@ Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E
|
|||||||
| `pnpm typecheck` / `pnpm lint` | Typprüfung, ESLint |
|
| `pnpm typecheck` / `pnpm lint` | Typprüfung, ESLint |
|
||||||
| `pnpm test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) |
|
| `pnpm test` | Unit- und Integrationstests (startet eine eigene PostgreSQL-Instanz) |
|
||||||
| `pnpm test:e2e` | Playwright: Smartphone, Tablet, Desktop, axe-Barrierefreiheit (gegen laufenden Server) |
|
| `pnpm test:e2e` | Playwright: Smartphone, Tablet, Desktop, axe-Barrierefreiheit (gegen laufenden Server) |
|
||||||
|
| `pnpm db:dev` | Eingebettetes PostgreSQL für die Entwicklung (Port 54329) |
|
||||||
| `pnpm db:migrate` / `pnpm db:deploy` | Migration erstellen / anwenden |
|
| `pnpm db:migrate` / `pnpm db:deploy` | Migration erstellen / anwenden |
|
||||||
|
| `pnpm db:seed` | Nur Entwicklung: Early Bird simulieren, Test-Gutschein `TEST-STAMMMODEL` |
|
||||||
| `pnpm worker` | Einmaliger Worker-Lauf |
|
| `pnpm worker` | Einmaliger Worker-Lauf |
|
||||||
| `pnpm admin:create` | Admin-Konto anlegen (kein Standardpasswort) |
|
| `pnpm admin:create` | Admin-Konto anlegen (kein Standardpasswort) |
|
||||||
|
| `pnpm media:optimize <ordner>` | Videos und große Bilder vor dem Hochladen aufbereiten (siehe docs/MEDIEN.md) |
|
||||||
|
|
||||||
|
Bei jedem Push auf `main` prüft die CI (`.gitea/workflows/docker-build.yml`) zuerst `lint` samt `pnpm audit`, `typecheck` und `test`; erst wenn alle drei grün sind, baut sie die beiden Images. Die Playwright-Tests laufen nicht in der CI.
|
||||||
|
|
||||||
## Wo was gepflegt wird
|
## Wo was gepflegt wird
|
||||||
|
|
||||||
@@ -59,4 +70,5 @@ Testkarte `4242 4242 4242 4242`, für 3-D-Secure-Fälle `4000 0027 6000 3184`. E
|
|||||||
- **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben).
|
- **Feste Bilder:** `public/images/unterkunft/` (Villa, aus dem Booking.com-Eintrag, Nutzung vom Auftraggeber freigegeben).
|
||||||
- **Logo, Headerbild, Porträts, Reels:** feste Dateinamen im Medienverzeichnis, werden automatisch erkannt. Liste: [docs/MEDIEN.md](docs/MEDIEN.md).
|
- **Logo, Headerbild, Porträts, Reels:** feste Dateinamen im Medienverzeichnis, werden automatisch erkannt. Liste: [docs/MEDIEN.md](docs/MEDIEN.md).
|
||||||
- **Widerrufsbelehrung:** Adminbereich → Einstellungen, erscheint unter `/widerruf`.
|
- **Widerrufsbelehrung:** Adminbereich → Einstellungen, erscheint unter `/widerruf`.
|
||||||
|
- **Empfänger des Formulars „Erstgespräch“:** die Veranstalter-E-Mail aus dem Adminbereich, ersatzweise `ADMIN_NOTIFY_EMAIL`. Ist keine von beiden gesetzt, lehnt das Formular Anfragen ab.
|
||||||
- **Weitere Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
|
- **Weitere Medien:** Dateien ins Medienverzeichnis (`MEDIA_DIR`) legen und in `site.ts` als `{ kind: "image", src: "/media/…" }` eintragen.
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
# Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file.
|
# Ohne Docker: pnpm db:dev (eingebettetes PostgreSQL) + MAIL_TRANSPORT=file.
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:18.1-alpine
|
image: postgres:18.6-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: artista
|
POSTGRES_USER: artista
|
||||||
POSTGRES_PASSWORD: artista-dev
|
POSTGRES_PASSWORD: artista-dev
|
||||||
|
|||||||
@@ -92,3 +92,15 @@ Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, da
|
|||||||
## ADR-7: Adminbereich
|
## ADR-7: Adminbereich
|
||||||
|
|
||||||
Better Auth übernimmt E-Mail und Passwort, Registrierung ist ausgeschaltet. Konten entstehen nur über die Kommandozeile, TOTP-2FA ist Pflicht. Rate Limits der Anmeldung liegen in PostgreSQL, Passwörter werden gegen Datenlecks geprüft. Jede Seite, jede Server-Action und der CSV-Export prüfen Sitzung und 2FA selbst. Der Proxy leitet nur vorab um. Alle Buchungs-, Zahlungs- und Konfigurationsaktionen landen im AuditLog.
|
Better Auth übernimmt E-Mail und Passwort, Registrierung ist ausgeschaltet. Konten entstehen nur über die Kommandozeile, TOTP-2FA ist Pflicht. Rate Limits der Anmeldung liegen in PostgreSQL, Passwörter werden gegen Datenlecks geprüft. Jede Seite, jede Server-Action und der CSV-Export prüfen Sitzung und 2FA selbst. Der Proxy leitet nur vorab um. Alle Buchungs-, Zahlungs- und Konfigurationsaktionen landen im AuditLog.
|
||||||
|
|
||||||
|
## ADR-8: Kontaktformular „Erstgespräch“ (seit 06.10.2026)
|
||||||
|
|
||||||
|
- Eigener Abschnitt nach den Fragen (Anker `#erstgespraech`, `POST /api/contact`). Pflicht sind Name, E-Mail, Telefon (seit 07.10.2026, gleiche Regel wie bei der Buchung) und die Bestätigung der Datenschutzhinweise; Erreichbarkeit und Nachricht sind optional.
|
||||||
|
- Kein eigenes Datenmodell: Die Anfrage geht als E-Mail über den transaktionalen Postausgang an die Veranstalter-E-Mail aus dem Admin, ersatzweise `ADMIN_NOTIFY_EMAIL`. Ohne Empfänger lehnt das Formular ab.
|
||||||
|
- Bewusst keine automatische Bestätigungsmail an die angegebene Adresse, damit das Formular nicht zum Versand an Dritte missbraucht werden kann.
|
||||||
|
- Schutz wie bei der Buchung: nur JSON von der eigenen Seite, Rate Limit 5 Anfragen je Stunde und IP, Honeypot-Feld gegen Bots, doppeltes Absenden in derselben Minute ergibt eine Mail.
|
||||||
|
|
||||||
|
## ADR-9: Client-IP und Rate Limits (seit 12.10.2026)
|
||||||
|
|
||||||
|
- Rate Limits liegen in PostgreSQL (`RateLimitBucket`, gehashter IP-Schlüssel, Aufräumen nach einem Tag durch den Worker). IPv6-Adressen zählen je /64-Netz.
|
||||||
|
- Die Client-IP ist die Gegenstelle laut Traefik (letzter `X-Forwarded-For`-Eintrag). `CF-Connecting-IP` gilt nur als öffentliche Unicast-Adresse, `X-Real-IP` ohne `X-Forwarded-For` nur außerhalb der Produktion. Better Auth bekommt die IP ausschließlich über den serverseitig gesetzten Header `x-artista-client-ip`; ein vom Browser mitgeschickter Wert wird verworfen.
|
||||||
+22
-12
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
## 1. Images bauen
|
## 1. Images bauen
|
||||||
|
|
||||||
**Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“ beide Images und pusht sie in die Gitea-Registry:
|
**Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` läuft bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“. Zuerst prüfen drei Jobs auf dem isolierten Runner (`ci-isolated`, ohne Docker-Socket) den Code: `lint` (inklusive `pnpm audit --prod --audit-level high`), `typecheck` und `test` (mit PostgreSQL 18.6 als Service). Nur wenn alle drei grün sind, baut `build-and-push` auf dem Runner `docker-builder` beide Images und pusht sie in die Gitea-Registry:
|
||||||
|
|
||||||
- `main` ergibt die Tags `latest` und `sha-<kurz>`
|
- `main` ergibt die Tags `latest` und `sha-<kurz>`
|
||||||
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
|
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
|
||||||
|
|
||||||
Einmalig einrichten:
|
Einmalig einrichten:
|
||||||
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
|
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
|
||||||
- einen Runner mit dem Label `docker-builder` bereitstellen (wie bei Wabenhain)
|
- Runner mit den Labels `ci-isolated` (Prüfungen) und `docker-builder` (Image-Build) bereitstellen; im EnCry-Cluster sind beide vorhanden
|
||||||
|
|
||||||
Die Manifeste in `k8s/` nutzen den Tag `latest`. Für feste Versionen dort `:latest` durch z. B. `:0.1.0` ersetzen (`git tag v0.1.0 && git push origin v0.1.0`).
|
Die Manifeste in `k8s/` sind auf einen CI-Build gepinnt (`sha-<commit>`, Platzhalter `sha-REPLACE_ME` im Repo), nicht auf `latest`. Ausgerollt wird mit `kubectl set image`, siehe Abschnitt 3 und [k8s/README.md](../k8s/README.md).
|
||||||
|
|
||||||
|
Scheitert `build-and-push` beim Push mit `500 … cannot allocate memory`, lag das bisher an der Gitea-Registry, wenn gleichzeitig ein anderes Repo pushte. Dann den Lauf neu starten, sobald der andere Build fertig ist.
|
||||||
|
|
||||||
**Manuell:**
|
**Manuell:**
|
||||||
|
|
||||||
@@ -29,24 +31,32 @@ Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber
|
|||||||
- der Worker-CronJob, nötig für Reservierungsfreigabe, Abgleich der Folgeraten und Mail-Wiederholung
|
- der Worker-CronJob, nötig für Reservierungsfreigabe, Abgleich der Folgeraten und Mail-Wiederholung
|
||||||
- ConfigMap
|
- ConfigMap
|
||||||
- zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
|
- zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
|
||||||
|
- der Sicherungs-CronJob `artista-db-backup` (täglicher `pg_dump`, Abschnitt 7)
|
||||||
|
|
||||||
Eine eigene Sicherung im Cluster gibt es nicht, die SMB-Freigabe wird auf dem NAS gesichert.
|
**Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-namespace` bis `09-db-backup-cronjob`, Namensschema wie bei Wabenhain) und werden in dieser Reihenfolge einzeln angewendet. Die Secrets kommen aus `k8s/02-secret.yaml.example` und werden nicht eingecheckt. Pod Security: Erzwungen wird `baseline`, damit der Zertifikats-Solver von cert-manager starten kann. `restricted` wird nur gemeldet, die eigenen Pods erfüllen es.
|
||||||
|
|
||||||
**Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-namespace` bis `08-networkpolicy`, Namensschema wie bei Wabenhain) und werden in dieser Reihenfolge einzeln angewendet. Die Secrets kommen aus `k8s/02-secret.yaml.example` und werden nicht eingecheckt. Pod Security: Erzwungen wird `baseline`, damit der Zertifikats-Solver von cert-manager starten kann. `restricted` wird nur gemeldet, die eigenen Pods erfüllen es.
|
Im EnCry-Cluster liegen die SMB-Freigaben auf der Hetzner Storage Box, nicht auf einem lokalen NAS; „NAS“ meint im Folgenden diese Freigabe. Im Namespace `artista` läuft außerdem Artista-Telefonie (eigenes Repo `Artista-TeleTracking`). Sie nutzt eine eigene Datenbank `telefonie` in derselben PostgreSQL-Instanz `artista-modelcamp-postgres`. Wer die Instanz oder den Namespace anfasst, trifft beide Anwendungen.
|
||||||
|
|
||||||
Zu PostgreSQL:
|
Zu PostgreSQL:
|
||||||
- **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden.
|
- **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden.
|
||||||
- **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar.
|
- **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar.
|
||||||
- **Sicherung:** Die NAS-Sicherung ist für die laufende Datenbank nur mit **atomaren Snapshots** der Freigabe (Btrfs/ZFS) konsistent. Zusätzlicher Schutz bei Bedarf: gelegentlich einen `pg_dump` ziehen (Abschnitt 7).
|
- **Sicherung:** Eine Kopie der Freigabe ist für die laufende Datenbank nur mit **atomaren Snapshots** (Btrfs/ZFS) konsistent. Deshalb zieht der CronJob `artista-db-backup` jede Nacht einen `pg_dump` (Abschnitt 7).
|
||||||
|
- **Nach SMB-Unterbrechungen** melden lang laufende Postgres-Prozesse gelegentlich `could not seek to end of file … Bad file descriptor`. Abhilfe: `kubectl -n artista rollout restart sts/artista-modelcamp-postgres` (fährt sauber herunter).
|
||||||
|
|
||||||
## 3. Update ausrollen
|
## 3. Update ausrollen
|
||||||
|
|
||||||
|
App, Migration und Worker immer mit demselben Tag `sha-<commit>` aus der CI ausrollen (genauer Befehl in [k8s/README.md](../k8s/README.md), Abschnitt „Bei einem neuen Image“):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl -n artista rollout restart deploy/artista-modelcamp-app
|
TAG=sha-<commit>
|
||||||
|
kubectl -n artista set image deployment/artista-modelcamp-app migrate=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG app=git.encry.de/nick.rosenloecher/artista-modelcamp:$TAG
|
||||||
|
kubectl -n artista set image cronjob/artista-modelcamp-worker worker=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG
|
||||||
kubectl -n artista rollout status deploy/artista-modelcamp-app
|
kubectl -n artista rollout status deploy/artista-modelcamp-app
|
||||||
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe
|
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ein `rollout restart` allein holt kein neues Image, weil die Manifeste auf einen festen Tag zeigen.
|
||||||
|
|
||||||
Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter.
|
Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter.
|
||||||
|
|
||||||
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
|
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
|
||||||
@@ -85,10 +95,10 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
|
|||||||
|
|
||||||
## 7. Sicherung und Wiederherstellung
|
## 7. Sicherung und Wiederherstellung
|
||||||
|
|
||||||
- **Sicherung:** Die NAS-Sicherung der SMB-Freigabe übernimmt das Speichersystem. Für PostgreSQL gilt das nur mit atomaren Snapshots, siehe Abschnitt 2. Im Cluster läuft kein Backup-Job.
|
- **Täglicher Dump im Cluster:** Der CronJob `artista-db-backup` (`k8s/09-db-backup-cronjob.yaml`) läuft um 02:25 Uhr (Zeitzone des Clusters) und schreibt `pg_dump -Fc` der Datenbank `artista` in die PVC `artista-db-backup` (StorageClass `smb-backup`, Ordner `Backups/artista/` auf der Storage Box). Aufbewahrung: 14 Tage täglich (`daily/`), 8 Wochen sonntags (`weekly/`). Die Datenbank `telefonie` derselben Instanz ist darin nicht enthalten. Stand prüfen: `kubectl -n artista get cronjob artista-db-backup` und `kubectl -n artista get jobs | grep artista-db-backup`.
|
||||||
- **Logischer Dump** (empfohlen vor Updates und vor dem Live-Verkauf):
|
- **Logischer Dump von Hand** (zusätzlich vor Updates und vor dem Live-Verkauf):
|
||||||
```bash
|
```bash
|
||||||
POD=$(kubectl -n artista get pod -l app.kubernetes.io/name=artista-modelcamp-postgres -o name | head -1)
|
POD=$(kubectl -n artista get pod -l app=artista-modelcamp-postgres -o name | head -1)
|
||||||
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
|
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
|
||||||
```
|
```
|
||||||
- **Wiederherstellen:**
|
- **Wiederherstellen:**
|
||||||
@@ -96,8 +106,8 @@ Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artist
|
|||||||
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
|
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
|
||||||
3. App wieder hochskalieren (der initContainer migriert dabei), Worker fortsetzen.
|
3. App wieder hochskalieren (der initContainer migriert dabei), Worker fortsetzen.
|
||||||
4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“).
|
4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“).
|
||||||
- **Medien:** liegen im SMB-Ordner `modelcamp-media` und sind in der NAS-Sicherung enthalten.
|
- **Medien:** liegen im SMB-Ordner `modelcamp-media`; gesichert sind sie nur über die Sicherung der Freigabe selbst.
|
||||||
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace.
|
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace. Das ist noch nicht geschehen.
|
||||||
|
|
||||||
## 8. Stornierung und Erstattung
|
## 8. Stornierung und Erstattung
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Noch benötigte Inhalte und Freigaben
|
# Noch benötigte Inhalte und Freigaben
|
||||||
|
|
||||||
Stand 01.10.2026. Bereits bestätigt und per Migration `20261001120000_camp_2027_bestaetigt` eingetragen (im Admin änderbar):
|
Stand 16.10.2026. Dieselben Punkte stehen mit Verweis auf die Detail-Issues in der Gitea-Sammelliste [#32 „Inhaber-Aufgaben“](https://git.encry.de/Nick.Rosenloecher/Artista-Filmcamp/issues/32).
|
||||||
|
|
||||||
|
Bereits bestätigt und per Migration `20261001120000_camp_2027_bestaetigt` bzw. `20261002090000_termin_mai_2027` eingetragen (im Admin änderbar). Damit sind im Admin die Freigaben Termin, Ort, Unterkunft und Platzzahl gesetzt, sofern sie dort niemand zurückgenommen hat:
|
||||||
|
|
||||||
- Termin 03.05. bis 09.05.2027 (laut Wochenplan vom 02.10.2026), Villa La Chiesina, Arcidosso (Toskana)
|
- Termin 03.05. bis 09.05.2027 (laut Wochenplan vom 02.10.2026), Villa La Chiesina, Arcidosso (Toskana)
|
||||||
- Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer
|
- Unterbringung: eigenes Bett im geteilten Zimmer, keine Einzelzimmer
|
||||||
@@ -9,7 +11,7 @@ Stand 01.10.2026. Bereits bestätigt und per Migration `20261001120000_camp_2027
|
|||||||
- Tagesablauf aller sieben Tage, Bustransfer ab und bis Florenz, Verpflegung (Frühstück, Snacks, Abendessen)
|
- Tagesablauf aller sieben Tage, Bustransfer ab und bis Florenz, Verpflegung (Frühstück, Snacks, Abendessen)
|
||||||
- Early Bird 01.11. bis 31.12.2026, ohne Anzeige eines Verkaufsstarts
|
- Early Bird 01.11. bis 31.12.2026, ohne Anzeige eines Verkaufsstarts
|
||||||
|
|
||||||
Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“.
|
Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf serverseitig gesperrt. Der aktuelle Stand steht im Admin-Dashboard unter „Fehlt für den Live-Verkauf“. Sind alle Freigaben erteilt und ist der Live-Verkauf eingeschaltet, startet der Verkauf sofort zum Early-Bird-Preis; die Early-Bird-Phase läuft vom 01.11. bis 31.12.2026.
|
||||||
|
|
||||||
## Inhalte
|
## Inhalte
|
||||||
|
|
||||||
@@ -40,7 +42,8 @@ Solange die mit **(Freigabe)** markierten Punkte fehlen, bleibt der Live-Verkauf
|
|||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt.
|
- Stripe-Konto live verifiziert, Webhook-Endpunkte (Test und Live) mit Secrets angelegt, Billing-Einstellungen für Folgeraten gesetzt (docs/BETRIEB.md, Abschnitt 4).
|
||||||
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain.
|
- SMTP-Anbieter sowie SPF, DKIM und DMARC der Absenderdomain; Veranstalter-E-Mail oder `ADMIN_NOTIFY_EMAIL` gesetzt, sonst lehnt das Formular „Erstgespräch“ Anfragen ab.
|
||||||
- Domain, TLS und StorageClasses im Cluster (siehe docs/BETRIEB.md).
|
- Erledigt: Domain `modelcamp.artistasphotography.de`, TLS über cert-manager, SMB-Speicher und die StorageClass `smb-backup` im Cluster.
|
||||||
- Wiederherstellung aus der Sicherung einmal geübt.
|
- Wiederherstellung aus der Sicherung einmal geübt (noch offen).
|
||||||
|
- Optional: Google-Rezensionen (`GOOGLE_PLACES_API_KEY`, `GOOGLE_PLACE_ID` im Secret); ohne Werte bleibt der Abschnitt ausgeblendet.
|
||||||
+11
-6
@@ -1,10 +1,14 @@
|
|||||||
# Tests und Abnahme
|
# Tests und Abnahme
|
||||||
|
|
||||||
Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden.
|
Stand 16.10.2026. Die Abschnitte unten sind chronologisch gewachsen; der aktuelle Stand steht hier oben.
|
||||||
|
|
||||||
|
**Aktuell:** `pnpm test` umfasst 10 Testdateien (rund 130 Tests; 132 beim lokalen Lauf am 14.10.2026 zu Commit `f29dbcf`, seither kam mit `9785eb2` ein Regressionstest hinzu). Seit `0421d7d` (14.10.2026) laufen `lint` mit `pnpm audit`, `typecheck` und `test` bei jedem Push auf `main` in der CI; der Test-Job nutzt PostgreSQL 18.6 als Service (`TEST_DATABASE_URL`). Letzter CI-Lauf (`9785eb2`, 15.10.2026): lint, typecheck und test grün, nur der Image-Build schlug fehl (Issue #20). Die Playwright-Tests (`pnpm test:e2e`, 3 Tests je Profil) laufen nur lokal.
|
||||||
|
|
||||||
|
Seit dem ersten Stand hinzugekommene Testdateien: `client-ip.test.ts` (Client-IP hinter Traefik und Cloudflare, IPv6 je /64, Proxy um NextRequest), `contact.integration.test.ts` (Formular „Erstgespräch“), `email-outbox.integration.test.ts` (Postausgang, endgültige und vorübergehende SMTP-Fehler), `review-format.test.ts` (Google-Rezensionen).
|
||||||
|
|
||||||
## Automatisierte Tests
|
## Automatisierte Tests
|
||||||
|
|
||||||
`pnpm test` umfasst 72 Tests in 6 Dateien. Sie laufen gegen eine echte, frisch gestartete PostgreSQL-18-Instanz, damit Zeilensperren und Trigger mitgetestet werden.
|
Stand 27.09.2026: `pnpm test` umfasste 72 Tests in 6 Dateien. Sie laufen gegen eine echte, frisch gestartete PostgreSQL-18-Instanz, damit Zeilensperren und Trigger mitgetestet werden.
|
||||||
|
|
||||||
| Geschäftsrisiko | Datei |
|
| Geschäftsrisiko | Datei |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -33,7 +37,7 @@ Stand 27.09.2026. Alle aufgeführten Läufe sind lokal ausgeführt und bestanden
|
|||||||
|
|
||||||
Bildschirmfotos aller drei Größen wurden visuell geprüft.
|
Bildschirmfotos aller drei Größen wurden visuell geprüft.
|
||||||
|
|
||||||
Letzter Lauf 01.10.2026 nach Einbau von Team, Villa-Bildern und bestätigtem Termin: 81/81 Unit- und Integrationstests, 9/9 E2E-Tests, Produktionsbuild grün. Team und Unterkunft wurden zusätzlich per Bildschirmfoto auf Smartphone (390 px) und Desktop (1440 px) geprüft. Mit Termin 08.05.2027 fällt die letzte Rate auf den 08.04.2027.
|
Letzter Lauf 01.10.2026 nach Einbau von Team, Villa-Bildern und bestätigtem Termin: 81/81 Unit- und Integrationstests, 9/9 E2E-Tests, Produktionsbuild grün. Team und Unterkunft wurden zusätzlich per Bildschirmfoto auf Smartphone (390 px) und Desktop (1440 px) geprüft. Mit dem damaligen Termin 08.05.2027 fiel die letzte Rate auf den 08.04.2027; seit dem Termin 03.05.2027 (Migration vom 02.10.2026) ist es der 03.04.2027.
|
||||||
|
|
||||||
Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der Same-Origin-Schutz die Preisabfrage ab (403). Dann hängt der Buchungsdialog bei „Preise werden geladen …“. Server deshalb mit passender `APP_URL` starten, z. B. `APP_URL=http://localhost:3005 PORT=3005 pnpm start` und `E2E_BASE_URL=http://localhost:3005 pnpm test:e2e`.
|
Hinweis: Läuft der Testserver nicht unter der Adresse aus `APP_URL`, lehnt der Same-Origin-Schutz die Preisabfrage ab (403). Dann hängt der Buchungsdialog bei „Preise werden geladen …“. Server deshalb mit passender `APP_URL` starten, z. B. `APP_URL=http://localhost:3005 PORT=3005 pnpm start` und `E2E_BASE_URL=http://localhost:3005 pnpm test:e2e`.
|
||||||
|
|
||||||
@@ -53,7 +57,8 @@ Lauf 06.10.2026 nach Umstellung der Folgeraten auf Stripe Billing (inkl. Anhalte
|
|||||||
|
|
||||||
## Nicht ausgeführt / Einschränkungen
|
## Nicht ausgeführt / Einschränkungen
|
||||||
|
|
||||||
- **Echte Stripe-Testbuchung** gegen die Stripe-API: Ein Stripe-Testschlüssel lag nicht vor. Die Stripe-Logik ist gegen eine Attrappe mit Stripe-Semantik getestet (Idempotenz, Session-Zustände, Rechnungen des Ratenplans), die Webhook-Signatur mit der echten Stripe-Bibliothek. Mit Schlüssel: Anleitung in der README (`stripe listen`, Testkarten).
|
- **Vollständige Stripe-Testbuchung** über die Oberfläche bis zum Webhook: In der Testsuite läuft die Stripe-Logik gegen eine Attrappe mit Stripe-Semantik (Idempotenz, Session-Zustände, Rechnungen des Ratenplans), die Webhook-Signatur mit der echten Stripe-Bibliothek. Gegen die echte Stripe-Test-API geprüft ist bisher nur der Ratenplan (`StripeGateway` mit Testuhr, Lauf vom 06.10.2026). Ein dokumentierter Durchlauf Buchung → Checkout → Webhook → Bestätigungsmail auf dem Cluster fehlt (Issue #27). Anleitung lokal in der README (`stripe listen`, Testkarten).
|
||||||
- **Docker-Image und Cluster:** Auf dem Entwicklungsrechner gibt es kein Docker und keinen Cluster-Zugang. Das Dockerfile ist nicht gebaut und nicht im Cluster ausgerollt. Geprüft sind der Standalone-Build und das Rendern der Manifeste.
|
- **Docker-Image und Cluster:** Seit dem 27.09.2026 baut die CI beide Images; die Seite läuft im Cluster (zuletzt belegt ausgerollt: `sha-5a88f3d`, 13.10.2026). Eine automatisierte Prüfung nach dem Rollout gibt es nicht; der Monitoring-Dienst Gatus im Cluster prüft die Erreichbarkeit.
|
||||||
- **SMTP-Versand:** nur über den Datei-Testausgang geprüft.
|
- **SMTP-Versand:** im Repo nur über den Datei-Testausgang geprüft. Ob im Cluster ein echter SMTP-Zugang eingetragen ist, ist hier nicht dokumentiert.
|
||||||
|
- **E2E-Tests** laufen nicht in der CI.
|
||||||
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).
|
- **Referenzseite:** nur über eine Textzusammenfassung ausgewertet (siehe ARCHITEKTUR.md).
|
||||||
@@ -23,7 +23,7 @@ spec:
|
|||||||
seccompProfile: { type: RuntimeDefault }
|
seccompProfile: { type: RuntimeDefault }
|
||||||
containers:
|
containers:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
image: postgres:18.1-alpine
|
image: postgres:18.6-alpine
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5432
|
- containerPort: 5432
|
||||||
env:
|
env:
|
||||||
@@ -41,7 +41,7 @@ spec:
|
|||||||
exec: { command: ["pg_isready", "-U", "artista"] }
|
exec: { command: ["pg_isready", "-U", "artista"] }
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 100m, memory: 256Mi }
|
requests: { cpu: "0", memory: "0" }
|
||||||
limits: { memory: 1Gi }
|
limits: { memory: 1Gi }
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -24,20 +24,22 @@ spec:
|
|||||||
seccompProfile: { type: RuntimeDefault }
|
seccompProfile: { type: RuntimeDefault }
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest
|
# Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
|
||||||
imagePullPolicy: Always
|
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
command: ["prisma", "migrate", "deploy"]
|
command: ["prisma", "migrate", "deploy"]
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef: { name: artista-modelcamp-secrets }
|
- secretRef: { name: artista-modelcamp-secrets }
|
||||||
resources:
|
resources:
|
||||||
|
requests: { cpu: "0", memory: "0" }
|
||||||
limits: { memory: 512Mi }
|
limits: { memory: 512Mi }
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
capabilities: { drop: ["ALL"] }
|
capabilities: { drop: ["ALL"] }
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp:latest
|
image: git.encry.de/nick.rosenloecher/artista-modelcamp:sha-REPLACE_ME
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 3000
|
containerPort: 3000
|
||||||
@@ -52,7 +54,7 @@ spec:
|
|||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
periodSeconds: 20
|
periodSeconds: 20
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 200m, memory: 256Mi }
|
requests: { cpu: "0", memory: "0" }
|
||||||
limits: { memory: 768Mi }
|
limits: { memory: 768Mi }
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Alle 10 Minuten: faellige Raten einziehen, abgelaufene Reservierungen
|
# Alle 10 Minuten: abgelaufene Reservierungen freigeben, fehlende
|
||||||
# freigeben, E-Mails nachsenden. Ohne ihn funktioniert Ratenzahlung nicht.
|
# Stripe-Ratenplaene anlegen, ueberfaellige Raten mit Stripe abgleichen,
|
||||||
|
# E-Mails nachsenden (Einzug der Folgeraten macht Stripe Billing,
|
||||||
|
# docs/ARCHITEKTUR.md ADR-3). Ohne ihn funktioniert Ratenzahlung nicht.
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: CronJob
|
kind: CronJob
|
||||||
metadata:
|
metadata:
|
||||||
@@ -10,8 +12,13 @@ spec:
|
|||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
jobTemplate:
|
jobTemplate:
|
||||||
spec:
|
spec:
|
||||||
backoffLimit: 1
|
# Kein Sofort-Neustart: der naechste Lauf kommt nach 10 Minuten ohnehin,
|
||||||
|
# ein zweiter Pod je Fehlschlag verdoppelt nur Fehler-Pods und Versuche.
|
||||||
|
backoffLimit: 0
|
||||||
activeDeadlineSeconds: 900
|
activeDeadlineSeconds: 900
|
||||||
|
# Beendete Jobs samt Pods nach einem Tag entfernen; fehlgeschlagene
|
||||||
|
# blieben sonst bis zum naechsten Fehlschlag stehen (failedJobsHistoryLimit 3).
|
||||||
|
ttlSecondsAfterFinished: 86400
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
@@ -25,13 +32,15 @@ spec:
|
|||||||
seccompProfile: { type: RuntimeDefault }
|
seccompProfile: { type: RuntimeDefault }
|
||||||
containers:
|
containers:
|
||||||
- name: worker
|
- name: worker
|
||||||
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest
|
# Gepinnt auf einen CI-Build (sha-<commit>), nicht :latest. Ausrollen siehe README.
|
||||||
imagePullPolicy: Always
|
image: git.encry.de/nick.rosenloecher/artista-modelcamp-tools:sha-REPLACE_ME
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
command: ["tsx", "scripts/worker.ts"]
|
command: ["tsx", "scripts/worker.ts"]
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef: { name: artista-modelcamp-config }
|
- configMapRef: { name: artista-modelcamp-config }
|
||||||
- secretRef: { name: artista-modelcamp-secrets }
|
- secretRef: { name: artista-modelcamp-secrets }
|
||||||
resources:
|
resources:
|
||||||
|
requests: { cpu: "0", memory: "0" }
|
||||||
limits: { memory: 512Mi }
|
limits: { memory: 512Mi }
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Taegliche pg_dump-Sicherung der Modelcamp-Datenbank auf die Storage Box.
|
||||||
|
# Voraussetzung im Cluster: StorageClass smb-backup (SMB 3.1.1 mit seal),
|
||||||
|
# legt pro PVC einen Ordner unter Server/Backups/<namespace>/ an.
|
||||||
|
# Aufbewahrung: 14 Tage taeglich, 8 Wochen sonntags.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: artista-db-backup
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteMany]
|
||||||
|
storageClassName: smb-backup
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 10Gi
|
||||||
|
---
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: artista-db-backup
|
||||||
|
namespace: artista
|
||||||
|
spec:
|
||||||
|
schedule: "25 2 * * *"
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
successfulJobsHistoryLimit: 3
|
||||||
|
failedJobsHistoryLimit: 3
|
||||||
|
jobTemplate:
|
||||||
|
spec:
|
||||||
|
backoffLimit: 1
|
||||||
|
activeDeadlineSeconds: 1800
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: artista-db-backup
|
||||||
|
db-client: "true"
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 70
|
||||||
|
runAsGroup: 70
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: backup
|
||||||
|
image: postgres:18.6-alpine
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
stamp=$(date +%Y-%m-%d)
|
||||||
|
kind=daily; [ "$(date +%u)" = "7" ] && kind=weekly
|
||||||
|
mkdir -p /backups/daily /backups/weekly
|
||||||
|
pg_dump -h artista-modelcamp-postgres -Fc -f /backups/$kind/artista-$stamp.dump.tmp
|
||||||
|
mv /backups/$kind/artista-$stamp.dump.tmp /backups/$kind/artista-$stamp.dump
|
||||||
|
find /backups/daily -name '*.dump' -mtime +14 -delete
|
||||||
|
find /backups/weekly -name '*.dump' -mtime +56 -delete
|
||||||
|
ls -l /backups/$kind | tail -3
|
||||||
|
env:
|
||||||
|
- {name: PGUSER, value: artista}
|
||||||
|
- {name: PGDATABASE, value: artista}
|
||||||
|
- name: PGPASSWORD
|
||||||
|
valueFrom: {secretKeyRef: {name: artista-modelcamp-secrets, key: DB_PASSWORD}}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: "0", memory: "0"}
|
||||||
|
limits: {memory: 512Mi}
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: {drop: [ALL]}
|
||||||
|
volumeMounts:
|
||||||
|
- {name: backups, mountPath: /backups}
|
||||||
|
volumes:
|
||||||
|
- name: backups
|
||||||
|
persistentVolumeClaim: {claimName: artista-db-backup}
|
||||||
+19
-3
@@ -4,6 +4,7 @@ Rohe Manifeste wie bei Wabenhain, Nummerierung = Reihenfolge. Voraussetzungen im
|
|||||||
- **Traefik** als Ingress-Controller (IngressClass `traefik`)
|
- **Traefik** als Ingress-Controller (IngressClass `traefik`)
|
||||||
- **cert-manager** mit dem ClusterIssuer `letsencrypt-prod`
|
- **cert-manager** mit dem ClusterIssuer `letsencrypt-prod`
|
||||||
- **csi-driver-smb** für die SMB-Volumes
|
- **csi-driver-smb** für die SMB-Volumes
|
||||||
|
- die StorageClass **`smb-backup`** für den Sicherungs-CronJob (`09-db-backup-cronjob.yaml`); im EnCry-Cluster seit 12.10.2026 vorhanden
|
||||||
|
|
||||||
Prüfen:
|
Prüfen:
|
||||||
|
|
||||||
@@ -11,8 +12,11 @@ Prüfen:
|
|||||||
kubectl get ingressclass
|
kubectl get ingressclass
|
||||||
kubectl get clusterissuer letsencrypt-prod
|
kubectl get clusterissuer letsencrypt-prod
|
||||||
kubectl get csidrivers | grep smb
|
kubectl get csidrivers | grep smb
|
||||||
|
kubectl get storageclass smb-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Im EnCry-Cluster läuft die Seite unter `modelcamp.artistasphotography.de`. Der DNS-Eintrag ist bei Cloudflare bewusst nicht geproxyt (DNS-only), deshalb bekommt dieser Ingress keine `cloudflare-only`-Middleware. Im selben Namespace läuft Artista-Telefonie (Repo `Artista-TeleTracking`) mit einer eigenen Datenbank `telefonie` in `artista-modelcamp-postgres`.
|
||||||
|
|
||||||
## Einmalige Vorbereitung
|
## Einmalige Vorbereitung
|
||||||
|
|
||||||
1. **Images:** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` die Images `git.encry.de/nick.rosenloecher/artista-modelcamp:latest` und `…-tools:latest`. Voraussetzung ist das Repo-Secret `REGISTRY_TOKEN` (Personal Access Token mit `package`-Scope), wie bei Wabenhain.
|
1. **Images:** `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` die Images `git.encry.de/nick.rosenloecher/artista-modelcamp:latest` und `…-tools:latest`. Voraussetzung ist das Repo-Secret `REGISTRY_TOKEN` (Personal Access Token mit `package`-Scope), wie bei Wabenhain.
|
||||||
@@ -22,8 +26,9 @@ kubectl get csidrivers | grep smb
|
|||||||
helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system
|
helm install csi-driver-smb csi-driver-smb/csi-driver-smb -n kube-system
|
||||||
```
|
```
|
||||||
3. **NAS:** Die Ordner `artista/modelcamp-postgres` (leer) und `artista/modelcamp-media` anlegen und SMB-Verschlüsselung erlauben. `nas.example.lan` in `03-storage.yaml` anpassen.
|
3. **NAS:** Die Ordner `artista/modelcamp-postgres` (leer) und `artista/modelcamp-media` anlegen und SMB-Verschlüsselung erlauben. `nas.example.lan` in `03-storage.yaml` anpassen.
|
||||||
4. **Domain:** `modelcamp.example.org` in `01-configmap.yaml` (APP_URL) und `06-ingress.yaml` (2×) ersetzen. Der DNS-Eintrag muss auf den Cluster zeigen, sonst stellt cert-manager kein Zertifikat aus.
|
4. **Domain:** `modelcamp.example.org` in `01-configmap.yaml` (APP_URL) und `06-ingress.yaml` (2×) ersetzen (im EnCry-Cluster `modelcamp.artistasphotography.de`). Der DNS-Eintrag muss auf den Cluster zeigen, sonst stellt cert-manager kein Zertifikat aus.
|
||||||
5. **Secrets:** `cp k8s/02-secret.yaml.example k8s/02-secret.yaml` und ausfüllen. Die Datei steht in `.gitignore`, bitte **nie** mit echten Werten committen.
|
5. **Secrets:** `cp k8s/02-secret.yaml.example k8s/02-secret.yaml` und ausfüllen. Die Datei steht in `.gitignore`, bitte **nie** mit echten Werten committen.
|
||||||
|
6. **Image-Tag:** In `05-app-deployment.yaml` (2×) und `07-worker-cronjob.yaml` steht der Platzhalter `sha-REPLACE_ME`. Vor dem ersten Anwenden durch den Tag des letzten grünen CI-Laufs ersetzen (`sha-<commit>`, 7 Zeichen) oder nach dem Anwenden wie unter „Bei einem neuen Image“ setzen.
|
||||||
|
|
||||||
## Deployment (auch für einen kompletten Neuaufbau)
|
## Deployment (auch für einen kompletten Neuaufbau)
|
||||||
|
|
||||||
@@ -38,6 +43,7 @@ kubectl apply -f k8s/05-app-deployment.yaml
|
|||||||
kubectl apply -f k8s/06-ingress.yaml
|
kubectl apply -f k8s/06-ingress.yaml
|
||||||
kubectl apply -f k8s/07-worker-cronjob.yaml
|
kubectl apply -f k8s/07-worker-cronjob.yaml
|
||||||
kubectl apply -f k8s/08-networkpolicy.yaml
|
kubectl apply -f k8s/08-networkpolicy.yaml
|
||||||
|
kubectl apply -f k8s/09-db-backup-cronjob.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Migration läuft automatisch als initContainer vor dem App-Start. Den Stand zeigen:
|
Die Migration läuft automatisch als initContainer vor dem App-Start. Den Stand zeigen:
|
||||||
@@ -47,7 +53,7 @@ kubectl rollout status deployment/artista-modelcamp-app -n artista
|
|||||||
kubectl get certificate -n artista # READY muss True werden (meist < 2 Minuten)
|
kubectl get certificate -n artista # READY muss True werden (meist < 2 Minuten)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Neuaufbau mit `kubectl delete namespace artista`:** Die PersistentVolumes (`artista-modelcamp-postgres`, `artista-modelcamp-media`) gehören keinem Namespace und bleiben auf `Released` stehen. Die Daten auf dem NAS bleiben erhalten (`Retain`). Damit die neuen Claims sie wieder binden können, vor dem erneuten Anlegen:
|
**Neuaufbau mit `kubectl delete namespace artista`:** Achtung, das löscht auch Artista-Telefonie samt deren Secret; deren Datenbank liegt auf demselben Postgres-Volume. Die PersistentVolumes (`artista-modelcamp-postgres`, `artista-modelcamp-media`) gehören keinem Namespace und bleiben auf `Released` stehen. Die Daten auf dem NAS bleiben erhalten (`Retain`). Damit die neuen Claims sie wieder binden können, vor dem erneuten Anlegen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl patch pv artista-modelcamp-postgres -p '{"spec":{"claimRef":null}}'
|
kubectl patch pv artista-modelcamp-postgres -p '{"spec":{"claimRef":null}}'
|
||||||
@@ -68,10 +74,20 @@ kubectl -n artista run admin-create --rm -i --restart=Never \
|
|||||||
|
|
||||||
## Bei einem neuen Image (Redeploy)
|
## Bei einem neuen Image (Redeploy)
|
||||||
|
|
||||||
|
Die Manifeste sind auf einen Build gepinnt, nicht auf `:latest`. Den Tag `sha-<commit>` (7 Zeichen) zeigt der CI-Lauf in der Zusammenfassung. App und Werkzeuge immer mit demselben Tag ausrollen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl rollout restart deployment/artista-modelcamp-app -n artista
|
TAG=sha-<commit>
|
||||||
|
kubectl -n artista set image deployment/artista-modelcamp-app \
|
||||||
|
migrate=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG \
|
||||||
|
app=git.encry.de/nick.rosenloecher/artista-modelcamp:$TAG
|
||||||
|
kubectl -n artista set image cronjob/artista-modelcamp-worker \
|
||||||
|
worker=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG
|
||||||
|
kubectl -n artista rollout status deployment/artista-modelcamp-app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Zurueck auf den vorherigen Stand: `kubectl -n artista rollout undo deployment/artista-modelcamp-app` (den CronJob dann wieder auf den alten Tag setzen).
|
||||||
|
|
||||||
## Fehlersuche
|
## Fehlersuche
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+3
-3
@@ -27,14 +27,14 @@
|
|||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"better-auth": "1.7.6",
|
"better-auth": "1.7.6",
|
||||||
"motion": "13.4.4",
|
"motion": "13.4.4",
|
||||||
"next": "16.3.6",
|
"next": "16.3.8",
|
||||||
"nodemailer": "10.0.10",
|
"nodemailer": "10.0.10",
|
||||||
"pg": "8.23.0",
|
"pg": "8.23.0",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"react": "19.3.0",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.3.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "0.35.4",
|
"sharp": "0.35.5",
|
||||||
"stripe": "22.6.2",
|
"stripe": "22.6.2",
|
||||||
"zod": "4.6.5"
|
"zod": "4.6.5"
|
||||||
},
|
},
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"dotenv": "18.0.4",
|
"dotenv": "18.0.4",
|
||||||
"embedded-postgres": "18.4.0-beta.17",
|
"embedded-postgres": "18.4.0-beta.17",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-config-next": "16.3.6",
|
"eslint-config-next": "16.3.8",
|
||||||
"ffmpeg-static": "5.3.0",
|
"ffmpeg-static": "5.3.0",
|
||||||
"prisma": "7.10.0",
|
"prisma": "7.10.0",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
|
|||||||
Generated
+217
-220
@@ -162,6 +162,12 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
mysql2: '>=3.23.1'
|
||||||
|
deepmerge-ts: '>=8.0.0'
|
||||||
|
sharp: '>=0.35.5'
|
||||||
|
source-map-js: '>=1.2.2'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -171,16 +177,16 @@ importers:
|
|||||||
version: 7.10.0
|
version: 7.10.0
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: 7.10.0
|
specifier: 7.10.0
|
||||||
version: 7.10.0([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
version: 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: 1.7.6
|
specifier: 1.7.6
|
||||||
version: 1.7.6(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))(mysql2@3.15.3)([email protected].6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])))
|
version: 1.7.6(@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))(mysql2@3.24.5(@types/[email protected]))([email protected].8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])))
|
||||||
motion:
|
motion:
|
||||||
specifier: 13.4.4
|
specifier: 13.4.4
|
||||||
version: 13.4.4([email protected]([email protected]))([email protected])
|
version: 13.4.4([email protected]([email protected]))([email protected])
|
||||||
next:
|
next:
|
||||||
specifier: 16.3.6
|
specifier: 16.3.8
|
||||||
version: 16.3.6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 16.3.8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: 10.0.10
|
specifier: 10.0.10
|
||||||
version: 10.0.10
|
version: 10.0.10
|
||||||
@@ -200,8 +206,8 @@ importers:
|
|||||||
specifier: 0.0.1
|
specifier: 0.0.1
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
sharp:
|
sharp:
|
||||||
specifier: 0.35.4
|
specifier: '>=0.35.5'
|
||||||
version: 0.35.4(@types/[email protected])
|
version: 0.35.5(@types/[email protected])
|
||||||
stripe:
|
stripe:
|
||||||
specifier: 22.6.2
|
specifier: 22.6.2
|
||||||
version: 22.6.2(@types/[email protected])
|
version: 22.6.2(@types/[email protected])
|
||||||
@@ -246,14 +252,14 @@ importers:
|
|||||||
specifier: ^9.39.5
|
specifier: ^9.39.5
|
||||||
version: 9.39.5([email protected])([email protected])
|
version: 9.39.5([email protected])([email protected])
|
||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 16.3.6
|
specifier: 16.3.8
|
||||||
version: 16.3.6(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected])
|
version: 16.3.8(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected])
|
||||||
ffmpeg-static:
|
ffmpeg-static:
|
||||||
specifier: 5.3.0
|
specifier: 5.3.0
|
||||||
version: 5.3.0([email protected])
|
version: 5.3.0([email protected])
|
||||||
prisma:
|
prisma:
|
||||||
specifier: 7.10.0
|
specifier: 7.10.0
|
||||||
version: 7.10.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
version: 7.10.0(@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: 4.3.3
|
specifier: 4.3.3
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
@@ -726,160 +732,160 @@ packages:
|
|||||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
|
resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
|
resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
|
resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
|
resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
|
resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
|
resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
|
resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
|
resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
|
resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
|
resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
|
resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
|
resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
|
resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
|
resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
|
resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
|
resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
|
resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
|
resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
|
resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
|
resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
|
resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
|
resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
|
resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [wasm32]
|
cpu: [wasm32]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
|
resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
|
resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
|
||||||
engines: {node: ^20.9.0}
|
engines: {node: ^20.9.0}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
|
resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -907,60 +913,60 @@ packages:
|
|||||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==}
|
resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==}
|
resolution: {integrity: sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==}
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==}
|
resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==}
|
resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==}
|
resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==}
|
resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==}
|
resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==}
|
resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==}
|
resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==}
|
resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -1788,7 +1794,7 @@ packages:
|
|||||||
drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
|
drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
|
||||||
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
|
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
|
||||||
mongodb: ^6.0.0 || ^7.0.0
|
mongodb: ^6.0.0 || ^7.0.0
|
||||||
mysql2: ^3.0.0
|
mysql2: '>=3.23.1'
|
||||||
next: ^14.0.0 || ^15.0.0 || ^16.0.0
|
next: ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||||
pg: ^8.0.0
|
pg: ^8.0.0
|
||||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
@@ -2038,9 +2044,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
deepmerge-ts@7.1.5:
|
deepmerge-ts@8.0.2:
|
||||||
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
|
resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||||
@@ -2056,10 +2062,6 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
|
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
|
||||||
engines: {node: '>=0.10'}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
@@ -2174,8 +2176,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
[email protected].6:
|
[email protected].8:
|
||||||
resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==}
|
resolution: {integrity: sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '>=9.0.0'
|
eslint: '>=9.0.0'
|
||||||
typescript: '>=3.3.1'
|
typescript: '>=3.3.1'
|
||||||
@@ -2956,9 +2958,11 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
mysql2@3.15.3:
|
mysql2@3.24.5:
|
||||||
resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==}
|
resolution: {integrity: sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==}
|
||||||
engines: {node: '>= 8.0'}
|
engines: {node: '>= 8.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>= 8'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
|
||||||
@@ -2981,8 +2985,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
[email protected].6:
|
[email protected].8:
|
||||||
resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==}
|
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3362,9 +3366,6 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||||
|
|
||||||
@@ -3386,8 +3387,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
[email protected].4:
|
[email protected].5:
|
||||||
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
|
resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/node': '*'
|
'@types/node': '*'
|
||||||
@@ -3429,17 +3430,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
[email protected].1:
|
[email protected].2:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
sql[email protected]:
|
sql[email protected]:
|
||||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||||
@@ -3938,13 +3939,13 @@ snapshots:
|
|||||||
'@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
'@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||||
'@better-auth/utils': 0.4.2
|
'@better-auth/utils': 0.4.2
|
||||||
|
|
||||||
'@better-auth/[email protected](@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))':
|
'@better-auth/[email protected](@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
'@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||||
'@better-auth/utils': 0.4.2
|
'@better-auth/utils': 0.4.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 7.10.0([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
'@prisma/client': 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
||||||
prisma: 7.10.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
prisma: 7.10.0(@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
|
|
||||||
'@better-auth/[email protected](@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@better-fetch/[email protected])':
|
'@better-auth/[email protected](@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@better-fetch/[email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4171,108 +4172,108 @@ snapshots:
|
|||||||
|
|
||||||
'@img/[email protected]': {}
|
'@img/[email protected]': {}
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/sharp-wasm32': 0.35.4
|
'@img/sharp-wasm32': 0.35.5
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].3':
|
'@img/[email protected].4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/runtime': 1.11.3
|
'@emnapi/runtime': 1.11.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/sharp-wasm32': 0.35.4
|
'@img/sharp-wasm32': 0.35.5
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/[email protected].4':
|
'@img/[email protected].5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@jridgewell/[email protected]':
|
'@jridgewell/[email protected]':
|
||||||
@@ -4301,37 +4302,37 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.4
|
'@tybys/wasm-util': 0.10.4
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].6': {}
|
'@next/[email protected].8': {}
|
||||||
|
|
||||||
'@next/[email protected].6([email protected]([email protected])([email protected]))':
|
'@next/[email protected].8([email protected]([email protected])([email protected]))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1([email protected]([email protected])([email protected]))
|
'@eslint-community/eslint-utils': 4.9.1([email protected]([email protected])([email protected]))
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- eslint
|
- eslint
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected].6':
|
'@next/[email protected].8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@noble/[email protected]': {}
|
'@noble/[email protected]': {}
|
||||||
@@ -4371,17 +4372,17 @@ snapshots:
|
|||||||
|
|
||||||
'@prisma/[email protected]': {}
|
'@prisma/[email protected]': {}
|
||||||
|
|
||||||
'@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])':
|
'@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/client-runtime-utils': 7.10.0
|
'@prisma/client-runtime-utils': 7.10.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
prisma: 7.10.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
prisma: 7.10.0(@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@prisma/[email protected]':
|
'@prisma/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
c12: 3.3.4
|
c12: 3.3.4
|
||||||
deepmerge-ts: 7.1.5
|
deepmerge-ts: 8.0.2
|
||||||
effect: 3.20.0
|
effect: 3.20.0
|
||||||
empathic: 2.0.0
|
empathic: 2.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -4584,7 +4585,7 @@ snapshots:
|
|||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.2
|
||||||
tailwindcss: 4.3.3
|
tailwindcss: 4.3.3
|
||||||
|
|
||||||
'@tailwindcss/[email protected]':
|
'@tailwindcss/[email protected]':
|
||||||
@@ -5142,14 +5143,14 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected](@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))(mysql2@3.15.3)([email protected].6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))):
|
[email protected](@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))(mysql2@3.24.5(@types/[email protected]))([email protected].8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
'@better-auth/core': 1.7.6(@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||||
'@better-auth/drizzle-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])
|
'@better-auth/drizzle-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])
|
||||||
'@better-auth/kysely-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])([email protected])
|
'@better-auth/kysely-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])([email protected])
|
||||||
'@better-auth/memory-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])
|
'@better-auth/memory-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])
|
||||||
'@better-auth/mongo-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])
|
'@better-auth/mongo-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])
|
||||||
'@better-auth/prisma-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@prisma/[email protected]([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))
|
'@better-auth/prisma-adapter': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@prisma/[email protected]([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected]))([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))
|
||||||
'@better-auth/telemetry': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@better-fetch/[email protected])
|
'@better-auth/telemetry': 1.7.6(@better-auth/[email protected](@better-auth/[email protected])(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))(@better-auth/[email protected])(@better-fetch/[email protected])
|
||||||
'@better-auth/utils': 0.4.2
|
'@better-auth/utils': 0.4.2
|
||||||
'@better-fetch/fetch': 1.3.2
|
'@better-fetch/fetch': 1.3.2
|
||||||
@@ -5162,11 +5163,11 @@ snapshots:
|
|||||||
nanostores: 1.5.4
|
nanostores: 1.5.4
|
||||||
zod: 4.6.5
|
zod: 4.6.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 7.10.0([email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
'@prisma/client': 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
||||||
mysql2: 3.15.3
|
mysql2: 3.24.5(@types/[email protected])
|
||||||
next: 16.3.6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
next: 16.3.8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
pg: 8.23.0
|
pg: 8.23.0
|
||||||
prisma: 7.10.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
prisma: 7.10.0(@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])
|
||||||
react: 19.3.0
|
react: 19.3.0
|
||||||
react-dom: 19.3.0([email protected])
|
react-dom: 19.3.0([email protected])
|
||||||
vitest: 4.1.11(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
vitest: 4.1.11(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
||||||
@@ -5379,7 +5380,7 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
deepmerge-ts@7.1.5: {}
|
deepmerge-ts@8.0.2: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5399,8 +5400,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
robust-predicates: 3.0.3
|
robust-predicates: 3.0.3
|
||||||
|
|
||||||
[email protected]: {}
|
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -5607,9 +5606,9 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected].6(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected]):
|
[email protected].8(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/eslint-plugin-next': 16.3.6([email protected]([email protected])([email protected]))
|
'@next/eslint-plugin-next': 16.3.8([email protected]([email protected])([email protected]))
|
||||||
eslint: 9.39.5([email protected])([email protected])
|
eslint: 9.39.5([email protected])([email protected])
|
||||||
eslint-import-resolver-node: 0.3.10([email protected])
|
eslint-import-resolver-node: 0.3.10([email protected])
|
||||||
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected])([email protected]))([email protected])
|
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected])([email protected]))([email protected])
|
||||||
@@ -6406,17 +6405,16 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
mysql2@3.15.3:
|
mysql2@3.24.5(@types/[email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@types/node': 22.20.4
|
||||||
aws-ssl-profiles: 1.1.2
|
aws-ssl-profiles: 1.1.2
|
||||||
denque: 2.1.0
|
|
||||||
generate-function: 2.3.1
|
generate-function: 2.3.1
|
||||||
iconv-lite: 0.7.3
|
iconv-lite: 0.7.3
|
||||||
long: 5.3.2
|
long: 5.3.2
|
||||||
lru.min: 1.1.5
|
lru.min: 1.1.5
|
||||||
named-placeholders: 1.1.6
|
named-placeholders: 1.1.6
|
||||||
seq-queue: 0.0.5
|
sql-escaper: 1.5.2
|
||||||
sqlstring: 2.3.3
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6430,9 +6428,9 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected].6(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
[email protected].8(@babel/[email protected]([email protected]))(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.3.6
|
'@next/env': 16.3.8
|
||||||
'@swc/helpers': 0.5.23
|
'@swc/helpers': 0.5.23
|
||||||
baseline-browser-mapping: 2.11.26
|
baseline-browser-mapping: 2.11.26
|
||||||
caniuse-lite: 1.0.30001812
|
caniuse-lite: 1.0.30001812
|
||||||
@@ -6441,16 +6439,16 @@ snapshots:
|
|||||||
react-dom: 19.3.0([email protected])
|
react-dom: 19.3.0([email protected])
|
||||||
styled-jsx: 5.1.6(@babel/[email protected]([email protected]))([email protected])
|
styled-jsx: 5.1.6(@babel/[email protected]([email protected]))([email protected])
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 16.3.6
|
'@next/swc-darwin-arm64': 16.3.8
|
||||||
'@next/swc-darwin-x64': 16.3.6
|
'@next/swc-darwin-x64': 16.3.8
|
||||||
'@next/swc-linux-arm64-gnu': 16.3.6
|
'@next/swc-linux-arm64-gnu': 16.3.8
|
||||||
'@next/swc-linux-arm64-musl': 16.3.6
|
'@next/swc-linux-arm64-musl': 16.3.8
|
||||||
'@next/swc-linux-x64-gnu': 16.3.6
|
'@next/swc-linux-x64-gnu': 16.3.8
|
||||||
'@next/swc-linux-x64-musl': 16.3.6
|
'@next/swc-linux-x64-musl': 16.3.8
|
||||||
'@next/swc-win32-arm64-msvc': 16.3.6
|
'@next/swc-win32-arm64-msvc': 16.3.8
|
||||||
'@next/swc-win32-x64-msvc': 16.3.6
|
'@next/swc-win32-x64-msvc': 16.3.8
|
||||||
'@playwright/test': 1.63.0
|
'@playwright/test': 1.63.0
|
||||||
sharp: 0.35.4(@types/[email protected])
|
sharp: 0.35.5(@types/[email protected])
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
@@ -6624,13 +6622,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.19
|
nanoid: 3.3.19
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.2
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.19
|
nanoid: 3.3.19
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.2
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
@@ -6648,17 +6646,18 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]):
|
[email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/config': 7.10.0
|
'@prisma/config': 7.10.0
|
||||||
'@prisma/dev': 0.24.17([email protected])
|
'@prisma/dev': 0.24.17([email protected])
|
||||||
'@prisma/engines': 7.10.0
|
'@prisma/engines': 7.10.0
|
||||||
'@prisma/studio-core': 0.33.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
'@prisma/studio-core': 0.33.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
mysql2: 3.15.3
|
mysql2: 3.24.5(@types/[email protected])
|
||||||
postgres: 3.4.7
|
postgres: 3.4.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
- '@types/react-dom'
|
- '@types/react-dom'
|
||||||
- magicast
|
- magicast
|
||||||
@@ -6822,8 +6821,6 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -6852,37 +6849,37 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
es-object-atoms: 1.1.2
|
es-object-atoms: 1.1.2
|
||||||
|
|
||||||
[email protected].4(@types/[email protected]):
|
[email protected].5(@types/[email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/colour': 1.1.0
|
'@img/colour': 1.1.0
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
semver: 7.8.5
|
semver: 7.8.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-darwin-arm64': 0.35.4
|
'@img/sharp-darwin-arm64': 0.35.5
|
||||||
'@img/sharp-darwin-x64': 0.35.4
|
'@img/sharp-darwin-x64': 0.35.5
|
||||||
'@img/sharp-freebsd-wasm32': 0.35.4
|
'@img/sharp-freebsd-wasm32': 0.35.5
|
||||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
'@img/sharp-libvips-darwin-arm64': 1.3.4
|
||||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
'@img/sharp-libvips-darwin-x64': 1.3.4
|
||||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
'@img/sharp-libvips-linux-arm': 1.3.4
|
||||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
'@img/sharp-libvips-linux-arm64': 1.3.4
|
||||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
'@img/sharp-libvips-linux-ppc64': 1.3.4
|
||||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
'@img/sharp-libvips-linux-riscv64': 1.3.4
|
||||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
'@img/sharp-libvips-linux-s390x': 1.3.4
|
||||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
'@img/sharp-libvips-linux-x64': 1.3.4
|
||||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
|
||||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
|
||||||
'@img/sharp-linux-arm': 0.35.4
|
'@img/sharp-linux-arm': 0.35.5
|
||||||
'@img/sharp-linux-arm64': 0.35.4
|
'@img/sharp-linux-arm64': 0.35.5
|
||||||
'@img/sharp-linux-ppc64': 0.35.4
|
'@img/sharp-linux-ppc64': 0.35.5
|
||||||
'@img/sharp-linux-riscv64': 0.35.4
|
'@img/sharp-linux-riscv64': 0.35.5
|
||||||
'@img/sharp-linux-s390x': 0.35.4
|
'@img/sharp-linux-s390x': 0.35.5
|
||||||
'@img/sharp-linux-x64': 0.35.4
|
'@img/sharp-linux-x64': 0.35.5
|
||||||
'@img/sharp-linuxmusl-arm64': 0.35.4
|
'@img/sharp-linuxmusl-arm64': 0.35.5
|
||||||
'@img/sharp-linuxmusl-x64': 0.35.4
|
'@img/sharp-linuxmusl-x64': 0.35.5
|
||||||
'@img/sharp-webcontainers-wasm32': 0.35.4
|
'@img/sharp-webcontainers-wasm32': 0.35.5
|
||||||
'@img/sharp-win32-arm64': 0.35.4
|
'@img/sharp-win32-arm64': 0.35.5
|
||||||
'@img/sharp-win32-ia32': 0.35.4
|
'@img/sharp-win32-ia32': 0.35.5
|
||||||
'@img/sharp-win32-x64': 0.35.4
|
'@img/sharp-win32-x64': 0.35.5
|
||||||
'@types/node': 22.20.4
|
'@types/node': 22.20.4
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -6925,11 +6922,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected].1: {}
|
[email protected].2: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
sql[email protected]: {}
|
sql[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,16 @@ allowBuilds:
|
|||||||
'@embedded-postgres/windows-x64': true
|
'@embedded-postgres/windows-x64': true
|
||||||
'@embedded-postgres/linux-x64': true
|
'@embedded-postgres/linux-x64': true
|
||||||
unrs-resolver: false
|
unrs-resolver: false
|
||||||
|
|
||||||
|
# Transitive Luecken ab "high" (pnpm audit --prod, CI-Gate in
|
||||||
|
# .gitea/workflows/docker-build.yml). Gleiche Untergrenzen wie im Wabenhain-Repo.
|
||||||
|
overrides:
|
||||||
|
# mysql2 und deepmerge-ts kommen ueber die prisma-CLI (@prisma/config) herein,
|
||||||
|
# nicht ueber den Laufzeitpfad (die App nutzt @prisma/adapter-pg).
|
||||||
|
# deepmerge-ts ist ein Hauptversionssprung; "prisma generate" laeuft durch
|
||||||
|
# @prisma/config und prueft ihn bei jedem typecheck/build mit.
|
||||||
|
mysql2: '>=3.23.1'
|
||||||
|
deepmerge-ts: '>=8.0.0'
|
||||||
|
# Transitiv ueber next (next pinnt sharp sonst auf 0.35.4); reine Patch-Spruenge.
|
||||||
|
sharp: '>=0.35.5'
|
||||||
|
source-map-js: '>=1.2.2'
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prüft im CI-Job die Grenzen des isolierten Runners (Label ci-isolated,
|
||||||
|
# Runner-Doku im VyCept-Repo, docs/BETRIEB.md 11b; Kopie von dort). Läuft als erster Schritt jedes Prüf-Jobs, damit jeder
|
||||||
|
# Runner bei jedem Lauf geprüft wird. Schlägt fehl, wenn der Job mehr darf als
|
||||||
|
# vorgesehen – oder wenn die Prüfung selbst nichts aussagen würde.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Runner-Isolation verletzt: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
reachable() {
|
||||||
|
timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Namen mit Wiederholung auflösen: Das DNS des Job-Netzes antwortet unter Last
|
||||||
|
# gelegentlich erst beim zweiten Versuch.
|
||||||
|
resolve() {
|
||||||
|
local address=""
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
address=$(getent hosts "$1" | awk '{ print $1; exit }' || true)
|
||||||
|
if [ -n "$address" ]; then echo "$address"; return 0; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Kein Zugang zum Docker-Daemon über den Socket.
|
||||||
|
[ -z "${DOCKER_HOST:-}" ] || fail "DOCKER_HOST ist gesetzt ($DOCKER_HOST)"
|
||||||
|
for socket in /var/run/docker.sock /run/docker.sock; do
|
||||||
|
[ ! -e "$socket" ] || fail "Docker-Socket $socket ist sichtbar"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Kein Zugang zur Docker-API des Daemons im Runner-Pod (Gateway des Job-Netzes):
|
||||||
|
# 2375 (ohne TLS) zu, 2376 nur mit Client-Zertifikat, das der Job nicht hat.
|
||||||
|
gateway_hex=$(awk '$2 == "00000000" { print $3; exit }' /proc/net/route)
|
||||||
|
[ -n "$gateway_hex" ] || fail "kein Standard-Gateway gefunden – Prüfung wäre wirkungslos"
|
||||||
|
gateway=$(printf '%d.%d.%d.%d' "0x${gateway_hex:6:2}" "0x${gateway_hex:4:2}" "0x${gateway_hex:2:2}" "0x${gateway_hex:0:2}")
|
||||||
|
if reachable "$gateway" 2375; then fail "Docker-API ohne TLS am Gateway $gateway:2375"; fi
|
||||||
|
if curl -sk --max-time 5 "https://$gateway:2376/version" >/dev/null 2>&1; then
|
||||||
|
fail "Docker-API am Gateway $gateway:2376 ohne Client-Zertifikat nutzbar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Kein Clusternetz: Kubernetes-API (Service und Knoten) und fremde Namespaces.
|
||||||
|
for target in 10.43.0.1:443 10.50.1.51:6443 10.50.1.52:6443 10.50.1.53:6443; do
|
||||||
|
if reachable "${target%:*}" "${target##*:}"; then fail "$target ist erreichbar"; fi
|
||||||
|
done
|
||||||
|
resolve kubernetes.default.svc.cluster.local >/dev/null || fail "Clusternamen nicht auflösbar – Prüfung wäre wirkungslos"
|
||||||
|
# Fremder Namespace (VyCept-Datenbank) und eigener Namespace (Gitea-Datenbank).
|
||||||
|
for target in vycept-postgres.vycept.svc.cluster.local:5432 gitea-postgres.gitea.svc.cluster.local:5432; do
|
||||||
|
host=${target%:*}
|
||||||
|
address=$(resolve "$host") || fail "$host nicht auflösbar – Prüfung wäre wirkungslos"
|
||||||
|
if reachable "$address" "${target##*:}"; then fail "$host ($address) ist erreichbar"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4. Internet bleibt erreichbar (npm, GitHub, Images).
|
||||||
|
reachable registry.npmjs.org 443 || fail "Internet (registry.npmjs.org) nicht erreichbar"
|
||||||
|
|
||||||
|
echo "Runner-Isolation in Ordnung (Gateway $gateway, kein Socket, kein Clusternetz)"
|
||||||
@@ -3,11 +3,33 @@ import { notFound } from "next/navigation";
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
import { Flash, PageTitle } from "@/components/admin/AdminUi";
|
||||||
import { VoucherForm } from "@/components/admin/VoucherForm";
|
import { VoucherForm } from "@/components/admin/VoucherForm";
|
||||||
|
import { getCamp, salesStateFor } from "@/lib/camp";
|
||||||
import { getDb } from "@/lib/db";
|
import { getDb } from "@/lib/db";
|
||||||
|
import { stripeModeOrNull } from "@/lib/env";
|
||||||
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
|
import { BOOKING_STATUS_LABEL, REDEMPTION_STATUS_LABEL } from "@/lib/labels";
|
||||||
|
import { evaluateVoucher, VOUCHER_REJECTION_ADMIN_TEXT } from "@/lib/vouchers";
|
||||||
|
|
||||||
export const metadata = { title: "Gutschein" };
|
export const metadata = { title: "Gutschein" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft wie der Buchungsdialog, ob der Code gerade einloesbar ist, und nennt
|
||||||
|
* den Grund, den Kundinnen bewusst nicht sehen (neutrale Ablehnung).
|
||||||
|
* Eine E-Mail-Bindung prueft erst der Checkout.
|
||||||
|
*/
|
||||||
|
async function redeemability(code: string): Promise<{ ok: boolean; text: string }> {
|
||||||
|
const mode = stripeModeOrNull();
|
||||||
|
if (!mode) return { ok: false, text: "Nicht prüfbar: STRIPE_SECRET_KEY fehlt oder ist ungültig." };
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
const camp = await getCamp(db);
|
||||||
|
const evaluation = await evaluateVoucher(db, { code, email: null, now, livemode: mode === "live", quota: camp.stammmodelQuota, lock: false });
|
||||||
|
if (!evaluation.ok) return { ok: false, text: `Derzeit nicht einlösbar: ${VOUCHER_REJECTION_ADMIN_TEXT[evaluation.reason]}.` };
|
||||||
|
if (!salesStateFor(camp, mode, now).checkoutOpen) {
|
||||||
|
return { ok: false, text: "Code ist gültig, aber die Buchung ist noch nicht geöffnet. Der Dialog zeigt deshalb keinen Preis und keinen Rabatt." };
|
||||||
|
}
|
||||||
|
return { ok: true, text: "Derzeit einlösbar (10 % auf den gültigen Preis)." };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
export default async function VoucherPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||||
await connection();
|
await connection();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -17,11 +39,13 @@ export default async function VoucherPage({ params, searchParams }: { params: Pr
|
|||||||
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
|
include: { redemptions: { orderBy: { createdAt: "desc" }, include: { booking: { select: { id: true, reference: true, status: true, livemode: true } } } } },
|
||||||
});
|
});
|
||||||
if (!voucher) notFound();
|
if (!voucher) notFound();
|
||||||
|
const check = await redeemability(voucher.code);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
|
<Link href="/admin/gutscheine" className="text-sm underline">Alle Gutscheine</Link>
|
||||||
<PageTitle>{voucher.code}</PageTitle>
|
<PageTitle>{voucher.code}</PageTitle>
|
||||||
<Flash ok={flash.ok} fehler={flash.fehler} />
|
<Flash ok={flash.ok} fehler={flash.fehler} />
|
||||||
|
<p className={`mb-6 rounded-lg border p-4 ${check.ok ? "border-line" : "border-rosso/40 text-rosso"}`}>{check.text}</p>
|
||||||
<div className="grid gap-10 lg:grid-cols-2">
|
<div className="grid gap-10 lg:grid-cols-2">
|
||||||
<VoucherForm voucher={voucher} />
|
<VoucherForm voucher={voucher} />
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
import { getAuth } from "@/lib/auth";
|
import { getAuth } from "@/lib/auth";
|
||||||
|
import { withClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
return toNextJsHandler(getAuth()).GET(request);
|
return toNextJsHandler(getAuth()).GET(await withClientIp(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
return toNextJsHandler(getAuth()).POST(request);
|
return toNextJsHandler(getAuth()).POST(await withClientIp(request));
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,8 @@ export function BookingDialog({
|
|||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [participant, setParticipant] = useState<Participant>(EMPTY);
|
const [participant, setParticipant] = useState<Participant>(EMPTY);
|
||||||
const [chosenPlan, setPlanType] = useState<PlanType>("FULL");
|
// Keine Vorauswahl: Preise erscheinen erst unter der gewaehlten Zahlungsweise.
|
||||||
|
const [chosenPlan, setPlanType] = useState<PlanType | null>(null);
|
||||||
const [voucherInput, setVoucherInput] = useState("");
|
const [voucherInput, setVoucherInput] = useState("");
|
||||||
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
const [appliedVoucher, setAppliedVoucher] = useState<string | null>(null);
|
||||||
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
const [voucherMessage, setVoucherMessage] = useState<string | null>(null);
|
||||||
@@ -114,11 +115,11 @@ export function BookingDialog({
|
|||||||
};
|
};
|
||||||
}, [open, quote]);
|
}, [open, quote]);
|
||||||
|
|
||||||
// Eine nicht (mehr) verfuegbare Ratenvariante faellt auf Einmalzahlung zurueck.
|
// Eine nicht (mehr) verfuegbare Variante gilt als nicht gewaehlt.
|
||||||
const chosen = quote?.plans.find((p) => p.type === chosenPlan);
|
const chosen = quote?.plans.find((p) => p.type === chosenPlan);
|
||||||
const planType: PlanType = chosen && !chosen.available ? "FULL" : chosenPlan;
|
const planType: PlanType | null = chosen?.available ? chosenPlan : null;
|
||||||
|
|
||||||
const selectedPlan = quote?.plans.find((p) => p.type === planType) ?? null;
|
const selectedPlan = chosen?.available ? chosen : null;
|
||||||
|
|
||||||
function updateParticipant(field: keyof Participant, value: string) {
|
function updateParticipant(field: keyof Participant, value: string) {
|
||||||
setParticipant((prev) => ({ ...prev, [field]: value }));
|
setParticipant((prev) => ({ ...prev, [field]: value }));
|
||||||
@@ -174,6 +175,7 @@ export function BookingDialog({
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
if (!planType) return;
|
||||||
const missing: FieldErrors = {};
|
const missing: FieldErrors = {};
|
||||||
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
|
if (!consents.adult) missing["consents.adult"] = "Bitte bestätige, dass du volljährig bist.";
|
||||||
if (!consents.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
|
if (!consents.terms) missing["consents.terms"] = "Bitte akzeptiere die Buchungsbedingungen.";
|
||||||
@@ -324,32 +326,29 @@ export function BookingDialog({
|
|||||||
<legend className="field-label">Wie möchtest du bezahlen?</legend>
|
<legend className="field-label">Wie möchtest du bezahlen?</legend>
|
||||||
<div className="mt-2 space-y-3">
|
<div className="mt-2 space-y-3">
|
||||||
{quote?.plans.map((plan) => (
|
{quote?.plans.map((plan) => (
|
||||||
<label
|
<div
|
||||||
key={plan.type}
|
key={plan.type}
|
||||||
className={`flex cursor-pointer gap-4 rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "cursor-not-allowed opacity-60"}`}
|
className={`rounded-xl border p-4 ${planType === plan.type ? "border-ink bg-paper" : "border-line"} ${plan.available ? "" : "opacity-60"}`}
|
||||||
>
|
>
|
||||||
<input
|
<label className={`flex gap-4 ${plan.available ? "cursor-pointer" : "cursor-not-allowed"}`}>
|
||||||
type="radio"
|
<input
|
||||||
name="planType"
|
type="radio"
|
||||||
value={plan.type}
|
name="planType"
|
||||||
checked={planType === plan.type}
|
value={plan.type}
|
||||||
disabled={!plan.available}
|
checked={planType === plan.type}
|
||||||
onChange={() => {
|
disabled={!plan.available}
|
||||||
setPlanType(plan.type);
|
onChange={() => {
|
||||||
}}
|
setPlanType(plan.type);
|
||||||
className="mt-1 h-5 w-5 accent-[var(--color-rosso)]"
|
}}
|
||||||
/>
|
className="mt-1 h-5 w-5 accent-[var(--color-rosso)]"
|
||||||
<span className="flex-1">
|
/>
|
||||||
<span className="block font-semibold">{plan.label}</span>
|
<span className="flex-1">
|
||||||
{plan.available ? (
|
<span className="block font-semibold">{plan.label}</span>
|
||||||
<span className="mt-1 block text-[0.9375rem] text-ink/80">
|
{!plan.available && <span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>}
|
||||||
{plan.installments.map((i) => money(i.amountCents)).join(" + ")}
|
</span>
|
||||||
</span>
|
</label>
|
||||||
) : (
|
{plan.available && planType === plan.type && quote.price && <PlanDetails plan={plan} quote={quote} />}
|
||||||
<span className="mt-1 block text-[0.9375rem] text-stone">{plan.reason}</span>
|
</div>
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
)) ?? <p className="text-stone">Preise werden geladen …</p>}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -384,8 +383,6 @@ export function BookingDialog({
|
|||||||
{voucherMessage}
|
{voucherMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{quote?.price && <PriceBreakdown quote={quote} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -462,7 +459,7 @@ export function BookingDialog({
|
|||||||
</a>{" "}
|
</a>{" "}
|
||||||
gelesen.
|
gelesen.
|
||||||
</Consent>
|
</Consent>
|
||||||
{planType !== "FULL" && (
|
{planType !== null && planType !== "FULL" && (
|
||||||
<Consent
|
<Consent
|
||||||
id="installmentMandate"
|
id="installmentMandate"
|
||||||
checked={consents.installmentMandate}
|
checked={consents.installmentMandate}
|
||||||
@@ -494,7 +491,7 @@ export function BookingDialog({
|
|||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
{step < STEPS.length - 1 ? (
|
{step < STEPS.length - 1 ? (
|
||||||
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && !quote?.price}>
|
<button type="button" className="btn btn-primary" onClick={next} disabled={step === 1 && (!quote?.price || !selectedPlan)}>
|
||||||
Weiter
|
Weiter
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -509,6 +506,27 @@ export function BookingDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Preis und Raten unter der gewaehlten Zahlungsweise. */
|
||||||
|
function PlanDetails({ plan, quote }: { plan: QuoteData["plans"][number]; quote: QuoteData }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 pl-9">
|
||||||
|
<PriceBreakdown quote={quote} />
|
||||||
|
{plan.installments.length > 1 && (
|
||||||
|
<ul className="mt-3 space-y-1 text-[0.9375rem] text-ink/80">
|
||||||
|
{plan.installments.map((i) => (
|
||||||
|
<li key={i.seq} className="flex justify-between gap-4">
|
||||||
|
<span>
|
||||||
|
Rate {i.seq}, {i.dueDate ? `fällig ${isoDate(i.dueDate)}` : "heute"}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">{money(i.amountCents)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PriceBreakdown({ quote }: { quote: QuoteData }) {
|
function PriceBreakdown({ quote }: { quote: QuoteData }) {
|
||||||
const p = quote.price!;
|
const p = quote.price!;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ export function ContactSection() {
|
|||||||
{error("email")}
|
{error("email")}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="field-label" htmlFor="ct-phone">Telefon (optional)</label>
|
<label className="field-label" htmlFor="ct-phone">Telefon</label>
|
||||||
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" maxLength={60} />
|
<input {...fieldProps("phone")} className="field" type="tel" autoComplete="tel" required maxLength={40} />
|
||||||
{error("phone")}
|
{error("phone")}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { headers } from "next/headers";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Actor } from "./audit";
|
import type { Actor } from "./audit";
|
||||||
import { getAuth } from "./auth";
|
import { getAuth } from "./auth";
|
||||||
|
import { headersWithClientIp } from "./rate-limit";
|
||||||
|
|
||||||
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
|
export type AdminUser = { id: string; email: string; name: string; twoFactorEnabled: boolean };
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ async function currentAdmin(): Promise<AdminUser | null> {
|
|||||||
// Sonst versucht "next build" sie vorab zu rendern und scheitert ohne
|
// Sonst versucht "next build" sie vorab zu rendern und scheitert ohne
|
||||||
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
|
// Secrets im Container (getAuth() braucht BETTER_AUTH_SECRET).
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
const session = await getAuth().api.getSession({ headers: requestHeaders });
|
const session = await getAuth().api.getSession({ headers: headersWithClientIp(requestHeaders) });
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null };
|
const user = session.user as typeof session.user & { twoFactorEnabled?: boolean | null };
|
||||||
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
|
return { id: user.id, email: user.email, name: user.name, twoFactorEnabled: Boolean(user.twoFactorEnabled) };
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ import { NextResponse } from "next/server";
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { env, requireAppSecret } from "./env";
|
import { env, requireAppSecret } from "./env";
|
||||||
import { clientIp, consumeRateLimit, type RateLimitRule } from "./rate-limit";
|
import { clientIp, consumeRateLimit, rateLimitSubject, type RateLimitRule } from "./rate-limit";
|
||||||
|
|
||||||
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
export type ApiError = { code: string; message: string; fields?: Record<string, string> };
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export async function readJson<T extends z.ZodType>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
export async function rateLimited(request: Request, rule: RateLimitRule): Promise<NextResponse | null> {
|
||||||
const result = await consumeRateLimit(getDb(), rule, clientIp(request.headers), requireAppSecret());
|
const result = await consumeRateLimit(getDb(), rule, rateLimitSubject(clientIp(request.headers)), requireAppSecret());
|
||||||
if (result.allowed) return null;
|
if (result.allowed) return null;
|
||||||
return fail(
|
return fail(
|
||||||
429,
|
429,
|
||||||
|
|||||||
+4
-1
@@ -17,6 +17,7 @@ import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";
|
|||||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
|
import { CLIENT_IP_HEADER } from "./rate-limit";
|
||||||
|
|
||||||
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
function createAuth(options: { allowSignUp?: boolean } = {}) {
|
||||||
const e = env();
|
const e = env();
|
||||||
@@ -51,7 +52,9 @@ function createAuth(options: { allowSignUp?: boolean } = {}) {
|
|||||||
},
|
},
|
||||||
advanced: {
|
advanced: {
|
||||||
useSecureCookies: e.APP_URL.startsWith("https://"),
|
useSecureCookies: e.APP_URL.startsWith("https://"),
|
||||||
ipAddress: { ipAddressHeaders: ["x-real-ip", "x-forwarded-for"] },
|
// Nur die vom Server ermittelte IP (withClientIp im Auth-Endpunkt), nie X-Real-IP
|
||||||
|
// oder X-Forwarded-For direkt: ueber Cloudflare kaemen sie vom Client.
|
||||||
|
ipAddress: { ipAddressHeaders: [CLIENT_IP_HEADER] },
|
||||||
},
|
},
|
||||||
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
|
plugins: [twoFactor({ issuer: "ARTISTA Modelcamp" }), haveIBeenPwned(), nextCookies()],
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-1
@@ -28,7 +28,13 @@ const optionalText = (max: number) =>
|
|||||||
export const contactRequestSchema = z.object({
|
export const contactRequestSchema = z.object({
|
||||||
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
|
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
|
||||||
email: z.string().trim().max(200).pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")),
|
email: z.string().trim().max(200).pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")),
|
||||||
phone: optionalText(60),
|
// Gleiche Regel wie bei der Buchung.
|
||||||
|
phone: z
|
||||||
|
.string("Bitte gib eine Telefonnummer an.")
|
||||||
|
.trim()
|
||||||
|
.min(6, "Bitte gib eine Telefonnummer an.")
|
||||||
|
.max(40)
|
||||||
|
.regex(/^[+0-9 ()/-]+$/, "Die Telefonnummer darf nur Ziffern, Leerzeichen und + ( ) / - enthalten."),
|
||||||
availability: optionalText(200),
|
availability: optionalText(200),
|
||||||
message: optionalText(2000),
|
message: optionalText(2000),
|
||||||
privacy: z.literal(true, { error: "Bitte bestätige die Datenschutzhinweise." }),
|
privacy: z.literal(true, { error: "Bitte bestätige die Datenschutzhinweise." }),
|
||||||
|
|||||||
+27
-3
@@ -85,6 +85,28 @@ async function deliver(message: { id: string; toAddress: string; subject: string
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RECIPIENT_REJECTION_CODES = new Set([550, 551, 553, 556]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endgueltig ist nur eine Ablehnung des Empfaengers (nodemailer EENVELOPE, RCPT TO):
|
||||||
|
* 550/551/553/556 oder 5xx mit Status 5.1.x (Adresse/Domain falsch). Alles andere
|
||||||
|
* (Anmeldung EAUTH, Richtlinie/Blocklist EMESSAGE, 552, 4xx, Verbindung) liegt am
|
||||||
|
* Server oder Absender; die Mail bleibt PENDING und wird bis MAX_EMAIL_ATTEMPTS
|
||||||
|
* wiederholt, damit ein kaputtes SMTP-Secret keine Mails endgueltig verwirft.
|
||||||
|
*/
|
||||||
|
export function isPermanentMailError(error: unknown): boolean {
|
||||||
|
if (typeof error !== "object" || error === null) return false;
|
||||||
|
const { code, responseCode, response } = error as { code?: unknown; responseCode?: unknown; response?: unknown };
|
||||||
|
if (code !== "EENVELOPE" || typeof responseCode !== "number") return false;
|
||||||
|
if (RECIPIENT_REJECTION_CODES.has(responseCode)) return true;
|
||||||
|
return responseCode >= 500 && responseCode < 600 && typeof response === "string" && /(^|\s)5\.1\.\d+(\s|$)/.test(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMTP-Fehlermeldungen nennen den Empfaenger; ins Log und in lastError kommt er nicht. */
|
||||||
|
export function redactAddresses(text: string): string {
|
||||||
|
return text.replace(/[^\s<>"',;:()]+@[^\s<>"',;:()]+/g, "[Adresse]");
|
||||||
|
}
|
||||||
|
|
||||||
/** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */
|
/** Versendet offene Mails. Fehler werden gezaehlt, nie mit Adresse geloggt. */
|
||||||
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
|
export async function flushEmails(db: Db, params: { bookingId?: string; limit?: number } = {}): Promise<{ sent: number; failed: number }> {
|
||||||
const pending = await db.emailMessage.findMany({
|
const pending = await db.emailMessage.findMany({
|
||||||
@@ -111,15 +133,17 @@ export async function flushEmails(db: Db, params: { bookingId?: string; limit?:
|
|||||||
sent++;
|
sent++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed++;
|
failed++;
|
||||||
const reason = error instanceof Error ? error.message.slice(0, 300) : "unbekannter Fehler";
|
const permanent = isPermanentMailError(error);
|
||||||
|
const reason = redactAddresses(error instanceof Error ? error.message : "unbekannter Fehler").slice(0, 300);
|
||||||
await db.emailMessage.update({
|
await db.emailMessage.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
data: {
|
data: {
|
||||||
lastError: reason,
|
lastError: reason,
|
||||||
status: message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING",
|
status: permanent || message.attempts + 1 >= MAX_EMAIL_ATTEMPTS ? "FAILED" : "PENDING",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}): ${reason}`);
|
const final = permanent ? ", endgueltig abgelehnt" : "";
|
||||||
|
console.error(`[mail] Versand fehlgeschlagen (Nachricht ${message.id}${final}): ${reason}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { sent, failed };
|
return { sent, failed };
|
||||||
|
|||||||
+104
-7
@@ -5,6 +5,7 @@
|
|||||||
*
|
*
|
||||||
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
|
* Die IP-Adresse wird nie gespeichert, nur ein HMAC davon.
|
||||||
*/
|
*/
|
||||||
|
import { BlockList, isIP } from "node:net";
|
||||||
import type { Db } from "./db";
|
import type { Db } from "./db";
|
||||||
import { hmacHex } from "./tokens";
|
import { hmacHex } from "./tokens";
|
||||||
|
|
||||||
@@ -51,14 +52,110 @@ export async function purgeRateLimits(db: Db): Promise<number> {
|
|||||||
return db.$executeRaw`DELETE FROM "RateLimitBucket" WHERE "windowStart" < now() - interval '1 day'`;
|
return db.$executeRaw`DELETE FROM "RateLimitBucket" WHERE "windowStart" < now() - interval '1 day'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vom Server gesetzte Client-IP fuer better-auth (withClientIp), nie vom Client uebernommen. */
|
||||||
|
export const CLIENT_IP_HEADER = "x-artista-client-ip";
|
||||||
|
|
||||||
|
/** https://www.cloudflare.com/ips/ (Stand 2026-10). */
|
||||||
|
const CLOUDFLARE: [string, number, "ipv4" | "ipv6"][] = [
|
||||||
|
...["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13", "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22"].map(
|
||||||
|
(cidr) => [cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv4"] as [string, number, "ipv4"],
|
||||||
|
),
|
||||||
|
...["2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32"].map(
|
||||||
|
(cidr) => [cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv6"] as [string, number, "ipv6"],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const cloudflare = new BlockList();
|
||||||
|
for (const [net, prefix, family] of CLOUDFLARE) cloudflare.addSubnet(net, prefix, family);
|
||||||
|
|
||||||
|
/** Nicht oeffentliche Bereiche; 240/4 nutzt Cloudflare fuer "Pseudo IPv4". */
|
||||||
|
const NON_PUBLIC_V4 = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "240.0.0.0/4"];
|
||||||
|
const nonPublic = new BlockList();
|
||||||
|
for (const cidr of NON_PUBLIC_V4) nonPublic.addSubnet(cidr.split("/")[0]!, Number(cidr.split("/")[1]), "ipv4");
|
||||||
|
nonPublic.addAddress("255.255.255.255", "ipv4");
|
||||||
|
nonPublic.addAddress("::", "ipv6");
|
||||||
|
nonPublic.addAddress("::1", "ipv6");
|
||||||
|
nonPublic.addSubnet("fc00::", 7, "ipv6");
|
||||||
|
nonPublic.addSubnet("fe80::", 10, "ipv6");
|
||||||
|
|
||||||
|
function isPublicUnicast(ip: string): boolean {
|
||||||
|
if (isIP(ip) === 4) return !nonPublic.check(ip, "ipv4");
|
||||||
|
const groups = ipv6Groups(ip);
|
||||||
|
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
|
||||||
|
const [high = 0, low = 0] = groups.slice(6);
|
||||||
|
return !nonPublic.check([high >> 8, high & 0xff, low >> 8, low & 0xff].join("."), "ipv4");
|
||||||
|
}
|
||||||
|
return !nonPublic.check(ip, "ipv6");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validIp(value: string | null | undefined): string | null {
|
||||||
|
const ip = value?.trim();
|
||||||
|
return ip && isIP(ip) ? ip : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-IP hinter Traefik. Traefik setzt X-Real-Ip selbst und ersetzt
|
* Client-IP hinter Traefik. Gegenstelle ist der letzte Eintrag in
|
||||||
* X-Forwarded-For von nicht vertrauenswuerdigen Absendern (EntryPoint-
|
* X-Forwarded-For, den Traefik aus der TCP-Verbindung anhaengt; alles links
|
||||||
* Einstellung forwardedHeaders.trustedIPs, siehe docs/BETRIEB.md).
|
* davon und X-Real-IP kann vom Client stammen, sobald die Anfrage ueber
|
||||||
|
* Cloudflare kommt (Traefik vertraut Cloudflare, forwardedHeaders.trustedIPs).
|
||||||
|
* Ist die Gegenstelle eine Cloudflare-Edge, gilt CF-Connecting-IP. Ohne
|
||||||
|
* X-Forwarded-For (kein Traefik davor, lokal) gilt X-Real-IP.
|
||||||
*/
|
*/
|
||||||
export function clientIp(headers: Headers): string {
|
export function clientIp(headers: Headers): string {
|
||||||
const realIp = headers.get("x-real-ip")?.trim();
|
const forwarded = headers.get("x-forwarded-for");
|
||||||
if (realIp) return realIp;
|
const realIp = process.env.NODE_ENV === "production" ? null : validIp(headers.get("x-real-ip"));
|
||||||
const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
const peer = forwarded === null ? realIp : validIp(forwarded.split(",").at(-1));
|
||||||
return forwarded || "unknown";
|
if (!peer) return "unknown";
|
||||||
|
const viaCloudflare = validIp(headers.get("cf-connecting-ip"));
|
||||||
|
return viaCloudflare && isPublicUnicast(viaCloudflare) && cloudflare.check(peer, isIP(peer) === 6 ? "ipv6" : "ipv4") ? viaCloudflare : peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die acht 16-Bit-Gruppen einer gueltigen IPv6-Adresse; eine IPv4-Endung (::ffff:a.b.c.d) wird umgerechnet. */
|
||||||
|
function ipv6Groups(ip: string): number[] {
|
||||||
|
let text = ip.toLowerCase().split("%")[0] ?? "";
|
||||||
|
const dotted = /(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
|
||||||
|
if (dotted) {
|
||||||
|
const [a = 0, b = 0, c = 0, d = 0] = dotted.slice(1).map(Number);
|
||||||
|
text = `${text.slice(0, dotted.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
||||||
|
}
|
||||||
|
const parse = (part: string) => (part ? part.split(":").map((group) => Number.parseInt(group, 16)) : []);
|
||||||
|
const [head = "", tail] = text.split("::");
|
||||||
|
const left = parse(head);
|
||||||
|
if (tail === undefined) return left;
|
||||||
|
const right = parse(tail);
|
||||||
|
return [...left, ...new Array<number>(8 - left.length - right.length).fill(0), ...right];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel fuer consumeRateLimit: IPv4 unveraendert, IPv6 als /64-Netz. Ein
|
||||||
|
* Anschluss bekommt meist ein ganzes /64; einzeln gezaehlt liesse sich jedes
|
||||||
|
* Limit durch Wechsel der Adresse innerhalb des eigenen Netzes umgehen.
|
||||||
|
*/
|
||||||
|
export function rateLimitSubject(ip: string): string {
|
||||||
|
if (isIP(ip) !== 6) return ip;
|
||||||
|
const groups = ipv6Groups(ip);
|
||||||
|
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
|
||||||
|
const [high = 0, low = 0] = groups.slice(6);
|
||||||
|
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
|
||||||
|
}
|
||||||
|
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(":")}::/64`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopie der Header mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt. */
|
||||||
|
export function headersWithClientIp(source: Headers): Headers {
|
||||||
|
const headers = new Headers(source);
|
||||||
|
headers.set(CLIENT_IP_HEADER, clientIp(source));
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopie der Anfrage mit CLIENT_IP_HEADER aus clientIp(); ein mitgeschickter Wert wird ersetzt.
|
||||||
|
*
|
||||||
|
* Bewusst aus URL, Methode und Body neu gebaut statt `new Request(request, …)`:
|
||||||
|
* Next reicht Route-Handlern einen Proxy um NextRequest, und undici 7 (Node 24)
|
||||||
|
* prueft private Felder (#state), die ein Proxy nicht hat ("Cannot read private
|
||||||
|
* member #state"). Unter Node 22 (undici 6, Symbole) fiel das nicht auf.
|
||||||
|
*/
|
||||||
|
export async function withClientIp(request: Request): Promise<Request> {
|
||||||
|
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer();
|
||||||
|
return new Request(request.url, { method: request.method, headers: headersWithClientIp(request.headers), body, signal: request.signal });
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { CLIENT_IP_HEADER, clientIp, headersWithClientIp, rateLimitSubject, withClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const h = (values: Record<string, string>) => new Headers(values);
|
||||||
|
|
||||||
|
describe("clientIp hinter Traefik", () => {
|
||||||
|
test("Gegenstelle ist der letzte Eintrag in X-Forwarded-For, nie ein Eintrag des Clients", () => {
|
||||||
|
expect(clientIp(h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", "x-real-ip": "1.2.3.4" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("über Cloudflare gefälschtes X-Real-IP zählt nicht, CF-Connecting-IP nur von Cloudflare-Edges", () => {
|
||||||
|
const viaCloudflare = { "x-real-ip": "1.2.3.4", "x-forwarded-for": "1.2.3.4, 203.0.113.7, 172.70.1.2", "cf-connecting-ip": "203.0.113.7" };
|
||||||
|
expect(clientIp(h(viaCloudflare))).toBe("203.0.113.7");
|
||||||
|
expect(clientIp(h({ "x-forwarded-for": "198.51.100.9", "cf-connecting-ip": "203.0.113.7" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ohne X-Forwarded-For (lokal) gilt X-Real-IP, ungültige Werte ergeben „unknown“", () => {
|
||||||
|
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
|
expect(clientIp(h({}))).toBe("unknown");
|
||||||
|
expect(clientIp(h({ "x-forwarded-for": "1.2.3.4, kein-ip" }))).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Rate-Limits zählen IPv6 je /64-Netz, IPv4-gemappte Adressen wie IPv4", () => {
|
||||||
|
expect(rateLimitSubject(clientIp(h({ "x-forwarded-for": "2001:db8:1:2:aaaa::1" })))).toBe("2001:db8:1:2::/64");
|
||||||
|
expect(rateLimitSubject("2001:0DB8:0001:0002:ffff:ffff:ffff:ffff")).toBe("2001:db8:1:2::/64");
|
||||||
|
expect(rateLimitSubject("2001:db8:1:3::1")).not.toBe(rateLimitSubject("2001:db8:1:2::1"));
|
||||||
|
expect(rateLimitSubject("::ffff:198.51.100.9")).toBe("198.51.100.9");
|
||||||
|
expect(rateLimitSubject("198.51.100.9")).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("better-auth bekommt eine einzelne gültige IP (es normalisiert IPv6 selbst auf /64)", async () => {
|
||||||
|
const request = new Request("https://example.org/api/auth/sign-in/email", { headers: { "x-forwarded-for": "2001:db8:1:2:aaaa::1" } });
|
||||||
|
expect((await withClientIp(request)).headers.get(CLIENT_IP_HEADER)).toBe("2001:db8:1:2:aaaa::1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withClientIp setzt den Header für better-auth und überschreibt einen mitgeschickten Wert", async () => {
|
||||||
|
const request = new Request("https://example.org/api/auth/sign-in/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
expect((await withClientIp(request)).headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withClientIp verträgt Nexts Proxy um NextRequest (Node 24, undici 7) und behält Methode und Body", async () => {
|
||||||
|
// Next 16 reicht Route-Handlern einen Proxy, dessen Methoden an das Original gebunden sind.
|
||||||
|
// undici 7 prüft private Felder (#state); ein Proxy besteht diese Prüfung nicht.
|
||||||
|
const original = new Request("https://example.org/api/auth/sign-in/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "x-forwarded-for": "198.51.100.9", "content-type": "application/json" },
|
||||||
|
body: '{"email":"[email protected]"}',
|
||||||
|
});
|
||||||
|
const wieNext = new Proxy(original, {
|
||||||
|
get(target, key) {
|
||||||
|
const wert = Reflect.get(target, key, target);
|
||||||
|
return typeof wert === "function" ? wert.bind(target) : wert;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const kopie = await withClientIp(wieNext);
|
||||||
|
expect(kopie.method).toBe("POST");
|
||||||
|
expect(kopie.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
|
||||||
|
expect(await kopie.text()).toBe('{"email":"[email protected]"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CF-Connecting-IP nur als öffentliche Unicast-Adresse", () => {
|
||||||
|
const edge = "172.70.1.2";
|
||||||
|
const viaEdge = (cf: string) => clientIp(h({ "x-forwarded-for": `1.2.3.4, ${edge}`, "cf-connecting-ip": cf }));
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"0.1.2.3", "10.0.0.5", "100.64.0.1", "100.127.255.255", "127.0.0.1", "169.254.1.1", "172.16.0.1", "172.31.255.255",
|
||||||
|
"192.168.1.1", "240.0.0.1", "255.255.255.255", "::", "::1", "fc00::1", "fd12::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:192.168.0.1",
|
||||||
|
])("%s wird verworfen, die Gegenstelle gilt", (cf) => {
|
||||||
|
expect(viaEdge(cf)).toBe(edge);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["203.0.113.7", "100.63.255.255", "172.32.0.1", "2001:db8::1", "::ffff:203.0.113.7"])("%s wird übernommen", (cf) => {
|
||||||
|
expect(viaEdge(cf)).toBe(cf);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("X-Real-IP in Produktion", () => {
|
||||||
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
test("ohne X-Forwarded-For ergibt X-Real-IP in Produktion „unknown“", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("unknown");
|
||||||
|
expect(clientIp(h({ "x-forwarded-for": "198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("außerhalb von Produktion gilt X-Real-IP weiter", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "development");
|
||||||
|
expect(clientIp(h({ "x-real-ip": "198.51.100.9" }))).toBe("198.51.100.9");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("headersWithClientIp", () => {
|
||||||
|
test("überschreibt einen mitgeschickten Wert und lässt das Original unverändert", () => {
|
||||||
|
const original = h({ "x-forwarded-for": "1.2.3.4, 198.51.100.9", [CLIENT_IP_HEADER]: "1.2.3.4" });
|
||||||
|
const result = headersWithClientIp(original);
|
||||||
|
expect(result.get(CLIENT_IP_HEADER)).toBe("198.51.100.9");
|
||||||
|
expect(original.get(CLIENT_IP_HEADER)).toBe("1.2.3.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ohne ermittelbare IP steht „unknown“ im Header, nie der Clientwert", () => {
|
||||||
|
expect(headersWithClientIp(h({ [CLIENT_IP_HEADER]: "1.2.3.4" })).get(CLIENT_IP_HEADER)).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@ function request(overrides: Record<string, unknown> = {}) {
|
|||||||
return contactRequestSchema.parse({
|
return contactRequestSchema.parse({
|
||||||
name: "Lena Beispiel",
|
name: "Lena Beispiel",
|
||||||
email: "[email protected]",
|
email: "[email protected]",
|
||||||
phone: "",
|
phone: "+49 170 1234567",
|
||||||
availability: "abends",
|
availability: "abends",
|
||||||
message: "Passt das Camp für Einsteigerinnen?",
|
message: "Passt das Camp für Einsteigerinnen?",
|
||||||
privacy: true,
|
privacy: true,
|
||||||
@@ -61,6 +61,10 @@ describe("Erstgespräch", () => {
|
|||||||
test("Pflichtangaben und Einwilligung werden serverseitig geprüft", () => {
|
test("Pflichtangaben und Einwilligung werden serverseitig geprüft", () => {
|
||||||
expect(contactRequestSchema.safeParse({ name: "L", email: "kein-mail", privacy: false }).success).toBe(false);
|
expect(contactRequestSchema.safeParse({ name: "L", email: "kein-mail", privacy: false }).success).toBe(false);
|
||||||
expect(contactRequestSchema.safeParse({ name: "Lena", email: "[email protected]", privacy: false }).success).toBe(false);
|
expect(contactRequestSchema.safeParse({ name: "Lena", email: "[email protected]", privacy: false }).success).toBe(false);
|
||||||
expect(request({ phone: " " }).phone).toBeNull();
|
const base = { name: "Lena", email: "[email protected]", privacy: true };
|
||||||
|
expect(contactRequestSchema.safeParse(base).success).toBe(false);
|
||||||
|
expect(contactRequestSchema.safeParse({ ...base, phone: " " }).success).toBe(false);
|
||||||
|
expect(contactRequestSchema.safeParse({ ...base, phone: "abc123456" }).success).toBe(false);
|
||||||
|
expect(request({ phone: " +49 170 1234567 " }).phone).toBe("+49 170 1234567");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { enqueueEmail, flushEmails } from "@/lib/email";
|
||||||
|
import { resetEnvCache } from "@/lib/env";
|
||||||
|
import { db, resetDb } from "./support/db";
|
||||||
|
|
||||||
|
const sendMail = vi.fn();
|
||||||
|
|
||||||
|
// SMTP-Versand simulieren; der Datei-Transport (streamTransport) bleibt echt.
|
||||||
|
vi.mock("nodemailer", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("nodemailer")>();
|
||||||
|
const createTransport = (options: Record<string, unknown>) =>
|
||||||
|
options.streamTransport ? actual.default.createTransport(options) : { sendMail };
|
||||||
|
return { ...actual, default: { ...actual.default, createTransport } };
|
||||||
|
});
|
||||||
|
|
||||||
|
function smtpError(message: string, responseCode?: number, code = "EENVELOPE") {
|
||||||
|
return Object.assign(new Error(message), { code, responseCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueMail(to = "[email protected]") {
|
||||||
|
await enqueueEmail(db, { dedupeKey: `test:${to}`, to, subject: "Buchung bestätigt", text: "Hallo", html: "<p>Hallo</p>" });
|
||||||
|
return db.emailMessage.findFirstOrThrow({ where: { toAddress: to } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
process.env.MAIL_TRANSPORT = "smtp";
|
||||||
|
process.env.SMTP_HOST = "smtp.example.org";
|
||||||
|
resetEnvCache();
|
||||||
|
sendMail.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.MAIL_TRANSPORT;
|
||||||
|
delete process.env.SMTP_HOST;
|
||||||
|
resetEnvCache();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Postausgang: Fehler beim SMTP-Versand", () => {
|
||||||
|
test("dauerhafte Ablehnung (5xx) wird sofort FAILED und nie wiederholt", async () => {
|
||||||
|
const mail = await queueMail();
|
||||||
|
sendMail.mockRejectedValue(
|
||||||
|
smtpError("Can't send mail - all recipients were rejected: 556 5.1.10 <[email protected]>: Recipient address rejected: Domain test.de does not accept mail (nullMX)", 556),
|
||||||
|
);
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 1 });
|
||||||
|
const after = await db.emailMessage.findUniqueOrThrow({ where: { id: mail.id } });
|
||||||
|
expect(after.status).toBe("FAILED");
|
||||||
|
expect(after.attempts).toBe(1);
|
||||||
|
|
||||||
|
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 0 });
|
||||||
|
expect(sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Log und gespeicherter Fehler enthalten keine E-Mail-Adresse", async () => {
|
||||||
|
await queueMail();
|
||||||
|
sendMail.mockRejectedValue(smtpError("all recipients were rejected: 556 5.1.10 <[email protected]>: Recipient address rejected", 556));
|
||||||
|
const log = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await flushEmails(db);
|
||||||
|
const logged = log.mock.calls.flat().join(" ");
|
||||||
|
expect(logged).toContain("556 5.1.10");
|
||||||
|
expect(logged).not.toContain("[email protected]");
|
||||||
|
expect((await db.emailMessage.findFirstOrThrow()).lastError).not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vorübergehende Fehler (4xx, Verbindung) bleiben PENDING und werden wiederholt", async () => {
|
||||||
|
const greylisted = await queueMail("[email protected]");
|
||||||
|
const offline = await queueMail("[email protected]");
|
||||||
|
sendMail
|
||||||
|
.mockRejectedValueOnce(smtpError("421 4.7.0 Try again later", 421))
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNECTION" }));
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 2 });
|
||||||
|
for (const id of [greylisted.id, offline.id]) {
|
||||||
|
expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Server- und Absenderfehler (Anmeldung, Richtlinie) bleiben PENDING statt FAILED", async () => {
|
||||||
|
const auth = await queueMail("[email protected]");
|
||||||
|
const policy = await queueMail("[email protected]");
|
||||||
|
sendMail
|
||||||
|
.mockRejectedValueOnce(smtpError("535 5.7.8 Authentication failed", 535, "EAUTH"))
|
||||||
|
.mockRejectedValueOnce(smtpError("554 5.7.1 Message rejected as spam", 554, "EMESSAGE"));
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(await flushEmails(db)).toEqual({ sent: 0, failed: 2 });
|
||||||
|
for (const id of [auth.id, policy.id]) {
|
||||||
|
expect(await db.emailMessage.findUniqueOrThrow({ where: { id } })).toMatchObject({ status: "PENDING", attempts: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("EENVELOPE mit 550/551/553/556 oder 5.1.x ist endgueltig, 552 und 4xx nicht", async () => {
|
||||||
|
const cases: [number, string, boolean][] = [
|
||||||
|
[550, "550 5.1.1 User unknown", true],
|
||||||
|
[551, "551 User not local", true],
|
||||||
|
[553, "553 Mailbox name not allowed", true],
|
||||||
|
[554, "554 5.1.2 Bad destination system address", true],
|
||||||
|
[552, "552 Mailbox full", false],
|
||||||
|
[451, "451 4.1.1 Try later", false],
|
||||||
|
];
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
for (const [index, [code, response, permanent]] of cases.entries()) {
|
||||||
|
await resetDb();
|
||||||
|
const mail = await queueMail(`r${index}@example.org`);
|
||||||
|
sendMail.mockRejectedValueOnce(Object.assign(smtpError(response, code), { response }));
|
||||||
|
await flushEmails(db);
|
||||||
|
const after = await db.emailMessage.findUniqueOrThrow({ where: { id: mail.id } });
|
||||||
|
expect(after.status, response).toBe(permanent ? "FAILED" : "PENDING");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user