From 56ea3348d61a0a83f7da857264eb605cd865bc61 Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Wed, 4 Mar 2026 14:13:16 +0100 Subject: [PATCH] Postgres --- README.md | 12 +- innungsapp/.env.example | 15 +- innungsapp/.env.production.example | 10 + innungsapp/.gitignore | 3 - innungsapp/README.md | 109 +++++- innungsapp/apps/admin/Dockerfile | 7 +- innungsapp/apps/admin/app/[slug]/actions.ts | 8 +- .../[slug]/dashboard/ForcePasswordChange.tsx | 9 +- innungsapp/apps/admin/app/[slug]/page.tsx | 22 +- .../app/api/ai/generate-landing-page/route.ts | 34 +- .../auth/clear-must-change-password/route.ts | 5 +- .../app/api/auth/force-set-password/route.ts | 5 +- .../admin/app/api/export/termin/[id]/route.ts | 4 +- .../apps/admin/app/api/push-token/route.ts | 4 +- innungsapp/apps/admin/app/api/setup/route.ts | 2 +- innungsapp/apps/admin/app/api/upload/route.ts | 15 +- .../admin/app/api/uploads/[...path]/route.ts | 19 +- innungsapp/apps/admin/app/dashboard/page.tsx | 9 +- .../apps/admin/app/passwort-aendern/page.tsx | 2 +- .../admin/app/superadmin/CreateOrgForm.tsx | 9 +- .../apps/admin/app/superadmin/actions.ts | 27 +- .../apps/admin/app/superadmin/layout.tsx | 5 +- .../organizations/[id]/EditOrgForm.tsx | 39 +- .../apps/admin/components/auth/LoginForm.tsx | 22 +- .../apps/admin/components/layout/Header.tsx | 2 +- innungsapp/apps/admin/docker-entrypoint.sh | 26 +- innungsapp/apps/admin/lib/auth.ts | 20 +- innungsapp/apps/admin/lib/email.ts | 27 +- innungsapp/apps/admin/middleware.ts | 18 +- innungsapp/apps/admin/server/context.ts | 4 +- .../apps/admin/server/routers/members.ts | 18 +- innungsapp/apps/admin/tsconfig.tsbuildinfo | 2 +- innungsapp/docker-compose.yml | 36 +- innungsapp/package.json | 1 + innungsapp/packages/shared/package.json | 1 + .../migration.sql | 350 ++++++++++++++++++ .../prisma/migrations/migration_lock.toml | 3 + .../packages/shared/prisma/schema.prisma | 10 +- .../packages/shared/prisma/seed-superadmin.js | 39 +- .../packages/shared/prisma/seed-superadmin.ts | 54 ++- innungsapp/packages/shared/src/index.ts | 1 + 41 files changed, 846 insertions(+), 162 deletions(-) create mode 100644 innungsapp/packages/shared/prisma/migrations/20260304080529_init_postgres/migration.sql create mode 100644 innungsapp/packages/shared/prisma/migrations/migration_lock.toml diff --git a/README.md b/README.md index 84d958c..ac2fbad 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,14 @@ --- +## Technisches Setup + +Die aktuelle und verbindliche Anleitung fuer Start, Docker-Deployment, Migrationen und Seeding liegt in: + +- `innungsapp/README.md` + +Dieses Root-README ist eine Produkt- und Strategieuebersicht. + ## Was ist InnungsApp? InnungsApp ist eine mobile-first SaaS-Plattform, die Innungen und Kreishandwerkerschaften digitalisiert. Sie löst zwei akute Probleme gleichzeitig: @@ -56,7 +64,7 @@ InnungsApp ist eine mobile-first SaaS-Plattform, die Innungen und Kreishandwerke - **Hosting:** Vercel - **Analytics:** PostHog -## Quickstart +## Quickstart (Legacy, nicht der aktuelle Betriebsweg) ```bash # Repo klonen @@ -78,3 +86,5 @@ pnpm --filter @innungsapp/admin dev -- --port 3032 npx expo start --clear Demo: admin@demo.de / demo1234 + +Hinweis: Fuer den aktuellen lokalen/produktiven Betrieb bitte `innungsapp/README.md` verwenden. diff --git a/innungsapp/.env.example b/innungsapp/.env.example index 2c08eff..ed815be 100644 --- a/innungsapp/.env.example +++ b/innungsapp/.env.example @@ -1,9 +1,10 @@ # ============================================= -# DATABASE (SQLite — kein externer DB-Server nötig) -# Dev: file:../../packages/shared/prisma/dev.db -# Prod: file:./prisma/prod.db +# DATABASE (PostgreSQL) # ============================================= -DATABASE_URL="file:../../packages/shared/prisma/dev.db" +POSTGRES_DB="innungsapp" +POSTGRES_USER="innungsapp" +POSTGRES_PASSWORD="innungsapp" +DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" # ============================================= # BETTER-AUTH @@ -28,6 +29,12 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" +# ============================================= +# SUPERADMIN SEED +# ============================================= +SUPERADMIN_EMAIL="superadmin@innungsapp.de" +SUPERADMIN_PASSWORD="change-me-strong-password" + # ============================================= # MOBILE APP (Expo) # ============================================= diff --git a/innungsapp/.env.production.example b/innungsapp/.env.production.example index a8a8347..0eeeb72 100644 --- a/innungsapp/.env.production.example +++ b/innungsapp/.env.production.example @@ -3,6 +3,12 @@ # Kopieren als: innungsapp/.env # ============================================= +# Database (PostgreSQL) +POSTGRES_DB="innungsapp" +POSTGRES_USER="innungsapp" +POSTGRES_PASSWORD="change-this-db-password" +DATABASE_URL="postgresql://innungsapp:change-this-db-password@postgres:5432/innungsapp?schema=public" + # Auth — UNBEDINGT ändern! BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string" BETTER_AUTH_URL="https://yourdomain.com" @@ -20,5 +26,9 @@ NEXT_PUBLIC_APP_URL="https://yourdomain.com" NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com" +# Superadmin Seed +SUPERADMIN_EMAIL="superadmin@yourdomain.com" +SUPERADMIN_PASSWORD="change-this-superadmin-password" + # Uploads UPLOAD_MAX_SIZE_MB="10" diff --git a/innungsapp/.gitignore b/innungsapp/.gitignore index d6a9c78..b02196f 100644 --- a/innungsapp/.gitignore +++ b/innungsapp/.gitignore @@ -21,9 +21,6 @@ out # Uploads (local file storage) apps/admin/uploads/ -# Prisma -packages/shared/prisma/migrations/ - # Expo apps/mobile/.expo apps/mobile/android diff --git a/innungsapp/README.md b/innungsapp/README.md index 930d634..f38e247 100644 --- a/innungsapp/README.md +++ b/innungsapp/README.md @@ -11,7 +11,7 @@ Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (E | Mobile App | Expo + React Native | | API | tRPC v11 | | Auth | better-auth (magic links + credential login) | -| Database | SQLite + Prisma ORM | +| Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) | | Styling | Tailwind CSS (admin), NativeWind (mobile) | ## Projektstruktur @@ -30,6 +30,11 @@ innungsapp/ ## Local Setup +Port-Hinweis: + +- Ohne Docker (lokales `pnpm dev`): App typischerweise auf `http://localhost:3000` +- Mit Docker Compose: App auf `http://localhost:3010` (Container-intern weiter `3000`) + ### Voraussetzungen - Node.js >= 20 @@ -45,13 +50,21 @@ pnpm install ### 2. Umgebungsvariablen setzen (Admin lokal) ```bash -cp .env.example apps/admin/.env.local +cp .env.example .env ``` -Danach `apps/admin/.env.local` anpassen (mindestens `BETTER_AUTH_SECRET`, SMTP-Werte). +Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte). ### 3. DB vorbereiten (lokal) +Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv): + +```bash +docker compose up -d postgres +``` + +Prisma vorbereiten: + ```bash pnpm db:generate pnpm db:push @@ -61,6 +74,7 @@ Optional Demo-Daten: ```bash pnpm db:seed +pnpm db:seed-superadmin ``` ### 4. Entwicklung starten @@ -102,6 +116,10 @@ cp .env.production.example .env Pflichtwerte in `.env`: +- `DATABASE_URL` (PostgreSQL DSN, z. B. `postgresql://innungsapp:...@postgres:5432/innungsapp?schema=public`) +- `POSTGRES_DB` +- `POSTGRES_USER` +- `POSTGRES_PASSWORD` - `BETTER_AUTH_SECRET` (mindestens 32 Zeichen) - `BETTER_AUTH_URL` (z. B. `https://app.deine-innung.de`) - `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`) @@ -111,6 +129,8 @@ Pflichtwerte in `.env`: - `SMTP_SECURE` - `SMTP_USER` - `SMTP_PASS` +- `SUPERADMIN_EMAIL` +- `SUPERADMIN_PASSWORD` ### 3. Container bauen und starten @@ -127,10 +147,14 @@ Hinweis zum DB-Start: ```bash docker compose logs -f admin -curl -fsS http://localhost:3000/api/health +curl -fsS http://localhost:3010/api/health ``` -Erwartet: JSON mit `status: "ok"`. +Erwartet: JSON mit `"status":"ok"`, z. B. + +```json +{"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"} +``` ### 5. Superadmin anlegen (nur beim ersten Start) @@ -138,16 +162,20 @@ Erwartet: JSON mit `status: "ok"`. docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js ``` -Default Login: +Login-Daten kommen aus `.env`: -- E-Mail: `superadmin@innungsapp.de` -- Passwort: `demo1234` +- E-Mail: `SUPERADMIN_EMAIL` +- Passwort: `SUPERADMIN_PASSWORD` -Passwort direkt nach dem ersten Login aendern. +Hinweis: + +- In `NODE_ENV=production` bricht der Seed ab, wenn `SUPERADMIN_PASSWORD` fehlt. +- In Entwicklung wird ohne `SUPERADMIN_PASSWORD` als Fallback `demo1234` genutzt. +- Der Seed ist idempotent (`upsert`) und kann bei Bedarf erneut ausgefuehrt werden. ### 6. HTTPS (Reverse Proxy) -Nginx sollte auf `localhost:3000` weiterleiten und TLS terminieren. +Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren. Beispiel: ```nginx @@ -165,7 +193,7 @@ server { ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem; location / { - proxy_pass http://localhost:3000; + proxy_pass http://localhost:3010; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -188,7 +216,7 @@ docker compose logs -f admin Vorher die exakten Volumenamen pruefen: ```bash -docker volume ls | grep db_data +docker volume ls | grep pg_data docker volume ls | grep uploads_data ``` @@ -197,9 +225,9 @@ Backup: ```bash mkdir -p backups docker run --rm \ - -v innungsapp_db_data:/volume \ + -v innungsapp_pg_data:/volume \ -v "$(pwd)/backups:/backup" \ - alpine sh -c "tar czf /backup/db_data_$(date +%F_%H%M).tar.gz -C /volume ." + alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ." ``` Restore (nur bei gestoppter App): @@ -207,12 +235,59 @@ Restore (nur bei gestoppter App): ```bash docker compose down docker run --rm \ - -v innungsapp_db_data:/volume \ + -v innungsapp_pg_data:/volume \ -v "$(pwd)/backups:/backup" \ alpine sh -c "rm -rf /volume/* && tar xzf /backup/.tar.gz -C /volume" docker compose up -d ``` +### 9. Verifizierte Kommandos (Stand 4. Maerz 2026) + +Die folgenden Befehle wurden in dieser Umgebung erfolgreich ausgefuehrt: + +```bash +# 1) Postgres starten (falls noch nicht aktiv) +docker compose up -d postgres + +# 2) Prisma Client generieren +(cd packages/shared && npx prisma generate) + +# 3) Initiale PostgreSQL-Migration erstellen (einmalig) +(cd packages/shared && \ + DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \ + npx prisma migrate dev --name init_postgres --schema=prisma/schema.prisma --create-only) + +# 4) Migration anwenden +(cd packages/shared && \ + DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \ + npx prisma migrate deploy --schema=prisma/schema.prisma) + +# 5) Gesamtes Setup bauen und starten +docker compose up -d --build + +# 6) Superadmin seeden (mit ENV-Werten) +docker compose exec -e SUPERADMIN_EMAIL=superadmin@innungsapp.de \ + -e SUPERADMIN_PASSWORD='demo1234' \ + -w /app admin node packages/shared/prisma/seed-superadmin.js + +# 7) Laufzeitstatus pruefen +docker compose ps +docker compose logs --tail 80 admin +curl -fsS http://localhost:3010/api/health +``` + +Optionale SQL-Verifikation (wurde ebenfalls erfolgreich getestet): + +```bash +# JSONB-Spalten pruefen +docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \ +"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'organizations' AND column_name IN ('landing_page_features','landing_page_footer') ORDER BY column_name;" + +# Seeded Superadmin pruefen +docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \ +"SELECT u.email, u.role, u.email_verified, a.provider_id, (a.password IS NOT NULL) AS has_password FROM \"user\" u LEFT JOIN account a ON a.user_id = u.id AND a.provider_id = 'credential' WHERE u.email = 'superadmin@innungsapp.de';" +``` + ## Mobile Release (EAS) ```bash @@ -231,14 +306,14 @@ Wichtig: ### `migrate deploy` oder `db push` fehlschlaegt - `DATABASE_URL` pruefen -- Rechte auf `/app/data` pruefen +- `postgres` Container Healthcheck pruefen (`docker compose ps`) - Logs: `docker compose logs -f admin` ### Healthcheck liefert Fehler - Containerstatus: `docker compose ps` - App-Logs lesen -- Reverse Proxy testweise umgehen und direkt `localhost:3000` pruefen +- Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen ### Login funktioniert nicht nach Seed diff --git a/innungsapp/apps/admin/Dockerfile b/innungsapp/apps/admin/Dockerfile index 4b49e36..2ccc0d0 100644 --- a/innungsapp/apps/admin/Dockerfile +++ b/innungsapp/apps/admin/Dockerfile @@ -79,6 +79,10 @@ COPY --from=builder /app/apps/admin/public ./apps/admin/public # Copy Prisma schema + migrations for runtime migrations COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma +# Copy Prisma Client package for runtime seed scripts. +COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/@prisma ./node_modules/@prisma +COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma ./node_modules/.prisma + # Copy Prisma Engine binaries directly to .next/server (where Next.js looks for them) COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/libquery_engine-debian-openssl-3.0.x.so.node /app/apps/admin/.next/server/ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/schema.prisma /app/apps/admin/.next/server/ @@ -89,9 +93,6 @@ RUN npm install -g prisma@5.22.0 # Create uploads directory RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads -# Create SQLite data directory -RUN mkdir -p /app/data && chown nextjs:nodejs /app/data - # Copy entrypoint COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh RUN chmod +x ./docker-entrypoint.sh diff --git a/innungsapp/apps/admin/app/[slug]/actions.ts b/innungsapp/apps/admin/app/[slug]/actions.ts index 4dee535..34466d4 100644 --- a/innungsapp/apps/admin/app/[slug]/actions.ts +++ b/innungsapp/apps/admin/app/[slug]/actions.ts @@ -2,7 +2,6 @@ import { auth, getSanitizedHeaders } from '@/lib/auth' import { prisma } from '@innungsapp/shared' -import { redirect } from 'next/navigation' // @ts-ignore import { hashPassword } from 'better-auth/crypto' @@ -25,7 +24,6 @@ export async function changePasswordAndDisableMustChange(prevState: any, formDat } const userId = session.user.id - const slug = formData.get('slug') as string // Hash and save new password directly — user is already authenticated so no old password needed const newHash = await hashPassword(newPassword) @@ -64,5 +62,9 @@ export async function changePasswordAndDisableMustChange(prevState: any, formDat // ignore } - redirect(`/login?message=password_changed&callbackUrl=/${slug}/dashboard`) + return { + success: true, + error: '', + redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`, + } } diff --git a/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx b/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx index 78b90a0..fe4384f 100644 --- a/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx +++ b/innungsapp/apps/admin/app/[slug]/dashboard/ForcePasswordChange.tsx @@ -1,10 +1,17 @@ 'use client' +import { useEffect } from 'react' import { useActionState } from 'react' import { changePasswordAndDisableMustChange } from '../actions' export function ForcePasswordChange({ slug }: { slug: string }) { - const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '' }) + const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '', redirectTo: '' }) + + useEffect(() => { + if (state?.success && state?.redirectTo) { + window.location.href = state.redirectTo + } + }, [state?.success, state?.redirectTo]) return (
diff --git a/innungsapp/apps/admin/app/[slug]/page.tsx b/innungsapp/apps/admin/app/[slug]/page.tsx index 842a345..535c413 100644 --- a/innungsapp/apps/admin/app/[slug]/page.tsx +++ b/innungsapp/apps/admin/app/[slug]/page.tsx @@ -2,6 +2,24 @@ import { prisma } from '@innungsapp/shared' import { notFound } from 'next/navigation' import Link from 'next/link' +function jsonToText(value: unknown): string { + if (value == null) { + return '' + } + + if (typeof value === 'string') { + return value + } + + if (Array.isArray(value)) { + return value + .map((item) => (typeof item === 'string' ? item : JSON.stringify(item))) + .join('\n') + } + + return JSON.stringify(value) +} + export default async function TenantLandingPage({ params, }: { @@ -26,8 +44,8 @@ export default async function TenantLandingPage({ const secondaryColor = org.secondaryColor || undefined const title = org.landingPageTitle || org.name || 'Zukunft durch Handwerk' const text = org.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.' - const features = org.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung' - const footer = org.landingPageFooter || `© ${new Date().getFullYear()} ${org.name}` + const features = jsonToText(org.landingPageFeatures) || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung' + const footer = jsonToText(org.landingPageFooter) || `© ${new Date().getFullYear()} ${org.name}` const sectionTitle = org.landingPageSectionTitle || `${org.name || 'Ihre Innung'} – Gemeinsam stark fürs Handwerk` const buttonText = org.landingPageButtonText || 'Jetzt App laden' diff --git a/innungsapp/apps/admin/app/api/ai/generate-landing-page/route.ts b/innungsapp/apps/admin/app/api/ai/generate-landing-page/route.ts index 9d17b89..8b664cd 100644 --- a/innungsapp/apps/admin/app/api/ai/generate-landing-page/route.ts +++ b/innungsapp/apps/admin/app/api/ai/generate-landing-page/route.ts @@ -39,9 +39,32 @@ function getModel(provider: LlmProvider): string { return process.env.OPENAI_MODEL || 'gpt-4o-mini' } +function hasApiKey(provider: LlmProvider): boolean { + if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY + return !!process.env.OPENAI_API_KEY +} + +function buildFallbackLandingContent(orgName: string, context: string) { + const cleanOrg = orgName.trim() + const cleanContext = context.trim().replace(/\s+/g, ' ') + const shortContext = cleanContext.slice(0, 180) + const detailSentence = shortContext + ? `Dabei stehen insbesondere ${shortContext}.` + : 'Dabei stehen regionale Vernetzung, starke Ausbildung und praxisnahe Unterstützung im Mittelpunkt.' + + return { + title: `${cleanOrg} - Stark im Handwerk`, + text: `${cleanOrg} verbindet Betriebe, stärkt die Gemeinschaft und setzt sich für die Interessen des Handwerks vor Ort ein. ${detailSentence}`, + fallbackUsed: true, + } +} + export async function POST(req: Request) { + let parsedBody: any = null + try { const body = await req.json() + parsedBody = body const { orgName, context } = body if (!orgName || !context) { @@ -49,9 +72,14 @@ export async function POST(req: Request) { } const provider = getProvider() - const client = createClient(provider) const model = getModel(provider) + if (!hasApiKey(provider)) { + return NextResponse.json(buildFallbackLandingContent(orgName, context)) + } + + const client = createClient(provider) + const systemMessage = `Sie sind ein professioneller Copywriter für eine moderne deutsche Innung oder Kreishandwerkerschaft. Erstellen Sie eine moderne, ansprechende Überschrift (Heading) und einen Einleitungstext für eine Landingpage. @@ -89,6 +117,10 @@ WICHTIG: Geben Sie AUSSCHLIESSLICH ein valides JSON-Objekt zurück, komplett ohn } catch (error: any) { console.error('Error generating AI landing page content:', error) + if (parsedBody?.orgName && parsedBody?.context) { + return NextResponse.json(buildFallbackLandingContent(parsedBody.orgName, parsedBody.context)) + } + return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 }) } } diff --git a/innungsapp/apps/admin/app/api/auth/clear-must-change-password/route.ts b/innungsapp/apps/admin/app/api/auth/clear-must-change-password/route.ts index ad53ac0..bf23d72 100644 --- a/innungsapp/apps/admin/app/api/auth/clear-must-change-password/route.ts +++ b/innungsapp/apps/admin/app/api/auth/clear-must-change-password/route.ts @@ -1,10 +1,9 @@ import { NextResponse } from 'next/server' -import { auth } from '@/lib/auth' +import { auth, getSanitizedHeaders } from '@/lib/auth' import { prisma } from '@innungsapp/shared' -import { headers } from 'next/headers' export async function POST() { - const session = await auth.api.getSession({ headers: await headers() }) + const session = await auth.api.getSession({ headers: await getSanitizedHeaders() }) if (!session?.user?.id) { return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 }) } diff --git a/innungsapp/apps/admin/app/api/auth/force-set-password/route.ts b/innungsapp/apps/admin/app/api/auth/force-set-password/route.ts index 2a9fabc..bb7658a 100644 --- a/innungsapp/apps/admin/app/api/auth/force-set-password/route.ts +++ b/innungsapp/apps/admin/app/api/auth/force-set-password/route.ts @@ -1,12 +1,11 @@ import { NextRequest, NextResponse } from 'next/server' -import { auth } from '@/lib/auth' +import { auth, getSanitizedHeaders } from '@/lib/auth' import { prisma } from '@innungsapp/shared' -import { headers } from 'next/headers' // @ts-ignore import { hashPassword } from 'better-auth/crypto' export async function POST(req: NextRequest) { - const session = await auth.api.getSession({ headers: await headers() }) + const session = await auth.api.getSession({ headers: await getSanitizedHeaders() }) if (!session?.user?.id) { return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 }) } diff --git a/innungsapp/apps/admin/app/api/export/termin/[id]/route.ts b/innungsapp/apps/admin/app/api/export/termin/[id]/route.ts index d4785d2..53791dc 100644 --- a/innungsapp/apps/admin/app/api/export/termin/[id]/route.ts +++ b/innungsapp/apps/admin/app/api/export/termin/[id]/route.ts @@ -1,9 +1,9 @@ import { NextRequest } from 'next/server' -import { auth } from '@/lib/auth' +import { auth, getSanitizedHeaders } from '@/lib/auth' import { prisma } from '@innungsapp/shared' export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await auth.api.getSession({ headers: req.headers }) + const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) }) if (!session?.user) { return new Response('Unauthorized', { status: 401 }) } diff --git a/innungsapp/apps/admin/app/api/push-token/route.ts b/innungsapp/apps/admin/app/api/push-token/route.ts index 53cbc7e..55422a0 100644 --- a/innungsapp/apps/admin/app/api/push-token/route.ts +++ b/innungsapp/apps/admin/app/api/push-token/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' -import { auth } from '@/lib/auth' +import { auth, getSanitizedHeaders } from '@/lib/auth' import { prisma } from '@innungsapp/shared' export async function POST(req: NextRequest) { - const session = await auth.api.getSession({ headers: req.headers }) + const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) }) if (!session?.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } diff --git a/innungsapp/apps/admin/app/api/setup/route.ts b/innungsapp/apps/admin/app/api/setup/route.ts index 5a16412..9c3b6ff 100644 --- a/innungsapp/apps/admin/app/api/setup/route.ts +++ b/innungsapp/apps/admin/app/api/setup/route.ts @@ -1,6 +1,6 @@ /** * DEV-ONLY: Sets a password for the demo admin user via better-auth. - * Call once after seeding: GET http://localhost:3032/api/setup + * Call once after seeding: GET http://localhost:3010/api/setup * Remove this file before going to production. */ import { NextResponse } from 'next/server' diff --git a/innungsapp/apps/admin/app/api/upload/route.ts b/innungsapp/apps/admin/app/api/upload/route.ts index 7e4f5d5..522f519 100644 --- a/innungsapp/apps/admin/app/api/upload/route.ts +++ b/innungsapp/apps/admin/app/api/upload/route.ts @@ -2,14 +2,21 @@ import { NextRequest, NextResponse } from 'next/server' import { writeFile, mkdir } from 'fs/promises' import path from 'path' import { randomUUID } from 'crypto' -import { auth } from '@/lib/auth' +import { auth, getSanitizedHeaders } from '@/lib/auth' -const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads' +const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads') const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024 +function getUploadRoot() { + if (path.isAbsolute(UPLOAD_DIR)) { + return UPLOAD_DIR + } + return path.resolve(process.cwd(), UPLOAD_DIR) +} + export async function POST(req: NextRequest) { // Auth check - const session = await auth.api.getSession({ headers: req.headers }) + const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) }) if (!session?.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -39,7 +46,7 @@ export async function POST(req: NextRequest) { const ext = path.extname(file.name) const fileName = `${randomUUID()}${ext}` - const uploadPath = path.join(process.cwd(), UPLOAD_DIR) + const uploadPath = getUploadRoot() await mkdir(uploadPath, { recursive: true }) const buffer = Buffer.from(await file.arrayBuffer()) diff --git a/innungsapp/apps/admin/app/api/uploads/[...path]/route.ts b/innungsapp/apps/admin/app/api/uploads/[...path]/route.ts index 1be0ca5..e5c4340 100644 --- a/innungsapp/apps/admin/app/api/uploads/[...path]/route.ts +++ b/innungsapp/apps/admin/app/api/uploads/[...path]/route.ts @@ -2,21 +2,28 @@ import { NextRequest, NextResponse } from 'next/server' import { readFile } from 'fs/promises' import path from 'path' -const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads' -// Added comment to force recompile after ENOSPC +const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads') + +function getUploadRoot() { + if (path.isAbsolute(UPLOAD_DIR)) { + return UPLOAD_DIR + } + return path.resolve(process.cwd(), UPLOAD_DIR) +} export async function GET( req: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { try { - const { path: filePathParams } = await params; - const filePath = path.join(process.cwd(), UPLOAD_DIR, ...filePathParams) + const { path: filePathParams } = await params + const uploadRoot = getUploadRoot() + const filePath = path.join(uploadRoot, ...filePathParams) // Security: prevent path traversal const resolved = path.resolve(filePath) - const uploadDir = path.resolve(path.join(process.cwd(), UPLOAD_DIR)) - if (!resolved.startsWith(uploadDir)) { + const uploadDir = path.resolve(uploadRoot) + if (!resolved.startsWith(uploadDir + path.sep) && resolved !== uploadDir) { return new NextResponse('Forbidden', { status: 403 }) } diff --git a/innungsapp/apps/admin/app/dashboard/page.tsx b/innungsapp/apps/admin/app/dashboard/page.tsx index da5dde9..8d8f72b 100644 --- a/innungsapp/apps/admin/app/dashboard/page.tsx +++ b/innungsapp/apps/admin/app/dashboard/page.tsx @@ -1,4 +1,4 @@ -import { auth } from '@/lib/auth' +import { auth, getSanitizedHeaders } from '@/lib/auth' import { prisma } from '@innungsapp/shared' import { headers } from 'next/headers' import Link from 'next/link' @@ -7,7 +7,7 @@ import { redirect } from 'next/navigation' export default async function GlobalDashboardRedirect() { const headerList = await headers() const host = headerList.get('host') || '' - const session = await auth.api.getSession({ headers: headerList }) + const session = await auth.api.getSession({ headers: await getSanitizedHeaders(headerList) }) if (!session?.user) { redirect('/login') @@ -93,9 +93,8 @@ export default async function GlobalDashboardRedirect() {
{ 'use server' - const { auth } = await import('@/lib/auth') - const { headers } = await import('next/headers') - await auth.api.signOut({ headers: await headers() }) + const { auth, getSanitizedHeaders } = await import('@/lib/auth') + await auth.api.signOut({ headers: await getSanitizedHeaders() }) redirect('/login') }}>