diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 78cebb2..1cf8518 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,8 +3,6 @@ "allow": [ "Bash(*)", "Read(*)", - "Edit(*)", - "Write(*)", "Glob(*)", "Grep(*)" ], @@ -12,4 +10,4 @@ "/workspaces/web_app/.vscode" ] } -} +} \ No newline at end of file diff --git a/.env.example b/.env.example index 79b733e..a9f4d04 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,12 @@ KMTN_CORS_ORIGINS='["http://localhost:4200", "http://localhost"]' # KMTN_ENV=development # ── Frontend (frontend service) ─────────────────────────────────────── -# URL of the API backend that nginx should proxy /api/ requests to. -# Dev (Docker Compose): http://api:8000 (DNS resolves to the api container) -# Prod: https://api.yourdomain.com (or whatever your API endpoint is) +# Internal API target that nginx proxies /api/ requests to. +# In Docker Compose this is the api service DNS name. API_UPSTREAM=http://api:8000 + +# Browser-facing API URL. Set this to control how the Angular app reaches the API. +# "" (empty) — proxy mode: browser calls /api/* relative paths; nginx forwards to API_UPSTREAM. +# "http://yourhost:8000" — direct mode: browser calls the API container directly (CORS required). +# Use direct mode when an external ingress layer (e.g., TrueNAS middleware) breaks the nginx proxy chain. +API_PUBLIC_URL="" diff --git a/Dockerfile b/Dockerfile index cc418af..13491c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,10 @@ RUN npx ng build --configuration production FROM docker.io/library/nginx:alpine COPY --from=build /app/dist/radio-station/browser /usr/share/nginx/html COPY nginx.conf.template /etc/nginx/conf.d/nginx.conf.template +COPY config.json.template /usr/share/nginx/html/config.json.template COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh ENV API_UPSTREAM=http://api:8000 +ENV API_PUBLIC_URL="" EXPOSE 80 ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 7561704..a31aff2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,13 @@ Open [http://localhost:4200](http://localhost:4200) and ensure the backend is ru ``` . +├── docker-compose.yml # Dev compose (builds from source) +├── docker-compose.prod.yml # Prod override (applied with -f docker-compose.yml -f docker-compose.prod.yml) ├── docker-compose.dev.yml # Dev-sidecar compose (dev container) +├── Dockerfile # Frontend: Node build stage + Nginx serve stage +├── docker-entrypoint.sh # Renders nginx config + config.json from env vars +├── nginx.conf.template # Nginx config template (envsubst) +├── config.json.template # Runtime API config template (envsubst) ├── .devcontainer/ # VS Code dev container │ ├── Dockerfile # Alpine Python 3.12 + Node.js + Angular CLI │ └── devcontainer.json # Dev container config @@ -75,8 +81,9 @@ Open [http://localhost:4200](http://localhost:4200) and ensure the backend is ru ├── src/ # Angular 21 frontend │ ├── app/ # Standalone components │ │ ├── app.routes.ts # Lazy-loaded route map +│ │ ├── app.config.ts # App providers + APP_INITIALIZER (loads config.json) │ │ ├── hero/ about/ schedule/ donate/ events/ contact/ navbar/ footer/ -│ │ ├── services/ # HTTP services (program, event, tier) +│ │ ├── services/ # HTTP services (program, event, tier, app-config) │ │ └── interfaces/ # TypeScript types (Program, Event, Tier) │ ├── environments/ # dev/prod config (apiBaseUrl) │ └── styles/ # SCSS design tokens @@ -85,6 +92,7 @@ Open [http://localhost:4200](http://localhost:4200) and ensure the backend is ru │ └── _themes.scss # SCSS → CSS custom properties │ └── backend/ # FastAPI backend + ├── Dockerfile # Python 3.12-slim + gunicorn/uvicorn ├── requirements.txt ├── seed.py # Initial data for all models └── app/ @@ -130,12 +138,15 @@ curl -X POST http://localhost:8000/api/admin/reset ## Configuration +| Variable | Default | Where | Description | +|---|---|---|---| | Variable | Default | Where | Description | |---|---|---|---| | `KMTN_DATABASE_URL` | `postgresql+asyncpg://...` (prod) / `sqlite+aiosqlite:///./kmountain.db` (dev) | [backend/app/config.py](backend/app/config.py) | Database connection string | | `KMTN_CORS_ORIGINS` | `["http://localhost:4200"]` | [backend/app/config.py](backend/app/config.py) | CORS allowlist (JSON array) | | `KMTN_ENV` | `development` | [backend/app/config.py](backend/app/config.py) | Set to `production` to disable admin endpoints | -| `apiBaseUrl` | `http://localhost:8000` (dev) / `''` (prod) | [src/environments/](src/environments/) | Where the Angular app finds the API | +| `API_UPSTREAM` | `http://api:8000` | [Dockerfile](Dockerfile) | Internal API target for Nginx proxy (Docker DNS) | +| `API_PUBLIC_URL` | `""` (empty) | [Dockerfile](Dockerfile) | Browser-facing API URL. Empty = proxy mode; set to direct URL when ingress breaks proxy chain | Environment variables are prefixed with `KMTN_` in the backend. For production, set `KMTN_DATABASE_URL` to your managed PostgreSQL connection and update `KMTN_CORS_ORIGINS` to your actual domain. @@ -182,7 +193,130 @@ Full interactive API docs at [http://localhost:8000/docs](http://localhost:8000/ ## Deployment -### Production +### Docker Compose (recommended) + +The app ships as two Docker images — a frontend (Angular + Nginx) and a backend (FastAPI + gunicorn). Build, tag, and push them to your registry, then deploy with Docker Compose. + +#### Build and push + +```bash +# Frontend +docker build -t /kmtnflower:latest . +docker push /kmtnflower:latest + +# Backend +docker build -t /kmtnflower-api:latest ./backend +docker push /kmtnflower-api:latest +``` + +#### Deploy + +On the target machine, create a Docker Compose file (see [docker-compose.test.yml](docker-compose.test.yml) for a reference) that pulls the images and sets the required environment variables: + +```yaml +services: + db: + image: docker.io/library/postgres:16-alpine + environment: + POSTGRES_DB: kmountain + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + api: + image: /kmtnflower-api:latest + environment: + KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain + KMTN_CORS_ORIGINS: '["http://yourdomain.com"]' + KMTN_ENV: production + depends_on: + db: + condition: service_healthy + + frontend: + image: /kmtnflower:latest + environment: + API_UPSTREAM: http://api:8000 + API_PUBLIC_URL: "" + ports: + - "4200:80" + depends_on: + - api + +volumes: + pgdata: +``` + +Then start: + +```bash +docker compose up -d +``` + +#### API\_PUBLIC\_URL + +The frontend container renders a `config.json` at startup from the `API_PUBLIC_URL` environment variable. This controls how the Angular app reaches the API: + +| Mode | Value | When to use | +|---|---|---| +| **Proxy** (default) | `""` (empty) | Browser calls `/api/*` relative paths; Nginx forwards to `API_UPSTREAM`. Use when no external ingress interferes. | +| **Direct** | `"http://yourhost:8000"` | Browser calls the API container directly. Use when an external ingress layer (e.g., TrueNAS middleware, Kubernetes ingress) breaks the Nginx proxy chain. Requires CORS to be configured on the backend. | + +#### Example: TrueNAS Scale + +For a TrueNAS Scale deployment where the built-in ingress layer may redirect traffic: + +```yaml +services: + db: + image: docker.io/library/postgres:16-alpine + environment: + POSTGRES_DB: kmountain + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + api: + image: truenas.local:35000/kmtnflower-api:latest + environment: + KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain + KMTN_CORS_ORIGINS: '["http://truenas.local:34200", "http://truenas.local"]' + KMTN_ENV: production + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + frontend: + image: truenas.local:35000/kmtnflower:latest + environment: + API_UPSTREAM: http://api:8000 + API_PUBLIC_URL: "http://truenas.local:8000" + ports: + - "34200:80" + depends_on: + - api + +volumes: + pgdata: +``` + +> **Note:** The database auto-seeds with initial data on first startup. To re-seed at any time (dev only): `curl -X POST http://localhost:8000/api/admin/reset`. + +### Bare-metal production For production, you'll need to set up: - A PostgreSQL database (managed or self-hosted) diff --git a/angular.json b/angular.json index c2b06c4..36dfc82 100644 --- a/angular.json +++ b/angular.json @@ -59,6 +59,12 @@ }, "configurations": { "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], "budgets": [ { "type": "initial", diff --git a/backend/requirements.txt b/backend/requirements.txt index 59a700c..f37a45f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,6 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 +gunicorn==23.0.0 psycopg2-binary==2.9.10 asyncpg==0.30.0 aiosqlite==0.20.0 diff --git a/config.json.template b/config.json.template new file mode 100644 index 0000000..362b7b2 --- /dev/null +++ b/config.json.template @@ -0,0 +1,3 @@ +{ + "apiBaseUrl": "${API_PUBLIC_URL}" +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index bb1fcf4..1ec448a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -14,6 +14,8 @@ services: frontend: environment: - # Point to your API's production URL + # Internal API target for nginx proxy API_UPSTREAM: https://api.yourdomain.com + # Browser-facing API URL. Empty = proxy mode. Set to direct URL if ingress breaks proxy chain. + API_PUBLIC_URL: "" # No port mapping — traffic arrives via reverse proxy / load balancer diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..f848f11 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,45 @@ +services: + db: + image: docker.io/library/postgres:16-alpine + restart: unless-stopped + # NOTE: Change POSTGRES_PASSWORD in production. Consider using Docker secrets or + # a managed PostgreSQL service instead of local credentials. + environment: + POSTGRES_DB: kmountain + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + # NOTE: Update KMTN_CORS_ORIGINS in production to your actual domain(s). + # Set KMTN_DATABASE_URL to your managed PostgreSQL connection if not using the local db service. + api: + image: truenas.local:35000/kmtnflower-api:latest + restart: unless-stopped + environment: + KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain + KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]' + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + frontend: + image: truenas.local:35000/kmtnflower:latest + restart: unless-stopped + environment: + API_UPSTREAM: http://api:8000 + API_PUBLIC_URL: "http://truenas.local:8000" + ports: + - "4200:80" + depends_on: + - api + +volumes: + pgdata: diff --git a/docker-compose.yml b/docker-compose.yml index 0bc2c73..a76cfb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: restart: unless-stopped environment: API_UPSTREAM: http://api:8000 + API_PUBLIC_URL: "" ports: - "4200:80" depends_on: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index a21ca9c..8eebcac 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,7 +1,8 @@ #!/bin/sh -# Render the nginx config template with environment variables, then start nginx. +# Render config templates with environment variables, then start nginx. set -e envsubst '${API_UPSTREAM}' < /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.conf +envsubst '${API_UPSTREAM},${API_PUBLIC_URL}' < /usr/share/nginx/html/config.json.template > /usr/share/nginx/html/config.json exec nginx -g 'daemon off;' diff --git a/nginx.conf.template b/nginx.conf.template index 8b31555..59cf4c0 100644 --- a/nginx.conf.template +++ b/nginx.conf.template @@ -13,8 +13,11 @@ server { location /api/ { proxy_pass ${API_UPSTREAM}; proxy_set_header Host $host; + proxy_set_header Origin $http_origin; + proxy_set_header Referer $http_referer; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 88f055d..4f19d13 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,11 +4,17 @@ import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { authInterceptor } from './interceptors/auth.interceptor'; +import { + appConfigInitProvider, + appConfigProvider, +} from './services/app-config.service'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), provideHttpClient(withInterceptors([authInterceptor])), + appConfigProvider, + appConfigInitProvider, ], }; diff --git a/src/app/services/app-config.service.ts b/src/app/services/app-config.service.ts new file mode 100644 index 0000000..703d090 --- /dev/null +++ b/src/app/services/app-config.service.ts @@ -0,0 +1,42 @@ +import { HttpClient } from '@angular/common/http'; +import { APP_INITIALIZER, InjectionToken } from '@angular/core'; +import { lastValueFrom } from 'rxjs'; + +export interface AppConfig { + apiBaseUrl: string; +} + +export const APP_CONFIG = new InjectionToken('appConfig'); + +const _appConfig: AppConfig = { + apiBaseUrl: '', +}; + +export function appConfigFactory(http: HttpClient) { + return (): Promise => { + return lastValueFrom(http.get('./config.json')).then( + (config) => { + Object.assign(_appConfig, config); + }, + () => { + // Keep defaults if config.json is not available (dev mode). + }, + ); + }; +} + +export const appConfigProvider = { + provide: APP_CONFIG, + useValue: _appConfig, +}; + +export const appConfigInitProvider = { + provide: APP_INITIALIZER, + useFactory: appConfigFactory, + deps: [HttpClient], + multi: true, +}; + +export function getAppConfig(): AppConfig { + return _appConfig; +} diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index c34e393..2cb0841 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, firstValueFrom, map, Observable, tap } from 'rxjs'; -import { environment } from '../../environments/environment'; +import { getAppConfig } from './app-config.service'; import { AppUser, AuthTokenResponse } from '../interfaces/auth'; const TOKEN_KEY = 'kmtn_access_token'; @@ -12,7 +12,7 @@ const TOKEN_KEY = 'kmtn_access_token'; }) export class AuthService { private http = inject(HttpClient); - private baseUrl = `${environment.apiBaseUrl}/api/auth`; + private baseUrl = `${getAppConfig().apiBaseUrl}/api/auth`; private authStateSubject = new BehaviorSubject(null); diff --git a/src/app/services/event.service.ts b/src/app/services/event.service.ts index d741b76..5e31975 100644 --- a/src/app/services/event.service.ts +++ b/src/app/services/event.service.ts @@ -2,15 +2,15 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { environment } from '../../environments/environment'; import { Event } from '../interfaces/event'; +import { getAppConfig } from './app-config.service'; @Injectable({ providedIn: 'root', }) export class EventService { private http = inject(HttpClient); - private baseUrl = `${environment.apiBaseUrl}/api/events`; + private baseUrl = `${getAppConfig().apiBaseUrl}/api/events`; /** Fetch events, optionally filtered by active status. */ getEvents(active?: boolean): Observable { diff --git a/src/app/services/program.service.ts b/src/app/services/program.service.ts index c05fe6d..be416d7 100644 --- a/src/app/services/program.service.ts +++ b/src/app/services/program.service.ts @@ -2,15 +2,15 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { environment } from '../../environments/environment'; import { Program } from '../interfaces/program'; +import { getAppConfig } from './app-config.service'; @Injectable({ providedIn: 'root', }) export class ProgramService { private http = inject(HttpClient); - private baseUrl = `${environment.apiBaseUrl}/api/programs`; + private baseUrl = `${getAppConfig().apiBaseUrl}/api/programs`; /** Fetch all programs, optionally filtered by day_of_week (1=Mon … 7=Sun). */ getPrograms(day?: number): Observable { diff --git a/src/app/services/tier.service.ts b/src/app/services/tier.service.ts index 111ba58..f3f88a7 100644 --- a/src/app/services/tier.service.ts +++ b/src/app/services/tier.service.ts @@ -2,15 +2,15 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { environment } from '../../environments/environment'; import { Tier } from '../interfaces/tier'; +import { getAppConfig } from './app-config.service'; @Injectable({ providedIn: 'root', }) export class TierService { private http = inject(HttpClient); - private baseUrl = `${environment.apiBaseUrl}/api/tiers`; + private baseUrl = `${getAppConfig().apiBaseUrl}/api/tiers`; /** Fetch all tiers ordered by display_order. */ getTiers(): Observable {