Initial scaffold: push-to-deploy + auth-proxy + public-switch template
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# Copy to .env for local dev. In production these come from Gitea Actions secrets
|
||||||
|
# (Settings → Actions → Secrets), NOT from a committed file.
|
||||||
|
#
|
||||||
|
# Generate strong values:
|
||||||
|
# for s in AUTH_SECRET JWT_SECRET DB_PASSWORD; do echo "$s=$(openssl rand -base64 32)"; done
|
||||||
|
|
||||||
|
# NextAuth v5 (Auth.js) session secret. Rotating invalidates all sessions.
|
||||||
|
AUTH_SECRET=changeme-base64-32
|
||||||
|
|
||||||
|
# Backend HMAC signing key (base64; JwtService base64-decodes it).
|
||||||
|
# Rotating invalidates all previously issued access/refresh tokens.
|
||||||
|
JWT_SECRET=changeme-base64-32
|
||||||
|
|
||||||
|
# Postgres role password for the live DB role.
|
||||||
|
# NOTE: only applies on FIRST volume init; the deploy reconciles existing
|
||||||
|
# volumes via ALTER USER (see .gitea/workflows/deploy.yml).
|
||||||
|
DB_PASSWORD=changeme-base64-24
|
||||||
|
|
||||||
|
# ── Local-only frontend origin (override in compose for public phase) ──
|
||||||
|
# For LOCAL phase point these at the LAN host:
|
||||||
|
# NEXTAUTH_URL=http://192.168.188.119:__FRONTEND_PORT__
|
||||||
|
# AUTH_URL=http://192.168.188.119:__FRONTEND_PORT__
|
||||||
|
# For PUBLIC phase the TrueNAS override sets them to https://__SUBDOMAIN__
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
AUTH_URL=http://localhost:3000
|
||||||
|
AUTH_TRUST_HOST=true
|
||||||
|
BACKEND_URL=http://backend:8080
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
name: Deploy to TrueNAS
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# HOMELAB APP TEMPLATE — push-to-deploy workflow.
|
||||||
|
# Proven on InspectFlow + CannaManage. See homelab-release-runbook.md.
|
||||||
|
#
|
||||||
|
# Before first push, replace these placeholders everywhere in the repo:
|
||||||
|
# __PROJECT__ compose project name + container prefix (e.g. "myapp")
|
||||||
|
# __FRONTEND_PORT__ LAN host port the frontend publishes (e.g. 3001) — must be
|
||||||
|
# unique across all stacks on TrueNAS (see runbook §2 registry)
|
||||||
|
# __BACKEND_PORT__ LAN host port for backend debug (e.g. 8082) — unique too,
|
||||||
|
# or remove the backend ports block entirely if not needed
|
||||||
|
#
|
||||||
|
# Auto-deploys on push to main via the INSTANCE-LEVEL self-hosted Gitea Actions
|
||||||
|
# runner on TrueNAS (no per-repo runner registration needed). The runner mounts
|
||||||
|
# the host Docker socket, so `docker compose` acts on the TrueNAS daemon and
|
||||||
|
# (re)builds + restarts the live __PROJECT__ stack from the exact pushed commit.
|
||||||
|
#
|
||||||
|
# db is internal-only (no host publish) — reachable as db:5432 on the compose net.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# Avoid overlapping deploys if pushes land in quick succession.
|
||||||
|
concurrency:
|
||||||
|
group: truenas-deploy-__PROJECT__
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
COMPOSE: docker compose -f docker-compose.yml -f docker-compose.truenas.yml -p __PROJECT__
|
||||||
|
# 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
|
||||||
|
|
||||||
|
- name: Show toolchain
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker version --format 'docker {{.Server.Version}}'
|
||||||
|
docker compose version
|
||||||
|
|
||||||
|
# NOTE: Backend tests and frontend lint are a LOCAL-ONLY gate. The
|
||||||
|
# self-hosted act runner uses Docker-in-Docker which doesn't support volume
|
||||||
|
# mounts for nested containers. Run them before pushing.
|
||||||
|
|
||||||
|
- name: Build images
|
||||||
|
run: |
|
||||||
|
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 __PROJECT___pgdata volume).
|
||||||
|
$COMPOSE up -d db
|
||||||
|
echo "Waiting for db to accept connections ..."
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
if docker exec __PROJECT__-db pg_isready -U __PROJECT__ -q; then break; fi
|
||||||
|
echo " attempt $i/20 — waiting 3s"; sleep 3
|
||||||
|
done
|
||||||
|
# POSTGRES_PASSWORD only applies on FIRST volume init, so an 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 __PROJECT__-db psql -U __PROJECT__ -d __PROJECT__ \
|
||||||
|
-c "ALTER USER __PROJECT__ 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
|
||||||
|
$COMPOSE up -d --remove-orphans
|
||||||
|
|
||||||
|
- name: Wait for backend health
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "Waiting for backend health on :__BACKEND_PORT__ ..."
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
if wget -q -O /dev/null http://192.168.188.119:__BACKEND_PORT__/actuator/health; then
|
||||||
|
echo "✅ Backend healthy after ${i} attempt(s)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo " attempt $i/20 — waiting 6s"
|
||||||
|
sleep 6
|
||||||
|
done
|
||||||
|
echo "❌ Backend did not become healthy — recent logs:"
|
||||||
|
$COMPOSE logs --tail=40 backend
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Verify frontend
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Probe the frontend on its own loopback INSIDE the container via the
|
||||||
|
# bundled node runtime. Network-namespace-independent (no reliance on the
|
||||||
|
# host port being wired during a mid-recreate window, which caused a
|
||||||
|
# transient false-failure previously) and needs no wget/curl in the image.
|
||||||
|
# Any HTTP status < 500 counts as "up" — root returns 307 -> /login when
|
||||||
|
# unauthenticated, which is healthy.
|
||||||
|
echo "Waiting for frontend on container loopback :3000 ..."
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
if docker exec __PROJECT__-frontend node -e "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"; then
|
||||||
|
echo "✅ Frontend responding after ${i} attempt(s)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo " attempt $i/20 — waiting 5s"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "❌ Frontend did not respond — recent logs:"
|
||||||
|
$COMPOSE logs --tail=40 frontend
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Prune dangling images
|
||||||
|
run: docker image prune -f || true
|
||||||
|
|
||||||
|
- name: Deployment summary
|
||||||
|
run: |
|
||||||
|
echo "=== __PROJECT__ deployed to TrueNAS ==="
|
||||||
|
echo "Commit: ${GITHUB_SHA}"
|
||||||
|
echo "Backend: http://192.168.188.119:__BACKEND_PORT__"
|
||||||
|
echo "Frontend: http://192.168.188.119:__FRONTEND_PORT__"
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Secrets — never commit. Use Gitea Actions secrets in production.
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Node / Next
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Java / Maven
|
||||||
|
target/
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# homelab-app-template
|
||||||
|
|
||||||
|
Scaffold for a new homelab alpha app that deploys to TrueNAS via Gitea Actions
|
||||||
|
and can be publicly hosted over HTTPS with one script. Encodes the proven
|
||||||
|
InspectFlow / CannaManage pattern so Work Lumen can onboard a project in minutes,
|
||||||
|
not days.
|
||||||
|
|
||||||
|
**Read first:** [`homelab-release-runbook.md`](../plans/homelab-release-runbook.md:1)
|
||||||
|
— the authoritative procedure and the **port/subdomain registry** (allocate your
|
||||||
|
ports there before touching configs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's in the box
|
||||||
|
|
||||||
|
```
|
||||||
|
.gitea/workflows/deploy.yml push-to-main → build + run on TrueNAS
|
||||||
|
docker-compose.yml base: db + backend + frontend
|
||||||
|
docker-compose.truenas.yml homelab override: ports, secrets, db internal-only
|
||||||
|
frontend/src/app/api/backend/[...path]/route.ts ⭐ the auth-token proxy fix
|
||||||
|
.env.example secret names + how to generate them
|
||||||
|
```
|
||||||
|
|
||||||
|
You bring the actual app code (Spring backend in `backend/`, Next.js frontend in
|
||||||
|
`frontend/`). The template wires the deploy + auth + hosting plumbing only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onboard a new project (local phase)
|
||||||
|
|
||||||
|
1. **Allocate** a frp remotePort + frontend/backend host ports + subdomain in the
|
||||||
|
registry (runbook §2). Next free frp port lives there.
|
||||||
|
|
||||||
|
2. **Substitute placeholders.** From the new repo root:
|
||||||
|
```bash
|
||||||
|
PROJECT=myapp
|
||||||
|
FRONTEND_PORT=3001 # unique LAN host port
|
||||||
|
BACKEND_PORT=8082 # unique LAN host port (or remove backend ports block)
|
||||||
|
SUBDOMAIN=myapp.plate-software.de
|
||||||
|
|
||||||
|
grep -rlZ '__PROJECT__\|__FRONTEND_PORT__\|__BACKEND_PORT__\|__SUBDOMAIN__' . \
|
||||||
|
| xargs -0 sed -i \
|
||||||
|
-e "s/__PROJECT__/$PROJECT/g" \
|
||||||
|
-e "s/__FRONTEND_PORT__/$FRONTEND_PORT/g" \
|
||||||
|
-e "s/__BACKEND_PORT__/$BACKEND_PORT/g" \
|
||||||
|
-e "s/__SUBDOMAIN__/$SUBDOMAIN/g"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Set Gitea Actions secrets** (repo → Settings → Actions → Secrets):
|
||||||
|
`AUTH_SECRET`, `JWT_SECRET`, `DB_PASSWORD`. Generate:
|
||||||
|
```bash
|
||||||
|
for s in AUTH_SECRET JWT_SECRET DB_PASSWORD; do echo "$s=$(openssl rand -base64 32)"; done
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Push to `main`.** The instance-level act_runner on TrueNAS auto-deploys.
|
||||||
|
App is live at `http://192.168.188.119:$FRONTEND_PORT`. Done — you can stay
|
||||||
|
here for the whole early-alpha period.
|
||||||
|
|
||||||
|
## Go public (additive switch, when ready)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DNS A-record $SUBDOMAIN → 82.165.206.45 must exist first.
|
||||||
|
./scripts/homelab-publish.sh $PROJECT $FRONTEND_PORT $FRP_REMOTE_PORT $SUBDOMAIN
|
||||||
|
```
|
||||||
|
(`homelab-publish.sh` lives in the pi_mcps repo `scripts/`.) Verify per runbook §5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this template exists
|
||||||
|
|
||||||
|
- **The auth fix.** `frontend/.../api/backend/[...path]/route.ts` injects the
|
||||||
|
Bearer token server-side via `auth()`. A static Next rewrite cannot do this and
|
||||||
|
silently breaks auth — the ~9-day CannaManage blocker. Never delete this file.
|
||||||
|
- **db is internal-only** (`ports: !override []`) — no LAN Postgres exposure.
|
||||||
|
- **Password rotation reconcile** in deploy.yml handles the persistent-volume
|
||||||
|
`POSTGRES_PASSWORD` gotcha.
|
||||||
|
- **Frontend verify** uses a container-loopback node probe (no transient
|
||||||
|
false-failures from host-port timing).
|
||||||
|
|
||||||
|
See the runbook for the full topology, auth gotchas (NextAuth v5 `auth()` vs
|
||||||
|
`getToken()`), and the Let's Encrypt CA pin.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# TrueNAS homelab override — applied on top of docker-compose.yml for the
|
||||||
|
# homelab deployment on TrueNAS.local. Proven on InspectFlow + CannaManage.
|
||||||
|
#
|
||||||
|
# Replace placeholders before first push:
|
||||||
|
# __PROJECT__ container prefix / compose project name
|
||||||
|
# __FRONTEND_PORT__ unique LAN host port for the frontend (registry §2)
|
||||||
|
# __BACKEND_PORT__ unique LAN host port for backend debug (or remove block)
|
||||||
|
# __SUBDOMAIN__ public hostname (only matters once you go public)
|
||||||
|
#
|
||||||
|
# Topology (public phase — additive, see runbook §4):
|
||||||
|
# browser ──HTTPS──> IONOS Apache (82.165.206.45, TLS via acme.sh/LE)
|
||||||
|
# ──ProxyPass──> VPS frps (85.214.154.199:<frpRemotePort>)
|
||||||
|
# ──frp tunnel──> TrueNAS frpc ──> frontend:__FRONTEND_PORT__ (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 __PROJECT__ up -d --build --remove-orphans
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
# Internal-only: drop any host :5432 publish inherited from docker-compose.yml.
|
||||||
|
# Postgres must NOT be exposed to the LAN. The backend reaches it over the
|
||||||
|
# compose network (db:5432) and the deploy's ALTER USER reconcile uses
|
||||||
|
# `docker exec`, so no published host port is needed. (!override [] replaces
|
||||||
|
# the inherited ports list — compose otherwise concatenates lists.)
|
||||||
|
ports: !override []
|
||||||
|
# POSTGRES_PASSWORD only takes effect on FIRST volume init; an existing
|
||||||
|
# volume keeps its current role password (the deploy reconciles it via
|
||||||
|
# ALTER USER). This value seeds a fresh volume with the prod password.
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-__PROJECT___dev}
|
||||||
|
|
||||||
|
backend:
|
||||||
|
# Remap host port to a unique value (8080 is taken by other stacks on TrueNAS).
|
||||||
|
# !override replaces the inherited ports list. Internal container port stays
|
||||||
|
# 8080 so frontend's BACKEND_URL=http://backend:8080 is unaffected.
|
||||||
|
# Remove this whole ports block if you don't need LAN debug access.
|
||||||
|
ports: !override
|
||||||
|
- "__BACKEND_PORT__:8080"
|
||||||
|
environment:
|
||||||
|
# Real production password (must match the live DB role; see ALTER USER).
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-__PROJECT___dev}
|
||||||
|
# Rotated production JWT signing key (base64 — JwtService base64-decodes it).
|
||||||
|
# Rotating this invalidates all previously issued access/refresh tokens.
|
||||||
|
__PROJECT___SECURITY_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
ports: !override
|
||||||
|
- "__FRONTEND_PORT__:3000"
|
||||||
|
environment:
|
||||||
|
# Public origin so NextAuth callbacks/cookies resolve to the HTTPS host.
|
||||||
|
# For LOCAL-ONLY phase you can set these to http://192.168.188.119:__FRONTEND_PORT__
|
||||||
|
NEXTAUTH_URL: https://__SUBDOMAIN__
|
||||||
|
AUTH_URL: https://__SUBDOMAIN__
|
||||||
|
# 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 (TLS terminates
|
||||||
|
# upstream; plain HTTP is proxied into the container).
|
||||||
|
AUTH_TRUST_HOST: "true"
|
||||||
|
# Server-side proxy target for /api/backend/* (internal compose DNS).
|
||||||
|
BACKEND_URL: http://backend:8080
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Base compose — defines the three services. The TrueNAS override
|
||||||
|
# (docker-compose.truenas.yml) layers homelab port/secret specifics on top.
|
||||||
|
#
|
||||||
|
# Replace __PROJECT__ before first push (container names + db credentials).
|
||||||
|
# This base is intentionally LAN/dev-shaped; the override hardens it for TrueNAS.
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: __PROJECT__-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: __PROJECT__
|
||||||
|
POSTGRES_DB: __PROJECT__
|
||||||
|
POSTGRES_PASSWORD: __PROJECT___dev
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
# In dev you may publish 5432; the TrueNAS override drops it (internal-only).
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U __PROJECT__"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: __PROJECT__-backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/__PROJECT__
|
||||||
|
SPRING_DATASOURCE_USERNAME: __PROJECT__
|
||||||
|
SPRING_DATASOURCE_PASSWORD: __PROJECT___dev
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: __PROJECT__-frontend
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
BACKEND_URL: http://backend:8080
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
name: __PROJECT___pgdata
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Server-side API proxy for the backend. ⭐ THE SYSTEMIC AUTH FIX ⭐
|
||||||
|
*
|
||||||
|
* Do NOT replace this with a static `rewrites()` proxy in next.config.mjs. A
|
||||||
|
* static rewrite forwards requests as-is and CANNOT inject an Authorization
|
||||||
|
* header — that was the root cause of the "no token reaches the backend" bug
|
||||||
|
* (every browser fetch hit the backend unauthenticated → 401/500). This cost
|
||||||
|
* ~9 days on CannaManage; the template ships the fix so it never recurs.
|
||||||
|
*
|
||||||
|
* 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/<path>` with `Authorization: Bearer <accessToken>`.
|
||||||
|
*
|
||||||
|
* Method- and content-agnostic: query string preserved; raw request body
|
||||||
|
* streamed unparsed (JSON + multipart uploads + binary all work); upstream
|
||||||
|
* response body streamed back verbatim (byte-exact downloads).
|
||||||
|
*
|
||||||
|
* Adjust the upstream path prefix (`/api/v1/`) if your backend differs.
|
||||||
|
*/
|
||||||
|
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<NextResponse> {
|
||||||
|
const session = await auth()
|
||||||
|
const accessToken = session?.accessToken
|
||||||
|
|
||||||
|
// Build the upstream URL: /api/backend/<path> → BACKEND_URL/api/v1/<path>
|
||||||
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user