diff --git a/.env.example b/.env.example index f875e3e34..f27227377 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ APP_URL="http://localhost:3000" PRINTER_APP_URL="http://host.docker.internal:3000" # --- Printer --- -PRINTER_ENDPOINT="ws://localhost:4000?token=1234567890" +PRINTER_ENDPOINT="ws://localhost:4000?token=change-me" # --- Database (PostgreSQL) --- DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" @@ -40,6 +40,11 @@ OAUTH_CLIENT_ID="" OAUTH_CLIENT_SECRET="" OAUTH_DISCOVERY_URL="" OAUTH_AUTHORIZATION_URL="" +OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS="" + +# AI provider base URL allowlist (optional, comma-separated hosts/origins) +# Example: api.openai.com,https://gateway.ai.vercel.com +AI_ALLOWED_BASE_URLS="" # --- Email (optional) --- # If all keys are disabled, the app logs the email to be sent to the console instead. diff --git a/compose.dev.yml b/compose.dev.yml index 67fa6a416..59a1ce1dd 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -35,7 +35,7 @@ services: retries: 3 browserless: - image: ghcr.io/browserless/chromium:latest + image: ${BROWSERLESS_IMAGE:-ghcr.io/browserless/chromium:latest} restart: unless-stopped ports: - "4000:3000" @@ -43,9 +43,11 @@ services: QUEUED: 10 HEALTH: "true" CONCURRENT: 5 - TOKEN: "1234567890" + TOKEN: ${BROWSERLESS_TOKEN:-change-me} + extra_hosts: + - "host.docker.internal:host-gateway" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"] + test: ["CMD-SHELL", 'curl -f "http://localhost:3000/pressure?token=${BROWSERLESS_TOKEN:-change-me}"'] interval: 10s timeout: 5s retries: 10 @@ -57,6 +59,8 @@ services: restart: unless-stopped ports: - "9222:9222" + extra_hosts: + - "host.docker.internal:host-gateway" seaweedfs: image: chrislusf/seaweedfs:latest @@ -118,15 +122,13 @@ services: - .env environment: PRINTER_APP_URL: http://web_app:3000 - PRINTER_ENDPOINT: ws://browserless:3000?token=1234567890 + PRINTER_ENDPOINT: ws://browserless:3000?token=${BROWSERLESS_TOKEN:-change-me} DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres SMTP_HOST: mailpit S3_ENDPOINT: http://seaweedfs:8333 CHOKIDAR_INTERVAL: 100 WATCHPACK_POLLING: "true" CHOKIDAR_USEPOLLING: "true" - extra_hosts: - - "host.docker.internal:host-gateway" volumes: - ./:/app - web_app_data:/app/data @@ -143,13 +145,21 @@ services: condition: service_healthy seaweedfs_create_bucket: condition: service_completed_successfully + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + start_period: 10s + interval: 30s + timeout: 10s + retries: 3 volumes: mailpit_data: postgres_data: seaweedfs_data: web_app_data: - web_app_node_modules: web_app_pnpm_store: - web_app_tanstack_cache: web_app_nitro_cache: + web_app_node_modules: + web_app_tanstack_cache: diff --git a/compose.yml b/compose.yml index 049beb15d..3632efa81 100644 --- a/compose.yml +++ b/compose.yml @@ -4,6 +4,8 @@ services: postgres: image: postgres:latest restart: unless-stopped + networks: + - data_network environment: - POSTGRES_DB=postgres - POSTGRES_USER=postgres @@ -18,15 +20,17 @@ services: retries: 3 browserless: - image: ghcr.io/browserless/chromium:latest + image: ${BROWSERLESS_IMAGE:-ghcr.io/browserless/chromium:latest} restart: unless-stopped + networks: + - printer_network environment: - QUEUED=10 - HEALTH=true - CONCURRENT=5 - - TOKEN=1234567890 + - TOKEN=${BROWSERLESS_TOKEN:-change-me} healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"] + test: ["CMD-SHELL", 'curl -f "http://localhost:3000/pressure?token=${BROWSERLESS_TOKEN:-change-me}"'] interval: 10s timeout: 5s retries: 10 @@ -36,6 +40,8 @@ services: # chrome: # image: chromedp/headless-shell:latest # restart: unless-stopped + # networks: + # - printer_network # ports: # - "9222:9222" @@ -43,6 +49,8 @@ services: image: chrislusf/seaweedfs:latest restart: unless-stopped command: server -s3 -filer -dir=/data -ip=0.0.0.0 + networks: + - storage_network environment: - AWS_ACCESS_KEY_ID=seaweedfs - AWS_SECRET_ACCESS_KEY=seaweedfs @@ -65,21 +73,32 @@ services: mc mb seaweedfs/reactive-resume; exit 0; " + networks: + - storage_network depends_on: seaweedfs: condition: service_healthy reactive_resume: - image: amruthpillai/reactive-resume:latest + # image: amruthpillai/reactive-resume:latest # image: ghcr.io/amruthpillai/reactive-resume:latest + build: + context: . + dockerfile: Dockerfile + networks: + - data_network + - printer_network + - storage_network + ports: + - "3000:3000" environment: # Server - TZ=Etc/UTC - NODE_ENV=production - APP_URL=http://localhost:3000 - - PRINTER_APP_URL=http://reactive_resume:3000 + - PRINTER_APP_URL=http://host.docker.internal:3000 # Printer - - PRINTER_ENDPOINT=ws://browserless:3000?token=1234567890 + - PRINTER_ENDPOINT=ws://browserless:3000?token=${BROWSERLESS_TOKEN:-change-me} # - PRINTER_ENDPOINT=http://chrome:9222 # Or, if you're using chromedp/headless-shell # Database - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres @@ -93,8 +112,6 @@ services: - S3_FORCE_PATH_STYLE=true volumes: - reactive_resume_data:/app/data - ports: - - "3000:3000" depends_on: postgres: condition: service_healthy @@ -102,6 +119,8 @@ services: condition: service_healthy seaweedfs_create_bucket: condition: service_completed_successfully + extra_hosts: + - "host.docker.internal:host-gateway" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] start_period: 10s @@ -113,3 +132,8 @@ volumes: postgres_data: seaweedfs_data: reactive_resume_data: + +networks: + data_network: + printer_network: + storage_network: diff --git a/package.json b/package.json index c22c00ff8..7b0a903ae 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "4.8.0-rc.3", "@orpc/client": "^1.14.0", + "@orpc/experimental-ratelimit": "^1.14.0", "@orpc/json-schema": "^1.14.0", "@orpc/openapi": "^1.14.0", "@orpc/server": "^1.14.0", @@ -64,7 +65,7 @@ "@phosphor-icons/web": "^2.1.2", "@sindresorhus/slugify": "^3.0.0", "@t3-oss/env-core": "^0.13.11", - "@tanstack/react-query": "^5.100.1", + "@tanstack/react-query": "^5.100.2", "@tanstack/react-router": "^1.168.24", "@tanstack/react-router-ssr-query": "^1.166.11", "@tanstack/react-start": "^1.167.48", @@ -108,7 +109,7 @@ "react-resizable-panels": "^4.10.0", "react-window": "^2.2.7", "react-zoom-pan-pinch": "^4.0.3", - "shadcn": "^4.4.0", + "shadcn": "^4.5.0", "sharp": "^0.34.5", "sonner": "^2.0.7", "srvx": "^0.11.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8baa56c7d..15cc3d042 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@orpc/client': specifier: ^1.14.0 version: 1.14.0(@opentelemetry/api@1.9.0) + '@orpc/experimental-ratelimit': + specifier: ^1.14.0 + version: 1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0) '@orpc/json-schema': specifier: ^1.14.0 version: 1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0) @@ -91,7 +94,7 @@ importers: version: 1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0) '@orpc/tanstack-query': specifier: ^1.14.0 - version: 1.14.0(@opentelemetry/api@1.9.0)(@orpc/client@1.14.0(@opentelemetry/api@1.9.0))(@tanstack/query-core@5.100.1) + version: 1.14.0(@opentelemetry/api@1.9.0)(@orpc/client@1.14.0(@opentelemetry/api@1.9.0))(@tanstack/query-core@5.100.2) '@orpc/zod': specifier: ^1.14.0 version: 1.14.0(@opentelemetry/api@1.9.0)(@orpc/contract@1.14.0(@opentelemetry/api@1.9.0))(@orpc/server@1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0))(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0)(zod@4.3.6) @@ -108,14 +111,14 @@ importers: specifier: ^0.13.11 version: 0.13.11(zod@4.3.6) '@tanstack/react-query': - specifier: ^5.100.1 - version: 5.100.1(react@19.2.5) + specifier: ^5.100.2 + version: 5.100.2(react@19.2.5) '@tanstack/react-router': specifier: ^1.168.24 version: 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-router-ssr-query': specifier: ^1.166.11 - version: 1.166.11(@tanstack/query-core@5.100.1)(@tanstack/react-query@5.100.1(react@19.2.5))(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.166.11(@tanstack/query-core@5.100.2)(@tanstack/react-query@5.100.2(react@19.2.5))(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-start': specifier: ^1.167.48 version: 1.167.48(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -240,8 +243,8 @@ importers: specifier: ^4.0.3 version: 4.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) shadcn: - specifier: ^4.4.0 - version: 4.4.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0) + specifier: ^4.5.0 + version: 4.5.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0) sharp: specifier: ^0.34.5 version: 0.34.5 @@ -2080,6 +2083,14 @@ packages: '@orpc/contract@1.14.0': resolution: {integrity: sha512-FUxBNqWr6mOjI+w1JPzO/iHmR3M+GA53ivaxp+eOnQu7g3ZGKB0RS5gJ/oz3cGF1gvuIcCw9FVYKK/5tkB8I1Q==} + '@orpc/experimental-ratelimit@1.14.0': + resolution: {integrity: sha512-9RuGjwcyP8IYKEzjpYYTA7vbDNfrOdEIKBkkgB3MZoRoMajA/xv+CApqbkwpGbJa+I8UeZdnPjZ42PKZA/qVag==} + peerDependencies: + '@upstash/ratelimit': '>=2.0.7' + peerDependenciesMeta: + '@upstash/ratelimit': + optional: true + '@orpc/interop@1.14.0': resolution: {integrity: sha512-T4z8ehjks4rP8Z4XDObHBQjFiH61Wz8HlDaW4SR+GK0+u3h4l3+Fdwwxvm7ZWtY2zJEraqhaJhEHf90W7w50fA==} @@ -3445,11 +3456,11 @@ packages: resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.100.1': - resolution: {integrity: sha512-awvQhOO/2TrSCHE5LKKsXcvvj6WSBncwEcMFCB/ez0Qs0b17iyyivoGArNV3HFfXryZwCpnb/olsaBBKrIbtSw==} + '@tanstack/query-core@5.100.2': + resolution: {integrity: sha512-HzzOC7xgSfGGzZ1gTsFZqYz6rxGg3tYF77nTPctin+wEYYLNMP7LjwPVFALEGNdjxkHvcewh1EM5ywixeukS4w==} - '@tanstack/react-query@5.100.1': - resolution: {integrity: sha512-UgWRLhQKprC37SsO6y1zRabOqDmM2gsdTNPbqTT35yl7kOOhwXU4nyfOiGHXPwoEFJV1IpSk85hjIFjNFWVpzw==} + '@tanstack/react-query@5.100.2': + resolution: {integrity: sha512-MvvzPcurtzVh4EcbsTfI1BL5GOfdi1S0dk/qhigEghW07MvcHUl/dhfc1FT8hPEquuMtUC+IIAxC0bdmSp/7kA==} peerDependencies: react: ^18 || ^19 @@ -6851,8 +6862,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.4.0: - resolution: {integrity: sha512-0V1AjVktKwhK1e0ONXb9SeBoyJePH04iTSJeMjl9eROqjjyb8OoWYtWDI39UdBU3GzpUlFBJFohhB9c6fGOkQA==} + shadcn@4.5.0: + resolution: {integrity: sha512-ZpNOz7IMI5aezbMEWNxBvl2aJ1ek6NuAMqpL/FUnk5IuRxERl8ohYEnqqAmhPOcur8RbGuCoqTZLQ3Oi4Xkf8A==} hasBin: true sharp@0.34.5: @@ -9694,6 +9705,18 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' + '@orpc/experimental-ratelimit@1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0)': + dependencies: + '@orpc/client': 1.14.0(@opentelemetry/api@1.9.0) + '@orpc/server': 1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0) + '@orpc/shared': 1.14.0(@opentelemetry/api@1.9.0) + '@orpc/standard-server': 1.14.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - '@opentelemetry/api' + - crossws + - fastify + - ws + '@orpc/interop@1.14.0': {} '@orpc/json-schema@1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0)': @@ -9808,11 +9831,11 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/tanstack-query@1.14.0(@opentelemetry/api@1.9.0)(@orpc/client@1.14.0(@opentelemetry/api@1.9.0))(@tanstack/query-core@5.100.1)': + '@orpc/tanstack-query@1.14.0(@opentelemetry/api@1.9.0)(@orpc/client@1.14.0(@opentelemetry/api@1.9.0))(@tanstack/query-core@5.100.2)': dependencies: '@orpc/client': 1.14.0(@opentelemetry/api@1.9.0) '@orpc/shared': 1.14.0(@opentelemetry/api@1.9.0) - '@tanstack/query-core': 5.100.1 + '@tanstack/query-core': 5.100.2 transitivePeerDependencies: - '@opentelemetry/api' @@ -10934,19 +10957,19 @@ snapshots: '@tanstack/history@1.161.6': {} - '@tanstack/query-core@5.100.1': {} + '@tanstack/query-core@5.100.2': {} - '@tanstack/react-query@5.100.1(react@19.2.5)': + '@tanstack/react-query@5.100.2(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.100.1 + '@tanstack/query-core': 5.100.2 react: 19.2.5 - '@tanstack/react-router-ssr-query@1.166.11(@tanstack/query-core@5.100.1)(@tanstack/react-query@5.100.1(react@19.2.5))(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router-ssr-query@1.166.11(@tanstack/query-core@5.100.2)(@tanstack/react-query@5.100.2(react@19.2.5))(@tanstack/react-router@1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.16)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.100.1 - '@tanstack/react-query': 5.100.1(react@19.2.5) + '@tanstack/query-core': 5.100.2 + '@tanstack/react-query': 5.100.2(react@19.2.5) '@tanstack/react-router': 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-ssr-query-core': 1.167.1(@tanstack/query-core@5.100.1)(@tanstack/router-core@1.168.16) + '@tanstack/router-ssr-query-core': 1.167.1(@tanstack/query-core@5.100.2)(@tanstack/router-core@1.168.16) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) transitivePeerDependencies: @@ -11074,9 +11097,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-ssr-query-core@1.167.1(@tanstack/query-core@5.100.1)(@tanstack/router-core@1.168.16)': + '@tanstack/router-ssr-query-core@1.167.1(@tanstack/query-core@5.100.2)(@tanstack/router-core@1.168.16)': dependencies: - '@tanstack/query-core': 5.100.1 + '@tanstack/query-core': 5.100.2 '@tanstack/router-core': 1.168.16 '@tanstack/router-utils@1.161.7': @@ -14401,7 +14424,7 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.4.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0): + shadcn@4.5.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 diff --git a/src/integrations/auth/config.ts b/src/integrations/auth/config.ts index fe5e9c02c..114bf2376 100644 --- a/src/integrations/auth/config.ts +++ b/src/integrations/auth/config.ts @@ -5,9 +5,10 @@ import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { dash } from "@better-auth/infra"; import { oauthProvider } from "@better-auth/oauth-provider"; import { passkey } from "@better-auth/passkey"; -import { BetterAuthError, betterAuth } from "better-auth"; +import { APIError, BetterAuthError, betterAuth } from "better-auth"; +import { createAuthMiddleware } from "better-auth/api"; import { verifyAccessToken } from "better-auth/oauth2"; -import { admin, jwt, openAPI, type GenericOAuthConfig } from "better-auth/plugins"; +import { admin, jwt, type GenericOAuthConfig } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins/generic-oauth"; import { twoFactor } from "better-auth/plugins/two-factor"; import { username } from "better-auth/plugins/username"; @@ -16,6 +17,7 @@ import { eq, or } from "drizzle-orm"; import { env } from "@/utils/env"; import { hashPassword, verifyPassword } from "@/utils/password"; import { generateId, toUsername } from "@/utils/string"; +import { isPrivateOrLoopbackHost, parseAllowedHostList, parseUrl } from "@/utils/url-security"; import { schema } from "../drizzle"; import { db } from "../drizzle/client"; @@ -51,8 +53,9 @@ function isCustomOAuthProviderEnabled() { } function getTrustedOrigins(): string[] { + const normalizeOrigin = (origin: string): string => origin.replace(/\/$/, ""); const appUrl = new URL(env.APP_URL); - const trustedOrigins = new Set([appUrl.origin.replace(/\/$/, "")]); + const trustedOrigins = new Set([normalizeOrigin(appUrl.origin)]); const LOCAL_ORIGINS = ["localhost", "127.0.0.1"]; if (LOCAL_ORIGINS.includes(appUrl.hostname)) { @@ -60,7 +63,7 @@ function getTrustedOrigins(): string[] { if (hostname !== appUrl.hostname) { const altUrl = new URL(env.APP_URL); altUrl.hostname = hostname; - trustedOrigins.add(altUrl.origin.replace(/\/$/, "")); + trustedOrigins.add(normalizeOrigin(altUrl.origin)); } } } @@ -69,6 +72,26 @@ function getTrustedOrigins(): string[] { } const TRUSTED_ORIGINS = getTrustedOrigins(); +const OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS = parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS); + +function isAllowedDynamicClientRedirectHost(origin: string, hostname: string): boolean { + if (TRUSTED_ORIGINS.includes(origin)) return true; + if (OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS.has(origin)) return true; + return OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS.has(hostname); +} + +function isAllowedDynamicClientRedirectUri(value: string) { + const parsed = parseUrl(value); + if (!parsed) return false; + if (parsed.username || parsed.password) return false; + if (parsed.hash) return false; + if (parsed.protocol !== "https:") return false; + if (isPrivateOrLoopbackHost(parsed.hostname)) return false; + + const origin = parsed.origin.toLowerCase(); + const hostname = parsed.hostname.toLowerCase(); + return isAllowedDynamicClientRedirectHost(origin, hostname); +} async function findExistingUserByEmail(email: string) { const normalizedEmail = email.trim().toLowerCase(); @@ -234,6 +257,41 @@ const getAuthConfig = () => { telemetry: { enabled: false }, trustedOrigins: TRUSTED_ORIGINS, + rateLimit: { + enabled: true, + window: 60, + max: 60, + customRules: { + "/sign-in/email": { window: 60, max: 5 }, + "/sign-up/email": { window: 60, max: 3 }, + "/request-password-reset": { window: 600, max: 3 }, + "/send-verification-email": { window: 600, max: 3 }, + "/two-factor/verify-otp": { window: 600, max: 5 }, + "/two-factor/verify-totp": { window: 600, max: 5 }, + "/two-factor/verify-backup-code": { window: 600, max: 5 }, + "/is-username-available": { window: 60, max: 20 }, + }, + }, + + hooks: { + before: createAuthMiddleware(async (ctx) => { + if (!ctx.path.includes("/oauth2/register")) return; + + const body = ctx.body as { redirect_uris?: unknown } | undefined; + const redirectUris = Array.isArray(body?.redirect_uris) ? body.redirect_uris : []; + + for (const uri of redirectUris) { + if (typeof uri !== "string") { + throw new APIError("BAD_REQUEST", { message: "redirect_uris entries must be strings" }); + } + if (!isAllowedDynamicClientRedirectUri(uri)) { + throw new APIError("BAD_REQUEST", { + message: "redirect_uri is not allowed for dynamic client registration", + }); + } + } + }), + }, advanced: { database: { generateId }, @@ -341,11 +399,10 @@ const getAuthConfig = () => { plugins: [ jwt(), admin(), - openAPI(), passkey(), genericOAuth({ config: authConfigs }), twoFactor({ issuer: "Reactive Resume" }), - apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), + apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: true } }), dash({ apiKey: env.BETTER_AUTH_API_KEY, activityTracking: { enabled: true } }), oauthProvider({ loginPage: "/auth/oauth", @@ -353,6 +410,14 @@ const getAuthConfig = () => { validAudiences: OAUTH_AUDIENCES, allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, + rateLimit: { + register: { window: 60, max: 5 }, + authorize: { window: 60, max: 30 }, + token: { window: 60, max: 20 }, + introspect: { window: 60, max: 60 }, + revoke: { window: 60, max: 30 }, + userinfo: { window: 60, max: 60 }, + }, silenceWarnings: { oauthAuthServerConfig: true }, }), username({ diff --git a/src/integrations/orpc/client.ts b/src/integrations/orpc/client.ts index 2b1260e38..bdb997391 100644 --- a/src/integrations/orpc/client.ts +++ b/src/integrations/orpc/client.ts @@ -21,9 +21,6 @@ export const getORPCClient = createIsomorphicFn() const locale = await getLocale(); const reqHeaders = getRequestHeaders(); - // Add a custom header to identify server-side calls - reqHeaders.set("x-server-side-call", "true"); - return { locale, reqHeaders }; }, }); diff --git a/src/integrations/orpc/context.ts b/src/integrations/orpc/context.ts index f5bd1df10..0008face5 100644 --- a/src/integrations/orpc/context.ts +++ b/src/integrations/orpc/context.ts @@ -5,8 +5,6 @@ import { eq } from "drizzle-orm"; import type { Locale } from "@/utils/locale"; -import { env } from "@/utils/env"; - import { auth, verifyOAuthToken } from "../auth/config"; import { db } from "../drizzle/client"; import { user } from "../drizzle/schema"; @@ -108,25 +106,3 @@ export const protectedProcedure = publicProcedure.use(async ({ context, next }) }, }); }); - -/** - * Server-only procedure that can only be called from server-side code (e.g., loaders). - * Rejects requests from the browser with a 401 UNAUTHORIZED error. - */ -export const serverOnlyProcedure = publicProcedure.use(async ({ context, next }) => { - const headers = context.reqHeaders ?? new Headers(); - const isDebugBypassEnabled = env.FLAG_DEBUG_PRINTER && process.env.NODE_ENV === "development"; - - // Check for the custom header that indicates this is a server-side call - // Server-side calls using createRouterClient have this header set - const isServerSideCall = isDebugBypassEnabled || headers.get("x-server-side-call") === "true"; - - // If the header is not present, this is a client-side HTTP request - reject it - if (!isServerSideCall) { - throw new ORPCError("UNAUTHORIZED", { - message: "This endpoint can only be called from server-side code", - }); - } - - return next({ context }); -}); diff --git a/src/integrations/orpc/rate-limit.ts b/src/integrations/orpc/rate-limit.ts new file mode 100644 index 000000000..3ad884154 --- /dev/null +++ b/src/integrations/orpc/rate-limit.ts @@ -0,0 +1,44 @@ +import { createRatelimitMiddleware } from "@orpc/experimental-ratelimit"; +import { MemoryRatelimiter } from "@orpc/experimental-ratelimit/memory"; + +type ContextWithHeaders = { + reqHeaders?: Headers; + user?: { id: string } | null; +}; + +function getClientKey(headers?: Headers): string { + const cfIp = headers?.get("cf-connecting-ip")?.trim(); + const cfRay = headers?.get("cf-ray"); + if (cfIp && cfRay) return `cf:${cfIp}`; + + const userAgent = headers?.get("user-agent")?.trim() ?? "unknown"; + const language = headers?.get("accept-language")?.split(",")[0]?.trim() ?? "none"; + + return `fp:${userAgent.slice(0, 64)}:${language.slice(0, 16)}`; +} + +function getUserKey(context: ContextWithHeaders): string { + return context.user?.id ?? "anon"; +} + +const resumePasswordLimiter = new MemoryRatelimiter({ maxRequests: 5, window: 10 * 60 * 1000 }); +const pdfLimiter = new MemoryRatelimiter({ maxRequests: 5, window: 60 * 1000 }); +const aiLimiter = new MemoryRatelimiter({ maxRequests: 20, window: 60 * 1000 }); + +export const resumePasswordRateLimit = createRatelimitMiddleware< + ContextWithHeaders, + { username: string; slug: string } +>({ + limiter: resumePasswordLimiter, + key: ({ context }, input) => `resume-password:${input.username}:${input.slug}:${getClientKey(context.reqHeaders)}`, +}); + +export const pdfExportRateLimit = createRatelimitMiddleware({ + limiter: pdfLimiter, + key: ({ context }, input) => `pdf-export:${getUserKey(context)}:${input.id}`, +}); + +export const aiRequestRateLimit = createRatelimitMiddleware({ + limiter: aiLimiter, + key: ({ context }, input) => `ai-request:${getUserKey(context)}:${input.provider}`, +}); diff --git a/src/integrations/orpc/router/ai.ts b/src/integrations/orpc/router/ai.ts index 089296a42..4409246a0 100644 --- a/src/integrations/orpc/router/ai.ts +++ b/src/integrations/orpc/router/ai.ts @@ -10,10 +10,34 @@ import { type ResumeData, resumeDataSchema } from "@/schema/resume/data"; import { tailorOutputSchema } from "@/schema/tailor"; import { protectedProcedure } from "../context"; -import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema } from "../services/ai"; +import { aiRequestRateLimit } from "../rate-limit"; +import { aiCredentialsSchema, aiService, fileInputSchema } from "../services/ai"; import { resumeService } from "../services/resume"; -type AIProvider = z.infer; +type AIProvider = z.infer; + +function isInvalidAiBaseUrlError(error: unknown): boolean { + return error instanceof Error && error.message === "INVALID_AI_BASE_URL"; +} + +function isAiProviderGatewayError(error: unknown): boolean { + return error instanceof AISDKError || error instanceof OllamaError; +} + +function throwAiProviderGatewayError(): never { + throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." }); +} + +function throwAiProviderConfigError(): never { + throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." }); +} + +function throwResumeStructureError(error: ZodError): never { + throw new ORPCError("BAD_REQUEST", { + message: "Invalid resume data structure", + cause: flattenError(error), + }); +} export const aiRouter = { testConnection: protectedProcedure @@ -29,25 +53,26 @@ export const aiRouter = { }) .input( z.object({ - provider: aiProviderSchema, - model: z.string(), - apiKey: z.string(), - baseURL: z.string(), + ...aiCredentialsSchema.shape, }), ) + .use(aiRequestRateLimit) .errors({ BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502, }, + BAD_REQUEST: { + message: "Invalid AI provider configuration.", + status: 400, + }, }) .handler(async ({ input }) => { try { return await aiService.testConnection(input); } catch (error) { - if (error instanceof AISDKError || error instanceof OllamaError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); throw error; } @@ -70,6 +95,7 @@ export const aiRouter = { file: fileInputSchema, }), ) + .use(aiRequestRateLimit) .errors({ BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", @@ -84,16 +110,10 @@ export const aiRouter = { try { return await aiService.parsePdf(input); } catch (error) { - if (error instanceof AISDKError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (error instanceof AISDKError) throwAiProviderGatewayError(); - if (error instanceof ZodError) { - throw new ORPCError("BAD_REQUEST", { - message: "Invalid resume data structure", - cause: flattenError(error), - }); - } + if (error instanceof ZodError) throwResumeStructureError(error); throw error; } }), @@ -119,6 +139,7 @@ export const aiRouter = { ]), }), ) + .use(aiRequestRateLimit) .errors({ BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", @@ -133,16 +154,10 @@ export const aiRouter = { try { return await aiService.parseDocx(input); } catch (error) { - if (error instanceof AISDKError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (error instanceof AISDKError) throwAiProviderGatewayError(); - if (error instanceof ZodError) { - throw new ORPCError("BAD_REQUEST", { - message: "Invalid resume data structure", - cause: flattenError(error), - }); - } + if (error instanceof ZodError) throwResumeStructureError(error); throw error; } @@ -168,13 +183,13 @@ export const aiRouter = { resumeData: ResumeData; }>(), ) + .use(aiRequestRateLimit) .handler(async ({ input }) => { try { return await aiService.chat(input); } catch (error) { - if (error instanceof AISDKError || error instanceof OllamaError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); throw error; } @@ -198,6 +213,7 @@ export const aiRouter = { job: jobResultSchema, }), ) + .use(aiRequestRateLimit) .output(tailorOutputSchema) .errors({ BAD_GATEWAY: { @@ -213,9 +229,8 @@ export const aiRouter = { try { return await aiService.tailorResume(input); } catch (error) { - if (error instanceof AISDKError || error instanceof OllamaError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); if (error instanceof ZodError) { throw new ORPCError("BAD_REQUEST", { @@ -246,6 +261,7 @@ export const aiRouter = { resumeData: resumeDataSchema, }), ) + .use(aiRequestRateLimit) .output(storedResumeAnalysisSchema) .errors({ BAD_GATEWAY: { @@ -282,9 +298,8 @@ export const aiRouter = { }, }); } catch (error) { - if (error instanceof AISDKError || error instanceof OllamaError) { - throw new ORPCError("BAD_GATEWAY", { message: error.message }); - } + if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); if (error instanceof ZodError) { throw new ORPCError("BAD_REQUEST", { diff --git a/src/integrations/orpc/router/jobs.ts b/src/integrations/orpc/router/jobs.ts index 3de995fe0..fce3c80a1 100644 --- a/src/integrations/orpc/router/jobs.ts +++ b/src/integrations/orpc/router/jobs.ts @@ -29,8 +29,9 @@ export const jobsRouter = { try { return await jobsService.testConnection(input.apiKey); } catch (error) { + console.error("[jobs.testConnection] Failed to test JSearch connection:", error); throw new ORPCError("BAD_GATEWAY", { - message: error instanceof Error ? error.message : "Connection test failed", + message: "The JSearch API returned an error or is unreachable.", }); } }), @@ -71,8 +72,9 @@ export const jobsRouter = { return { data: jobs, rapidApiQuota: response.rapidApiQuota }; } catch (error) { + console.error("[jobs.search] Failed to search jobs via JSearch:", error); throw new ORPCError("BAD_GATEWAY", { - message: error instanceof Error ? error.message : "Search failed", + message: "The JSearch API returned an error or is unreachable.", }); } }), diff --git a/src/integrations/orpc/router/printer.ts b/src/integrations/orpc/router/printer.ts index b4265f5a0..52b57fecd 100644 --- a/src/integrations/orpc/router/printer.ts +++ b/src/integrations/orpc/router/printer.ts @@ -1,11 +1,22 @@ import z from "zod"; -import { protectedProcedure, publicProcedure } from "../context"; +import { protectedProcedure } from "../context"; +import { pdfExportRateLimit } from "../rate-limit"; import { printerService } from "../services/printer"; import { resumeService } from "../services/resume"; +async function getResumeScreenshotUrl(input: { id: string; currentUserId: string }): Promise { + try { + const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter(input); + return await printerService.getResumeScreenshot({ id, data, userId, updatedAt }); + } catch { + // ignore errors, as the screenshot is not critical + return null; + } +} + export const printerRouter = { - printResumeAsPDF: publicProcedure + printResumeAsPDF: protectedProcedure .route({ method: "GET", path: "/resumes/{id}/pdf", @@ -13,19 +24,19 @@ export const printerRouter = { operationId: "exportResumePdf", summary: "Export resume as PDF", description: - "Generates a PDF from the specified resume and uploads it to storage. Returns a URL to download the generated PDF file. If the request is made by an unauthenticated user (e.g. via a public share link), the resume's download count is incremented. Authentication is optional.", + "Generates a PDF from the specified resume and uploads it to storage. Returns a URL to download the generated PDF file. Requires authentication.", successDescription: "The PDF was generated successfully. Returns a URL to download the file.", }) .input(z.object({ id: z.string().describe("The unique identifier of the resume to export.") })) + .use(pdfExportRateLimit) .output(z.object({ url: z.string().describe("The URL to download the generated PDF file.") })) .handler(async ({ input, context }) => { - const { id, data, userId } = await resumeService.getByIdForPrinter({ id: input.id, userId: context.user?.id }); + const { id, data, userId } = await resumeService.getByIdForPrinter({ + id: input.id, + currentUserId: context.user.id, + }); const url = await printerService.printResumeAsPDF({ id, data, userId }); - if (!context.user) { - await resumeService.statistics.increment({ id: input.id, downloads: true }); - } - return { url }; }), @@ -43,19 +54,7 @@ export const printerRouter = { .input(z.object({ id: z.string().describe("The unique identifier of the resume.") })) .output(z.object({ url: z.string().nullable().describe("The URL to the screenshot image, or null.") })) .handler(async ({ context, input }) => { - try { - const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({ - id: input.id, - userId: context.user.id, - }); - - const url = await printerService.getResumeScreenshot({ id, data, userId, updatedAt }); - - return { url }; - } catch { - // ignore errors, as the screenshot is not critical - } - - return { url: null }; + const url = await getResumeScreenshotUrl({ id: input.id, currentUserId: context.user.id }); + return { url }; }), }; diff --git a/src/integrations/orpc/router/resume.ts b/src/integrations/orpc/router/resume.ts index 70f14e9c4..ffd255e02 100644 --- a/src/integrations/orpc/router/resume.ts +++ b/src/integrations/orpc/router/resume.ts @@ -4,8 +4,9 @@ import { storedResumeAnalysisSchema } from "@/schema/resume/analysis"; import { sampleResumeData } from "@/schema/resume/sample"; import { generateRandomName, slugify } from "@/utils/string"; -import { protectedProcedure, publicProcedure, serverOnlyProcedure } from "../context"; +import { protectedProcedure, publicProcedure } from "../context"; import { resumeDto } from "../dto/resume"; +import { resumePasswordRateLimit } from "../rate-limit"; import { resumeService } from "../services/resume"; const tagsRouter = { @@ -22,7 +23,7 @@ const tagsRouter = { }) .output(z.array(z.string())) .handler(async ({ context }) => { - return await resumeService.tags.list({ userId: context.user.id }); + return resumeService.tags.list({ userId: context.user.id }); }), }; @@ -49,14 +50,7 @@ const statisticsRouter = { }), ) .handler(async ({ context, input }) => { - return await resumeService.statistics.getById({ id: input.id, userId: context.user.id }); - }), - - increment: publicProcedure - .route({ tags: ["Internal"], operationId: "incrementResumeStatistics", summary: "Increment resume statistics" }) - .input(z.object({ id: z.string(), views: z.boolean().default(false), downloads: z.boolean().default(false) })) - .handler(async ({ input }) => { - return await resumeService.statistics.increment(input); + return resumeService.statistics.getById({ id: input.id, userId: context.user.id }); }), }; @@ -75,7 +69,7 @@ const analysisRouter = { .input(z.object({ id: z.string().describe("The unique identifier of the resume.") })) .output(storedResumeAnalysisSchema.nullable()) .handler(async ({ context, input }) => { - return await resumeService.analysis.getById({ id: input.id, userId: context.user.id }); + return resumeService.analysis.getById({ id: input.id, userId: context.user.id }); }), }; @@ -98,7 +92,7 @@ export const resumeRouter = { .input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" })) .output(resumeDto.list.output) .handler(async ({ input, context }) => { - return await resumeService.list({ + return resumeService.list({ userId: context.user.id, tags: input.tags, sort: input.sort, @@ -119,14 +113,22 @@ export const resumeRouter = { .input(resumeDto.getById.input) .output(resumeDto.getById.output) .handler(async ({ context, input }) => { - return await resumeService.getById({ id: input.id, userId: context.user.id }); + return resumeService.getById({ id: input.id, userId: context.user.id }); }), - getByIdForPrinter: serverOnlyProcedure + getByIdForPrinter: publicProcedure .route({ tags: ["Internal"], operationId: "getResumeForPrinter", summary: "Get resume by ID for printer" }) - .input(resumeDto.getById.input) - .handler(async ({ input }) => { - return await resumeService.getByIdForPrinter({ id: input.id }); + .input( + resumeDto.getById.input.extend({ + token: z.string().optional(), + }), + ) + .handler(async ({ input, context }) => { + return resumeService.getByIdForPrinter({ + id: input.id, + currentUserId: context.user?.id, + printerToken: input.token, + }); }), getBySlug: publicProcedure @@ -143,7 +145,7 @@ export const resumeRouter = { .input(resumeDto.getBySlug.input) .output(resumeDto.getBySlug.output) .handler(async ({ input, context }) => { - return await resumeService.getBySlug({ ...input, currentUserId: context.user?.id }); + return resumeService.getBySlug({ ...input, currentUserId: context.user?.id }); }), create: protectedProcedure @@ -166,7 +168,7 @@ export const resumeRouter = { }, }) .handler(async ({ context, input }) => { - return await resumeService.create({ + return resumeService.create({ name: input.name, slug: input.slug, tags: input.tags, @@ -199,7 +201,7 @@ export const resumeRouter = { const name = generateRandomName(); const slug = slugify(name); - return await resumeService.create({ + return resumeService.create({ name, slug, tags: [], @@ -229,7 +231,7 @@ export const resumeRouter = { }, }) .handler(async ({ context, input }) => { - return await resumeService.update({ + return resumeService.update({ id: input.id, userId: context.user.id, name: input.name, @@ -260,7 +262,7 @@ export const resumeRouter = { }, }) .handler(async ({ context, input }) => { - return await resumeService.patch({ + return resumeService.patch({ id: input.id, userId: context.user.id, operations: input.operations, @@ -281,7 +283,7 @@ export const resumeRouter = { .input(resumeDto.setLocked.input) .output(resumeDto.setLocked.output) .handler(async ({ context, input }) => { - return await resumeService.setLocked({ + return resumeService.setLocked({ id: input.id, userId: context.user.id, isLocked: input.isLocked, @@ -302,7 +304,7 @@ export const resumeRouter = { .input(resumeDto.setPassword.input) .output(resumeDto.setPassword.output) .handler(async ({ context, input }) => { - return await resumeService.setPassword({ + return resumeService.setPassword({ id: input.id, userId: context.user.id, password: input.password, @@ -327,9 +329,10 @@ export const resumeRouter = { password: z.string().min(1).describe("The password to verify."), }), ) + .use(resumePasswordRateLimit) .output(z.boolean()) .handler(async ({ input }): Promise => { - return await resumeService.verifyPassword({ + return resumeService.verifyPassword({ username: input.username, slug: input.slug, password: input.password, @@ -350,7 +353,7 @@ export const resumeRouter = { .input(resumeDto.removePassword.input) .output(resumeDto.removePassword.output) .handler(async ({ context, input }) => { - return await resumeService.removePassword({ + return resumeService.removePassword({ id: input.id, userId: context.user.id, }); @@ -372,7 +375,7 @@ export const resumeRouter = { .handler(async ({ context, input }) => { const original = await resumeService.getById({ id: input.id, userId: context.user.id }); - return await resumeService.create({ + return resumeService.create({ userId: context.user.id, name: input.name ?? original.name, slug: input.slug ?? original.slug, @@ -396,6 +399,6 @@ export const resumeRouter = { .input(resumeDto.delete.input) .output(resumeDto.delete.output) .handler(async ({ context, input }) => { - return await resumeService.delete({ id: input.id, userId: context.user.id }); + return resumeService.delete({ id: input.id, userId: context.user.id }); }), }; diff --git a/src/integrations/orpc/services/ai.ts b/src/integrations/orpc/services/ai.ts index 323f39145..9ed41bb6c 100644 --- a/src/integrations/orpc/services/ai.ts +++ b/src/integrations/orpc/services/ai.ts @@ -38,7 +38,9 @@ import { resumeAnalysisSchema, type ResumeAnalysis } from "@/schema/resume/analy import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data"; import { type TailorOutput, tailorOutputSchema } from "@/schema/tailor"; import { buildAiExtractionTemplate } from "@/utils/ai-template"; +import { env } from "@/utils/env"; import { isObject } from "@/utils/sanitize"; +import { isAllowedExternalUrl, parseAllowedHostList } from "@/utils/url-security"; const aiExtractionTemplate = buildAiExtractionTemplate(); @@ -97,23 +99,27 @@ function logAndRethrow(context: string, error: unknown): never { throw new Error(`An unknown error occurred during ${context}.`); } +function getJsonBoundaryIndices(value: string): { first: number; last: number } { + const firstCurly = value.indexOf("{"); + const firstSquare = value.indexOf("["); + const lastCurly = value.lastIndexOf("}"); + const lastSquare = value.lastIndexOf("]"); + + let first = -1; + if (firstCurly !== -1 && firstSquare !== -1) { + first = Math.min(firstCurly, firstSquare); + } else { + first = Math.max(firstCurly, firstSquare); + } + + return { first, last: Math.max(lastCurly, lastSquare) }; +} + function parseAndValidateResumeJson(resultText: string): ResumeData { let jsonString = resultText; - const firstCurly = jsonString.indexOf("{"); - const firstSquare = jsonString.indexOf("["); - const lastCurly = jsonString.lastIndexOf("}"); - const lastSquare = jsonString.lastIndexOf("]"); - - let firstIndex = -1; - if (firstCurly !== -1 && firstSquare !== -1) { - firstIndex = Math.min(firstCurly, firstSquare); - } else { - firstIndex = Math.max(firstCurly, firstSquare); - } - const lastIndex = Math.max(lastCurly, lastSquare); - - if (firstIndex !== -1 && lastIndex !== -1 && lastIndex >= firstIndex) { - jsonString = jsonString.substring(firstIndex, lastIndex + 1); + const { first, last } = getJsonBoundaryIndices(jsonString); + if (first !== -1 && last !== -1 && last >= first) { + jsonString = jsonString.substring(first, last + 1); } try { @@ -203,15 +209,40 @@ type GetModelInput = { provider: AIProvider; model: string; apiKey: string; - baseURL: string; + baseURL?: string; }; const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4; +const adminAllowedBaseUrls = parseAllowedHostList(env.AI_ALLOWED_BASE_URLS); +const defaultProviderHosts: Record, string[]> = { + openai: ["api.openai.com"], + anthropic: ["api.anthropic.com"], + gemini: ["generativelanguage.googleapis.com"], + "vercel-ai-gateway": ["gateway.ai.vercel.com"], +}; + +function resolveBaseUrl(input: GetModelInput) { + const baseURL = input.baseURL?.trim(); + if (!baseURL) { + if (input.provider === "ollama") { + throw new Error("INVALID_AI_BASE_URL"); + } + return undefined; + } + + const providerHosts = input.provider === "ollama" ? [] : defaultProviderHosts[input.provider]; + const allowedHosts = new Set([...providerHosts, ...adminAllowedBaseUrls]); + if (!isAllowedExternalUrl(baseURL, allowedHosts)) { + throw new Error("INVALID_AI_BASE_URL"); + } + + return baseURL; +} function getModel(input: GetModelInput) { const { provider, model, apiKey } = input; - const baseURL = input.baseURL || undefined; + const baseURL = resolveBaseUrl(input); return match(provider) .with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model)) @@ -226,7 +257,7 @@ export const aiCredentialsSchema = z.object({ provider: aiProviderSchema, model: z.string(), apiKey: z.string(), - baseURL: z.string(), + baseURL: z.string().optional().default(""), }); export const fileInputSchema = z.object({ diff --git a/src/integrations/orpc/services/printer.ts b/src/integrations/orpc/services/printer.ts index fb861e0f2..d53a3b031 100644 --- a/src/integrations/orpc/services/printer.ts +++ b/src/integrations/orpc/services/printer.ts @@ -62,6 +62,20 @@ async function closeBrowser(): Promise { } } +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return "Unknown error"; +} + +function throwPrinterStepError(step: string, hint: string, error: unknown): never { + const details = toErrorMessage(error); + + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: `${step}. ${hint}. Details: ${details}`, + cause: error, + }); +} + // Close browser on process termination process.on("exit", async () => { await closeBrowser(); @@ -120,70 +134,126 @@ async function doPrintResumeAsPDF( try { // Step 4: Connect to the browser and navigate to the printer route // Use an isolated browser context so concurrent requests with different locales don't interfere - const browser = await getBrowser(); - context = await browser.createBrowserContext(); + const browser = await getBrowser().catch((error) => + throwPrinterStepError( + "Failed to connect to the printer browser", + "Check PRINTER_ENDPOINT and ensure Browserless/Chrome is reachable", + error, + ), + ); + context = await browser + .createBrowserContext() + .catch((error) => + throwPrinterStepError( + "Failed to create an isolated browser context for PDF generation", + "Retry the request", + error, + ), + ); - await context.setCookie({ name: "locale", value: locale, domain }); + await context + .setCookie({ name: "locale", value: locale, domain }) + .catch((error) => + throwPrinterStepError( + "Failed to set locale cookie in printer browser context", + "Verify APP_URL/PRINTER_APP_URL host is valid and reachable", + error, + ), + ); - page = await context.newPage(); + page = await context + .newPage() + .catch((error) => + throwPrinterStepError("Failed to open a new browser page for PDF generation", "Retry the request", error), + ); - // Wait for the page to fully load (network idle + custom loaded attribute) - await page.emulateMediaType("print"); - await page.setViewport(pageDimensionsAsPixels[format]); - await page.goto(url, { waitUntil: "networkidle0" }); - await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 }); + // Wait for the page to become ready without relying on strict network idle. + await page + .emulateMediaType("print") + .catch((error) => + throwPrinterStepError("Failed to switch browser page to print media mode", "Retry the request", error), + ); + await page + .setViewport(pageDimensionsAsPixels[format]) + .catch((error) => throwPrinterStepError("Failed to apply PDF viewport dimensions", "Retry the request", error)); + await page + .goto(url, { waitUntil: "domcontentloaded" }) + .catch((error) => + throwPrinterStepError( + "Failed to open the internal printer route", + "Check PRINTER_APP_URL/APP_URL reachability from the browser container and verify printer token configuration", + error, + ), + ); + await page + .waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 }) + .catch((error) => + throwPrinterStepError( + "Resume print page did not finish loading", + "Check frontend runtime errors and ensure the /printer route can render successfully", + error, + ), + ); // Step 5a: Prepare the DOM for PDF rendering (background colors, reset margins, print padding) - await page.evaluate( - (pagePaddingX: number, pagePaddingY: number, backgroundColor: string) => { - const root = document.documentElement; - const body = document.body; - const pageElements = document.querySelectorAll("[data-page-index]"); - const pageContentElements = document.querySelectorAll(".page-content"); + await page + .evaluate( + (pagePaddingX: number, pagePaddingY: number, backgroundColor: string) => { + const root = document.documentElement; + const body = document.body; + const pageElements = document.querySelectorAll("[data-page-index]"); + const pageContentElements = document.querySelectorAll(".page-content"); - // Ensure PDF margins inherit the resume background color instead of defaulting to white. - root.style.backgroundColor = backgroundColor; - body.style.backgroundColor = backgroundColor; - root.style.margin = "0"; - body.style.margin = "0"; - root.style.padding = "0"; - body.style.padding = "0"; + // Ensure PDF margins inherit the resume background color instead of defaulting to white. + root.style.backgroundColor = backgroundColor; + body.style.backgroundColor = backgroundColor; + root.style.margin = "0"; + body.style.margin = "0"; + root.style.padding = "0"; + body.style.padding = "0"; - for (const el of pageElements) { - const pageWrapper = el as HTMLElement; - const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null; + for (const el of pageElements) { + const pageWrapper = el as HTMLElement; + const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null; - pageWrapper.style.backgroundColor = backgroundColor; - pageWrapper.style.breakInside = "auto"; + pageWrapper.style.backgroundColor = backgroundColor; + pageWrapper.style.breakInside = "auto"; - if (pageSurface) pageSurface.style.backgroundColor = backgroundColor; - } + if (pageSurface) pageSurface.style.backgroundColor = backgroundColor; + } - // Apply print-only margins as padding inside each page's content surface. - if (pagePaddingX > 0 || pagePaddingY > 0) { - for (const el of pageContentElements) { - const pageContent = el as HTMLElement; + // Apply print-only margins as padding inside each page's content surface. + if (pagePaddingX > 0 || pagePaddingY > 0) { + for (const el of pageContentElements) { + const pageContent = el as HTMLElement; - pageContent.style.boxSizing = "border-box"; - // Ensure padding is repeated on every printed fragment when content - // flows across physical PDF pages (not just the first fragment). - pageContent.style.boxDecorationBreak = "clone"; - pageContent.style.setProperty("-webkit-box-decoration-break", "clone"); - if (pagePaddingX > 0) { - pageContent.style.paddingLeft = `${pagePaddingX}pt`; - pageContent.style.paddingRight = `${pagePaddingX}pt`; - } - if (pagePaddingY > 0) { - pageContent.style.paddingTop = `${pagePaddingY}pt`; - pageContent.style.paddingBottom = `${pagePaddingY}pt`; + pageContent.style.boxSizing = "border-box"; + // Ensure padding is repeated on every printed fragment when content + // flows across physical PDF pages (not just the first fragment). + pageContent.style.boxDecorationBreak = "clone"; + pageContent.style.setProperty("-webkit-box-decoration-break", "clone"); + if (pagePaddingX > 0) { + pageContent.style.paddingLeft = `${pagePaddingX}pt`; + pageContent.style.paddingRight = `${pagePaddingX}pt`; + } + if (pagePaddingY > 0) { + pageContent.style.paddingTop = `${pagePaddingY}pt`; + pageContent.style.paddingBottom = `${pagePaddingY}pt`; + } } } - } - }, - pagePaddingX, - pagePaddingY, - data.metadata.design.colors.background, - ); + }, + pagePaddingX, + pagePaddingY, + data.metadata.design.colors.background, + ) + .catch((error) => + throwPrinterStepError( + "Failed to apply PDF print styles", + "Check that resume template styles can be evaluated in headless browser mode", + error, + ), + ); // Step 5b: Format-specific layout adjustments const isFreeForm = format === "free-form"; @@ -191,57 +261,73 @@ async function doPrintResumeAsPDF( if (isFreeForm) { // Free-form: measure actual content height after adding inter-page margins - contentHeight = await page.evaluate( - (pagePaddingY: number, minPageHeight: number) => { - const pageElements = document.querySelectorAll("[data-page-index]"); - const numberOfPages = pageElements.length; + contentHeight = await page + .evaluate( + (pagePaddingY: number, minPageHeight: number) => { + const pageElements = document.querySelectorAll("[data-page-index]"); + const numberOfPages = pageElements.length; - // Add margin between pages (except the last one) - for (let i = 0; i < numberOfPages - 1; i++) { - const pageEl = pageElements[i] as HTMLElement; - if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`; - } + // Add margin between pages (except the last one) + for (let i = 0; i < numberOfPages - 1; i++) { + const pageEl = pageElements[i] as HTMLElement; + if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`; + } - // Measure the total height (margins are now part of the DOM) - let totalHeight = 0; + // Measure the total height (margins are now part of the DOM) + let totalHeight = 0; - for (const el of pageElements) { - const pageEl = el as HTMLElement; - const style = getComputedStyle(pageEl); - const marginBottom = Number.parseFloat(style.marginBottom) || 0; - totalHeight += pageEl.offsetHeight + marginBottom; - } + for (const el of pageElements) { + const pageEl = el as HTMLElement; + const style = getComputedStyle(pageEl); + const marginBottom = Number.parseFloat(style.marginBottom) || 0; + totalHeight += pageEl.offsetHeight + marginBottom; + } - return Math.max(totalHeight, minPageHeight); - }, - pagePaddingY, - pageDimensionsAsPixels[format].height, - ); + return Math.max(totalHeight, minPageHeight); + }, + pagePaddingY, + pageDimensionsAsPixels[format].height, + ) + .catch((error) => + throwPrinterStepError( + "Failed to compute free-form PDF content height", + "Check resume layout and page metadata values", + error, + ), + ); } else { // A4/Letter: set fixed page height and add page breaks between pages - await page.evaluate((pageHeight: number) => { - const root = document.documentElement; - const pageElements = document.querySelectorAll("[data-page-index]"); - const container = document.querySelector(".resume-preview-container") as HTMLElement | null; + await page + .evaluate((pageHeight: number) => { + const root = document.documentElement; + const pageElements = document.querySelectorAll("[data-page-index]"); + const container = document.querySelector(".resume-preview-container") as HTMLElement | null; - const newHeight = `${pageHeight}px`; - if (container) container.style.setProperty("--page-height", newHeight); - root.style.setProperty("--page-height", newHeight); + const newHeight = `${pageHeight}px`; + if (container) container.style.setProperty("--page-height", newHeight); + root.style.setProperty("--page-height", newHeight); - for (const el of pageElements) { - const element = el as HTMLElement; - const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10); + for (const el of pageElements) { + const element = el as HTMLElement; + const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10); - // Force a page break before each page except the first - if (index > 0) { - element.style.breakBefore = "page"; - element.style.pageBreakBefore = "always"; + // Force a page break before each page except the first + if (index > 0) { + element.style.breakBefore = "page"; + element.style.pageBreakBefore = "always"; + } + + // Allow content within a page to break naturally if it overflows + element.style.breakInside = "auto"; } - - // Allow content within a page to break naturally if it overflows - element.style.breakInside = "auto"; - } - }, pageDimensionsAsPixels[format].height); + }, pageDimensionsAsPixels[format].height) + .catch((error) => + throwPrinterStepError( + "Failed to prepare fixed-page PDF layout", + "Check template/page format configuration", + error, + ), + ); } // Step 6: Generate the PDF with the specified dimensions and margins @@ -249,19 +335,27 @@ async function doPrintResumeAsPDF( // For A4/Letter: use fixed dimensions from pageDimensionsAsPixels const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height; - const pdfBuffer = await page.pdf({ - width: `${pageDimensionsAsPixels[format].width}px`, - height: `${pdfHeight}px`, - tagged: true, // Adds accessibility tags to the PDF - waitForFonts: true, // Ensures all fonts are loaded before rendering - printBackground: true, // Includes background colors and images - margin: { - bottom: 0, - top: 0, - right: 0, - left: 0, - }, - }); + const pdfBuffer = await page + .pdf({ + width: `${pageDimensionsAsPixels[format].width}px`, + height: `${pdfHeight}px`, + tagged: true, // Adds accessibility tags to the PDF + waitForFonts: true, // Ensures all fonts are loaded before rendering + printBackground: true, // Includes background colors and images + margin: { + bottom: 0, + top: 0, + right: 0, + left: 0, + }, + }) + .catch((error) => + throwPrinterStepError( + "Failed to render PDF document", + "Check font loading, page dimensions, and Browserless resource limits", + error, + ), + ); // Step 7: Upload the generated PDF to storage const result = await uploadFile({ @@ -270,11 +364,21 @@ async function doPrintResumeAsPDF( data: new Uint8Array(pdfBuffer), contentType: "application/pdf", type: "pdf", - }); + }).catch((error) => + throwPrinterStepError( + "Failed to upload generated PDF", + "Check storage configuration (S3/local disk permissions and related environment variables)", + error, + ), + ); return result.url; } catch (error) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to generate PDF", cause: error }); + if (error instanceof ORPCError) throw error; + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: `Failed to generate PDF. Details: ${toErrorMessage(error)}`, + cause: error, + }); } finally { if (page) await page.close().catch(() => null); if (context) await context.close().catch(() => null); @@ -295,7 +399,15 @@ async function doGetResumeScreenshot( const storageService = getStorageService(); const screenshotPrefix = `uploads/${userId}/screenshots/${id}`; - const existingScreenshots = await storageService.list(screenshotPrefix); + const existingScreenshots = await storageService + .list(screenshotPrefix) + .catch((error) => + throwPrinterStepError( + "Failed to list existing screenshots", + "Check storage configuration (S3/local disk permissions and related environment variables)", + error, + ), + ); const now = Date.now(); const resumeUpdatedAt = updatedAt.getTime(); @@ -324,7 +436,13 @@ async function doGetResumeScreenshot( } // Resume was updated after the screenshot - delete old ones and regenerate - await Promise.all(sortedFiles.map((file) => storageService.delete(file.path))); + await Promise.all(sortedFiles.map((file) => storageService.delete(file.path))).catch((error) => + throwPrinterStepError( + "Failed to clean up stale screenshots", + "Check storage delete permissions and connectivity", + error, + ), + ); } } @@ -340,18 +458,72 @@ async function doGetResumeScreenshot( let page: Page | null = null; try { - const browser = await getBrowser(); - context = await browser.createBrowserContext(); + const browser = await getBrowser().catch((error) => + throwPrinterStepError( + "Failed to connect to the printer browser", + "Check PRINTER_ENDPOINT and ensure Browserless/Chrome is reachable", + error, + ), + ); + context = await browser + .createBrowserContext() + .catch((error) => + throwPrinterStepError( + "Failed to create an isolated browser context for screenshot capture", + "Retry the request", + error, + ), + ); - await context.setCookie({ name: "locale", value: locale, domain }); + await context + .setCookie({ name: "locale", value: locale, domain }) + .catch((error) => + throwPrinterStepError( + "Failed to set locale cookie in screenshot browser context", + "Verify APP_URL/PRINTER_APP_URL host is valid and reachable", + error, + ), + ); - page = await context.newPage(); + page = await context + .newPage() + .catch((error) => + throwPrinterStepError("Failed to open a new browser page for screenshot capture", "Retry the request", error), + ); - await page.setViewport(pageDimensionsAsPixels.a4); - await page.goto(url, { waitUntil: "networkidle0" }); - await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 }); + await page + .setViewport(pageDimensionsAsPixels.a4) + .catch((error) => + throwPrinterStepError("Failed to apply screenshot viewport dimensions", "Retry the request", error), + ); + await page + .goto(url, { waitUntil: "domcontentloaded" }) + .catch((error) => + throwPrinterStepError( + "Failed to open the internal printer route for screenshot capture", + "Check PRINTER_APP_URL/APP_URL reachability from the browser container and verify printer token configuration", + error, + ), + ); + await page + .waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 }) + .catch((error) => + throwPrinterStepError( + "Resume screenshot page did not finish loading", + "Check frontend runtime errors and ensure the /printer route can render successfully", + error, + ), + ); - const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 }); + const screenshotBuffer = await page + .screenshot({ type: "webp", quality: 80 }) + .catch((error) => + throwPrinterStepError( + "Failed to capture screenshot image", + "Check renderer stability and Browserless resource limits", + error, + ), + ); const result = await uploadFile({ userId, @@ -359,11 +531,21 @@ async function doGetResumeScreenshot( data: new Uint8Array(screenshotBuffer), contentType: "image/webp", type: "screenshot", - }); + }).catch((error) => + throwPrinterStepError( + "Failed to upload generated screenshot", + "Check storage configuration (S3/local disk permissions and related environment variables)", + error, + ), + ); return result.url; } catch (error) { - throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to capture screenshot", cause: error }); + if (error instanceof ORPCError) throw error; + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: `Failed to capture screenshot. Details: ${toErrorMessage(error)}`, + cause: error, + }); } finally { if (page) await page.close().catch(() => null); if (context) await context.close().catch(() => null); diff --git a/src/integrations/orpc/services/resume.ts b/src/integrations/orpc/services/resume.ts index a366ba802..20ea04e7f 100644 --- a/src/integrations/orpc/services/resume.ts +++ b/src/integrations/orpc/services/resume.ts @@ -14,8 +14,10 @@ import { type StoredResumeAnalysis } from "@/schema/resume/analysis"; import { defaultResumeData } from "@/schema/resume/data"; import { env } from "@/utils/env"; import { hashPassword, verifyPassword } from "@/utils/password"; +import { verifyPrinterToken } from "@/utils/printer-token"; import { applyResumePatches, ResumePatchError } from "@/utils/resume/patch"; import { generateId } from "@/utils/string"; +import { sanitizeResumePictureUrl } from "@/utils/url-security"; import { grantResumeAccess, hasResumeAccess } from "../helpers/resume-access"; import { getStorageService } from "./storage"; @@ -121,6 +123,50 @@ const analysis = { }, }; +function hasValidPrinterToken(printerToken: string | undefined, resumeId: string): boolean { + if (!printerToken) return false; + + try { + return verifyPrinterToken(printerToken) === resumeId; + } catch { + return false; + } +} + +function toSharedResumeResponse( + resume: { + id: string; + name: string; + slug: string; + tags: string[]; + data: ResumeData; + isPublic: boolean; + isLocked: boolean; + }, + hasPassword: TPassword, +) { + return { + id: resume.id, + name: resume.name, + slug: resume.slug, + tags: resume.tags, + data: resume.data, + isPublic: resume.isPublic, + isLocked: resume.isLocked, + hasPassword, + }; +} + +function sanitizeResumeDataPictureUrl(data: ResumeData): ResumeData { + return { + ...data, + picture: { + ...data.picture, + url: sanitizeResumePictureUrl(data.picture.url, env.APP_URL), + }, + }; +} + export const resumeService = { tags, statistics, @@ -176,7 +222,7 @@ export const resumeService = { return resume; }, - getByIdForPrinter: async (input: { id: string; userId?: string }) => { + getByIdForPrinter: async (input: { id: string; currentUserId?: string; printerToken?: string }) => { const [resume] = await db .select({ id: schema.resume.id, @@ -189,27 +235,15 @@ export const resumeService = { updatedAt: schema.resume.updatedAt, }) .from(schema.resume) - .where( - input.userId - ? and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)) - : eq(schema.resume.id, input.id), - ); + .where(eq(schema.resume.id, input.id)); if (!resume) throw new ORPCError("NOT_FOUND"); - try { - if (!resume.data.picture.url) throw new Error("Picture is not available"); + const isOwner = !!input.currentUserId && resume.userId === input.currentUserId; - // Convert picture URL to base64 data, so there's no fetching required on the client. - const url = resume.data.picture.url.replace(env.APP_URL, "http://localhost:3000"); - const base64 = await fetch(url) - .then((res) => res.arrayBuffer()) - .then((buffer) => Buffer.from(buffer).toString("base64")); + const hasValidToken = hasValidPrinterToken(input.printerToken, input.id); - resume.data.picture.url = `data:image/jpeg;base64,${base64}`; - } catch { - // Ignore errors, as the picture is not always available - } + if (!isOwner && !hasValidToken) throw new ORPCError("NOT_FOUND"); return resume; }, @@ -243,32 +277,12 @@ export const resumeService = { if (!resume.hasPassword) { await resumeService.statistics.increment({ id: resume.id, views: true }); - - return { - id: resume.id, - name: resume.name, - slug: resume.slug, - tags: resume.tags, - data: resume.data, - isPublic: resume.isPublic, - isLocked: resume.isLocked, - hasPassword: false as const, - }; + return toSharedResumeResponse(resume, false); } if (hasResumeAccess(resume.id, resume.passwordHash)) { await resumeService.statistics.increment({ id: resume.id, views: true }); - - return { - id: resume.id, - name: resume.name, - slug: resume.slug, - tags: resume.tags, - data: resume.data, - isPublic: resume.isPublic, - isLocked: resume.isLocked, - hasPassword: true as const, - }; + return toSharedResumeResponse(resume, true); } throw new ORPCError("NEED_PASSWORD", { @@ -288,6 +302,7 @@ export const resumeService = { const id = generateId(); input.data = input.data ?? defaultResumeData; + input.data = sanitizeResumeDataPictureUrl(input.data); input.data.metadata.page.locale = input.locale; try { @@ -333,7 +348,7 @@ export const resumeService = { name: input.name, slug: input.slug, tags: input.tags, - data: input.data, + data: input.data ? sanitizeResumeDataPictureUrl(input.data) : undefined, isPublic: input.isPublic, }; @@ -383,6 +398,7 @@ export const resumeService = { try { patchedData = applyResumePatches(existing.data, input.operations); + patchedData = sanitizeResumeDataPictureUrl(patchedData); } catch (error) { if (error instanceof ResumePatchError) { throw new ORPCError("INVALID_PATCH_OPERATIONS", { @@ -447,12 +463,12 @@ export const resumeService = { ), ); - if (!resume) throw new ORPCError("NOT_FOUND"); + if (!resume) throw new ORPCError("INVALID_PASSWORD", { status: 401 }); const passwordHash = resume.password as string; const isValid = await verifyPassword(input.password, passwordHash); - if (!isValid) throw new ORPCError("INVALID_PASSWORD"); + if (!isValid) throw new ORPCError("INVALID_PASSWORD", { status: 401 }); grantResumeAccess(resume.id, passwordHash); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index ebf3df88a..6c8f2c316 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as ApiHealthRouteImport } from "./routes/api/health"; import { Route as DotwellKnownOpenidConfigurationRouteImport } from "./routes/[.]well-known/openid-configuration"; import { Route as DotwellKnownOauthProtectedResourceRouteImport } from "./routes/[.]well-known/oauth-protected-resource"; import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from "./routes/[.]well-known/oauth-authorization-server"; +import { Route as DotwellKnownSplatRouteImport } from "./routes/[.]well-known/$"; import { Route as UsernameSlugRouteImport } from "./routes/$username/$slug"; import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route"; import { Route as DashboardResumesIndexRouteImport } from "./routes/dashboard/resumes/index"; @@ -157,6 +158,11 @@ const DotwellKnownOauthAuthorizationServerRoute = path: "/.well-known/oauth-authorization-server", getParentRoute: () => rootRouteImport, } as any); +const DotwellKnownSplatRoute = DotwellKnownSplatRouteImport.update({ + id: "/.well-known/$", + path: "/.well-known/$", + getParentRoute: () => rootRouteImport, +} as any); const UsernameSlugRoute = UsernameSlugRouteImport.update({ id: "/$username/$slug", path: "/$username/$slug", @@ -269,6 +275,7 @@ export interface FileRoutesByFullPath { "/schema.json": typeof SchemaDotjsonRoute; "/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren; "/$username/$slug": typeof UsernameSlugRoute; + "/.well-known/$": typeof DotwellKnownSplatRoute; "/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren; "/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren; "/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute; @@ -306,6 +313,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/schema.json": typeof SchemaDotjsonRoute; "/$username/$slug": typeof UsernameSlugRoute; + "/.well-known/$": typeof DotwellKnownSplatRoute; "/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren; "/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren; "/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute; @@ -349,6 +357,7 @@ export interface FileRoutesById { "/schema.json": typeof SchemaDotjsonRoute; "/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren; "/$username/$slug": typeof UsernameSlugRoute; + "/.well-known/$": typeof DotwellKnownSplatRoute; "/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren; "/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren; "/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute; @@ -393,6 +402,7 @@ export interface FileRouteTypes { | "/schema.json" | "/builder/$resumeId" | "/$username/$slug" + | "/.well-known/$" | "/.well-known/oauth-authorization-server" | "/.well-known/oauth-protected-resource" | "/.well-known/openid-configuration" @@ -430,6 +440,7 @@ export interface FileRouteTypes { to: | "/schema.json" | "/$username/$slug" + | "/.well-known/$" | "/.well-known/oauth-authorization-server" | "/.well-known/oauth-protected-resource" | "/.well-known/openid-configuration" @@ -472,6 +483,7 @@ export interface FileRouteTypes { | "/schema.json" | "/builder/$resumeId" | "/$username/$slug" + | "/.well-known/$" | "/.well-known/oauth-authorization-server" | "/.well-known/oauth-protected-resource" | "/.well-known/openid-configuration" @@ -515,6 +527,7 @@ export interface RootRouteChildren { SchemaDotjsonRoute: typeof SchemaDotjsonRoute; BuilderResumeIdRouteRoute: typeof BuilderResumeIdRouteRouteWithChildren; UsernameSlugRoute: typeof UsernameSlugRoute; + DotwellKnownSplatRoute: typeof DotwellKnownSplatRoute; DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren; DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRouteWithChildren; DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute; @@ -677,6 +690,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport; parentRoute: typeof rootRouteImport; }; + "/.well-known/$": { + id: "/.well-known/$"; + path: "/.well-known/$"; + fullPath: "/.well-known/$"; + preLoaderRoute: typeof DotwellKnownSplatRouteImport; + parentRoute: typeof rootRouteImport; + }; "/$username/$slug": { id: "/$username/$slug"; path: "/$username/$slug"; @@ -932,6 +952,7 @@ const rootRouteChildren: RootRouteChildren = { SchemaDotjsonRoute: SchemaDotjsonRoute, BuilderResumeIdRouteRoute: BuilderResumeIdRouteRouteWithChildren, UsernameSlugRoute: UsernameSlugRoute, + DotwellKnownSplatRoute: DotwellKnownSplatRoute, DotwellKnownOauthAuthorizationServerRoute: DotwellKnownOauthAuthorizationServerRouteWithChildren, DotwellKnownOauthProtectedResourceRoute: diff --git a/src/routes/[.]well-known/$.ts b/src/routes/[.]well-known/$.ts new file mode 100644 index 000000000..cd8141976 --- /dev/null +++ b/src/routes/[.]well-known/$.ts @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; + +const okResponse = () => new Response("OK", { status: 200 }); + +export const Route = createFileRoute("/.well-known/$")({ + server: { + handlers: { + GET: () => okResponse(), + HEAD: () => okResponse(), + }, + }, +}); diff --git a/src/routes/api/auth.$.ts b/src/routes/api/auth.$.ts index e731b0c1a..7d21992c9 100644 --- a/src/routes/api/auth.$.ts +++ b/src/routes/api/auth.$.ts @@ -1,6 +1,40 @@ import { createFileRoute } from "@tanstack/react-router"; import { auth } from "@/integrations/auth/config"; +import { env } from "@/utils/env"; +import { isPrivateOrLoopbackHost, parseAllowedHostList, parseUrl } from "@/utils/url-security"; + +const oauthDynamicClientRedirectHosts = parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS); +const oauthAuthorizeSanitizedParams = [ + "prompt", + "redirect_uri", + "client_id", + "code_challenge", + "code_challenge_method", + "response_type", + "scope", + "state", + "resource", +] as const; + +function isAllowedDynamicClientRedirectUri(value: string) { + const parsed = parseUrl(value); + if (!parsed) return false; + if (parsed.protocol !== "https:") return false; + if (parsed.username || parsed.password) return false; + if (parsed.hash) return false; + if (isPrivateOrLoopbackHost(parsed.hostname)) return false; + + const appOrigin = new URL(env.APP_URL).origin.toLowerCase(); + const origin = parsed.origin.toLowerCase(); + const hostname = parsed.hostname.toLowerCase(); + + if (origin === appOrigin) return true; + if (oauthDynamicClientRedirectHosts.has(origin)) return true; + if (oauthDynamicClientRedirectHosts.has(hostname)) return true; + + return false; +} function sanitizeOAuthAuthorizeRequest(request: Request): Request { if (request.method !== "GET") return request; @@ -19,15 +53,7 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request { url.searchParams.set(key, sanitizeValue(value)); }; - sanitizeParam("prompt"); - sanitizeParam("redirect_uri"); - sanitizeParam("client_id"); - sanitizeParam("code_challenge"); - sanitizeParam("code_challenge_method"); - sanitizeParam("response_type"); - sanitizeParam("scope"); - sanitizeParam("state"); - sanitizeParam("resource"); + for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key); const redirectUri = url.searchParams.get("redirect_uri"); if (redirectUri && !URL.canParse(redirectUri)) { @@ -74,16 +100,36 @@ async function defaultPublicClientRegistration(request: Request): Promise { + if (request.method !== "POST") return; + + const url = new URL(request.url); + if (!url.pathname.endsWith("/oauth2/register")) return; + + const cloned = request.clone(); + let body: Record; + + try { + body = await cloned.json(); + } catch { + return Response.json({ message: "Invalid registration payload" }, { status: 400 }); + } + + const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : []; + for (const redirectUri of redirectUris) { + if (typeof redirectUri !== "string" || !isAllowedDynamicClientRedirectUri(redirectUri)) { + return Response.json({ message: "redirect_uri is not allowed" }, { status: 400 }); + } + } +} + async function handler({ request }: { request: Request }) { + const registrationValidationError = await validateDynamicClientRegistrationRequest(request); + if (registrationValidationError) return registrationValidationError; + const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request); const finalRequest = await defaultPublicClientRegistration(sanitizedRequest); - if (request.method === "GET" && request.url.endsWith("/spec.json")) { - const spec = await auth.api.generateOpenAPISchema(); - - return Response.json(spec); - } - return auth.handler(finalRequest); } diff --git a/src/routes/printer/$resumeId.tsx b/src/routes/printer/$resumeId.tsx index 10ec8d05a..c9001282d 100644 --- a/src/routes/printer/$resumeId.tsx +++ b/src/routes/printer/$resumeId.tsx @@ -1,3 +1,4 @@ +import { t } from "@lingui/core/macro"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { zodValidator } from "@tanstack/zod-adapter"; import { useEffect } from "react"; @@ -14,6 +15,12 @@ const searchSchema = z.object({ token: z.string().catch(""), }); +function assertValidPrinterToken(token: string, resumeId: string): void { + const tokenResumeId = verifyPrinterToken(token); + if (tokenResumeId === resumeId) return; + throw new Error("Printer token does not match resume ID"); +} + export const Route = createFileRoute("/printer/$resumeId")({ component: RouteComponent, validateSearch: zodValidator(searchSchema), @@ -21,22 +28,32 @@ export const Route = createFileRoute("/printer/$resumeId")({ if (env.FLAG_DEBUG_PRINTER) return; try { - // Verify the token and ensure it matches the resume ID - const tokenResumeId = verifyPrinterToken(search.token); - if (tokenResumeId !== params.resumeId) throw new Error(); + assertValidPrinterToken(search.token, params.resumeId); } catch { - // Invalid or missing token - throw error to be caught by error handler throw redirect({ to: "/", search: {}, throw: true }); } }, - loader: async ({ params }) => { + loaderDeps: ({ search }) => ({ token: search.token }), + loader: async ({ params, deps }) => { const client = getORPCClient(); - const resume = await client.resume.getByIdForPrinter({ id: params.resumeId }); + const resume = await client.resume.getByIdForPrinter({ id: params.resumeId, token: deps.token }); return { resume }; }, head: ({ loaderData }) => ({ - meta: [{ title: loaderData ? `${loaderData.resume.data.basics.name} - Resume` : "Resume" }], + meta: [ + { + title: loaderData + ? `${loaderData.resume.data.basics.name} - ${t({ + comment: "Browser tab suffix for printable resume pages", + message: "Resume", + })}` + : t({ + comment: "Browser tab title before printable resume data finishes loading", + message: "Resume", + }), + }, + ], }), }); diff --git a/src/schema/resume/data.ts b/src/schema/resume/data.ts index 172e2068d..a30171d45 100644 --- a/src/schema/resume/data.ts +++ b/src/schema/resume/data.ts @@ -29,7 +29,7 @@ export const pictureSchema = z.object({ url: z .string() .describe( - "The URL to the picture to display on the resume. Must be a valid URL with a protocol (http:// or https://).", + "The URL to the picture to display on the resume. Prefer local app-served paths (for example /uploads/...) populated via upload.", ), size: z .number() diff --git a/src/schema/resume/sample.ts b/src/schema/resume/sample.ts index 95db997e3..75896cb8a 100644 --- a/src/schema/resume/sample.ts +++ b/src/schema/resume/sample.ts @@ -3,7 +3,7 @@ import type { ResumeData } from "./data"; export const sampleResumeData: ResumeData = { picture: { hidden: false, - url: "https://i.imgur.com/o4Jpt1p.jpeg", + url: "", size: 100, rotation: 0, aspectRatio: 1, diff --git a/src/server.ts b/src/server.ts index e7a282171..928de472c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,8 +3,43 @@ import { FastResponse } from "srvx"; globalThis.Response = FastResponse; +function setIfMissing(headers: Headers, key: string, value: string) { + if (headers.has(key)) return; + headers.set(key, value); +} + +const allowedWebfontOrigins = ["https://cdn.jsdelivr.net", "https://fonts.gstatic.com"] as const; + +const fontSrcWithWebfonts = ["'self'", ...allowedWebfontOrigins].join(" "); + export default createServerEntry({ - fetch(request) { - return handler.fetch(request); + async fetch(request) { + const response = await handler.fetch(request); + + const headers = new Headers(response.headers); + const contentType = headers.get("content-type") ?? ""; + + if (request.url.includes("/printer/")) { + headers.set( + "Content-Security-Policy", + `default-src 'self'; img-src 'self' data:; font-src ${fontSrcWithWebfonts}; style-src 'self' 'unsafe-inline'; connect-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self';`, + ); + } + + if (contentType.includes("text/html")) { + setIfMissing(headers, "Cross-Origin-Opener-Policy", "same-origin"); + setIfMissing(headers, "Cross-Origin-Resource-Policy", "same-site"); + setIfMissing( + headers, + "Content-Security-Policy", + `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src ${fontSrcWithWebfonts} data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';`, + ); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); }, }); diff --git a/src/utils/env.ts b/src/utils/env.ts index ca92f2a14..c6073b52c 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -44,12 +44,16 @@ export const env = createEnv({ OAUTH_AUTHORIZATION_URL: z.url({ protocol: /https?/ }).optional(), OAUTH_TOKEN_URL: z.url({ protocol: /https?/ }).optional(), OAUTH_USER_INFO_URL: z.url({ protocol: /https?/ }).optional(), + OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS: z.string().optional(), OAUTH_SCOPES: z .string() .min(1) .transform((value) => value.split(" ")) .default(["openid", "profile", "email"]), + // AI provider URL restrictions + AI_ALLOWED_BASE_URLS: z.string().optional(), + // Email (SMTP) SMTP_HOST: z.string().min(1).optional(), SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587), diff --git a/src/utils/error-message.ts b/src/utils/error-message.ts new file mode 100644 index 000000000..c3dfb6237 --- /dev/null +++ b/src/utils/error-message.ts @@ -0,0 +1,36 @@ +import { ORPCError } from "@orpc/client"; + +export function getReadableErrorMessage(error: unknown, fallback: string): string { + if (typeof error === "string" && error) return error; + if (error instanceof Error && error.message) return error.message; + return fallback; +} + +type ErrorMessageByCode = Record; + +export function getOrpcErrorMessage( + error: unknown, + options: { + fallback: string; + byCode?: ErrorMessageByCode; + allowServerMessage?: boolean; + }, +): string { + if (!(error instanceof ORPCError)) return getReadableErrorMessage(error, options.fallback); + + const mappedMessage = options.byCode?.[error.code]; + if (mappedMessage) return mappedMessage; + + if (options.allowServerMessage && error.message) return error.message; + return options.fallback; +} + +export function getResumeErrorMessage(error: unknown): string { + return getOrpcErrorMessage(error, { + byCode: { + RESUME_SLUG_ALREADY_EXISTS: "A resume with this slug already exists.", + RESUME_LOCKED: "This resume is locked. Unlock it first to make changes.", + }, + fallback: "Something went wrong. Please try again.", + }); +} diff --git a/src/utils/url-security.ts b/src/utils/url-security.ts new file mode 100644 index 000000000..c0e00c847 --- /dev/null +++ b/src/utils/url-security.ts @@ -0,0 +1,99 @@ +import { isIP } from "node:net"; + +function normalizeHostname(hostname: string) { + return hostname.trim().toLowerCase(); +} + +function stripIpv6Brackets(hostname: string): string { + return hostname.replace(/^\[/, "").replace(/\]$/, ""); +} + +function isLoopbackOrLocalHostname(hostname: string) { + const normalized = normalizeHostname(hostname); + return ( + normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.endsWith(".localhost") + ); +} + +function isPrivateIPv4(hostname: string) { + const [first = 0, second = 0] = hostname.split(".").map((part) => Number.parseInt(part, 10)); + if (Number.isNaN(first) || Number.isNaN(second)) return false; + + if (first === 10) return true; + if (first === 127) return true; + if (first === 169 && second === 254) return true; + if (first === 172 && second >= 16 && second <= 31) return true; + if (first === 192 && second === 168) return true; + if (first === 0) return true; + + return false; +} + +function isPrivateIPv6(hostname: string) { + const normalized = stripIpv6Brackets(normalizeHostname(hostname)); + return ( + normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") + ); +} + +export function isPrivateOrLoopbackHost(hostname: string) { + const normalized = stripIpv6Brackets(normalizeHostname(hostname)); + if (isLoopbackOrLocalHostname(normalized)) return true; + + const ipVersion = isIP(normalized); + if (ipVersion === 4) return isPrivateIPv4(normalized); + if (ipVersion === 6) return isPrivateIPv6(normalized); + + return false; +} + +export function parseUrl(input: string) { + try { + return new URL(input); + } catch { + return null; + } +} + +export function parseAllowedHostList(value?: string) { + if (!value) return new Set(); + + const hosts = value + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + + return new Set(hosts); +} + +export function isAllowedExternalUrl(input: string, allowedHosts: Set) { + const parsed = parseUrl(input); + if (!parsed) return false; + if (parsed.protocol !== "https:") return false; + if (parsed.username || parsed.password) return false; + if (isPrivateOrLoopbackHost(parsed.hostname)) return false; + + const hostname = normalizeHostname(parsed.hostname); + if (allowedHosts.has(hostname)) return true; + + const origin = parsed.origin.toLowerCase(); + return allowedHosts.has(origin); +} + +export function sanitizeResumePictureUrl(url: string, appUrl: string) { + if (!url) return ""; + if (url.startsWith("/uploads/")) return url; + + const parsed = parseUrl(url); + if (!parsed) return ""; + if (parsed.username || parsed.password) return ""; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return ""; + + const app = parseUrl(appUrl); + if (!app) return ""; + + if (parsed.origin !== app.origin) return ""; + if (!parsed.pathname.startsWith("/uploads/")) return ""; + + return `${parsed.pathname}${parsed.search}${parsed.hash}`; +} diff --git a/vite.config.ts b/vite.config.ts index 22910023b..038bd8183 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -221,15 +221,17 @@ const config = defineConfig({ }, plugins: [ "eslint", + "import", + "jest", + "jsdoc", + "jsx-a11y", + "node", + "oxc", + "promise", + "react-perf", + "react", "typescript", "unicorn", - "oxc", - "react", - "react-perf", - "import", - "jsdoc", - "node", - "promise", "vitest", ], rules: {