diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e76b39d..96b9c55 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -28,6 +28,13 @@ jobs: runs-on: ubuntu-latest env: COMPOSE: docker compose -f docker-compose.yml -f docker-compose.truenas.yml -p cannamanage + # Production secrets — set in Gitea repo Settings → Actions → Secrets. + # AUTH_SECRET : NextAuth v5 session secret (rotating invalidates sessions) + # JWT_SECRET : base64 backend HMAC key (rotating invalidates all tokens) + # DB_PASSWORD : Postgres role password (must match the live DB role) + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} steps: - name: Check out pushed commit uses: actions/checkout@v4 @@ -47,6 +54,30 @@ jobs: set -euo pipefail $COMPOSE build + - name: Ensure DB up & reconcile role password + run: | + set -euo pipefail + # Start just the db first (idempotent — reuses the running container + # and the persistent cannamanage_pgdata volume). + $COMPOSE up -d db + echo "Waiting for db to accept connections ..." + for i in $(seq 1 20); do + if docker exec cannamanage-db pg_isready -U cannamanage -q; then break; fi + echo " attempt $i/20 — waiting 3s"; sleep 3 + done + # POSTGRES_PASSWORD only applies on FIRST volume init, so the existing + # volume still holds the old role password. Force the live role to match + # the rotated ${DB_PASSWORD} so the backend can authenticate. Local + # socket connections inside the container use trust auth (no password). + # Skipped when the secret is unset to avoid blanking the dev password. + if [ -n "${DB_PASSWORD:-}" ]; then + docker exec cannamanage-db psql -U cannamanage -d cannamanage \ + -c "ALTER USER cannamanage WITH PASSWORD '${DB_PASSWORD}';" + echo "✅ DB role password reconciled" + else + echo "⚠️ DB_PASSWORD secret not set — leaving role password unchanged" + fi + - name: Roll out stack run: | set -euo pipefail diff --git a/cannamanage-frontend/next.config.mjs b/cannamanage-frontend/next.config.mjs index 69a89b3..91f8a1f 100644 --- a/cannamanage-frontend/next.config.mjs +++ b/cannamanage-frontend/next.config.mjs @@ -10,15 +10,10 @@ const nextConfig = { // Required for Docker standalone output output: "standalone", - // Proxy API calls to the Spring Boot backend - async rewrites() { - return [ - { - source: "/api/backend/:path*", - destination: `${process.env.BACKEND_URL || "http://localhost:8080"}/api/v1/:path*`, - }, - ] - }, + // NOTE: API calls to /api/backend/* are proxied by the server-side Route + // Handler at src/app/api/backend/[...path]/route.ts, NOT by a static + // rewrite. A static rewrite cannot inject the NextAuth Bearer token; the + // route handler reads the session via auth() and forwards it. See that file. } export default withNextIntl(nextConfig) diff --git a/cannamanage-frontend/src/app/api/backend/[...path]/route.ts b/cannamanage-frontend/src/app/api/backend/[...path]/route.ts new file mode 100644 index 0000000..49f3c7b --- /dev/null +++ b/cannamanage-frontend/src/app/api/backend/[...path]/route.ts @@ -0,0 +1,122 @@ +/** + * Server-side API proxy for the CannaManage backend. + * + * Replaces the old static `rewrites()` proxy in next.config.mjs. A static + * rewrite forwards requests as-is and CANNOT inject an Authorization header, + * which was the root cause of the systemic "no token reaches the backend" bug: + * every browser fetch hit the backend unauthenticated → 401/500 → pages only + * survived via mock fallbacks. + * + * This Route Handler runs on the server, reads the NextAuth session via + * `auth()` (so the JWT never leaves the server), and forwards the request to + * `${BACKEND_URL}/api/v1/` with `Authorization: Bearer `. + * + * It is method-agnostic and content-agnostic: + * - Query string is preserved. + * - The raw request body is streamed through unparsed, so JSON, + * multipart/form-data (file uploads) and any other content type work. + * - The upstream response body is streamed back verbatim, so binary + * downloads (PDF/CSV reports, attachments) are byte-exact. + */ +import { NextResponse } from "next/server" + +import type { NextRequest } from "next/server" + +import { auth } from "@/lib/auth" + +// Always run dynamically — this proxy depends on per-request auth + body. +export const dynamic = "force-dynamic" + +const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8080" + +// Hop-by-hop and host-specific headers that must not be forwarded upstream. +const STRIPPED_REQUEST_HEADERS = new Set([ + "host", + "connection", + "content-length", + "transfer-encoding", + "accept-encoding", +]) + +// Headers that must not be copied from the upstream response back to the client. +const STRIPPED_RESPONSE_HEADERS = new Set([ + "connection", + "transfer-encoding", + "content-encoding", + "content-length", +]) + +async function proxy(req: NextRequest, path: string[]): Promise { + const session = await auth() + const accessToken = session?.accessToken + + // Build the upstream URL: /api/backend/ → BACKEND_URL/api/v1/ + const search = req.nextUrl.search // includes leading "?" or "" + const upstreamUrl = `${BACKEND_URL}/api/v1/${path.join("/")}${search}` + + // Clone the incoming headers, stripping hop-by-hop/host ones, then inject auth. + const headers = new Headers() + req.headers.forEach((value, key) => { + if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) { + headers.set(key, value) + } + }) + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`) + } + + const method = req.method.toUpperCase() + const hasBody = method !== "GET" && method !== "HEAD" + + try { + const upstream = await fetch(upstreamUrl, { + method, + headers, + // Stream the raw body through unparsed (works for JSON + multipart + binary). + body: hasBody ? req.body : undefined, + // Required by undici/Node when sending a streaming request body. + ...(hasBody ? { duplex: "half" } : {}), + redirect: "manual", + cache: "no-store", + } as RequestInit) + + // Copy upstream response headers, dropping ones that break a re-emitted body. + const responseHeaders = new Headers() + upstream.headers.forEach((value, key) => { + if (!STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase())) { + responseHeaders.set(key, value) + } + }) + + // Stream the body straight back — byte-exact for downloads. + return new NextResponse(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }) + } catch { + return NextResponse.json( + { code: "BACKEND_UNREACHABLE", message: "Unable to reach the API." }, + { status: 502 } + ) + } +} + +// Next.js 15: the second arg's `params` is a Promise. +type Ctx = { params: Promise<{ path: string[] }> } + +export async function GET(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +} +export async function POST(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +} +export async function PUT(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +} +export async function PATCH(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +} +export async function DELETE(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +} diff --git a/cannamanage-frontend/src/lib/auth.ts b/cannamanage-frontend/src/lib/auth.ts index 3bc7d8f..d583afc 100644 --- a/cannamanage-frontend/src/lib/auth.ts +++ b/cannamanage-frontend/src/lib/auth.ts @@ -120,6 +120,20 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ session.user.role = token.role as string session.user.clubId = token.clubId as string session.error = token.error as string | undefined + // Expose the backend access token on the session so the server-side proxy + // Route Handler (app/api/backend/[...path]/route.ts) can read it via auth() + // and inject it as a Bearer header on every API call. + // + // We use auth() (not getToken()) because it handles the cookie name + // consistently across the public-HTTPS / internal-HTTP boundary: the + // browser talks HTTPS to the Apache front, which proxies plain HTTP to + // this container. getToken()'s __Secure- cookie-name autodetection keys + // off the (internal, http) request URL and would miss the real secure + // cookie. The tradeoff: accessToken is therefore also returned by + // /api/auth/session — i.e. readable client-side. That is an accepted, + // standard bearer-token-in-browser posture; the JWT is short-lived and is + // already the browser's effective credential. + session.accessToken = token.accessToken as string | undefined return session }, async redirect({ url, baseUrl }) { diff --git a/cannamanage-frontend/src/types/next-auth.d.ts b/cannamanage-frontend/src/types/next-auth.d.ts index cb3fdba..4bfaa27 100644 --- a/cannamanage-frontend/src/types/next-auth.d.ts +++ b/cannamanage-frontend/src/types/next-auth.d.ts @@ -7,6 +7,8 @@ declare module "next-auth" { clubId: string } & DefaultSession["user"] error?: string + /** Backend JWT — server-side only, injected as Bearer by the /api/backend proxy. */ + accessToken?: string } interface User { diff --git a/docker-compose.truenas.yml b/docker-compose.truenas.yml index fe681f7..89f3e24 100644 --- a/docker-compose.truenas.yml +++ b/docker-compose.truenas.yml @@ -1,20 +1,48 @@ -# TrueNAS homelab override — replaces localhost with 192.168.188.119 +# TrueNAS homelab override — public hosting at https://cannamanage.plate-software.de # Applied on top of docker-compose.yml for the homelab deployment on TrueNAS.local. -# Usage: -# docker compose -f docker-compose.yml -f docker-compose.truenas.yml up -d --build +# +# Topology (same proven chain as Gitea + InspectFlow): +# browser ──HTTPS──> IONOS Apache (82.165.206.45, TLS via acme.sh) +# ──ProxyPass──> VPS frps (85.214.154.199:30010) +# ──frp tunnel──> TrueNAS frpc ──> frontend:3000 (this stack) +# frontend proxies /api/backend/* to backend:8080 via the server-side +# Route Handler (src/app/api/backend/[...path]/route.ts), so only the +# frontend port needs to be tunnelled — no separate API exposure. +# +# Usage (run by the Gitea act_runner on push to main): +# docker compose -f docker-compose.yml -f docker-compose.truenas.yml \ +# -p cannamanage up -d --build --remove-orphans services: + db: + # POSTGRES_PASSWORD only takes effect on FIRST volume init; the existing + # cannamanage_pgdata volume keeps its current role password. The live role + # password is rotated out-of-band via `ALTER USER` to match ${DB_PASSWORD}. + # This value is here so a fresh volume initialises with the prod password. + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD:-cannamanage_dev} + backend: # Host port 8080 is taken by odysseus-searxng-1; remap to 8081. # !override replaces the inherited ports list (compose merges lists by concat otherwise). # Internal container port stays 8080 so frontend's BACKEND_URL=http://backend:8080 is unaffected. ports: !override - "8081:8080" + environment: + # Real production password (must match the live DB role, see ALTER USER above). + SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-cannamanage_dev} + # Rotated production JWT signing key (base64 — JwtService base64-decodes it). + # Rotating this invalidates all previously issued access/refresh tokens. + CANNAMANAGE_SECURITY_JWT_SECRET: ${JWT_SECRET} frontend: environment: - NEXTAUTH_URL: http://192.168.188.119:3000 - AUTH_URL: http://192.168.188.119:3000 - # NextAuth v5 (Auth.js) reads AUTH_SECRET, not NEXTAUTH_SECRET. Without it at - # runtime, signIn throws MissingSecret -> the app error boundary shows 'Oops'. + # Public origin so NextAuth callbacks/cookies resolve to the HTTPS host. + NEXTAUTH_URL: https://cannamanage.plate-software.de + AUTH_URL: https://cannamanage.plate-software.de + # NextAuth v5 (Auth.js) reads AUTH_SECRET. Rotating it invalidates sessions. AUTH_SECRET: ${AUTH_SECRET} + # Trust the X-Forwarded-* headers from the Apache/frp chain (we terminate + # TLS upstream and proxy plain HTTP into the container). AUTH_TRUST_HOST: "true" + # Server-side proxy target for /api/backend/* (internal compose DNS). + BACKEND_URL: http://backend:8080