diff --git a/README.md b/README.md index 40a08d4e..8a3fc788 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,24 @@ La prod utilise uniquement `docker-compose.prod.yml`. Ce compose ne contient pas de `build:`. Il tire les images du registry. +Sur Coolify, `docker-compose.prod.yml` est la source de vérité des variables +d'environnement. Les variables sont déclarées dans les blocs `environment:` des +services pour que Coolify les crée dans son UI au chargement du compose. Il ne +faut pas compter sur un fichier `.env` du dépôt en prod : il est ignoré par Git. + +Variables générées/préremplies pour Coolify : + +- `SERVICE_PASSWORD_POSTGRES` : mot de passe PostgreSQL partagé entre la base et le backend +- `SERVICE_BASE64_DATA_ENCRYPTION_KEY` : clé stable de 32 caractères pour `DATA_ENCRYPTION_KEY` +- `SERVICE_URL_HOPPSCOTCH_APP` : URL publique de l'app +- `SERVICE_URL_HOPPSCOTCH_ADMIN` : URL publique de l'admin +- `SERVICE_URL_HOPPSCOTCH_BACKEND` : URL publique du backend +- `SERVICE_FQDN_HOPPSCOTCH_BACKEND` : domaine du backend, utilisé pour `VITE_BACKEND_WS_URL` + +Après l'import du compose dans Coolify, vérifier au minimum que les trois URLs +publiques correspondent aux domaines assignés aux services app, admin et +backend. + Démarrer avec le tag `latest` : ```sh diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1ab9484f..b5e00d9a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,9 +3,9 @@ services: image: postgres:15 user: postgres environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-testpass} - POSTGRES_DB: ${POSTGRES_DB:-hoppscotch} + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES:-testpass} + - POSTGRES_DB=${POSTGRES_DB:-hoppscotch} volumes: - hoppscotch-db:/var/lib/postgresql/data healthcheck: @@ -22,19 +22,26 @@ services: container_name: hoppscotch-backend restart: unless-stopped image: ${API_CLIENT_REGISTRY:-forge.lclr.dev}/${API_CLIENT_NAMESPACE:-thibaud-lclr}/${API_CLIENT_IMAGE_PREFIX:-api-client}-backend:${API_CLIENT_TAG:-latest} - env_file: - - ./.env environment: - DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-testpass}@hoppscotch-db:5432/${POSTGRES_DB:-hoppscotch} + - DATABASE_URL=postgresql://postgres:${SERVICE_PASSWORD_POSTGRES:-testpass}@hoppscotch-db:5432/${POSTGRES_DB:-hoppscotch} + - DATA_ENCRYPTION_KEY=${SERVICE_BASE64_DATA_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef} + - VITE_BASE_URL=${SERVICE_URL_HOPPSCOTCH_APP:-http://localhost:3000} + - VITE_SHORTCODE_BASE_URL=${SERVICE_URL_HOPPSCOTCH_APP:-http://localhost:3000} + - VITE_ADMIN_URL=${SERVICE_URL_HOPPSCOTCH_ADMIN:-http://localhost:3100} + - VITE_BACKEND_GQL_URL=${SERVICE_URL_HOPPSCOTCH_BACKEND:-http://localhost:3170}/graphql + - VITE_BACKEND_WS_URL=wss://${SERVICE_FQDN_HOPPSCOTCH_BACKEND:-localhost:3170}/graphql + - VITE_BACKEND_API_URL=${SERVICE_URL_HOPPSCOTCH_BACKEND:-http://localhost:3170}/v1 + - VITE_APP_TOS_LINK=${VITE_APP_TOS_LINK:-https://docs.hoppscotch.io/support/terms} + - VITE_APP_PRIVACY_POLICY_LINK=${VITE_APP_PRIVACY_POLICY_LINK:-https://docs.hoppscotch.io/support/privacy} + - VITE_PROXYSCOTCH_ACCESS_TOKEN=${VITE_PROXYSCOTCH_ACCESS_TOKEN:-} + - ENABLE_SUBPATH_BASED_ACCESS=${ENABLE_SUBPATH_BASED_ACCESS:-false} + - WHITELISTED_ORIGINS=${SERVICE_URL_HOPPSCOTCH_APP:-http://localhost:3000},${SERVICE_URL_HOPPSCOTCH_ADMIN:-http://localhost:3100} + - TRUST_PROXY=${TRUST_PROXY:-true} depends_on: hoppscotch-db: condition: service_healthy command: - [ - "sh", - "-c", - "pnpm exec prisma migrate deploy && node prod_run.mjs", - ] + ["sh", "-c", "pnpm exec prisma migrate deploy && node prod_run.mjs"] ports: - "3170:3170" @@ -42,8 +49,17 @@ services: container_name: hoppscotch-app restart: unless-stopped image: ${API_CLIENT_REGISTRY:-forge.lclr.dev}/${API_CLIENT_NAMESPACE:-thibaud-lclr}/${API_CLIENT_IMAGE_PREFIX:-api-client}-app:${API_CLIENT_TAG:-latest} - env_file: - - ./.env + environment: + - VITE_BASE_URL=${SERVICE_URL_HOPPSCOTCH_APP:-http://localhost:3000} + - VITE_SHORTCODE_BASE_URL=${SERVICE_URL_HOPPSCOTCH_APP:-http://localhost:3000} + - VITE_ADMIN_URL=${SERVICE_URL_HOPPSCOTCH_ADMIN:-http://localhost:3100} + - VITE_BACKEND_GQL_URL=${SERVICE_URL_HOPPSCOTCH_BACKEND:-http://localhost:3170}/graphql + - VITE_BACKEND_WS_URL=wss://${SERVICE_FQDN_HOPPSCOTCH_BACKEND:-localhost:3170}/graphql + - VITE_BACKEND_API_URL=${SERVICE_URL_HOPPSCOTCH_BACKEND:-http://localhost:3170}/v1 + - VITE_APP_TOS_LINK=${VITE_APP_TOS_LINK:-https://docs.hoppscotch.io/support/terms} + - VITE_APP_PRIVACY_POLICY_LINK=${VITE_APP_PRIVACY_POLICY_LINK:-https://docs.hoppscotch.io/support/privacy} + - VITE_PROXYSCOTCH_ACCESS_TOKEN=${VITE_PROXYSCOTCH_ACCESS_TOKEN:-} + - ENABLE_SUBPATH_BASED_ACCESS=${ENABLE_SUBPATH_BASED_ACCESS:-false} depends_on: hoppscotch-backend: condition: service_started @@ -55,8 +71,16 @@ services: container_name: hoppscotch-sh-admin restart: unless-stopped image: ${API_CLIENT_REGISTRY:-forge.lclr.dev}/${API_CLIENT_NAMESPACE:-thibaud-lclr}/${API_CLIENT_IMAGE_PREFIX:-api-client}-sh-admin:${API_CLIENT_TAG:-latest} - env_file: - - ./.env + environment: + - VITE_BASE_URL=${SERVICE_URL_HOPPSCOTCH_APP:-http://localhost:3000} + - VITE_SHORTCODE_BASE_URL=${SERVICE_URL_HOPPSCOTCH_APP:-http://localhost:3000} + - VITE_ADMIN_URL=${SERVICE_URL_HOPPSCOTCH_ADMIN:-http://localhost:3100} + - VITE_BACKEND_GQL_URL=${SERVICE_URL_HOPPSCOTCH_BACKEND:-http://localhost:3170}/graphql + - VITE_BACKEND_API_URL=${SERVICE_URL_HOPPSCOTCH_BACKEND:-http://localhost:3170}/v1 + - VITE_APP_TOS_LINK=${VITE_APP_TOS_LINK:-https://docs.hoppscotch.io/support/terms} + - VITE_APP_PRIVACY_POLICY_LINK=${VITE_APP_PRIVACY_POLICY_LINK:-https://docs.hoppscotch.io/support/privacy} + - VITE_PROXYSCOTCH_ACCESS_TOKEN=${VITE_PROXYSCOTCH_ACCESS_TOKEN:-} + - ENABLE_SUBPATH_BASED_ACCESS=${ENABLE_SUBPATH_BASED_ACCESS:-false} depends_on: hoppscotch-backend: condition: service_started diff --git a/packages/hoppscotch-backend/src/cors-config.spec.ts b/packages/hoppscotch-backend/src/cors-config.spec.ts new file mode 100644 index 00000000..70aa5537 --- /dev/null +++ b/packages/hoppscotch-backend/src/cors-config.spec.ts @@ -0,0 +1,39 @@ +import { ConfigService } from '@nestjs/config'; +import { resolveProductionCorsOrigins } from './cors-config'; + +describe('resolveProductionCorsOrigins', () => { + const configService = (values: Record) => + ({ + get: (key: string) => values[key], + }) as ConfigService; + + test('Should use WHITELISTED_ORIGINS when it is configured', () => { + expect( + resolveProductionCorsOrigins( + configService({ + WHITELISTED_ORIGINS: + 'https://app.example.test, https://admin.example.test', + VITE_BASE_URL: 'https://ignored.example.test', + VITE_ADMIN_URL: 'https://ignored-admin.example.test', + }), + ), + ).toEqual(['https://app.example.test', 'https://admin.example.test']); + }); + + test('Should fall back to app and admin URLs when WHITELISTED_ORIGINS is missing', () => { + expect( + resolveProductionCorsOrigins( + configService({ + VITE_BASE_URL: 'https://app.example.test', + VITE_ADMIN_URL: 'https://admin.example.test', + }), + ), + ).toEqual(['https://app.example.test', 'https://admin.example.test']); + }); + + test('Should explain the missing production CORS configuration', () => { + expect(() => resolveProductionCorsOrigins(configService({}))).toThrow( + 'Missing production CORS configuration. Set WHITELISTED_ORIGINS or configure VITE_BASE_URL/VITE_ADMIN_URL.', + ); + }); +}); diff --git a/packages/hoppscotch-backend/src/cors-config.ts b/packages/hoppscotch-backend/src/cors-config.ts new file mode 100644 index 00000000..d145ef88 --- /dev/null +++ b/packages/hoppscotch-backend/src/cors-config.ts @@ -0,0 +1,36 @@ +import { ConfigService } from '@nestjs/config'; + +const MISSING_PRODUCTION_CORS_CONFIG = + 'Missing production CORS configuration. Set WHITELISTED_ORIGINS or configure VITE_BASE_URL/VITE_ADMIN_URL.'; + +function parseOrigins(origins: string | undefined): string[] { + return ( + origins + ?.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) ?? [] + ); +} + +export function resolveProductionCorsOrigins( + configService: ConfigService, +): string[] { + const whitelistedOrigins = parseOrigins( + configService.get('WHITELISTED_ORIGINS'), + ); + + if (whitelistedOrigins.length > 0) { + return whitelistedOrigins; + } + + const derivedOrigins = [ + configService.get('VITE_BASE_URL'), + configService.get('VITE_ADMIN_URL'), + ].filter(Boolean); + + if (derivedOrigins.length === 0) { + throw new Error(MISSING_PRODUCTION_CORS_CONFIG); + } + + return derivedOrigins; +} diff --git a/packages/hoppscotch-backend/src/main.ts b/packages/hoppscotch-backend/src/main.ts index 94be62be..30bc1561 100644 --- a/packages/hoppscotch-backend/src/main.ts +++ b/packages/hoppscotch-backend/src/main.ts @@ -9,6 +9,7 @@ import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { InfraTokenModule } from './infra-token/infra-token.module'; import { NestExpressApplication } from '@nestjs/platform-express'; +import { resolveProductionCorsOrigins } from './cors-config'; function setupSwagger( app: NestExpressApplication, @@ -58,7 +59,7 @@ async function bootstrap() { if (isProduction) { console.log('Enabling CORS with production settings'); app.enableCors({ - origin: configService.get('WHITELISTED_ORIGINS').split(','), + origin: resolveProductionCorsOrigins(configService), credentials: true, }); } else {