Răsfoiți Sursa

Merge branch 'worktree-agent-a8472147'

User 2 luni în urmă
părinte
comite
b3ea26dcad
12 a modificat fișierele cu 599 adăugiri și 4 ștergeri
  1. 20 0
      .dockerignore
  2. 28 3
      .env.example
  3. 20 0
      Caddyfile
  4. 46 0
      Dockerfile
  5. 20 0
      backup/backup.sh
  6. 22 0
      cron/Dockerfile
  7. 30 0
      cron/index.ts
  8. 12 0
      cron/package.json
  9. 260 0
      docker-compose.yml
  10. 35 0
      scripts/check-defaults.sh
  11. 105 0
      supabase/kong/kong.yml
  12. 1 1
      tsconfig.json

+ 20 - 0
.dockerignore

@@ -0,0 +1,20 @@
+node_modules
+.next
+.git
+.gitignore
+.env
+.env.*
+!.env.example
+research/
+*.md
+!README.md
+.husky
+.prettierrc
+.prettierignore
+coverage
+.DS_Store
+*.pem
+npm-debug.log*
+yarn-debug.log*
+.pnpm-debug.log*
+.claude

+ 28 - 3
.env.example

@@ -1,3 +1,4 @@
+# ─── Application ────────────────────────────────────────────────────
 # TMDB API (server-side only — never NEXT_PUBLIC_)
 TMDB_API_KEY=your_tmdb_api_key_here
 
@@ -6,7 +7,7 @@ NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
 NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
 
 # Supabase (server-side — internal Docker network)
-SUPABASE_INTERNAL_URL=http://supabase_kong:8000
+SUPABASE_INTERNAL_URL=http://supabase-kong:8000
 SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
 
 # Master Admin
@@ -19,5 +20,29 @@ IRON_SESSION_SECRET=this_must_be_at_least_32_characters_long
 # Sentry (optional)
 NEXT_PUBLIC_SENTRY_DSN=
 
-# GoTrue (set in docker-compose, not here)
-# GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true
+# ─── Supabase Infrastructure ───────────────────────────────────────
+# IMPORTANT: Replace ALL defaults before first docker compose up.
+# ANON_KEY and SERVICE_ROLE_KEY must be regenerated from JWT_SECRET.
+
+JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
+POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password
+ANON_KEY=your_anon_key_here
+SERVICE_ROLE_KEY=your_service_role_key_here
+
+# Studio credentials (dev only — Studio bound to 127.0.0.1)
+DASHBOARD_USERNAME=supabase
+DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated
+
+# Realtime secret (generate a 64+ char random string)
+REALTIME_SECRET_KEY_BASE=please-generate-a-64-char-secret-key-base-for-realtime-service!!
+
+# ─── Caddy / Domain ────────────────────────────────────────────────
+DOMAIN=localhost
+TLS_EMAIL=admin@example.com
+
+# ─── Postgres (optional overrides) ─────────────────────────────────
+POSTGRES_USER=supabase_admin
+POSTGRES_DB=supabase
+
+# Site URL (for GoTrue redirects)
+SITE_URL=http://localhost:3000

+ 20 - 0
Caddyfile

@@ -0,0 +1,20 @@
+{$DOMAIN} {
+	# TLS — use Let's Encrypt staging for initial testing
+	tls {$TLS_EMAIL} {
+		ca https://acme-staging-v02.api.letsencrypt.org/directory
+	}
+
+	# Security headers
+	header {
+		Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https://image.tmdb.org; connect-src 'self' wss://{$DOMAIN}; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
+		X-Frame-Options "DENY"
+		X-Content-Type-Options "nosniff"
+		Referrer-Policy "strict-origin-when-cross-origin"
+		Strict-Transport-Security "max-age=86400; includeSubDomains"
+		Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()"
+		-Server
+	}
+
+	# Reverse proxy to Next.js app
+	reverse_proxy app:3000
+}

+ 46 - 0
Dockerfile

