diff --git a/docker-compose.yml b/docker-compose.yml index 7206a71..e5256f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,181 +1,184 @@ -services: - # PostgreSQL Database - db: - image: postgres:16-alpine - container_name: qrmaster-db - restart: unless-stopped - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8" - ports: - - "5435:5432" - volumes: - - dbdata:/var/lib/postgresql/data - - ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U postgres -d qrmaster" ] - interval: 5s - timeout: 5s - retries: 10 - networks: - - qrmaster-network - - # Redis Cache - redis: - image: redis:7-alpine - container_name: qrmaster-redis - restart: unless-stopped - command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru - volumes: - - redisdata:/data - healthcheck: - test: [ "CMD", "redis-cli", "ping" ] - interval: 5s - timeout: 3s - retries: 5 - networks: - - qrmaster-network - - # Next.js Application - web: - build: - context: . - dockerfile: Dockerfile - args: - NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-} - NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-} - COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} - AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} - container_name: qrmaster-web - restart: unless-stopped - environment: - NODE_ENV: production - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public - DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public - REDIS_URL: redis://redis:6379 - NEXTAUTH_URL: ${NEXTAUTH_URL} - NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} - NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050} - NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:-http://localhost:3050} - COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} - AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} - INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} - CRON_SECRET: ${CRON_SECRET:-} - SOCIAL_MILESTONE_THRESHOLDS: ${SOCIAL_MILESTONE_THRESHOLDS:-} - SOCIAL_MILESTONE_POST_DELAY_HOURS: ${SOCIAL_MILESTONE_POST_DELAY_HOURS:-} - SOCIAL_MILESTONE_MIN_GAP_HOURS: ${SOCIAL_MILESTONE_MIN_GAP_HOURS:-} - # Channels the consent dialog may ask for. Only extend this once the - # worker actually publishes that channel. - SOCIAL_MILESTONE_CHANNELS: ${SOCIAL_MILESTONE_CHANNELS:-x} - TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} - TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} - TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback} - TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-} - # Guards the asset upload route that Instagram (and TikTok) pull media - # from. Falls back to the TikTok key so existing setups keep working. - SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-} - TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-} - IP_SALT: ${IP_SALT:-your-salt-change-in-production} - ENABLE_DEMO: ${ENABLE_DEMO:-false} - NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true} - # Google OAuth - GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} - GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} - # Stripe - STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} - STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-} - STRIPE_PRICE_ID_PRO_MONTHLY: ${STRIPE_PRICE_ID_PRO_MONTHLY:-} - STRIPE_PRICE_ID_PRO_YEARLY: ${STRIPE_PRICE_ID_PRO_YEARLY:-} - STRIPE_PRICE_ID_BUSINESS_MONTHLY: ${STRIPE_PRICE_ID_BUSINESS_MONTHLY:-} - STRIPE_PRICE_ID_BUSINESS_YEARLY: ${STRIPE_PRICE_ID_BUSINESS_YEARLY:-} - # Email & Analytics - RESEND_API_KEY: ${RESEND_API_KEY:-} - SMTP_HOST: ${SMTP_HOST:-smtp.qrmaster.net} - SMTP_PORT: ${SMTP_PORT:-465} - SMTP_USER: ${SMTP_USER:-info@qrmaster.net} - SMTP_PASS: ${SMTP_PASS:-} - NEWSLETTER_ADMIN_EMAIL: ${NEWSLETTER_ADMIN_EMAIL:-} - NEWSLETTER_ADMIN_PASSWORD: ${NEWSLETTER_ADMIN_PASSWORD:-} - NEWSLETTER_TEST_EMAIL: ${NEWSLETTER_TEST_EMAIL:-} - EMAIL_UNSUBSCRIBE_SECRET: ${EMAIL_UNSUBSCRIBE_SECRET:-} - NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-} - NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com} - # Cloudflare R2 Storage - R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} - R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} - R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} - R2_BUCKET_NAME: ${R2_BUCKET_NAME:-qrmaster-menus} - R2_PUBLIC_URL: ${R2_PUBLIC_URL:-} - NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-} - NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-} - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: [ "CMD", "node", "-e", "require('http').get('http://localhost:3000',()=>process.exit(0)).on('error',()=>process.exit(1))" ] - interval: 10s - timeout: 3s - retries: 10 - networks: - - qrmaster-network - - social-worker: - build: - context: ./scripts/social-worker - restart: unless-stopped - environment: - QRMASTER_API_BASE: http://web:3000 - INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} - SOCIAL_MILESTONE_POSTING_ENABLED: ${SOCIAL_MILESTONE_POSTING_ENABLED:-false} - SOCIAL_WORKER_INTERVAL_SECONDS: ${SOCIAL_WORKER_INTERVAL_SECONDS:-10} - X_API_KEY: ${X_API_KEY:-} - X_API_SECRET: ${X_API_SECRET:-} - X_ACCESS_TOKEN: ${X_ACCESS_TOKEN:-} - X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET:-} - # Channels this worker publishes. Must stay a subset of the app's - # SOCIAL_MILESTONE_CHANNELS - a channel the dialog offers but nobody - # publishes would leave approvals sitting in the queue. - SOCIAL_WORKER_CHANNELS: ${SOCIAL_WORKER_CHANNELS:-x} - INSTAGRAM_USER_ID: ${INSTAGRAM_USER_ID:-} - INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-} - GRAPH_API_VERSION: ${GRAPH_API_VERSION:-v22.0} - # Instagram downloads the image itself, so the worker hosts it through - # /api/social-assets on the verified domain. Same key as the web service. - SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-${TIKTOK_ADMIN_KEY:-}} - depends_on: - web: - condition: service_started - networks: - - qrmaster-network - - - # Adminer - Database Management UI (Optional) - adminer: - image: adminer:latest - container_name: qrmaster-adminer - restart: unless-stopped - ports: - - "8080:8080" - environment: - ADMINER_DEFAULT_SERVER: db - depends_on: - - db - networks: - - qrmaster-network - profiles: - - dev - -volumes: - dbdata: - driver: local - redisdata: - driver: local - -networks: - qrmaster-network: - external: true +services: + # PostgreSQL Database + db: + image: postgres:16-alpine + container_name: qrmaster-db + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_INITDB_ARGS: "-E UTF8 --locale=en_US.utf8" + ports: + - "5435:5432" + volumes: + - dbdata:/var/lib/postgresql/data + - ./docker/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres -d qrmaster" ] + interval: 5s + timeout: 5s + retries: 10 + networks: + - qrmaster-network + + # Redis Cache + redis: + image: redis:7-alpine + container_name: qrmaster-redis + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redisdata:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 5 + networks: + - qrmaster-network + + # Next.js Application + web: + build: + context: . + dockerfile: Dockerfile + args: + NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-} + NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} + AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} + container_name: qrmaster-web + restart: unless-stopped + environment: + NODE_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public + DIRECT_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public + REDIS_URL: redis://redis:6379 + NEXTAUTH_URL: ${NEXTAUTH_URL} + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050} + NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:-http://localhost:3050} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} + AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-} + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} + CRON_SECRET: ${CRON_SECRET:-} + SOCIAL_MILESTONE_THRESHOLDS: ${SOCIAL_MILESTONE_THRESHOLDS:-} + SOCIAL_MILESTONE_POST_DELAY_HOURS: ${SOCIAL_MILESTONE_POST_DELAY_HOURS:-} + SOCIAL_MILESTONE_MIN_GAP_HOURS: ${SOCIAL_MILESTONE_MIN_GAP_HOURS:-} + # Channels the consent dialog may ask for. Only extend this once the + # worker actually publishes that channel. + SOCIAL_MILESTONE_CHANNELS: ${SOCIAL_MILESTONE_CHANNELS:-x} + TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} + TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} + TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://qrmaster.net/api/tiktok/callback} + TIKTOK_ADMIN_KEY: ${TIKTOK_ADMIN_KEY:-} + # Guards the asset upload route that Instagram (and TikTok) pull media + # from. Falls back to the TikTok key so existing setups keep working. + SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-} + TIKTOK_EXPECTED_OPEN_ID: ${TIKTOK_EXPECTED_OPEN_ID:-} + IP_SALT: ${IP_SALT:-your-salt-change-in-production} + ENABLE_DEMO: ${ENABLE_DEMO:-false} + NEXT_PUBLIC_INDEXABLE: ${NEXT_PUBLIC_INDEXABLE:-true} + # Google OAuth + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + # Stripe + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-} + STRIPE_PRICE_ID_PRO_MONTHLY: ${STRIPE_PRICE_ID_PRO_MONTHLY:-} + STRIPE_PRICE_ID_PRO_YEARLY: ${STRIPE_PRICE_ID_PRO_YEARLY:-} + STRIPE_PRICE_ID_BUSINESS_MONTHLY: ${STRIPE_PRICE_ID_BUSINESS_MONTHLY:-} + STRIPE_PRICE_ID_BUSINESS_YEARLY: ${STRIPE_PRICE_ID_BUSINESS_YEARLY:-} + # Email & Analytics + RESEND_API_KEY: ${RESEND_API_KEY:-} + SMTP_HOST: ${SMTP_HOST:-smtp.qrmaster.net} + SMTP_PORT: ${SMTP_PORT:-465} + SMTP_USER: ${SMTP_USER:-info@qrmaster.net} + SMTP_PASS: ${SMTP_PASS:-} + NEWSLETTER_ADMIN_EMAIL: ${NEWSLETTER_ADMIN_EMAIL:-} + NEWSLETTER_ADMIN_PASSWORD: ${NEWSLETTER_ADMIN_PASSWORD:-} + NEWSLETTER_TEST_EMAIL: ${NEWSLETTER_TEST_EMAIL:-} + EMAIL_UNSUBSCRIBE_SECRET: ${EMAIL_UNSUBSCRIBE_SECRET:-} + NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-} + NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://us.i.posthog.com} + # Cloudflare R2 Storage + R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} + R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} + R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} + R2_BUCKET_NAME: ${R2_BUCKET_NAME:-qrmaster-menus} + R2_PUBLIC_URL: ${R2_PUBLIC_URL:-} + NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-} + NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: [ "CMD", "node", "-e", "require('http').get('http://localhost:3000',()=>process.exit(0)).on('error',()=>process.exit(1))" ] + interval: 10s + timeout: 3s + retries: 10 + networks: + - qrmaster-network + + social-worker: + build: + context: ./scripts/social-worker + restart: unless-stopped + environment: + # Container name, not the `web` service alias: the staging stack joins this same + # external network and registers `web` as an alias too, so the alias can resolve + # to the staging app - which has a different INTERNAL_API_SECRET and answers 401. + QRMASTER_API_BASE: http://qrmaster-web:3000 + INTERNAL_API_SECRET: ${INTERNAL_API_SECRET} + SOCIAL_MILESTONE_POSTING_ENABLED: ${SOCIAL_MILESTONE_POSTING_ENABLED:-false} + SOCIAL_WORKER_INTERVAL_SECONDS: ${SOCIAL_WORKER_INTERVAL_SECONDS:-10} + X_API_KEY: ${X_API_KEY:-} + X_API_SECRET: ${X_API_SECRET:-} + X_ACCESS_TOKEN: ${X_ACCESS_TOKEN:-} + X_ACCESS_TOKEN_SECRET: ${X_ACCESS_TOKEN_SECRET:-} + # Channels this worker publishes. Must stay a subset of the app's + # SOCIAL_MILESTONE_CHANNELS - a channel the dialog offers but nobody + # publishes would leave approvals sitting in the queue. + SOCIAL_WORKER_CHANNELS: ${SOCIAL_WORKER_CHANNELS:-x} + INSTAGRAM_USER_ID: ${INSTAGRAM_USER_ID:-} + INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-} + GRAPH_API_VERSION: ${GRAPH_API_VERSION:-v22.0} + # Instagram downloads the image itself, so the worker hosts it through + # /api/social-assets on the verified domain. Same key as the web service. + SOCIAL_ASSET_ADMIN_KEY: ${SOCIAL_ASSET_ADMIN_KEY:-${TIKTOK_ADMIN_KEY:-}} + depends_on: + web: + condition: service_started + networks: + - qrmaster-network + + + # Adminer - Database Management UI (Optional) + adminer: + image: adminer:latest + container_name: qrmaster-adminer + restart: unless-stopped + ports: + - "8080:8080" + environment: + ADMINER_DEFAULT_SERVER: db + depends_on: + - db + networks: + - qrmaster-network + profiles: + - dev + +volumes: + dbdata: + driver: local + redisdata: + driver: local + +networks: + qrmaster-network: + external: true diff --git a/src/app/(main)/api/internal/social-milestones/route.ts b/src/app/(main)/api/internal/social-milestones/route.ts index 422a8d7..6e658eb 100644 --- a/src/app/(main)/api/internal/social-milestones/route.ts +++ b/src/app/(main)/api/internal/social-milestones/route.ts @@ -5,9 +5,13 @@ import { isSocialChannel, SOCIAL_CHANNELS, SocialChannel } from '@/lib/social-mi export const dynamic = 'force-dynamic'; +// The worker trims its own copy of the secret before sending it, so a stray +// trailing space or CR in the compose env file would otherwise show up here as a +// permanent 401 while both sides still look identical in a hash comparison. function isAuthorized(request: NextRequest) { - const secret = process.env.INTERNAL_API_SECRET; - return Boolean(secret) && request.headers.get('authorization') === `Bearer ${secret}`; + const secret = process.env.INTERNAL_API_SECRET?.trim(); + const presented = request.headers.get('authorization')?.trim().replace(/^Bearer\s+/i, ''); + return Boolean(secret) && presented === secret; } function approvalDelayHours() {