diff --git a/.dockerignore b/.dockerignore index 2f923b7e0..813779bb6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,7 +15,6 @@ out/ # Environment files .env* -!.env.docker # Development files .git @@ -38,6 +37,8 @@ coverage/ # Documentation docs/ *.md +# Read at build/runtime by the API reference page (src/app/docs/api/reference). +!docs/MOBILE_API.md # Logs logs diff --git a/.env.docker.example b/.env.docker.example index deccc7ed8..42739f99f 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -112,12 +112,19 @@ PRISMA_DEBUG="false" APP_URL="http://localhost:3000" INTERNAL_API_KEY="dev-internal-api-key" -# Cloudflare R2 (Android releases) +# Cloudflare R2 (Android releases and the default user-upload store) +# Fill in the credentials to enable uploads. Use a non-production bucket for development. R2_ACCOUNT_ID="" R2_ACCESS_KEY_ID="" R2_SECRET_ACCESS_KEY="" -R2_BUCKET="emuready-app-downloads" -R2_PUBLIC_BASE_URL="https://cdn.emuready.com" +R2_BUCKET="" +R2_PUBLIC_BASE_URL="" +NEXT_PUBLIC_R2_PUBLIC_BASE_URL="" + +# Optional dedicated user-upload store. Set both values together. +R2_UPLOADS_BUCKET="" +R2_UPLOADS_PUBLIC_BASE_URL="" +NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL="" # Google Play Orders (purchase claim) ANDROID_PACKAGE_NAME="com.producdevity.emureadyapp" diff --git a/.env.example b/.env.example index 5f565192d..63dd9e1fd 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ #DATABASE_URL="file:./dev.db" # SQLite database file -DATABASE_URL="postgres://postgres.url:pooler.supabase.com:6543/postgres?pgbouncer=true" -DATABASE_DIRECT_URL="postgres://postgres:pooler.supabase.com:5432/postgres" +# Persistent servers should use the Supabase session pooler on port 5432. +DATABASE_URL="postgresql://postgres.PROJECT_REF:PASSWORD@aws-0-us-east-1.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true&connection_limit=5" +DATABASE_DIRECT_URL="postgresql://postgres.PROJECT_REF:PASSWORD@aws-0-us-east-1.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true" # Clerk NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" @@ -12,7 +13,6 @@ CLERK_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" STEAM_API_KEY="your-steam-web-api-key-here" RAWG_API_KEY="RAWG-API-KEY" THE_GAMES_DB_API_KEY="The-Games-DB-API-KEY" -NEXT_PUBLIC_THE_GAMES_DB_API_KEY="The-Games-DB-Public-API-KEY" NEXT_PUBLIC_IGDB_CLIENT_ID="IGDB-Client-ID" IGDB_CLIENT_KEY="IGDB-Client-Secret" @@ -47,15 +47,20 @@ NEXT_PUBLIC_PATREON_LINK="https://www.patreon.com/Producdevity" NEXT_PUBLIC_KOFI_LINK="https://ko-fi.com/producdevity" NEXT_PUBLIC_EMUREADY_EMAIL="info@emuready.com" NEXT_PUBLIC_GITHUB_URL="https://github.com/Producdevity/EmuReady" +NEXT_PUBLIC_TWITTER_URL="" NEXT_PUBLIC_EMUREADY_LITE_GITHUB_URL="https://github.com/Producdevity/EmuReadyLite/releases" NEXT_PUBLIC_APP_URL="http://localhost:3000" # Make sure to change this if you are using a tunnel NEXT_PUBLIC_ENABLE_SW=false NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION=true +NEXT_PUBLIC_DISABLE_COOKIE_BANNER=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=false +# Set this to the source commit SHA for production images. +NEXT_BUILD_ID="" # Android Downloads (feature flag + public endpoints) NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS=true +ENABLE_ANDROID_ENTITLEMENT_VERIFICATION=false NEXT_PUBLIC_ANDROID_LATEST_JSON_URL="https://cdn.emuready.com/xxx/xxx/xxx.json" NEXT_PUBLIC_ANDROID_LATEST_APK_URL="https://cdn.emuready.com/xxx/xxx/xxx-latest.apk" @@ -75,6 +80,11 @@ NEXT_PUBLIC_ANDROID_LATEST_APK_URL="https://cdn.emuready.com/xxx/xxx/xxx-latest. # Internal API + App URL (used by release script and admin upload) APP_URL="http://localhost:3000" INTERNAL_API_KEY="dev-internal-api-key" +# Set to "true" ONLY when the origin is reachable exclusively through Cloudflare +# (origin firewall restricted to Cloudflare IPs). Makes the rate limiter prefer +# the trustworthy cf-connecting-ip header. Leave unset on Vercel or any origin +# reachable without Cloudflare, where that header is client-forgeable. +# TRUST_CF_CONNECTING_IP="" # Cloudflare R2 (Android releases) R2_ACCOUNT_ID="" @@ -82,6 +92,16 @@ R2_ACCESS_KEY_ID="" R2_SECRET_ACCESS_KEY="" R2_BUCKET="emuready-app-downloads" R2_PUBLIC_BASE_URL="https://cdn.emuready.com" +# Same public URL exposed to browser code at build time. +NEXT_PUBLIC_R2_PUBLIC_BASE_URL="https://cdn.emuready.com" + +# Cloudflare R2 (user uploads). Optional: when unset, uploads fall back to +# R2_BUCKET + R2_PUBLIC_BASE_URL (shared with Android releases). Set both to +# isolate uploads in their own bucket with a dedicated public hostname. The +# current implementation uses the shared R2 credentials above. +# R2_UPLOADS_BUCKET="" +# R2_UPLOADS_PUBLIC_BASE_URL="https://media.emuready.com" +# NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL="https://media.emuready.com" # Google Play Orders (purchase claim) ANDROID_PACKAGE_NAME="com.producdevity.emureadyapp" diff --git a/Dockerfile b/Dockerfile index 81daeefe2..7b54ec74e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,71 +1,106 @@ -FROM node:22.17-alpine AS base +# syntax=docker/dockerfile:1 -RUN apk add --no-cache libc6-compat -WORKDIR /app +# glibc base — the repo pins linux-x64-gnu native binaries (sharp, rollup, +# tailwindcss/oxide, lightningcss) that do not resolve on Alpine/musl. +ARG NODE_IMAGE=node:22-bookworm-slim +FROM ${NODE_IMAGE} AS base +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && corepack enable +WORKDIR /app ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +# Pin pnpm to match package.json#packageManager (pnpm@11.5.2). +RUN corepack prepare pnpm@11.5.2 --activate -RUN corepack enable pnpm && corepack prepare pnpm@11.1.0 --activate - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY prisma/ ./prisma/ - -# Install production dependencies for runtime layers. -RUN pnpm install --prod --frozen-lockfile --prefer-offline --ignore-scripts - -# Development stage +# Development: retained for docker-compose.yml and scripts/docker-dev.sh. FROM base AS dev - -# Install all dependencies including devDependencies for development -RUN pnpm install --frozen-lockfile --prefer-offline --ignore-scripts - +ARG DATABASE_URL=postgresql://docker-build.invalid/emuready +ARG DATABASE_DIRECT_URL +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts COPY . . - -# Ensure Prisma client is generated for the current environment -RUN pnpm exec prisma generate - -# Expose port +RUN DATABASE_URL="${DATABASE_URL}" DATABASE_DIRECT_URL="${DATABASE_DIRECT_URL:-${DATABASE_URL}}" \ + pnpm exec prisma generate EXPOSE 3000 - -# Start development server CMD ["pnpm", "dev"] -# Build stage for production +# The build database must be migrated and disposable. Prisma TypedSQL inspects it. FROM base AS builder - -# Install all dependencies for building -RUN pnpm install --frozen-lockfile --prefer-offline --ignore-scripts - -# Copy source code +ARG DATABASE_URL +ARG DATABASE_DIRECT_URL +ARG NEXT_IMAGE_UNOPTIMIZED +ARG NEXT_PUBLIC_ALLOWED_ORIGINS +ARG NEXT_PUBLIC_ANDROID_LATEST_APK_URL +ARG NEXT_PUBLIC_ANDROID_LATEST_JSON_URL +ARG NEXT_PUBLIC_APP_ENV +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY +ARG NEXT_PUBLIC_DISABLE_COOKIE_BANNER +ARG NEXT_PUBLIC_DISCORD_LINK +ARG NEXT_PUBLIC_EMUREADY_BETA_URL +ARG NEXT_PUBLIC_EMUREADY_EMAIL +ARG NEXT_PUBLIC_EMUREADY_LITE_GITHUB_URL +ARG NEXT_PUBLIC_ENABLE_ANALYTICS +ARG NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS +ARG NEXT_PUBLIC_ENABLE_KOFI_WIDGET +ARG NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION +ARG NEXT_PUBLIC_ENABLE_SENTRY +ARG NEXT_PUBLIC_ENABLE_SW +ARG NEXT_PUBLIC_GA_ID +ARG NEXT_PUBLIC_GITHUB_URL +ARG NEXT_PUBLIC_IGDB_CLIENT_ID +ARG NEXT_PUBLIC_KOFI_LINK +ARG NEXT_PUBLIC_LOCAL_STORAGE_PREFIX +ARG NEXT_PUBLIC_PATREON_LINK +ARG NEXT_PUBLIC_R2_PUBLIC_BASE_URL +ARG NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL +ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY +ARG NEXT_PUBLIC_TWITTER_URL +ARG NEXT_BUILD_ID +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts COPY . . - -# Generate Prisma client for building -RUN pnpm exec prisma generate - -# Build the application -RUN pnpm build - -# Production stage -FROM base AS production - -# Copy built application -COPY --from=builder /app/.next ./.next -COPY --from=builder /app/public ./public -COPY --from=builder /app/next.config.ts ./ -COPY --from=builder /app/prisma/generated ./prisma/generated - -# Create non-root user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs - -# Change ownership of the app directory -RUN chown -R nextjs:nodejs /app +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm version:sync +RUN DATABASE_URL="${DATABASE_URL}" DATABASE_DIRECT_URL="${DATABASE_DIRECT_URL:-${DATABASE_URL}}" \ + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}" \ + NEXT_PUBLIC_R2_PUBLIC_BASE_URL="${NEXT_PUBLIC_R2_PUBLIC_BASE_URL}" \ + NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL="${NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL}" \ + NEXT_IMAGE_UNOPTIMIZED="${NEXT_IMAGE_UNOPTIMIZED}" \ + NEXT_BUILD_ID="${NEXT_BUILD_ID}" \ + pnpm build + +# One-shot migration image. DATABASE_DIRECT_URL is supplied at runtime. +FROM base AS migrator +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts +COPY prisma.config.ts ./ +COPY prisma ./prisma +CMD ["pnpm", "exec", "prisma", "migrate", "deploy"] + +# Standalone Next.js runtime. +FROM ${NODE_IMAGE} AS app +ARG NEXT_BUILD_ID +WORKDIR /app +ENV NODE_ENV=production \ + HOSTNAME=0.0.0.0 \ + PORT=3000 \ + APP_VERSION=${NEXT_BUILD_ID} \ + NEXT_TELEMETRY_DISABLED=1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid 1001 --create-home nextjs +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/docs/MOBILE_API.md ./docs/MOBILE_API.md USER nextjs - -# Expose port EXPOSE 3000 - -# Start production server -CMD ["pnpm", "start"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||'3000')+'/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 27978f107..afef37465 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ pnpm dev Then open [http://localhost:3000](http://localhost:3000). Environment setup is documented in [docs/DEVELOPMENT_SETUP.md](docs/DEVELOPMENT_SETUP.md). Docker-specific setup is documented in -[docs/DOCKER.md](docs/DOCKER.md). +[docs/DOCKER.md](docs/DOCKER.md), and the production container contract is in [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md). ## Common Commands diff --git a/config/image-hosts.ts b/config/image-hosts.ts index eb8746aee..2c493e6b7 100644 --- a/config/image-hosts.ts +++ b/config/image-hosts.ts @@ -9,6 +9,19 @@ export const GAME_IMAGE_PROVIDER_HOST_PATTERNS = [ 'images.gog-statics.com', ] as const +function r2UploadsHost(): string | null { + const base = + process.env.NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL || process.env.NEXT_PUBLIC_R2_PUBLIC_BASE_URL + if (!base) return null + try { + return new URL(base).hostname + } catch { + return null + } +} + +const R2_UPLOADS_HOST = r2UploadsHost() + export const NEXT_IMAGE_REMOTE_HOST_PATTERNS = [ 'placehold.co', '*.clerk.com', @@ -16,6 +29,7 @@ export const NEXT_IMAGE_REMOTE_HOST_PATTERNS = [ 'storage.ko-fi.com', 'ko-fi.com', ...GAME_IMAGE_PROVIDER_HOST_PATTERNS, + ...(R2_UPLOADS_HOST ? [R2_UPLOADS_HOST] : []), ] as const export const NEXT_IMAGE_REMOTE_PATTERNS = NEXT_IMAGE_REMOTE_HOST_PATTERNS.map((hostname) => ({ diff --git a/docker-compose.yml b/docker-compose.yml index a3e897c98..273b29ac6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,8 +50,6 @@ services: # Source code for hot reload - .:/app - /app/node_modules - # Persistent uploads - - uploads_data:/app/public/uploads # Flag file to track if initial setup has been done - setup_data:/app/.setup depends_on: @@ -145,8 +143,6 @@ services: volumes: postgres_data: driver: local - uploads_data: - driver: local pgadmin_data: driver: local setup_data: diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 9d6afed6f..835ab2b41 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -38,7 +38,7 @@ When you run the Docker setup, you'll have: - Prisma Studio at http://localhost:5555 - PostgreSQL database with seeded data - Hot reload for development -- Persistent database and uploaded files +- Persistent database; uploads require external R2 credentials - One-time initial seeding ## Configuration @@ -60,10 +60,21 @@ CLERK_SECRET_KEY="sk_test_your_key" RAWG_API_KEY="your_rawg_key" # Game data THE_GAMES_DB_API_KEY="your_tgdb_key" # Game images +# User uploads (use a non-production R2 bucket and scoped credentials) +R2_ACCOUNT_ID="your_account_id" +R2_ACCESS_KEY_ID="your_access_key" +R2_SECRET_ACCESS_KEY="your_secret_key" +R2_UPLOADS_BUCKET="your_development_uploads_bucket" +R2_UPLOADS_PUBLIC_BASE_URL="https://your-development-r2-host.example.com" +NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL="https://your-development-r2-host.example.com" + # For webhook testing (Clerk auth) TUNNEL_TOKEN="your_cloudflare_tunnel_token" # Cloudflare tunnel ``` +Without R2 credentials, the application and local database still run, but upload operations fail +without writing files locally. + ### Getting API Keys 1. **Clerk (Authentication)** - Required @@ -340,7 +351,6 @@ Docker creates the following persistent volumes: ``` emuready/ -├── public/uploads/ # File uploads (persistent) ├── .env.docker # Your environment config ├── docker-compose.yml # Service configuration ├── Dockerfile # App container definition diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md new file mode 100644 index 000000000..df2bb31c9 --- /dev/null +++ b/docs/SELF_HOSTING.md @@ -0,0 +1,47 @@ +# Self-hosting + +EmuReady runs as a standalone Next.js container behind Coolify and Cloudflare. Supabase, Clerk, R2, Sentry, and email remain managed services. + +## Build and release contract + +- Build the `app` target from `Dockerfile`; run the resulting immutable image in Coolify. +- Supply all `NEXT_PUBLIC_*` values while building. Runtime values cannot change the browser bundle. +- Use a migrated, disposable Postgres database while building because Prisma TypedSQL generation introspects the schema. Never use production for this. +- In Coolify, mark the build database URLs as build variables and enable **Use Docker Build Secrets**. Ordinary Docker build arguments expose their values in image metadata. +- Keep runtime-only secrets, such as `CLERK_SECRET_KEY`, out of the build phase. +- For a release containing migrations, build the `migrator` target from the same commit and run it with `DATABASE_DIRECT_URL` before deploying the `app` image. + +The VPS currently builds from source in Coolify. A verified GitHub App webhook automatically deploys pushes to the configured branch. Publishing prebuilt immutable images remains deferred. + +## Coolify application + +- Use the Dockerfile build pack, target `app`, and exposed port `3000`. +- Set `NEXT_BUILD_ID=$SOURCE_COMMIT` and enable **Include Source Commit in Build**. +- Use `/api/health/ready` for deployment health checks and `/api/health/live` for process liveness. + +## Production configuration + +- Use the Supabase session pooler on port 5432. Outside Vercel, the app retains one warm connection and allows at most five by default; override the maximum with `connection_limit` in `DATABASE_URL`. +- Store production user uploads in R2. Set `R2_UPLOADS_BUCKET`, `R2_UPLOADS_PUBLIC_BASE_URL`, and the matching `NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL` together. Keeping R2 credentials unset in staging prevents authenticated R2 API access and writes, but public objects remain readable when their URLs are known. +- Set `TRUST_CF_CONNECTING_IP=true` only after the origin accepts web traffic exclusively through Cloudflare. Enabling it on a directly reachable origin lets clients forge the trusted header. + +## Deferred follow-ups + +- Provision an isolated staging upload bucket, scoped token, and hostname before enabling upload testing in staging. +- Make APK objects private so entitlement checks cannot be bypassed with a known public R2 URL. +- Move builds to GitHub-hosted Actions, publish immutable images to GHCR, and have Coolify deploy them by digest. Do not run the build runner on the application VPS. +- Replace the current `staging` default branch and `master` production convention with a documented release and promotion flow. +- Consolidate the duplicate mobile tRPC paths and remove the unused transport. +- Define how authored handheld and PC Compatibility Reports are retained when a Clerk user is deleted; their required author relations currently block deletion (observed as `Listing_authorId_fkey`). +- Define how duplicate-email `user.created` events should reconcile different Clerk identities instead of retrying indefinitely. +- Audit the stale TransIP, FTP, and mail DNS records, then add DMARC after confirming the mail policy. + +## Verification and cutover + +1. Deploy with staging Clerk and Supabase credentials under a temporary hostname. +2. Verify `/api/health/live`, `/api/health/ready`, public pages, authentication, API routes, and image optimization. +3. Measure baseline and burst performance against staging, including p95 latency, errors, CPU, memory, image processing, disk use, and Supabase pool usage. +4. Deploy the production configuration while the production domain still points to Vercel. Verify web and mobile Clerk flows through the temporary hostname. +5. Point both the apex and `www` Cloudflare records at the VPS, preserve the current apex-to-`www` canonical redirect, and keep the previous Vercel deployment available for rollback. + +Do not run an upload backfill unless a read-only production database inventory confirms that `/uploads/...` references still exist and the matching source files have been recovered. diff --git a/next.config.ts b/next.config.ts index 2d2dbc6d3..9bee1bcb4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,7 @@ type Header = Awaited>>[number] const isVercelBuild = process.env.VERCEL === '1' const isSentryEnabled = process.env.NEXT_PUBLIC_ENABLE_SENTRY === 'true' +const nextBuildId = process.env.NEXT_BUILD_ID const contentSecurityPolicyDirectives = [ { @@ -147,6 +148,12 @@ function createContentSecurityPolicy(): string { } const nextConfig: NextConfig = { + output: 'standalone', + + // Keep build identity stable and protect clients from version skew while + // Coolify briefly overlaps the old and new containers during deployment. + ...(nextBuildId ? { deploymentId: nextBuildId, generateBuildId: () => nextBuildId } : {}), + images: { unoptimized: process.env.NEXT_IMAGE_UNOPTIMIZED === 'true', qualities: [50, 75, 85, 100], @@ -225,7 +232,7 @@ const nextConfig: NextConfig = { serverExternalPackages: ['@prisma/client', 'jsdom', 'markdown-it', 'dompurify'], outputFileTracingIncludes: { - '/*': ['docs/**/*.md'], + '/*': ['docs/**/*.md', 'prisma/generated/client/**'], }, outputFileTracingExcludes: { diff --git a/playwright.config.ts b/playwright.config.ts index ac11fb179..a15baaa2b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -49,7 +49,7 @@ export default defineConfig({ globalSetup: path.resolve(currentDir, './tests/global.setup.ts'), use: { - baseURL: 'http://localhost:3000', + baseURL: process.env.PW_BASE_URL || 'http://localhost:3000', actionTimeout: 10 * 1000, navigationTimeout: 30 * 1000, trace: 'on-first-retry', @@ -79,17 +79,18 @@ export default defineConfig({ ], /* Let Playwright handle starting the server */ - webServer: process.env.PWTEST_SKIP_WEBSERVER - ? undefined - : { - command: - process.env.PWTEST_SERVER_COMMAND || - (isGitHubActions ? 'pnpm start' : 'pnpm build && pnpm start'), - url: 'http://localhost:3000', - env: createWebServerEnv(), - reuseExistingServer: !isCI, - timeout: isGitHubActions ? 180 * 1000 : 300 * 1000, - stdout: 'pipe', - stderr: 'pipe', - }, + webServer: + process.env.PW_BASE_URL || process.env.PWTEST_SKIP_WEBSERVER + ? undefined + : { + command: + process.env.PWTEST_SERVER_COMMAND || + (isGitHubActions ? 'pnpm start' : 'pnpm build && pnpm start'), + url: 'http://localhost:3000', + env: createWebServerEnv(), + reuseExistingServer: !isCI, + timeout: isGitHubActions ? 180 * 1000 : 300 * 1000, + stdout: 'pipe', + stderr: 'pipe', + }, }) diff --git a/src/app/api/health/live/route.test.ts b/src/app/api/health/live/route.test.ts new file mode 100644 index 000000000..9e69d5328 --- /dev/null +++ b/src/app/api/health/live/route.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { GET } from './route' + +const healthMocks = vi.hoisted(() => ({ + connection: vi.fn(), +})) + +vi.mock('next/server', async () => { + const actual = await vi.importActual>('next/server') + return { ...actual, connection: healthMocks.connection } +}) + +describe('GET /api/health/live', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + }) + + it('reports process liveness without exposing diagnostics', async () => { + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ status: 'alive' }) + expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate') + expect(healthMocks.connection).toHaveBeenCalledOnce() + }) +}) diff --git a/src/app/api/health/live/route.ts b/src/app/api/health/live/route.ts new file mode 100644 index 000000000..fb1895e63 --- /dev/null +++ b/src/app/api/health/live/route.ts @@ -0,0 +1,39 @@ +import { connection, NextResponse } from 'next/server' + +/** + * Liveness probe — confirms the process is up and serving HTTP. Performs no + * dependency I/O (no database query) so it stays fast and independent of + * downstream health. Use this for container/orchestrator liveness gates; use + * /api/health/ready for a dependency-aware readiness check. + * @openapi + * /api/health/live: + * get: + * tags: + * - Health + * summary: Liveness probe + * description: Lightweight process liveness check with no dependency I/O + * responses: + * 200: + * description: Process is alive + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [alive] + */ +export async function GET() { + await connection() + + return NextResponse.json( + { + status: 'alive', + }, + { + status: 200, + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }, + }, + ) +} diff --git a/src/app/api/health/ready/route.test.ts b/src/app/api/health/ready/route.test.ts new file mode 100644 index 000000000..b11a5a34e --- /dev/null +++ b/src/app/api/health/ready/route.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const healthMocks = vi.hoisted(() => ({ + connection: vi.fn(), + checkDatabase: vi.fn(), +})) + +vi.mock('next/server', async () => { + const actual = await vi.importActual>('next/server') + return { ...actual, connection: healthMocks.connection } +}) + +vi.mock('@/features/health/server/health.service', () => ({ + createHealthService: () => ({ checkDatabase: healthMocks.checkDatabase }), +})) + +vi.mock('@/server/db', () => ({ prisma: {} })) + +const { GET } = await import('./route') + +describe('GET /api/health/ready', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + healthMocks.checkDatabase.mockResolvedValue(undefined) + vi.stubEnv('CLERK_SECRET_KEY', 'sk_test') + }) + + it('reports ready without exposing dependency diagnostics', async () => { + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ status: 'healthy' }) + expect(healthMocks.checkDatabase).toHaveBeenCalledOnce() + }) + + it('does not require the build-time Clerk publishable key at runtime', async () => { + vi.stubEnv('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', '') + + const response = await GET() + + expect(response.status).toBe(200) + }) + + it('reports not ready when auth configuration is missing', async () => { + vi.stubEnv('CLERK_SECRET_KEY', '') + + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(503) + expect(body).toEqual({ status: 'unhealthy' }) + }) + + it('reports not ready when the database cannot be reached', async () => { + healthMocks.checkDatabase.mockRejectedValueOnce(new Error('database unavailable')) + + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(503) + expect(body).toEqual({ status: 'unhealthy' }) + }) +}) diff --git a/src/app/api/health/ready/route.ts b/src/app/api/health/ready/route.ts new file mode 100644 index 000000000..b25d4c0ed --- /dev/null +++ b/src/app/api/health/ready/route.ts @@ -0,0 +1,52 @@ +import { connection, NextResponse } from 'next/server' +import { createHealthService } from '@/features/health/server/health.service' +import { prisma } from '@/server/db' + +const NO_CACHE_HEADERS = { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', +} as const + +/** + * Dependency-aware readiness probe for container and load-balancer health + * checks. Public responses intentionally expose only the aggregate status. + * @openapi + * /api/health/ready: + * get: + * tags: + * - Health + * summary: Server readiness check + * responses: + * 200: + * description: Server is ready + * 503: + * description: Server is not ready + */ +export async function GET() { + await connection() + + try { + await createHealthService(prisma).checkDatabase() + + const authAvailable = Boolean(process.env.CLERK_SECRET_KEY) + + return NextResponse.json( + { status: authAvailable ? 'healthy' : 'unhealthy' }, + { + status: authAvailable ? 200 : 503, + headers: NO_CACHE_HEADERS, + }, + ) + } catch (error) { + console.error('Health check failed:', error) + + return NextResponse.json( + { status: 'unhealthy' }, + { + status: 503, + headers: NO_CACHE_HEADERS, + }, + ) + } +} diff --git a/src/app/api/health/route.test.ts b/src/app/api/health/route.test.ts new file mode 100644 index 000000000..32ba3b38d --- /dev/null +++ b/src/app/api/health/route.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const healthMocks = vi.hoisted(() => ({ + connection: vi.fn(), + checkDatabase: vi.fn(), +})) + +vi.mock('next/server', async () => { + const actual = await vi.importActual>('next/server') + return { ...actual, connection: healthMocks.connection } +}) + +vi.mock('@/features/health/server/health.service', () => ({ + createHealthService: () => ({ checkDatabase: healthMocks.checkDatabase }), +})) + +vi.mock('@/server/db', () => ({ prisma: {} })) + +const { GET } = await import('./route') + +describe('GET /api/health', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + vi.stubEnv('NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', 'pk_test') + vi.stubEnv('CLERK_SECRET_KEY', 'sk_test') + vi.stubEnv('APP_VERSION', 'commit-sha') + healthMocks.checkDatabase.mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('preserves the detailed legacy health response', async () => { + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate') + expect(body).toMatchObject({ + status: 'healthy', + version: 'commit-sha', + services: { + database: { status: 'connected', latency: expect.any(Number) }, + auth: { status: 'available' }, + }, + system: { + memory: { + used: expect.any(Number), + total: expect.any(Number), + percentage: expect.any(Number), + }, + nodeVersion: expect.any(String), + }, + }) + expect(body.timestamp).toEqual(expect.any(String)) + expect(body.uptime).toEqual(expect.any(Number)) + expect(healthMocks.connection).toHaveBeenCalledOnce() + expect(healthMocks.checkDatabase).toHaveBeenCalledOnce() + }) + + it('preserves the legacy unavailable auth status', async () => { + vi.stubEnv('CLERK_SECRET_KEY', '') + + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.services.auth).toEqual({ status: 'unavailable' }) + }) + + it('preserves the legacy unhealthy response when the database check fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + healthMocks.checkDatabase.mockRejectedValueOnce(new Error('database unavailable')) + + const response = await GET() + const body = await response.json() + + expect(response.status).toBe(503) + expect(body).toMatchObject({ status: 'unhealthy', error: 'Health check failed' }) + expect(body.timestamp).toEqual(expect.any(String)) + expect(consoleError).toHaveBeenCalled() + }) +}) diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 32b647bdb..4bd50e579 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,17 +1,17 @@ import { connection, NextResponse } from 'next/server' +import { createHealthService } from '@/features/health/server/health.service' import { prisma } from '@/server/db' -import type { NextRequest } from 'next/server' interface HealthResponse { - status: 'healthy' | 'unhealthy' + status: 'healthy' timestamp: string uptime: number version: string environment: string services: { database: { - status: 'connected' | 'disconnected' - latency?: number + status: 'connected' + latency: number } auth: { status: 'available' | 'unavailable' @@ -27,109 +27,44 @@ interface HealthResponse { } } +const NO_CACHE_HEADERS = { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', +} as const + /** - * Health check endpoint for monitoring and load balancers + * Legacy detailed health endpoint retained for existing monitoring consumers. + * New container checks should use /api/health/live and /api/health/ready. * @openapi * /api/health: * get: * tags: * - Health * summary: Server health check - * description: Returns the current health status of the server and its dependencies * responses: * 200: * description: Server is healthy - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [healthy, unhealthy] - * timestamp: - * type: string - * format: date-time - * uptime: - * type: number - * description: Server uptime in seconds - * version: - * type: string - * description: Application version - * environment: - * type: string - * description: Current environment - * services: - * type: object - * properties: - * database: - * type: object - * properties: - * status: - * type: string - * enum: [connected, disconnected] - * latency: - * type: number - * description: Database response time in ms - * auth: - * type: object - * properties: - * status: - * type: string - * enum: [available, unavailable] - * system: - * type: object - * properties: - * memory: - * type: object - * properties: - * used: - * type: number - * total: - * type: number - * percentage: - * type: number - * nodeVersion: - * type: string * 503: * description: Server is unhealthy - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [unhealthy] - * timestamp: - * type: string - * format: date-time - * error: - * type: string - * description: Error message */ -export async function GET(_request: NextRequest) { +export async function GET() { await connection() try { const dbStart = Date.now() - await prisma.$queryRaw`SELECT 1` + await createHealthService(prisma).checkDatabase() const dbLatency = Date.now() - dbStart const memUsage = process.memoryUsage() const memoryUsed = memUsage.rss const memoryTotal = memUsage.rss + memUsage.external - const memoryPercentage = Math.round((memoryUsed / memoryTotal) * 100) - - const authAvailable = !!( - process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY && process.env.CLERK_SECRET_KEY - ) const healthData: HealthResponse = { status: 'healthy', timestamp: new Date().toISOString(), uptime: Math.floor(process.uptime()), - version: process.env.npm_package_version || '0.0.0', + version: process.env.APP_VERSION || process.env.npm_package_version || '0.0.0', environment: process.env.NODE_ENV || 'unknown', services: { database: { @@ -137,43 +72,33 @@ export async function GET(_request: NextRequest) { latency: dbLatency, }, auth: { - status: authAvailable ? 'available' : 'unavailable', + status: + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY && process.env.CLERK_SECRET_KEY + ? 'available' + : 'unavailable', }, }, system: { memory: { used: Math.round(memoryUsed / 1024 / 1024), total: Math.round(memoryTotal / 1024 / 1024), - percentage: memoryPercentage, + percentage: Math.round((memoryUsed / memoryTotal) * 100), }, nodeVersion: process.version, }, } - return NextResponse.json(healthData, { - status: 200, - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache', - Expires: '0', - }, - }) + return NextResponse.json(healthData, { status: 200, headers: NO_CACHE_HEADERS }) } catch (error) { console.error('Health check failed:', error) - const unhealthyResponse = { - status: 'unhealthy' as const, - timestamp: new Date().toISOString(), - error: 'Health check failed', - } - - return NextResponse.json(unhealthyResponse, { - status: 503, - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - Pragma: 'no-cache', - Expires: '0', + return NextResponse.json( + { + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: 'Health check failed', }, - }) + { status: 503, headers: NO_CACHE_HEADERS }, + ) } } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 0e583de88..ff4c4ea91 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,63 +1,32 @@ -import { writeFile, mkdir } from 'fs/promises' -import { join } from 'path' import { auth } from '@clerk/nextjs/server' -import { NextResponse, type NextRequest } from 'next/server' -import { prisma } from '@/server/db' +import { NextResponse } from 'next/server' +import { handleFileUpload } from '@/lib/upload' import getErrorMessage from '@/utils/getErrorMessage' -import { hasRolePermission } from '@/utils/permissions' -import { Role } from '@orm' - -function isImage(file: File) { - return file.type.startsWith('image/') -} +import type { NextRequest } from 'next/server' export async function POST(request: NextRequest) { try { const { userId } = await auth() - if (!userId) { return NextResponse.json({ error: 'Unauthorized access' }, { status: 401 }) } - // Check user role for upload permissions - const user = await prisma.user.findUnique({ - where: { clerkId: userId }, - select: { role: true }, - }) - - if (!user || !hasRolePermission(user.role, Role.USER)) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - const formData = await request.formData() - const file = formData.get('file') as File | null - if (!file) { - return NextResponse.json({ error: 'No file uploaded' }, { status: 400 }) - } + const result = await handleFileUpload(formData, userId, 'games') - if (!isImage(file)) { - return NextResponse.json({ error: 'Uploaded file is not an image' }, { status: 400 }) + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: result.status }) } - // Create unique filename - const fileExtension = file.name.split('.').pop() - const timestamp = Date.now() - const fileName = `game-${timestamp}.${fileExtension}` - - // Create directory if it doesn't exist - const publicDir = join(process.cwd(), 'public') - const uploadDir = join(publicDir, 'uploads', 'games') - await mkdir(uploadDir, { recursive: true }) - - // Write file to disk - const filePath = join(uploadDir, fileName) - const buffer = Buffer.from(await file.arrayBuffer()) - await writeFile(filePath, buffer) + const response = NextResponse.json({ + success: true, + imageUrl: result.imageUrl, + }) - const imageUrl = `/uploads/games/${fileName}` + response.headers.set('Cache-Control', 'no-store, max-age=0') - return NextResponse.json({ success: true, imageUrl }) + return response } catch (error) { console.error('Error uploading file:', error) const errorMessage = getErrorMessage(error, 'An error occurred during upload') diff --git a/src/app/api/webhooks/clerk/route.test.ts b/src/app/api/webhooks/clerk/route.test.ts new file mode 100644 index 000000000..b76c4afdc --- /dev/null +++ b/src/app/api/webhooks/clerk/route.test.ts @@ -0,0 +1,146 @@ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { POST } from './route' +import type * as ClerkWebhooks from '@clerk/nextjs/webhooks' + +const mocks = vi.hoisted(() => ({ + verifyWebhook: vi.fn(), + user: { + create: vi.fn(), + deleteMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + analytics: { + signedUp: vi.fn(), + registrationCompleted: vi.fn(), + registrationStarted: vi.fn(), + funnelStepCompleted: vi.fn(), + }, +})) + +vi.mock('@clerk/nextjs/webhooks', async () => { + const actual = await vi.importActual('@clerk/nextjs/webhooks') + return { ...actual, verifyWebhook: mocks.verifyWebhook } +}) + +vi.mock('@/server/db', () => ({ prisma: { user: mocks.user } })) + +vi.mock('@/lib/analytics', () => ({ + default: { + user: { signedUp: mocks.analytics.signedUp }, + userJourney: { + registrationCompleted: mocks.analytics.registrationCompleted, + registrationStarted: mocks.analytics.registrationStarted, + }, + conversion: { funnelStepCompleted: mocks.analytics.funnelStepCompleted }, + }, +})) + +const request = new NextRequest('http://localhost/api/webhooks/clerk', { method: 'POST' }) + +function createdEvent() { + return { + type: 'user.created', + data: { + id: 'user_clerk_1', + username: 'TestUser', + primary_email_address_id: 'email_1', + email_addresses: [{ id: 'email_1', email_address: 'test@example.com' }], + image_url: 'https://example.com/avatar.png', + public_metadata: {}, + }, + } +} + +describe('Clerk webhook route', () => { + beforeEach(() => { + vi.stubEnv('CLERK_WEBHOOK_SECRET', 'whsec_test') + mocks.verifyWebhook.mockReset() + mocks.user.create.mockReset() + mocks.user.deleteMany.mockReset() + mocks.user.findUnique.mockReset() + mocks.user.update.mockReset() + mocks.analytics.signedUp.mockReset() + mocks.analytics.registrationCompleted.mockReset() + mocks.analytics.registrationStarted.mockReset() + mocks.analytics.funnelStepCompleted.mockReset() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('creates a new user and emits signup analytics once', async () => { + mocks.verifyWebhook.mockResolvedValueOnce(createdEvent()) + mocks.user.findUnique.mockResolvedValueOnce(null) + mocks.user.create.mockResolvedValueOnce({ id: 'database_user_1' }) + + const response = await POST(request) + + expect(response.status).toBe(200) + expect(mocks.user.create).toHaveBeenCalledOnce() + expect(mocks.analytics.signedUp).toHaveBeenCalledOnce() + expect(mocks.analytics.signedUp).toHaveBeenCalledWith({ userId: 'database_user_1' }) + expect(mocks.analytics.registrationCompleted).toHaveBeenCalledOnce() + expect(mocks.analytics.registrationStarted).toHaveBeenCalledOnce() + expect(mocks.analytics.funnelStepCompleted).toHaveBeenCalledOnce() + }) + + it('accepts a repeated user.created event without creating or tracking the user again', async () => { + mocks.verifyWebhook.mockResolvedValueOnce(createdEvent()) + mocks.user.findUnique.mockResolvedValueOnce({ id: 'database_user_1' }) + + const response = await POST(request) + + expect(response.status).toBe(200) + expect(mocks.user.create).not.toHaveBeenCalled() + expect(mocks.analytics.signedUp).not.toHaveBeenCalled() + }) + + it('accepts a concurrent duplicate after the competing create wins', async () => { + const uniqueConstraintError = new Error('Unique constraint failed') + Object.assign(uniqueConstraintError, { code: 'P2002' }) + mocks.verifyWebhook.mockResolvedValueOnce(createdEvent()) + mocks.user.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'database_user_1' }) + mocks.user.create.mockRejectedValueOnce(uniqueConstraintError) + + const response = await POST(request) + + expect(response.status).toBe(200) + expect(mocks.analytics.signedUp).not.toHaveBeenCalled() + }) + + it('rejects an email collision that belongs to another Clerk user', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const uniqueConstraintError = new Error('Unique constraint failed') + Object.assign(uniqueConstraintError, { code: 'P2002' }) + mocks.verifyWebhook.mockResolvedValueOnce(createdEvent()) + mocks.user.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(null) + mocks.user.create.mockRejectedValueOnce(uniqueConstraintError) + + const response = await POST(request) + + expect(response.status).toBe(500) + expect(mocks.analytics.signedUp).not.toHaveBeenCalled() + expect(consoleError).toHaveBeenCalled() + consoleError.mockRestore() + }) + + it('accepts a repeated user.deleted event when the user is already absent', async () => { + mocks.verifyWebhook.mockResolvedValueOnce({ + type: 'user.deleted', + data: { id: 'user_clerk_1', email_addresses: [] }, + }) + mocks.user.deleteMany.mockResolvedValueOnce({ count: 0 }) + + const response = await POST(request) + + expect(response.status).toBe(200) + expect(mocks.user.deleteMany).toHaveBeenCalledWith({ + where: { clerkId: 'user_clerk_1' }, + }) + }) +}) diff --git a/src/app/api/webhooks/clerk/route.ts b/src/app/api/webhooks/clerk/route.ts index ad256e5ea..fd0a8e388 100644 --- a/src/app/api/webhooks/clerk/route.ts +++ b/src/app/api/webhooks/clerk/route.ts @@ -2,6 +2,7 @@ import { verifyWebhook } from '@clerk/nextjs/webhooks' import { NextResponse } from 'next/server' import analytics from '@/lib/analytics' import { prisma } from '@/server/db' +import { isPrismaError, PRISMA_ERROR_CODES } from '@/server/utils/prisma-errors' import { Role } from '@orm' import type { NextRequest } from 'next/server' @@ -29,6 +30,12 @@ async function handleUserCreated(data: ClerkWebhookEvent['data']) { const role = (data.public_metadata?.role as Role) ?? Role.USER + const existingUser = await prisma.user.findUnique({ + where: { clerkId: data.id }, + select: { id: true }, + }) + if (existingUser) return + // Normalize username to lowercase for consistency let displayName: string | null = null if (data.username) { @@ -70,6 +77,14 @@ async function handleUserCreated(data: ClerkWebhookEvent['data']) { stepIndex: 1, }) } catch (error) { + if (isPrismaError(error, PRISMA_ERROR_CODES.UNIQUE_CONSTRAINT_VIOLATION)) { + const concurrentlyCreatedUser = await prisma.user.findUnique({ + where: { clerkId: data.id }, + select: { id: true }, + }) + if (concurrentlyCreatedUser) return + } + console.error('❌ Failed to create user in database:', error) throw error } @@ -138,7 +153,7 @@ async function handleUserUpdated(data: ClerkWebhookEvent['data']) { async function handleUserDeleted(data: ClerkWebhookEvent['data']) { try { - await prisma.user.delete({ where: { clerkId: data.id } }) + await prisma.user.deleteMany({ where: { clerkId: data.id } }) } catch (error) { console.error('❌ Failed to delete user from database:', error) throw error diff --git a/src/app/downloads/components/DownloadsSection.tsx b/src/app/downloads/components/DownloadsSection.tsx index 4228d6830..60e417fee 100644 --- a/src/app/downloads/components/DownloadsSection.tsx +++ b/src/app/downloads/components/DownloadsSection.tsx @@ -26,6 +26,7 @@ export default function DownloadsSection() { const latest: LatestRelease | null = latestQuery.data ?? null const entitlementQuery = api.entitlements.getMy.useQuery() const eligible = entitlementQuery.data?.eligible ?? false + const playVerificationEnabled = entitlementQuery.data?.playVerificationEnabled ?? false const signDownload = api.releases.signDownload.useMutation() const handleClickDownload = async () => { @@ -130,8 +131,9 @@ export default function DownloadsSection() { ) ) : (
- You’re not yet eligible to download. Use the actions below to verify your purchase - or link Patreon (one paid month unlocks lifetime downloads). + You’re not yet eligible to download. Use the actions below to{' '} + {playVerificationEnabled ? 'verify your purchase or ' : ''}link Patreon (one paid + month unlocks lifetime downloads).
)} diff --git a/src/app/downloads/components/EligibilityPanel.tsx b/src/app/downloads/components/EligibilityPanel.tsx index dd31ef2c5..ad204ca10 100644 --- a/src/app/downloads/components/EligibilityPanel.tsx +++ b/src/app/downloads/components/EligibilityPanel.tsx @@ -22,6 +22,7 @@ export default function EligibilityPanel(_: Props) { const play = useMemo(() => items.find((e) => e.source === EntitlementSource.PLAY), [items]) const patreon = useMemo(() => items.find((e) => e.source === EntitlementSource.PATREON), [items]) const hasPlayEntitlement = Boolean(play) + const playVerificationEnabled = entitlementsQuery.data?.playVerificationEnabled ?? false return ( @@ -75,7 +76,7 @@ export default function EligibilityPanel(_: Props) {
- {!hasPlayEntitlement && ( + {!hasPlayEntitlement && playVerificationEnabled && ( )} {!patreon && ( diff --git a/src/features/health/server/health.repository.test.ts b/src/features/health/server/health.repository.test.ts new file mode 100644 index 000000000..e29239ec7 --- /dev/null +++ b/src/features/health/server/health.repository.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma } from '@/server/db' +import { HealthRepository } from './health.repository' + +const transaction = vi.hoisted(() => ({ + $queryRaw: vi.fn(), +})) + +const mockPrisma = vi.hoisted(() => ({ + $transaction: vi.fn(), +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +describe('HealthRepository', () => { + beforeEach(() => { + transaction.$queryRaw.mockReset() + mockPrisma.$transaction.mockReset() + mockPrisma.$transaction.mockImplementation( + (operation: (client: typeof transaction) => Promise) => operation(transaction), + ) + }) + + it('bounds the readiness query with transaction and statement timeouts', async () => { + transaction.$queryRaw.mockResolvedValue(undefined) + const repository = new HealthRepository(prisma) + + await expect(repository.checkDatabase()).resolves.toBeUndefined() + + expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { + maxWait: 5_000, + timeout: 5_000, + }) + expect(transaction.$queryRaw).toHaveBeenNthCalledWith( + 1, + ["SELECT set_config('statement_timeout', ", ', true)'], + '5000', + ) + expect(transaction.$queryRaw).toHaveBeenNthCalledWith(2, ['SELECT 1']) + }) + + it('propagates database failures to the readiness handler', async () => { + const error = new Error('database unavailable') + transaction.$queryRaw.mockResolvedValueOnce(undefined).mockRejectedValueOnce(error) + const repository = new HealthRepository(prisma) + + await expect(repository.checkDatabase()).rejects.toBe(error) + }) +}) diff --git a/src/features/health/server/health.repository.ts b/src/features/health/server/health.repository.ts new file mode 100644 index 000000000..b0212d17f --- /dev/null +++ b/src/features/health/server/health.repository.ts @@ -0,0 +1,23 @@ +import { PrismaRepository } from '@/server/persistence/prisma.repository' +import type { PrismaClient } from '@orm/client' + +const DATABASE_CHECK_TIMEOUT_MS = 5_000 + +export class HealthRepository extends PrismaRepository { + constructor(prisma: PrismaClient) { + super(prisma) + } + + async checkDatabase(): Promise { + await this.prisma.$transaction( + async (transaction) => { + await transaction.$queryRaw`SELECT set_config('statement_timeout', ${String(DATABASE_CHECK_TIMEOUT_MS)}, true)` + await transaction.$queryRaw`SELECT 1` + }, + { + maxWait: DATABASE_CHECK_TIMEOUT_MS, + timeout: DATABASE_CHECK_TIMEOUT_MS, + }, + ) + } +} diff --git a/src/features/health/server/health.service.test.ts b/src/features/health/server/health.service.test.ts new file mode 100644 index 000000000..3ebbd0e67 --- /dev/null +++ b/src/features/health/server/health.service.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma } from '@/server/db' +import { HealthRepository } from './health.repository' +import { HealthService } from './health.service' + +const mockPrisma = vi.hoisted(() => ({ + $transaction: vi.fn(), +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +describe('HealthService', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('delegates the database check to the repository', async () => { + const repository = new HealthRepository(prisma) + const checkDatabase = vi.spyOn(repository, 'checkDatabase').mockResolvedValueOnce() + const service = new HealthService(repository) + + await expect(service.checkDatabase()).resolves.toBeUndefined() + + expect(checkDatabase).toHaveBeenCalledOnce() + }) + + it('propagates repository failures', async () => { + const error = new Error('database unavailable') + const repository = new HealthRepository(prisma) + vi.spyOn(repository, 'checkDatabase').mockRejectedValueOnce(error) + const service = new HealthService(repository) + + await expect(service.checkDatabase()).rejects.toBe(error) + }) +}) diff --git a/src/features/health/server/health.service.ts b/src/features/health/server/health.service.ts new file mode 100644 index 000000000..2556e7455 --- /dev/null +++ b/src/features/health/server/health.service.ts @@ -0,0 +1,14 @@ +import { HealthRepository } from './health.repository' +import type { PrismaClient } from '@orm/client' + +export class HealthService { + constructor(private readonly repository: HealthRepository) {} + + async checkDatabase(): Promise { + await this.repository.checkDatabase() + } +} + +export function createHealthService(prisma: PrismaClient): HealthService { + return new HealthService(new HealthRepository(prisma)) +} diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts index e4064e4ff..be54cd674 100644 --- a/src/lib/env.test.ts +++ b/src/lib/env.test.ts @@ -79,6 +79,17 @@ describe('env', () => { expect(env.IS_TEST_BUILD).toBe(true) }) + it('uses the test app env when a test run inherits a deployment app env', async () => { + const { env } = await loadEnv({ + [ENV_KEYS.nodeEnv]: ENV_VALUES.test, + [ENV_KEYS.appEnv]: ENV_VALUES.production, + }) + + expect(env.APP_ENV).toBe(ENV_VALUES.test) + expect(env.IS_PUBLIC_PRODUCTION).toBe(false) + expect(env.IS_TEST_BUILD).toBe(true) + }) + it('only enables optional browser services when their public flags are true', async () => { const { env } = await loadEnv({ [ENV_KEYS.nodeEnv]: ENV_VALUES.production, diff --git a/src/lib/env.ts b/src/lib/env.ts index 8b7d4ef90..6bd90a702 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -35,9 +35,10 @@ interface Env { } function resolveAppEnv(): AppEnv { + if (process.env.NODE_ENV === 'test') return 'test' + const appEnv = process.env.NEXT_PUBLIC_APP_ENV if (APP_ENV_VALUES.includes(appEnv as AppEnv)) return appEnv as AppEnv - if (process.env.NODE_ENV === 'test') return 'test' if (process.env.NODE_ENV === 'development') return 'local' return 'local' } diff --git a/src/lib/upload.test.ts b/src/lib/upload.test.ts new file mode 100644 index 000000000..979730f3b --- /dev/null +++ b/src/lib/upload.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const uploadMocks = vi.hoisted(() => ({ + deleteObject: vi.fn(), + findUser: vi.fn(), + putUpload: vi.fn(), + updateUser: vi.fn(), +})) + +vi.mock('@/server/db', () => ({ + prisma: { user: { findUnique: uploadMocks.findUser, update: uploadMocks.updateUser } }, +})) + +vi.mock('@/server/services/r2.service', () => ({ + deleteObject: uploadMocks.deleteObject, +})) + +vi.mock('@/server/services/uploads.service', () => ({ + putUpload: uploadMocks.putUpload, +})) + +const { uploadFile } = await import('./upload') + +function createImageFile(): File { + const file = new File(['image'], 'avatar.png', { type: 'image/png' }) + Object.defineProperty(file, 'arrayBuffer', { + value: vi.fn().mockResolvedValue(Uint8Array.from([1, 2, 3]).buffer), + }) + return file +} + +describe('uploadFile', () => { + beforeEach(() => { + vi.clearAllMocks() + uploadMocks.putUpload.mockResolvedValue({ + url: 'https://media.example.com/uploads/profiles/avatar.png', + bucket: 'uploads', + key: 'uploads/profiles/avatar.png', + }) + uploadMocks.deleteObject.mockResolvedValue(undefined) + uploadMocks.findUser.mockResolvedValue({ profileImage: null }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('deletes a profile image when the database update fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + uploadMocks.updateUser.mockRejectedValueOnce(new Error('database unavailable')) + + const result = await uploadFile(createImageFile(), 'clerk_user_1', { + directory: 'profiles', + updateUserProfile: true, + }) + + expect(result).toEqual({ success: false, error: 'Failed to upload file' }) + expect(uploadMocks.findUser).toHaveBeenCalledWith({ + where: { clerkId: 'clerk_user_1' }, + select: { profileImage: true }, + }) + expect(uploadMocks.deleteObject).toHaveBeenCalledWith({ + bucket: 'uploads', + key: 'uploads/profiles/avatar.png', + }) + expect(consoleError).toHaveBeenCalled() + }) + + it('keeps a profile image when the failed update response was committed', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + uploadMocks.updateUser.mockRejectedValueOnce(new Error('connection lost after commit')) + uploadMocks.findUser.mockResolvedValueOnce({ + profileImage: 'https://media.example.com/uploads/profiles/avatar.png', + }) + + await expect( + uploadFile(createImageFile(), 'clerk_user_1', { + directory: 'profiles', + updateUserProfile: true, + }), + ).resolves.toEqual({ + success: true, + imageUrl: 'https://media.example.com/uploads/profiles/avatar.png', + }) + expect(uploadMocks.deleteObject).not.toHaveBeenCalled() + }) + + it('does not delete the upload when persistence cannot be verified', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + uploadMocks.updateUser.mockRejectedValueOnce(new Error('database unavailable')) + uploadMocks.findUser.mockRejectedValueOnce(new Error('database unavailable')) + + await expect( + uploadFile(createImageFile(), 'clerk_user_1', { + directory: 'profiles', + updateUserProfile: true, + }), + ).resolves.toEqual({ success: false, error: 'Failed to upload file' }) + expect(uploadMocks.deleteObject).not.toHaveBeenCalled() + }) + + it('does not delete a successfully persisted profile image', async () => { + uploadMocks.updateUser.mockResolvedValueOnce({}) + + await expect( + uploadFile(createImageFile(), 'clerk_user_1', { + directory: 'profiles', + updateUserProfile: true, + }), + ).resolves.toEqual({ + success: true, + imageUrl: 'https://media.example.com/uploads/profiles/avatar.png', + }) + expect(uploadMocks.deleteObject).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/upload.ts b/src/lib/upload.ts index 73f6cc10e..443423801 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -1,6 +1,6 @@ -import { writeFile, mkdir } from 'fs/promises' -import { join } from 'path' import { prisma } from '@/server/db' +import { deleteObject } from '@/server/services/r2.service' +import { putUpload } from '@/server/services/uploads.service' import { IMAGE_EXTENSIONS, type ImageExtension } from '@/utils/imageValidation' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' @@ -16,7 +16,6 @@ export const ALLOWED_EXTENSIONS = IMAGE_EXTENSIONS // Upload configuration types export interface UploadConfig { directory: string - filenamePrefix: string requiredRole?: Role updateUserProfile?: boolean } @@ -25,23 +24,16 @@ export interface UploadConfig { export const UPLOAD_CONFIGS: Record = { games: { directory: 'games', - filenamePrefix: 'game', requiredRole: Role.USER, }, profiles: { directory: 'profiles', - filenamePrefix: 'profile', updateUserProfile: true, }, } as const export type UploadType = keyof typeof UPLOAD_CONFIGS -// Sanitize userId to prevent path traversal and filesystem issues -function sanitizeUserId(userId: string): string { - return userId.replace(/[^a-zA-Z0-9_-]/g, '_') -} - // Validation functions export function isValidImage(file: File): boolean { if (!file) return false @@ -101,32 +93,48 @@ export async function uploadFile( config: UploadConfig, ): Promise { try { - // Generate unique filename const fileExtension = getFileExtension(file.name) - const timestamp = Date.now() - const randomString = Math.random().toString(36).substring(2, 10) - const sanitizedUserId = sanitizeUserId(userId) - const fileName = `${config.filenamePrefix}-${sanitizedUserId}-${timestamp}-${randomString}.${fileExtension}` - - // Create directory if it doesn't exist - const publicDir = join(process.cwd(), 'public') - const uploadDir = join(publicDir, 'uploads', config.directory) - await mkdir(uploadDir, { recursive: true }) - - // Write file to disk - const filePath = join(uploadDir, fileName) const buffer = Buffer.from(await file.arrayBuffer()) - await writeFile(filePath, buffer) - // Generate public URL - const imageUrl = `/uploads/${config.directory}/${fileName}` + const storedUpload = await putUpload({ + directory: config.directory, + body: buffer, + contentType: file.type, + ext: fileExtension, + }) + const imageUrl = storedUpload.url - // Update user profile if configured if (config.updateUserProfile) { - await prisma.user.update({ - where: { clerkId: userId }, - data: { profileImage: imageUrl }, - }) + try { + await prisma.user.update({ + where: { clerkId: userId }, + data: { profileImage: imageUrl }, + }) + } catch (error) { + let shouldDeleteUpload = false + try { + const persistedUser = await prisma.user.findUnique({ + where: { clerkId: userId }, + select: { profileImage: true }, + }) + + if (persistedUser?.profileImage === imageUrl) { + return { success: true, imageUrl } + } + shouldDeleteUpload = true + } catch (verificationError) { + console.error('Failed to verify profile upload persistence:', verificationError) + } + + if (shouldDeleteUpload) { + try { + await deleteObject({ bucket: storedUpload.bucket, key: storedUpload.key }) + } catch (cleanupError) { + console.error('Failed to delete orphaned profile upload:', cleanupError) + } + } + throw error + } } return { diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 7783cb718..08dc8edd5 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -71,3 +71,46 @@ describe('proxy mobile tRPC origin handling', () => { expect(response.status).toBe(200) }) }) + +describe('getClientIdentifier', () => { + it('prefers cf-connecting-ip when TRUST_CF_CONNECTING_IP is true', async () => { + vi.stubEnv('TRUST_CF_CONNECTING_IP', 'true') + const { getClientIdentifier } = await loadProxy() + + const req = new NextRequest('https://emuready.com/x', { + headers: { + 'cf-connecting-ip': '203.0.113.10', + 'x-forwarded-for': '198.51.100.20', + }, + }) + + expect(getClientIdentifier(req)).toBe('203.0.113.10') + }) + + it('ignores forgeable cf-connecting-ip by default and uses x-forwarded-for (Vercel-safe)', async () => { + vi.stubEnv('TRUST_CF_CONNECTING_IP', '') + const { getClientIdentifier } = await loadProxy() + + const req = new NextRequest('https://emuready.com/x', { + headers: { + 'cf-connecting-ip': '203.0.113.10', + 'x-forwarded-for': '198.51.100.20, 10.0.0.1', + }, + }) + + expect(getClientIdentifier(req)).toBe('198.51.100.20') + }) + + it('falls back to x-real-ip then unknown when no trusted header is present', async () => { + vi.stubEnv('TRUST_CF_CONNECTING_IP', '') + const { getClientIdentifier } = await loadProxy() + + const withRealIp = new NextRequest('https://emuready.com/x', { + headers: { 'x-real-ip': '198.51.100.99' }, + }) + expect(getClientIdentifier(withRealIp)).toBe('198.51.100.99') + + const empty = new NextRequest('https://emuready.com/x') + expect(getClientIdentifier(empty)).toBe('unknown') + }) +}) diff --git a/src/proxy.ts b/src/proxy.ts index 2618f564a..51e6b3d01 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -33,14 +33,24 @@ function applyDevNoStoreHeader( return response } -function getClientIdentifier(req: NextRequest): string { - const forwarded = req.headers.get('x-forwarded-for') - const realIp = req.headers.get('x-real-ip') - const cfConnectingIp = req.headers.get('cf-connecting-ip') +export function getClientIdentifier(req: NextRequest): string { + // Prefer cf-connecting-ip only when the origin is reachable exclusively + // through Cloudflare, signaled by TRUST_CF_CONNECTING_IP=true. On deployments + // without that restriction (e.g. Vercel) the header is client-settable and + // forgeable, so the default order is x-forwarded-for (populated by the + // platform) first. + if (process.env.TRUST_CF_CONNECTING_IP === 'true') { + const cfConnectingIp = req.headers.get('cf-connecting-ip') + if (cfConnectingIp) return cfConnectingIp.trim() + } + const forwarded = req.headers.get('x-forwarded-for') if (forwarded) return forwarded.split(',')[0].trim() - return realIp || cfConnectingIp || 'unknown' + const realIp = req.headers.get('x-real-ip') + if (realIp) return realIp.trim() + + return 'unknown' } function shouldBypassRateLimit(identifier: string): boolean { diff --git a/src/server/api/routers/entitlements.test.ts b/src/server/api/routers/entitlements.test.ts new file mode 100644 index 000000000..554921b92 --- /dev/null +++ b/src/server/api/routers/entitlements.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { prisma } from '@/server/db' +import { EntitlementSource, Role } from '@orm' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') +vi.unmock('@/server/db') +vi.unmock('@orm') +vi.unmock('@orm/client') + +const entitlementMocks = vi.hoisted(() => ({ + fetchPlayOrder: vi.fn(), + isPaidAppOrder: vi.fn(), + grant: vi.fn(), + listActiveByUser: vi.fn(), +})) +const TEST_ORDER_ID = 'GPA.1234-5678' +const TEST_USER = { + id: '00000000-0000-4000-a000-000000000001', + email: 'test@test.com', + name: 'Test User', + role: Role.USER, + permissions: [], + showNsfw: false, +} + +vi.mock('@/server/services/googlePlayOrders.service', () => ({ + fetchPlayOrder: entitlementMocks.fetchPlayOrder, + isPaidAppOrder: entitlementMocks.isPaidAppOrder, +})) + +vi.mock('@/server/repositories/entitlements.repository', () => ({ + EntitlementsRepository: class MockEntitlementsRepository { + grant = entitlementMocks.grant + listActiveByUser = entitlementMocks.listActiveByUser + }, +})) + +const { entitlementsRouter } = await import('./entitlements') + +function createCaller() { + return entitlementsRouter.createCaller({ + session: { + user: TEST_USER, + }, + prisma, + headers: new Headers(), + }) +} + +describe('entitlements router', () => { + afterEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + }) + + it.each([ + ['true', true], + ['false', false], + ])('reports whether Google Play verification is enabled', async (value, expected) => { + vi.stubEnv('ENABLE_ANDROID_ENTITLEMENT_VERIFICATION', value) + entitlementMocks.listActiveByUser.mockResolvedValueOnce([]) + + await expect(createCaller().getMy()).resolves.toEqual({ + items: [], + eligible: false, + playVerificationEnabled: expected, + }) + }) + + it('rejects Google Play claims when Android entitlement verification is disabled', async () => { + vi.stubEnv('ENABLE_ANDROID_ENTITLEMENT_VERIFICATION', 'false') + + await expect( + createCaller().claimPlayOrder({ orderId: 'GPA.1234-5678-9012-34567' }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Operation not allowed: Android entitlement verification is disabled', + }) + expect(entitlementMocks.fetchPlayOrder).not.toHaveBeenCalled() + expect(entitlementMocks.grant).not.toHaveBeenCalled() + }) + + it('grants an entitlement for a paid Google Play order when verification is enabled', async () => { + vi.stubEnv('ENABLE_ANDROID_ENTITLEMENT_VERIFICATION', 'true') + vi.stubEnv('ANDROID_PACKAGE_NAME', 'com.example.emuready') + entitlementMocks.fetchPlayOrder.mockResolvedValueOnce({ orderId: TEST_ORDER_ID }) + entitlementMocks.isPaidAppOrder.mockReturnValueOnce(true) + entitlementMocks.grant.mockResolvedValueOnce({}) + + await expect(createCaller().claimPlayOrder({ orderId: TEST_ORDER_ID })).resolves.toEqual({ + ok: true, + }) + expect(entitlementMocks.fetchPlayOrder).toHaveBeenCalledWith( + 'com.example.emuready', + TEST_ORDER_ID, + ) + expect(entitlementMocks.grant).toHaveBeenCalledWith(TEST_USER.id, EntitlementSource.PLAY, { + referenceId: TEST_ORDER_ID, + }) + }) + + it('rejects a Google Play order that is not recognized as paid', async () => { + vi.stubEnv('ENABLE_ANDROID_ENTITLEMENT_VERIFICATION', 'true') + vi.stubEnv('ANDROID_PACKAGE_NAME', 'com.example.emuready') + entitlementMocks.fetchPlayOrder.mockResolvedValueOnce({ orderId: TEST_ORDER_ID }) + entitlementMocks.isPaidAppOrder.mockReturnValueOnce(false) + + await expect(createCaller().claimPlayOrder({ orderId: TEST_ORDER_ID })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Order not recognized as paid app', + }) + expect(entitlementMocks.grant).not.toHaveBeenCalled() + }) +}) diff --git a/src/server/api/routers/entitlements.ts b/src/server/api/routers/entitlements.ts index bc0584473..dde6baef9 100644 --- a/src/server/api/routers/entitlements.ts +++ b/src/server/api/routers/entitlements.ts @@ -26,12 +26,20 @@ export const entitlementsRouter = createTRPCRouter({ getMy: protectedProcedure.query(async ({ ctx }) => { const repo = new EntitlementsRepository(ctx.prisma) const items = await repo.listActiveByUser(ctx.session.user.id) - return { items, eligible: items.length > 0 } + return { + items, + eligible: items.length > 0, + playVerificationEnabled: process.env.ENABLE_ANDROID_ENTITLEMENT_VERIFICATION === 'true', + } }), claimPlayOrder: protectedProcedure .input(ClaimPlayOrderSchema) .mutation(async ({ ctx, input }) => { + if (process.env.ENABLE_ANDROID_ENTITLEMENT_VERIFICATION !== 'true') { + return AppError.operationNotAllowed('Android entitlement verification is disabled') + } + const packageName = process.env.ANDROID_PACKAGE_NAME if (!packageName) return AppError.internalError('ANDROID_PACKAGE_NAME missing') const order = await fetchPlayOrder(packageName, input.orderId) diff --git a/src/server/api/routers/releases.test.ts b/src/server/api/routers/releases.test.ts new file mode 100644 index 000000000..1a9f599cf --- /dev/null +++ b/src/server/api/routers/releases.test.ts @@ -0,0 +1,60 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { Role } from '@orm' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') +vi.unmock('@/server/db') +vi.unmock('@orm') +vi.unmock('@orm/client') + +vi.stubEnv('NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS', 'false') +vi.resetModules() + +const { prisma } = await import('@/server/db') +const releaseFindFirst = vi.spyOn(prisma.release, 'findFirst') +const entitlementCount = vi.spyOn(prisma.entitlement, 'count') +const TEST_USER = { + id: '00000000-0000-4000-a000-000000000001', + email: 'test@test.com', + name: 'Test User', + role: Role.USER, + permissions: [], + showNsfw: false, +} + +const { releasesRouter } = await import('./releases') + +function createCaller() { + return releasesRouter.createCaller({ + session: { + user: TEST_USER, + }, + prisma, + headers: new Headers(), + }) +} + +describe('releases router', () => { + afterAll(() => { + vi.unstubAllEnvs() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('does not expose release metadata when Android downloads are disabled', async () => { + await expect(createCaller().latest({})).resolves.toBeUndefined() + expect(releaseFindFirst).not.toHaveBeenCalled() + }) + + it('does not sign downloads when Android downloads are disabled', async () => { + await expect( + createCaller().signDownload({ releaseId: '00000000-0000-4000-a000-000000000002' }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Operation not allowed: Android downloads are disabled', + }) + expect(entitlementCount).not.toHaveBeenCalled() + }) +}) diff --git a/src/server/api/routers/releases.ts b/src/server/api/routers/releases.ts index 5d83442ca..6c6ac0afb 100644 --- a/src/server/api/routers/releases.ts +++ b/src/server/api/routers/releases.ts @@ -1,4 +1,5 @@ import { env } from '@/lib/env' +import { AppError } from '@/lib/errors' import { GetLatestReleaseSchema, SignDownloadSchema } from '@/schemas/releases' import { createTRPCRouter, publicProcedure, protectedProcedure } from '@/server/api/trpc' import { EntitlementsRepository } from '@/server/repositories/entitlements.repository' @@ -7,6 +8,8 @@ import { presignGetObject } from '@/server/services/r2.service' export const releasesRouter = createTRPCRouter({ latest: publicProcedure.input(GetLatestReleaseSchema).query(async ({ ctx, input }) => { + if (!env.ENABLE_ANDROID_DOWNLOADS) return undefined + const repo = new ReleasesRepository(ctx.prisma) // Try selected channel if provided; otherwise prefer stable, then beta const tryChannels = input?.channel ? [input.channel] : (['stable', 'beta'] as const) @@ -39,6 +42,10 @@ export const releasesRouter = createTRPCRouter({ // Records a download (and could issue a signed URL in the future) signDownload: protectedProcedure.input(SignDownloadSchema).mutation(async ({ ctx, input }) => { + if (!env.ENABLE_ANDROID_DOWNLOADS) { + return AppError.operationNotAllowed('Android downloads are disabled') + } + const entRepo = new EntitlementsRepository(ctx.prisma) const eligible = await entRepo.eligible(ctx.session.user.id) if (!eligible) return { url: env.ANDROID_LATEST_APK_URL } diff --git a/src/server/prisma-client.test.ts b/src/server/prisma-client.test.ts new file mode 100644 index 000000000..1f246dcbc --- /dev/null +++ b/src/server/prisma-client.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Keep the test free of the generated Prisma client and driver adapter. +vi.mock('@orm/client', () => ({ PrismaClient: class {} })) +vi.mock('@prisma/adapter-pg', () => ({ PrismaPg: class {} })) + +const { getPoolMax, getPoolMin } = await import('./prisma-client') + +const LOCAL_DATABASE_URL = 'postgres://u:p@localhost:5432/db' +const LOCAL_IP_DATABASE_URL = 'postgres://u:p@127.0.0.1:5432/db' +const REMOTE_DATABASE_URL = 'postgres://u:p@db.supabase.co:5432/postgres' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('getPoolMax', () => { + it('returns undefined for local hosts (Prisma default pool)', () => { + expect(getPoolMax(LOCAL_DATABASE_URL)).toBeUndefined() + expect(getPoolMax(LOCAL_IP_DATABASE_URL)).toBeUndefined() + }) + + it('returns 1 on Vercel (one connection per ephemeral instance)', () => { + vi.stubEnv('VERCEL', '1') + expect(getPoolMax(REMOTE_DATABASE_URL)).toBe(1) + }) + + it('returns 5 for a remote persistent server when not on Vercel', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMax(REMOTE_DATABASE_URL)).toBe(5) + }) + + it('honors an explicit connection_limit over the default', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=10')).toBe(10) + // still honored on Vercel + vi.stubEnv('VERCEL', '1') + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=3')).toBe(3) + }) + + it('ignores invalid connection_limit values and falls back to the default', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=abc')).toBe(5) + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=0')).toBe(5) + expect(getPoolMax('postgres://u:p@db.supabase.co:5432/postgres?connection_limit=-2')).toBe(5) + }) + + it('falls back when the connection string is not parseable', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMax('not-a-url')).toBe(5) + vi.stubEnv('VERCEL', '1') + expect(getPoolMax('not-a-url')).toBe(1) + }) +}) + +describe('getPoolMin', () => { + it('keeps one connection warm on a persistent server', () => { + vi.stubEnv('VERCEL', '') + expect(getPoolMin()).toBe(1) + }) + + it('does not retain a connection in a Vercel instance', () => { + vi.stubEnv('VERCEL', '1') + expect(getPoolMin()).toBe(0) + }) +}) diff --git a/src/server/prisma-client.ts b/src/server/prisma-client.ts index 90eeae8ee..23b26c03c 100644 --- a/src/server/prisma-client.ts +++ b/src/server/prisma-client.ts @@ -15,7 +15,7 @@ function getDatabaseUrl() { return connectionString } -function getPoolMax(connectionString: string): number | undefined { +export function getPoolMax(connectionString: string): number | undefined { try { const url = new URL(connectionString) const raw = url.searchParams.get('connection_limit') @@ -24,12 +24,22 @@ function getPoolMax(connectionString: string): number | undefined { if (Number.isInteger(parsed) && parsed > 0) return parsed } - return LOCAL_DATABASE_HOSTS.has(url.hostname.toLowerCase()) ? undefined : 1 + if (LOCAL_DATABASE_HOSTS.has(url.hostname.toLowerCase())) return undefined + + // Vercel serverless instances are short-lived and each constructs its own + // pool, so one connection per instance avoids exhausting the database budget. + // A persistent self-hosted server holds a small pool instead. Override either + // via the `connection_limit` query param on DATABASE_URL. + return process.env.VERCEL === '1' ? 1 : 5 } catch { - return 1 + return process.env.VERCEL === '1' ? 1 : 5 } } +export function getPoolMin(): number { + return process.env.VERCEL === '1' ? 0 : 1 +} + export function createPrismaClient(options?: PrismaClientConfig) { const connectionString = getDatabaseUrl() const poolMax = getPoolMax(connectionString) @@ -37,6 +47,7 @@ export function createPrismaClient(options?: PrismaClientConfig) { const adapter = new PrismaPg({ connectionString, ...(poolMax ? { max: poolMax } : {}), + min: getPoolMin(), connectionTimeoutMillis: 5_000, idleTimeoutMillis: 10_000, }) diff --git a/src/server/services/uploads.service.test.ts b/src/server/services/uploads.service.test.ts new file mode 100644 index 000000000..0689b849f --- /dev/null +++ b/src/server/services/uploads.service.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { putUpload } from './uploads.service' +const r2Mocks = vi.hoisted(() => ({ + send: vi.fn(), +})) + +vi.mock('@/server/services/r2.service', () => ({ + r2Client: () => ({ send: r2Mocks.send }), +})) + +describe('putUpload', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllEnvs() + vi.stubEnv('R2_BUCKET', 'uploads-test') + vi.stubEnv('R2_PUBLIC_BASE_URL', 'https://media.example.com/') + vi.stubEnv('R2_UPLOADS_BUCKET', '') + vi.stubEnv('R2_UPLOADS_PUBLIC_BASE_URL', '') + }) + + it('validates the public base URL before writing an object', async () => { + vi.stubEnv('R2_PUBLIC_BASE_URL', '') + + await expect( + putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }), + ).rejects.toThrow('R2_UPLOADS_PUBLIC_BASE_URL (or R2_PUBLIC_BASE_URL) is required') + expect(r2Mocks.send).not.toHaveBeenCalled() + }) + + it.each(['not-a-url', 'http://media.example.com', 'https://user:pass@media.example.com'])( + 'rejects an unsafe public base URL: %s', + async (publicBaseUrl) => { + vi.stubEnv('R2_PUBLIC_BASE_URL', publicBaseUrl) + + await expect( + putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }), + ).rejects.toThrow('R2 uploads public base URL must be a valid HTTPS URL') + expect(r2Mocks.send).not.toHaveBeenCalled() + }, + ) + + it.each([ + ['R2_UPLOADS_BUCKET', 'dedicated-uploads'], + ['R2_UPLOADS_PUBLIC_BASE_URL', 'https://uploads.example.com'], + ])('rejects a partial uploads override when only %s is set', async (name, value) => { + vi.stubEnv(name, value) + + await expect( + putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }), + ).rejects.toThrow('R2_UPLOADS_BUCKET and R2_UPLOADS_PUBLIC_BASE_URL must be set together') + expect(r2Mocks.send).not.toHaveBeenCalled() + }) + + it('uses the dedicated uploads bucket and public base when both are set', async () => { + vi.stubEnv('R2_UPLOADS_BUCKET', 'dedicated-uploads') + vi.stubEnv('R2_UPLOADS_PUBLIC_BASE_URL', 'https://uploads.example.com') + r2Mocks.send.mockResolvedValueOnce({}) + + const result = await putUpload({ + directory: 'profiles', + body: Buffer.from('image'), + contentType: 'image/webp', + ext: 'webp', + }) + + expect(result.bucket).toBe('dedicated-uploads') + expect(result.url).toBe(`https://uploads.example.com/${result.key}`) + expect(r2Mocks.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ Bucket: 'dedicated-uploads' }), + }), + ) + }) + + it('writes the object and returns its public URL', async () => { + r2Mocks.send.mockResolvedValueOnce({}) + + const result = await putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }) + + expect(r2Mocks.send).toHaveBeenCalledOnce() + expect(result.bucket).toBe('uploads-test') + expect(result.key).toMatch(/^uploads\/games\/[0-9a-f-]+\.png$/) + expect(result.url).toBe(`https://media.example.com/${result.key}`) + }) + + it('normalizes multiple trailing slashes in the public base URL', async () => { + vi.stubEnv('R2_PUBLIC_BASE_URL', 'https://media.example.com///') + r2Mocks.send.mockResolvedValueOnce({}) + + const result = await putUpload({ + directory: 'games', + body: Buffer.from('image'), + contentType: 'image/png', + ext: 'png', + }) + + expect(result.url).toBe(`https://media.example.com/${result.key}`) + }) +}) diff --git a/src/server/services/uploads.service.ts b/src/server/services/uploads.service.ts new file mode 100644 index 000000000..bdb69b2ab --- /dev/null +++ b/src/server/services/uploads.service.ts @@ -0,0 +1,80 @@ +import { randomUUID } from 'node:crypto' +import { PutObjectCommand } from '@aws-sdk/client-s3' +import { AppError } from '@/lib/errors' +import { r2Client } from '@/server/services/r2.service' +import type { ImageExtension } from '@/utils/imageValidation' + +const UPLOAD_PREFIX = 'uploads' + +export interface StoredUpload { + url: string + key: string + bucket: string +} + +interface UploadsConfig { + bucket: string + publicBase: string +} + +function getUploadsConfig(): UploadsConfig { + const uploadsBucket = process.env.R2_UPLOADS_BUCKET + const uploadsPublicBase = process.env.R2_UPLOADS_PUBLIC_BASE_URL + + if (Boolean(uploadsBucket) !== Boolean(uploadsPublicBase)) { + return AppError.internalError( + 'R2_UPLOADS_BUCKET and R2_UPLOADS_PUBLIC_BASE_URL must be set together', + ) + } + + const bucket = uploadsBucket || process.env.R2_BUCKET + const publicBase = uploadsPublicBase || process.env.R2_PUBLIC_BASE_URL + + if (!bucket) { + return AppError.internalError('R2_UPLOADS_BUCKET (or R2_BUCKET) is required for uploads') + } + if (!publicBase) { + return AppError.internalError( + 'R2_UPLOADS_PUBLIC_BASE_URL (or R2_PUBLIC_BASE_URL) is required for uploads', + ) + } + + return { bucket, publicBase: validatePublicBase(publicBase) } +} + +function validatePublicBase(value: string): string { + let base: URL + try { + base = new URL(value) + } catch { + return AppError.internalError('R2 uploads public base URL must be a valid HTTPS URL') + } + + if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) { + return AppError.internalError('R2 uploads public base URL must be a valid HTTPS URL') + } + + return base.toString().replace(/\/+$/, '') +} + +export async function putUpload(params: { + directory: string + body: Buffer + contentType: string + ext: ImageExtension +}): Promise { + const config = getUploadsConfig() + + const key = `${UPLOAD_PREFIX}/${params.directory}/${randomUUID()}.${params.ext}` + await r2Client().send( + new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + Body: params.body, + ContentType: params.contentType, + CacheControl: 'public, max-age=31536000, immutable', + }), + ) + + return { url: `${config.publicBase}/${key}`, key, bucket: config.bucket } +} diff --git a/src/utils/imageUrls.test.ts b/src/utils/imageUrls.test.ts index b6417fe59..4e5cbb5bd 100644 --- a/src/utils/imageUrls.test.ts +++ b/src/utils/imageUrls.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getGameImageUrlValidationError, getImageRenderMode, @@ -11,6 +11,20 @@ describe('imageUrls', () => { expect(getImageRenderMode('https://media.rawg.io/media/games/example.jpg')).toBe('next-image') }) + it('treats the configured R2 uploads host as a next-image host', async () => { + vi.stubEnv('NEXT_PUBLIC_R2_UPLOADS_PUBLIC_BASE_URL', 'https://media.test.emuready.com') + vi.resetModules() + try { + const { getImageRenderMode } = await import('./imageUrls') + expect(getImageRenderMode('https://media.test.emuready.com/uploads/games/abc.jpg')).toBe( + 'next-image', + ) + } finally { + vi.unstubAllEnvs() + vi.resetModules() + } + }) + it('uses native browser image rendering for arbitrary HTTPS hosts', () => { expect(getImageRenderMode('https://example.com/image.jpg')).toBe('external-img') })