mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
feat(security): harden auth, oauth, and printer endpoints
Add stricter URL and redirect validation, endpoint rate limiting, safer defaults for printer and compose config, and CSP protections across server and API surfaces. Made-with: Cursor
This commit is contained in:
+6
-1
@@ -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.
|
||||
|
||||
+18
-8
@@ -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:
|
||||
|
||||
+32
-8
@@ -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:
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
Generated
+47
-24
@@ -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
|
||||
|
||||
@@ -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<string>([appUrl.origin.replace(/\/$/, "")]);
|
||||
const trustedOrigins = new Set<string>([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({
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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<ContextWithHeaders, { id: string }>({
|
||||
limiter: pdfLimiter,
|
||||
key: ({ context }, input) => `pdf-export:${getUserKey(context)}:${input.id}`,
|
||||
});
|
||||
|
||||
export const aiRequestRateLimit = createRatelimitMiddleware<ContextWithHeaders, { provider: string }>({
|
||||
limiter: aiLimiter,
|
||||
key: ({ context }, input) => `ai-request:${getUserKey(context)}:${input.provider}`,
|
||||
});
|
||||
@@ -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<typeof aiProviderSchema>;
|
||||
type AIProvider = z.infer<typeof aiCredentialsSchema.shape.provider>;
|
||||
|
||||
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", {
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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<string | null> {
|
||||
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 };
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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<boolean> => {
|
||||
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 });
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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<Exclude<AIProvider, "ollama">, 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({
|
||||
|
||||
@@ -62,6 +62,20 @@ async function closeBrowser(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<TPassword extends boolean>(
|
||||
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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
+61
-15
@@ -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<Reques
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
|
||||
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<string, unknown>;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
+37
-2
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
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.",
|
||||
});
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
const hosts = value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return new Set(hosts);
|
||||
}
|
||||
|
||||
export function isAllowedExternalUrl(input: string, allowedHosts: Set<string>) {
|
||||
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}`;
|
||||
}
|
||||
+9
-7
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user