@@ -0,0 +1,46 @@
+# Stage 1: Install dependencies
+FROM node:22-slim AS deps
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
+# Stage 2: Build the application
+FROM node:22-slim AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+# Skip env validation during build (validated at runtime)
+ENV SKIP_ENV_VALIDATION=1
+ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN npm run build
+
+# Stage 3: Production runner
+FROM node:22-slim AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+# Install tini for proper PID 1 handling
+RUN apt-get update && apt-get install -y --no-install-recommends tini \
+    && rm -rf /var/lib/apt/lists/*
+
+# Create non-root user
+RUN addgroup --system --gid 1001 nodejs \
+    && adduser --system --uid 1001 nextjs
+
+# Copy standalone output
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+ENTRYPOINT ["tini", "--"]
+CMD ["node", "server.js"]

+ 20 - 0
backup/backup.sh

@@ -0,0 +1,20 @@
+#!/bin/sh
+set -eu
+
+BACKUP_DIR="/backups"
+RETENTION_DAYS=7
+TIMESTAMP=$(date +%Y%m%d_%H%M%S)
+BACKUP_FILE="${BACKUP_DIR}/moviedice_${TIMESTAMP}.sql.gz"
+
+echo "[$(date -Iseconds)] Starting database backup"
+
+pg_dump -h supabase-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
+  | gzip > "${BACKUP_FILE}"
+
+echo "[$(date -Iseconds)] Backup saved to ${BACKUP_FILE}"
+
+# Remove backups older than retention period
+find "${BACKUP_DIR}" -name "moviedice_*.sql.gz" -mtime +${RETENTION_DAYS} -delete
+
+REMAINING=$(find "${BACKUP_DIR}" -name "moviedice_*.sql.gz" | wc -l)
+echo "[$(date -Iseconds)] Cleanup complete. ${REMAINING} backup(s) retained."

+ 22 - 0
cron/Dockerfile

@@ -0,0 +1,22 @@
+FROM node:22-alpine AS builder
+
+WORKDIR /app
+
+COPY package.json ./
+RUN npm install
+
+COPY index.ts ./
+RUN npx tsc index.ts --esModuleInterop --module commonjs --target es2022
+
+FROM node:22-alpine
+
+WORKDIR /app
+
+COPY package.json ./
+RUN npm install --production
+
+COPY --from=builder /app/index.js ./
+
+USER node
+
+CMD ["node", "index.js"]

+ 30 - 0
cron/index.ts

@@ -0,0 +1,30 @@
+import * as cron from "node-cron";
+
+console.log("MovieDice cron service started");
+
+// Refresh landing reel posters — daily at 3:00 AM UTC
+cron.schedule("0 3 * * *", async () => {
+  console.log(`[${new Date().toISOString()}] Reel refresh: starting`);
+  // TODO: fetch trending movies from TMDB and update landing_reel_posters
+  console.log(`[${new Date().toISOString()}] Reel refresh: complete`);
+});
+
+// Refresh trailer URLs — daily at 4:00 AM UTC
+cron.schedule("0 4 * * *", async () => {
+  console.log(`[${new Date().toISOString()}] Trailer refresh: starting`);
+  // TODO: re-validate trailer URLs for movies missing trailers
+  console.log(`[${new Date().toISOString()}] Trailer refresh: complete`);
+});
+
+// Refresh TMDB metadata — monthly on the 1st at 5:00 AM UTC
+cron.schedule("0 5 1 * *", async () => {
+  console.log(`[${new Date().toISOString()}] Metadata refresh: starting`);
+  // TODO: refresh metadata for movies where metadata_refreshed_at > 30 days
+  console.log(`[${new Date().toISOString()}] Metadata refresh: complete`);
+});
+
+// Keep the process alive
+process.on("SIGTERM", () => {
+  console.log("Received SIGTERM, shutting down cron service");
+  process.exit(0);
+});

+ 12 - 0
cron/package.json

@@ -0,0 +1,12 @@
+{
+  "name": "moviedice-cron",
+  "version": "0.1.0",
+  "private": true,
+  "dependencies": {
+    "node-cron": "^3.0.3"
+  },
+  "devDependencies": {
+    "@types/node": "^20",
+    "typescript": "^5"
+  }
+}

+ 260 - 0
docker-compose.yml

@@ -0,0 +1,260 @@
+x-logging: &default-logging
+  driver: json-file
+  options:
+    max-size: "10m"
+    max-file: "5"
+
+services:
+  # ─── Next.js Application ──────────────────────────────────────────
+  app:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    restart: unless-stopped
+    logging: *default-logging
+    depends_on:
+      supabase-kong:
+        condition: service_started
+    environment:
+      - TMDB_API_KEY=${TMDB_API_KEY}
+      - NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
+      - NEXT_PUBLIC_SUPABASE_ANON_KEY=${ANON_KEY}
+      - SUPABASE_INTERNAL_URL=http://supabase-kong:8000
+      - SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY}
+      - MASTER_ADMIN_USERNAME=${MASTER_ADMIN_USERNAME}
+      - MASTER_ADMIN_TOTP_SECRET=${MASTER_ADMIN_TOTP_SECRET}
+      - IRON_SESSION_SECRET=${IRON_SESSION_SECRET}
+    networks:
+      - internal
+
+  # ─── Caddy Reverse Proxy ──────────────────────────────────────────
+  caddy:
+    image: caddy:2-alpine
+    restart: unless-stopped
+    logging: *default-logging
+    ports:
+      - "80:80"
+      - "443:443"
+      - "443:443/udp"
+    environment:
+      - DOMAIN=${DOMAIN:-localhost}
+      - TLS_EMAIL=${TLS_EMAIL:-}
+    volumes:
+      - ./Caddyfile:/etc/caddy/Caddyfile:ro
+      - caddy_data:/data
+      - caddy_config:/config
+    depends_on:
+      - app
+    networks:
+      - internal
+
+  # ─── Supabase: Postgres ───────────────────────────────────────────
+  supabase-db:
+    image: supabase/postgres:15.8.1.060
+    restart: unless-stopped
+    logging: *default-logging
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-supabase_admin} -d ${POSTGRES_DB:-supabase}"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+    environment:
+      POSTGRES_USER: ${POSTGRES_USER:-supabase_admin}
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+      POSTGRES_DB: ${POSTGRES_DB:-supabase}
+      JWT_SECRET: ${JWT_SECRET}
+    volumes:
+      - supabase_db:/var/lib/postgresql/data
+    networks:
+      - internal
+
+  # ─── Supabase: GoTrue (Auth) ──────────────────────────────────────
+  supabase-auth:
+    image: supabase/gotrue:v2.170.0
+    restart: unless-stopped
+    logging: *default-logging
+    depends_on:
+      supabase-db:
+        condition: service_healthy
+    environment:
+      GOTRUE_API_HOST: "0.0.0.0"
+      GOTRUE_API_PORT: "9999"
+      API_EXTERNAL_URL: ${NEXT_PUBLIC_SUPABASE_URL}
+
+      GOTRUE_DB_DRIVER: postgres
+      GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER:-supabase_admin}:${POSTGRES_PASSWORD}@supabase-db:5432/${POSTGRES_DB:-supabase}?search_path=auth
+
+      GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000}
+      GOTRUE_URI_ALLOW_LIST: ""
+      GOTRUE_DISABLE_SIGNUP: "false"
+
+      GOTRUE_JWT_SECRET: ${JWT_SECRET}
+      GOTRUE_JWT_EXP: "3600"
+      GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
+
+      # Anonymous auth enabled — core requirement
+      GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true"
+
+      # Disable all other auth methods
+      GOTRUE_EXTERNAL_EMAIL_ENABLED: "false"
+      GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
+      GOTRUE_MAILER_AUTOCONFIRM: "false"
+      GOTRUE_SMS_AUTOCONFIRM: "false"
+    networks:
+      - internal
+
+  # ─── Supabase: PostgREST ──────────────────────────────────────────
+  supabase-rest:
+    image: postgrest/postgrest:v12.2.8
+    restart: unless-stopped
+    logging: *default-logging
+    depends_on:
+      supabase-db:
+        condition: service_healthy
+    environment:
+      PGRST_DB_URI: postgres://${POSTGRES_USER:-supabase_admin}:${POSTGRES_PASSWORD}@supabase-db:5432/${POSTGRES_DB:-supabase}
+      PGRST_DB_SCHEMAS: public
+      PGRST_DB_ANON_ROLE: anon
+      PGRST_JWT_SECRET: ${JWT_SECRET}
+      PGRST_DB_USE_LEGACY_GUCS: "false"
+    networks:
+      - internal
+
+  # ─── Supabase: Realtime ───────────────────────────────────────────
+  supabase-realtime:
+    image: supabase/realtime:v2.34.47
+    restart: unless-stopped
+    logging: *default-logging
+    depends_on:
+      supabase-db:
+        condition: service_healthy
+    environment:
+      PORT: "4000"
+      DB_HOST: supabase-db
+      DB_PORT: "5432"
+      DB_USER: ${POSTGRES_USER:-supabase_admin}
+      DB_PASSWORD: ${POSTGRES_PASSWORD}
+      DB_NAME: ${POSTGRES_DB:-supabase}
+      DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
+      DB_ENC_KEY: supabaserealtime
+      API_JWT_SECRET: ${JWT_SECRET}
+      SECRET_KEY_BASE: ${REALTIME_SECRET_KEY_BASE:-please-generate-a-64-char-secret-key-base-for-realtime-service!!}
+      ERL_AFLAGS: "-proto_dist inet_tcp"
+      DNS_NODES: "''"
+      RLIMIT_NOFILE: "10000"
+      APP_NAME: realtime
+      SEED_SELF_HOST: "true"
+    networks:
+      - internal
+
+  # ─── Supabase: Kong (API Gateway) ─────────────────────────────────
+  supabase-kong:
+    image: kong:2.8.1
+    restart: unless-stopped
+    logging: *default-logging
+    depends_on:
+      - supabase-auth
+      - supabase-rest
+      - supabase-realtime
+    environment:
+      KONG_DATABASE: "off"
+      KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
+      KONG_DNS_ORDER: LAST,A,CNAME
+      KONG_PLUGINS: request-transformer,cors,key-auth,acl
+      KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
+      KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
+      ANON_KEY: ${ANON_KEY}
+      SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+    volumes:
+      - ./supabase/kong/kong.yml:/var/lib/kong/kong.yml:ro
+    networks:
+      - internal
+
+  # ─── Supabase: Studio (dev only) ──────────────────────────────────
+  supabase-studio:
+    image: supabase/studio:20250317-b9tried
+    restart: unless-stopped
+    logging: *default-logging
+    ports:
+      - "127.0.0.1:3001:3000"
+    depends_on:
+      - supabase-kong
+    environment:
+      STUDIO_PG_META_URL: http://supabase-meta:8080
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+      SUPABASE_URL: http://supabase-kong:8000
+      SUPABASE_REST_URL: http://supabase-kong:8000/rest/v1/
+      SUPABASE_ANON_KEY: ${ANON_KEY}
+      SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
+      DEFAULT_ORGANIZATION_NAME: MovieDice
+      DEFAULT_PROJECT_NAME: MovieDice
+      NEXT_PUBLIC_ENABLE_LOGS: "true"
+      NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
+    networks:
+      - internal
+
+  # ─── Supabase: pg_meta (required by Studio) ───────────────────────
+  supabase-meta:
+    image: supabase/postgres-meta:v0.84.2
+    restart: unless-stopped
+    logging: *default-logging
+    depends_on:
+      supabase-db:
+        condition: service_healthy
+    environment:
+      PG_META_PORT: "8080"
+      PG_META_DB_HOST: supabase-db
+      PG_META_DB_PORT: "5432"
+      PG_META_DB_NAME: ${POSTGRES_DB:-supabase}
+      PG_META_DB_USER: ${POSTGRES_USER:-supabase_admin}
+      PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
+    networks:
+      - internal
+
+  # ─── Cron Service ─────────────────────────────────────────────────
+  cron:
+    build:
+      context: ./cron
+      dockerfile: Dockerfile
+    restart: unless-stopped
+    logging: *default-logging
+    depends_on:
+      supabase-kong:
+        condition: service_started
+    environment:
+      - SUPABASE_URL=http://supabase-kong:8000
+      - SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY}
+      - TMDB_API_KEY=${TMDB_API_KEY}
+    networks:
+      - internal
+
+  # ─── Database Backup ──────────────────────────────────────────────
+  backup:
+    image: postgres:15-alpine
+    restart: "no"
+    logging: *default-logging
+    depends_on:
+      supabase-db:
+        condition: service_healthy
+    environment:
+      POSTGRES_USER: ${POSTGRES_USER:-supabase_admin}
+      POSTGRES_DB: ${POSTGRES_DB:-supabase}
+      PGPASSWORD: ${POSTGRES_PASSWORD}
+    volumes:
+      - ./backup/backup.sh:/backup.sh:ro
+      - supabase_backups:/backups
+    entrypoint: ["/bin/sh", "/backup.sh"]
+    networks:
+      - internal
+    profiles:
+      - backup
+
+volumes:
+  supabase_db:
+  supabase_backups:
+  caddy_data:
+  caddy_config:
+
+networks:
+  internal:
+    driver: bridge

+ 35 - 0
scripts/check-defaults.sh

@@ -0,0 +1,35 @@
+#!/bin/sh
+set -e
+
+# Refuse to start if default Supabase secrets are still in use.
+# Run this before docker compose up to catch misconfigurations.
+
+ERRORS=0
+
+check_default() {
+  var_name="$1"
+  default_value="$2"
+  actual_value="$3"
+
+  if [ "$actual_value" = "$default_value" ] || [ -z "$actual_value" ]; then
+    echo "ERROR: ${var_name} is set to the default/empty value. Change it before starting." >&2
+    ERRORS=$((ERRORS + 1))
+  fi
+}
+
+check_default "JWT_SECRET" "super-secret-jwt-token-with-at-least-32-characters-long" "${JWT_SECRET:-}"
+check_default "POSTGRES_PASSWORD" "your-super-secret-and-long-postgres-password" "${POSTGRES_PASSWORD:-}"
+check_default "ANON_KEY" "your_anon_key_here" "${ANON_KEY:-}"
+check_default "SERVICE_ROLE_KEY" "your_service_role_key_here" "${SERVICE_ROLE_KEY:-}"
+check_default "DASHBOARD_USERNAME" "supabase" "${DASHBOARD_USERNAME:-}"
+check_default "DASHBOARD_PASSWORD" "this_password_is_insecure_and_should_be_updated" "${DASHBOARD_PASSWORD:-}"
+check_default "IRON_SESSION_SECRET" "this_must_be_at_least_32_characters_long" "${IRON_SESSION_SECRET:-}"
+
+if [ "$ERRORS" -gt 0 ]; then
+  echo "" >&2
+  echo "Found ${ERRORS} default secret(s). Refusing to start." >&2
+  echo "Generate new secrets and update your .env file before running docker compose up." >&2
+  exit 1
+fi
+
+echo "All secrets verified — no defaults detected."

+ 105 - 0
supabase/kong/kong.yml

@@ -0,0 +1,105 @@
+_format_version: "1.1"
+
+services:
+  - name: auth-v1-open
+    url: http://supabase-auth:9999/verify
+    routes:
+      - name: auth-v1-open
+        strip_path: true
+        paths:
+          - /auth/v1/verify
+    plugins:
+      - name: cors
+
+  - name: auth-v1-open-callback
+    url: http://supabase-auth:9999/callback
+    routes:
+      - name: auth-v1-open-callback
+        strip_path: true
+        paths:
+          - /auth/v1/callback
+    plugins:
+      - name: cors
+
+  - name: auth-v1-open-authorize
+    url: http://supabase-auth:9999/authorize
+    routes:
+      - name: auth-v1-open-authorize
+        strip_path: true
+        paths:
+          - /auth/v1/authorize
+    plugins:
+      - name: cors
+
+  - name: auth-v1
+    _comment: "GoTrue: /auth/v1/* -> http://supabase-auth:9999/*"
+    url: http://supabase-auth:9999
+    routes:
+      - name: auth-v1-all
+        strip_path: true
+        paths:
+          - /auth/v1/
+    plugins:
+      - name: cors
+      - name: key-auth
+        config:
+          hide_credentials: false
+      - name: acl
+        config:
+          hide_groups_header: true
+          allow:
+            - admin
+            - anon
+
+  - name: rest-v1
+    _comment: "PostgREST: /rest/v1/* -> http://supabase-rest:3000/*"
+    url: http://supabase-rest:3000/
+    routes:
+      - name: rest-v1-all
+        strip_path: true
+        paths:
+          - /rest/v1/
+    plugins:
+      - name: cors
+      - name: key-auth
+        config:
+          hide_credentials: false
+      - name: acl
+        config:
+          hide_groups_header: true
+          allow:
+            - admin
+            - anon
+
+  - name: realtime-v1
+    _comment: "Realtime: /realtime/v1/* -> http://supabase-realtime:4000/socket/*"
+    url: http://supabase-realtime:4000/socket
+    routes:
+      - name: realtime-v1-all
+        strip_path: true
+        paths:
+          - /realtime/v1/
+    plugins:
+      - name: cors
+      - name: key-auth
+        config:
+          hide_credentials: false
+      - name: acl
+        config:
+          hide_groups_header: true
+          allow:
+            - admin
+            - anon
+
+consumers:
+  - username: anon
+    keyauth_credentials:
+      - key: ${ANON_KEY}
+    acls:
+      - group: anon
+
+  - username: service_role
+    keyauth_credentials:
+      - key: ${SERVICE_ROLE_KEY}
+    acls:
+      - group: admin

+ 1 - 1
tsconfig.json

@@ -30,5 +30,5 @@
     ".next/dev/types/**/*.ts",
     "**/*.mts"
   ],
-  "exclude": ["node_modules"]
+  "exclude": ["node_modules", "cron"]
 }