mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 18:04:45 +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_APP_URL="http://host.docker.internal:3000"
|
||||||
|
|
||||||
# --- Printer ---
|
# --- Printer ---
|
||||||
PRINTER_ENDPOINT="ws://localhost:4000?token=1234567890"
|
PRINTER_ENDPOINT="ws://localhost:4000?token=change-me"
|
||||||
|
|
||||||
# --- Database (PostgreSQL) ---
|
# --- Database (PostgreSQL) ---
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||||
@@ -40,6 +40,11 @@ OAUTH_CLIENT_ID=""
|
|||||||
OAUTH_CLIENT_SECRET=""
|
OAUTH_CLIENT_SECRET=""
|
||||||
OAUTH_DISCOVERY_URL=""
|
OAUTH_DISCOVERY_URL=""
|
||||||
OAUTH_AUTHORIZATION_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) ---
|
# --- Email (optional) ---
|
||||||
# If all keys are disabled, the app logs the email to be sent to the console instead.
|
# 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
|
retries: 3
|
||||||
|
|
||||||
browserless:
|
browserless:
|
||||||
image: ghcr.io/browserless/chromium:latest
|
image: ${BROWSERLESS_IMAGE:-ghcr.io/browserless/chromium:latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "4000:3000"
|
- "4000:3000"
|
||||||
@@ -43,9 +43,11 @@ services:
|
|||||||
QUEUED: 10
|
QUEUED: 10
|
||||||
HEALTH: "true"
|
HEALTH: "true"
|
||||||
CONCURRENT: 5
|
CONCURRENT: 5
|
||||||
TOKEN: "1234567890"
|
TOKEN: ${BROWSERLESS_TOKEN:-change-me}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -57,6 +59,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "9222:9222"
|
- "9222:9222"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
seaweedfs:
|
seaweedfs:
|
||||||
image: chrislusf/seaweedfs:latest
|
image: chrislusf/seaweedfs:latest
|
||||||
@@ -118,15 +122,13 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
PRINTER_APP_URL: http://web_app:3000
|
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
|
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
|
||||||
SMTP_HOST: mailpit
|
SMTP_HOST: mailpit
|
||||||
S3_ENDPOINT: http://seaweedfs:8333
|
S3_ENDPOINT: http://seaweedfs:8333
|
||||||
CHOKIDAR_INTERVAL: 100
|
CHOKIDAR_INTERVAL: 100
|
||||||
WATCHPACK_POLLING: "true"
|
WATCHPACK_POLLING: "true"
|
||||||
CHOKIDAR_USEPOLLING: "true"
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
- web_app_data:/app/data
|
- web_app_data:/app/data
|
||||||
@@ -143,13 +145,21 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
seaweedfs_create_bucket:
|
seaweedfs_create_bucket:
|
||||||
condition: service_completed_successfully
|
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:
|
volumes:
|
||||||
mailpit_data:
|
mailpit_data:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
seaweedfs_data:
|
seaweedfs_data:
|
||||||
web_app_data:
|
web_app_data:
|
||||||
web_app_node_modules:
|
|
||||||
web_app_pnpm_store:
|
web_app_pnpm_store:
|
||||||
web_app_tanstack_cache:
|
|
||||||
web_app_nitro_cache:
|
web_app_nitro_cache:
|
||||||
|
web_app_node_modules:
|
||||||
|
web_app_tanstack_cache:
|
||||||
|
|||||||
+32
-8
@@ -4,6 +4,8 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:latest
|
image: postgres:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- data_network
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=postgres
|
- POSTGRES_DB=postgres
|
||||||
- POSTGRES_USER=postgres
|
- POSTGRES_USER=postgres
|
||||||
@@ -18,15 +20,17 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
browserless:
|
browserless:
|
||||||
image: ghcr.io/browserless/chromium:latest
|
image: ${BROWSERLESS_IMAGE:-ghcr.io/browserless/chromium:latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- printer_network
|
||||||
environment:
|
environment:
|
||||||
- QUEUED=10
|
- QUEUED=10
|
||||||
- HEALTH=true
|
- HEALTH=true
|
||||||
- CONCURRENT=5
|
- CONCURRENT=5
|
||||||
- TOKEN=1234567890
|
- TOKEN=${BROWSERLESS_TOKEN:-change-me}
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -36,6 +40,8 @@ services:
|
|||||||
# chrome:
|
# chrome:
|
||||||
# image: chromedp/headless-shell:latest
|
# image: chromedp/headless-shell:latest
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
# networks:
|
||||||
|
# - printer_network
|
||||||
# ports:
|
# ports:
|
||||||
# - "9222:9222"
|
# - "9222:9222"
|
||||||
|
|
||||||
@@ -43,6 +49,8 @@ services:
|
|||||||
image: chrislusf/seaweedfs:latest
|
image: chrislusf/seaweedfs:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: server -s3 -filer -dir=/data -ip=0.0.0.0
|
command: server -s3 -filer -dir=/data -ip=0.0.0.0
|
||||||
|
networks:
|
||||||
|
- storage_network
|
||||||
environment:
|
environment:
|
||||||
- AWS_ACCESS_KEY_ID=seaweedfs
|
- AWS_ACCESS_KEY_ID=seaweedfs
|
||||||
- AWS_SECRET_ACCESS_KEY=seaweedfs
|
- AWS_SECRET_ACCESS_KEY=seaweedfs
|
||||||
@@ -65,21 +73,32 @@ services:
|
|||||||
mc mb seaweedfs/reactive-resume;
|
mc mb seaweedfs/reactive-resume;
|
||||||
exit 0;
|
exit 0;
|
||||||
"
|
"
|
||||||
|
networks:
|
||||||
|
- storage_network
|
||||||
depends_on:
|
depends_on:
|
||||||
seaweedfs:
|
seaweedfs:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
reactive_resume:
|
reactive_resume:
|
||||||
image: amruthpillai/reactive-resume:latest
|
# image: amruthpillai/reactive-resume:latest
|
||||||
# image: ghcr.io/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:
|
environment:
|
||||||
# Server
|
# Server
|
||||||
- TZ=Etc/UTC
|
- TZ=Etc/UTC
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- APP_URL=http://localhost:3000
|
- APP_URL=http://localhost:3000
|
||||||
- PRINTER_APP_URL=http://reactive_resume:3000
|
- PRINTER_APP_URL=http://host.docker.internal:3000
|
||||||
# Printer
|
# 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
|
# - PRINTER_ENDPOINT=http://chrome:9222 # Or, if you're using chromedp/headless-shell
|
||||||
# Database
|
# Database
|
||||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres
|
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres
|
||||||
@@ -93,8 +112,6 @@ services:
|
|||||||
- S3_FORCE_PATH_STYLE=true
|
- S3_FORCE_PATH_STYLE=true
|
||||||
volumes:
|
volumes:
|
||||||
- reactive_resume_data:/app/data
|
- reactive_resume_data:/app/data
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -102,6 +119,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
seaweedfs_create_bucket:
|
seaweedfs_create_bucket:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
@@ -113,3 +132,8 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
seaweedfs_data:
|
seaweedfs_data:
|
||||||
reactive_resume_data:
|
reactive_resume_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
data_network:
|
||||||
|
printer_network:
|
||||||
|
storage_network:
|
||||||
|
|||||||
+3
-2
@@ -55,6 +55,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@monaco-editor/react": "4.8.0-rc.3",
|
"@monaco-editor/react": "4.8.0-rc.3",
|
||||||
"@orpc/client": "^1.14.0",
|
"@orpc/client": "^1.14.0",
|
||||||
|
"@orpc/experimental-ratelimit": "^1.14.0",
|
||||||
"@orpc/json-schema": "^1.14.0",
|
"@orpc/json-schema": "^1.14.0",
|
||||||
"@orpc/openapi": "^1.14.0",
|
"@orpc/openapi": "^1.14.0",
|
||||||
"@orpc/server": "^1.14.0",
|
"@orpc/server": "^1.14.0",
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
"@phosphor-icons/web": "^2.1.2",
|
"@phosphor-icons/web": "^2.1.2",
|
||||||
"@sindresorhus/slugify": "^3.0.0",
|
"@sindresorhus/slugify": "^3.0.0",
|
||||||
"@t3-oss/env-core": "^0.13.11",
|
"@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": "^1.168.24",
|
||||||
"@tanstack/react-router-ssr-query": "^1.166.11",
|
"@tanstack/react-router-ssr-query": "^1.166.11",
|
||||||
"@tanstack/react-start": "^1.167.48",
|
"@tanstack/react-start": "^1.167.48",
|
||||||
@@ -108,7 +109,7 @@
|
|||||||
"react-resizable-panels": "^4.10.0",
|
"react-resizable-panels": "^4.10.0",
|
||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
"react-zoom-pan-pinch": "^4.0.3",
|
"react-zoom-pan-pinch": "^4.0.3",
|
||||||
"shadcn": "^4.4.0",
|
"shadcn": "^4.5.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"srvx": "^0.11.15",
|
"srvx": "^0.11.15",
|
||||||
|
|||||||
Generated
+47
-24
@@ -80,6 +80,9 @@ importers:
|
|||||||
'@orpc/client':
|
'@orpc/client':
|
||||||
specifier: ^1.14.0
|
specifier: ^1.14.0
|
||||||
version: 1.14.0(@opentelemetry/api@1.9.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':
|
'@orpc/json-schema':
|
||||||
specifier: ^1.14.0
|
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)
|
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)
|
version: 1.14.0(@opentelemetry/api@1.9.0)(crossws@0.4.5(srvx@0.11.15))(ws@8.20.0)
|
||||||
'@orpc/tanstack-query':
|
'@orpc/tanstack-query':
|
||||||
specifier: ^1.14.0
|
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':
|
'@orpc/zod':
|
||||||
specifier: ^1.14.0
|
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)
|
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
|
specifier: ^0.13.11
|
||||||
version: 0.13.11(zod@4.3.6)
|
version: 0.13.11(zod@4.3.6)
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.100.1
|
specifier: ^5.100.2
|
||||||
version: 5.100.1(react@19.2.5)
|
version: 5.100.2(react@19.2.5)
|
||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
specifier: ^1.168.24
|
specifier: ^1.168.24
|
||||||
version: 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 1.168.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
'@tanstack/react-router-ssr-query':
|
'@tanstack/react-router-ssr-query':
|
||||||
specifier: ^1.166.11
|
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':
|
'@tanstack/react-start':
|
||||||
specifier: ^1.167.48
|
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)
|
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
|
specifier: ^4.0.3
|
||||||
version: 4.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 4.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
shadcn:
|
shadcn:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.5.0
|
||||||
version: 4.4.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0)
|
version: 4.5.0(@types/node@25.6.0)(babel-plugin-macros@3.1.0)
|
||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.34.5
|
specifier: ^0.34.5
|
||||||
version: 0.34.5
|
version: 0.34.5
|
||||||
@@ -2080,6 +2083,14 @@ packages:
|
|||||||
'@orpc/contract@1.14.0':
|
'@orpc/contract@1.14.0':
|
||||||
resolution: {integrity: sha512-FUxBNqWr6mOjI+w1JPzO/iHmR3M+GA53ivaxp+eOnQu7g3ZGKB0RS5gJ/oz3cGF1gvuIcCw9FVYKK/5tkB8I1Q==}
|
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':
|
'@orpc/interop@1.14.0':
|
||||||
resolution: {integrity: sha512-T4z8ehjks4rP8Z4XDObHBQjFiH61Wz8HlDaW4SR+GK0+u3h4l3+Fdwwxvm7ZWtY2zJEraqhaJhEHf90W7w50fA==}
|
resolution: {integrity: sha512-T4z8ehjks4rP8Z4XDObHBQjFiH61Wz8HlDaW4SR+GK0+u3h4l3+Fdwwxvm7ZWtY2zJEraqhaJhEHf90W7w50fA==}
|
||||||
|
|
||||||
@@ -3445,11 +3456,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
|
resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
|
|
||||||
'@tanstack/query-core@5.100.1':
|
'@tanstack/query-core@5.100.2':
|
||||||
resolution: {integrity: sha512-awvQhOO/2TrSCHE5LKKsXcvvj6WSBncwEcMFCB/ez0Qs0b17iyyivoGArNV3HFfXryZwCpnb/olsaBBKrIbtSw==}
|
resolution: {integrity: sha512-HzzOC7xgSfGGzZ1gTsFZqYz6rxGg3tYF77nTPctin+wEYYLNMP7LjwPVFALEGNdjxkHvcewh1EM5ywixeukS4w==}
|
||||||
|
|
||||||
'@tanstack/react-query@5.100.1':
|
'@tanstack/react-query@5.100.2':
|
||||||
resolution: {integrity: sha512-UgWRLhQKprC37SsO6y1zRabOqDmM2gsdTNPbqTT35yl7kOOhwXU4nyfOiGHXPwoEFJV1IpSk85hjIFjNFWVpzw==}
|
resolution: {integrity: sha512-MvvzPcurtzVh4EcbsTfI1BL5GOfdi1S0dk/qhigEghW07MvcHUl/dhfc1FT8hPEquuMtUC+IIAxC0bdmSp/7kA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19
|
react: ^18 || ^19
|
||||||
|
|
||||||
@@ -6851,8 +6862,8 @@ packages:
|
|||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
shadcn@4.4.0:
|
shadcn@4.5.0:
|
||||||
resolution: {integrity: sha512-0V1AjVktKwhK1e0ONXb9SeBoyJePH04iTSJeMjl9eROqjjyb8OoWYtWDI39UdBU3GzpUlFBJFohhB9c6fGOkQA==}
|
resolution: {integrity: sha512-ZpNOz7IMI5aezbMEWNxBvl2aJ1ek6NuAMqpL/FUnk5IuRxERl8ohYEnqqAmhPOcur8RbGuCoqTZLQ3Oi4Xkf8A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
@@ -9694,6 +9705,18 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@opentelemetry/api'
|
- '@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/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)':
|
'@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:
|
transitivePeerDependencies:
|
||||||
- '@opentelemetry/api'
|
- '@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:
|
dependencies:
|
||||||
'@orpc/client': 1.14.0(@opentelemetry/api@1.9.0)
|
'@orpc/client': 1.14.0(@opentelemetry/api@1.9.0)
|
||||||
'@orpc/shared': 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:
|
transitivePeerDependencies:
|
||||||
- '@opentelemetry/api'
|
- '@opentelemetry/api'
|
||||||
|
|
||||||
@@ -10934,19 +10957,19 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/history@1.161.6': {}
|
'@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:
|
dependencies:
|
||||||
'@tanstack/query-core': 5.100.1
|
'@tanstack/query-core': 5.100.2
|
||||||
react: 19.2.5
|
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:
|
dependencies:
|
||||||
'@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)
|
||||||
'@tanstack/react-router': 1.168.24(react-dom@19.2.5(react@19.2.5))(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: 19.2.5
|
||||||
react-dom: 19.2.5(react@19.2.5)
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -11074,9 +11097,9 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@tanstack/query-core': 5.100.1
|
'@tanstack/query-core': 5.100.2
|
||||||
'@tanstack/router-core': 1.168.16
|
'@tanstack/router-core': 1.168.16
|
||||||
|
|
||||||
'@tanstack/router-utils@1.161.7':
|
'@tanstack/router-utils@1.161.7':
|
||||||
@@ -14401,7 +14424,7 @@ snapshots:
|
|||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
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:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/parser': 7.29.2
|
'@babel/parser': 7.29.2
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
|||||||
import { dash } from "@better-auth/infra";
|
import { dash } from "@better-auth/infra";
|
||||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||||
import { passkey } from "@better-auth/passkey";
|
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 { 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 { genericOAuth } from "better-auth/plugins/generic-oauth";
|
||||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||||
import { username } from "better-auth/plugins/username";
|
import { username } from "better-auth/plugins/username";
|
||||||
@@ -16,6 +17,7 @@ import { eq, or } from "drizzle-orm";
|
|||||||
import { env } from "@/utils/env";
|
import { env } from "@/utils/env";
|
||||||
import { hashPassword, verifyPassword } from "@/utils/password";
|
import { hashPassword, verifyPassword } from "@/utils/password";
|
||||||
import { generateId, toUsername } from "@/utils/string";
|
import { generateId, toUsername } from "@/utils/string";
|
||||||
|
import { isPrivateOrLoopbackHost, parseAllowedHostList, parseUrl } from "@/utils/url-security";
|
||||||
|
|
||||||
import { schema } from "../drizzle";
|
import { schema } from "../drizzle";
|
||||||
import { db } from "../drizzle/client";
|
import { db } from "../drizzle/client";
|
||||||
@@ -51,8 +53,9 @@ function isCustomOAuthProviderEnabled() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTrustedOrigins(): string[] {
|
function getTrustedOrigins(): string[] {
|
||||||
|
const normalizeOrigin = (origin: string): string => origin.replace(/\/$/, "");
|
||||||
const appUrl = new URL(env.APP_URL);
|
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"];
|
const LOCAL_ORIGINS = ["localhost", "127.0.0.1"];
|
||||||
|
|
||||||
if (LOCAL_ORIGINS.includes(appUrl.hostname)) {
|
if (LOCAL_ORIGINS.includes(appUrl.hostname)) {
|
||||||
@@ -60,7 +63,7 @@ function getTrustedOrigins(): string[] {
|
|||||||
if (hostname !== appUrl.hostname) {
|
if (hostname !== appUrl.hostname) {
|
||||||
const altUrl = new URL(env.APP_URL);
|
const altUrl = new URL(env.APP_URL);
|
||||||
altUrl.hostname = hostname;
|
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 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) {
|
async function findExistingUserByEmail(email: string) {
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
@@ -234,6 +257,41 @@ const getAuthConfig = () => {
|
|||||||
|
|
||||||
telemetry: { enabled: false },
|
telemetry: { enabled: false },
|
||||||
trustedOrigins: TRUSTED_ORIGINS,
|
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: {
|
advanced: {
|
||||||
database: { generateId },
|
database: { generateId },
|
||||||
@@ -341,11 +399,10 @@ const getAuthConfig = () => {
|
|||||||
plugins: [
|
plugins: [
|
||||||
jwt(),
|
jwt(),
|
||||||
admin(),
|
admin(),
|
||||||
openAPI(),
|
|
||||||
passkey(),
|
passkey(),
|
||||||
genericOAuth({ config: authConfigs }),
|
genericOAuth({ config: authConfigs }),
|
||||||
twoFactor({ issuer: "Reactive Resume" }),
|
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 } }),
|
dash({ apiKey: env.BETTER_AUTH_API_KEY, activityTracking: { enabled: true } }),
|
||||||
oauthProvider({
|
oauthProvider({
|
||||||
loginPage: "/auth/oauth",
|
loginPage: "/auth/oauth",
|
||||||
@@ -353,6 +410,14 @@ const getAuthConfig = () => {
|
|||||||
validAudiences: OAUTH_AUDIENCES,
|
validAudiences: OAUTH_AUDIENCES,
|
||||||
allowDynamicClientRegistration: true,
|
allowDynamicClientRegistration: true,
|
||||||
allowUnauthenticatedClientRegistration: 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 },
|
silenceWarnings: { oauthAuthServerConfig: true },
|
||||||
}),
|
}),
|
||||||
username({
|
username({
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ export const getORPCClient = createIsomorphicFn()
|
|||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const reqHeaders = getRequestHeaders();
|
const reqHeaders = getRequestHeaders();
|
||||||
|
|
||||||
// Add a custom header to identify server-side calls
|
|
||||||
reqHeaders.set("x-server-side-call", "true");
|
|
||||||
|
|
||||||
return { locale, reqHeaders };
|
return { locale, reqHeaders };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { eq } from "drizzle-orm";
|
|||||||
|
|
||||||
import type { Locale } from "@/utils/locale";
|
import type { Locale } from "@/utils/locale";
|
||||||
|
|
||||||
import { env } from "@/utils/env";
|
|
||||||
|
|
||||||
import { auth, verifyOAuthToken } from "../auth/config";
|
import { auth, verifyOAuthToken } from "../auth/config";
|
||||||
import { db } from "../drizzle/client";
|
import { db } from "../drizzle/client";
|
||||||
import { user } from "../drizzle/schema";
|
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 { tailorOutputSchema } from "@/schema/tailor";
|
||||||
|
|
||||||
import { protectedProcedure } from "../context";
|
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";
|
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 = {
|
export const aiRouter = {
|
||||||
testConnection: protectedProcedure
|
testConnection: protectedProcedure
|
||||||
@@ -29,25 +53,26 @@ export const aiRouter = {
|
|||||||
})
|
})
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
provider: aiProviderSchema,
|
...aiCredentialsSchema.shape,
|
||||||
model: z.string(),
|
|
||||||
apiKey: z.string(),
|
|
||||||
baseURL: z.string(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.use(aiRequestRateLimit)
|
||||||
.errors({
|
.errors({
|
||||||
BAD_GATEWAY: {
|
BAD_GATEWAY: {
|
||||||
message: "The AI provider returned an error or is unreachable.",
|
message: "The AI provider returned an error or is unreachable.",
|
||||||
status: 502,
|
status: 502,
|
||||||
},
|
},
|
||||||
|
BAD_REQUEST: {
|
||||||
|
message: "Invalid AI provider configuration.",
|
||||||
|
status: 400,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
return await aiService.testConnection(input);
|
return await aiService.testConnection(input);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AISDKError || error instanceof OllamaError) {
|
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -70,6 +95,7 @@ export const aiRouter = {
|
|||||||
file: fileInputSchema,
|
file: fileInputSchema,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.use(aiRequestRateLimit)
|
||||||
.errors({
|
.errors({
|
||||||
BAD_GATEWAY: {
|
BAD_GATEWAY: {
|
||||||
message: "The AI provider returned an error or is unreachable.",
|
message: "The AI provider returned an error or is unreachable.",
|
||||||
@@ -84,16 +110,10 @@ export const aiRouter = {
|
|||||||
try {
|
try {
|
||||||
return await aiService.parsePdf(input);
|
return await aiService.parsePdf(input);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AISDKError) {
|
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
if (error instanceof AISDKError) throwAiProviderGatewayError();
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "Invalid resume data structure",
|
|
||||||
cause: flattenError(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -119,6 +139,7 @@ export const aiRouter = {
|
|||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.use(aiRequestRateLimit)
|
||||||
.errors({
|
.errors({
|
||||||
BAD_GATEWAY: {
|
BAD_GATEWAY: {
|
||||||
message: "The AI provider returned an error or is unreachable.",
|
message: "The AI provider returned an error or is unreachable.",
|
||||||
@@ -133,16 +154,10 @@ export const aiRouter = {
|
|||||||
try {
|
try {
|
||||||
return await aiService.parseDocx(input);
|
return await aiService.parseDocx(input);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AISDKError) {
|
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
if (error instanceof AISDKError) throwAiProviderGatewayError();
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "Invalid resume data structure",
|
|
||||||
cause: flattenError(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -168,13 +183,13 @@ export const aiRouter = {
|
|||||||
resumeData: ResumeData;
|
resumeData: ResumeData;
|
||||||
}>(),
|
}>(),
|
||||||
)
|
)
|
||||||
|
.use(aiRequestRateLimit)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
return await aiService.chat(input);
|
return await aiService.chat(input);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AISDKError || error instanceof OllamaError) {
|
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -198,6 +213,7 @@ export const aiRouter = {
|
|||||||
job: jobResultSchema,
|
job: jobResultSchema,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.use(aiRequestRateLimit)
|
||||||
.output(tailorOutputSchema)
|
.output(tailorOutputSchema)
|
||||||
.errors({
|
.errors({
|
||||||
BAD_GATEWAY: {
|
BAD_GATEWAY: {
|
||||||
@@ -213,9 +229,8 @@ export const aiRouter = {
|
|||||||
try {
|
try {
|
||||||
return await aiService.tailorResume(input);
|
return await aiService.tailorResume(input);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AISDKError || error instanceof OllamaError) {
|
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
@@ -246,6 +261,7 @@ export const aiRouter = {
|
|||||||
resumeData: resumeDataSchema,
|
resumeData: resumeDataSchema,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.use(aiRequestRateLimit)
|
||||||
.output(storedResumeAnalysisSchema)
|
.output(storedResumeAnalysisSchema)
|
||||||
.errors({
|
.errors({
|
||||||
BAD_GATEWAY: {
|
BAD_GATEWAY: {
|
||||||
@@ -282,9 +298,8 @@ export const aiRouter = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AISDKError || error instanceof OllamaError) {
|
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ export const jobsRouter = {
|
|||||||
try {
|
try {
|
||||||
return await jobsService.testConnection(input.apiKey);
|
return await jobsService.testConnection(input.apiKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("[jobs.testConnection] Failed to test JSearch connection:", error);
|
||||||
throw new ORPCError("BAD_GATEWAY", {
|
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 };
|
return { data: jobs, rapidApiQuota: response.rapidApiQuota };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("[jobs.search] Failed to search jobs via JSearch:", error);
|
||||||
throw new ORPCError("BAD_GATEWAY", {
|
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 z from "zod";
|
||||||
|
|
||||||
import { protectedProcedure, publicProcedure } from "../context";
|
import { protectedProcedure } from "../context";
|
||||||
|
import { pdfExportRateLimit } from "../rate-limit";
|
||||||
import { printerService } from "../services/printer";
|
import { printerService } from "../services/printer";
|
||||||
import { resumeService } from "../services/resume";
|
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 = {
|
export const printerRouter = {
|
||||||
printResumeAsPDF: publicProcedure
|
printResumeAsPDF: protectedProcedure
|
||||||
.route({
|
.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/resumes/{id}/pdf",
|
path: "/resumes/{id}/pdf",
|
||||||
@@ -13,19 +24,19 @@ export const printerRouter = {
|
|||||||
operationId: "exportResumePdf",
|
operationId: "exportResumePdf",
|
||||||
summary: "Export resume as PDF",
|
summary: "Export resume as PDF",
|
||||||
description:
|
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.",
|
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.") }))
|
.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.") }))
|
.output(z.object({ url: z.string().describe("The URL to download the generated PDF file.") }))
|
||||||
.handler(async ({ input, context }) => {
|
.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 });
|
const url = await printerService.printResumeAsPDF({ id, data, userId });
|
||||||
|
|
||||||
if (!context.user) {
|
|
||||||
await resumeService.statistics.increment({ id: input.id, downloads: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { url };
|
return { url };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -43,19 +54,7 @@ export const printerRouter = {
|
|||||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
.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.") }))
|
.output(z.object({ url: z.string().nullable().describe("The URL to the screenshot image, or null.") }))
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
try {
|
const url = await getResumeScreenshotUrl({ id: input.id, currentUserId: context.user.id });
|
||||||
const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({
|
return { url };
|
||||||
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 };
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { storedResumeAnalysisSchema } from "@/schema/resume/analysis";
|
|||||||
import { sampleResumeData } from "@/schema/resume/sample";
|
import { sampleResumeData } from "@/schema/resume/sample";
|
||||||
import { generateRandomName, slugify } from "@/utils/string";
|
import { generateRandomName, slugify } from "@/utils/string";
|
||||||
|
|
||||||
import { protectedProcedure, publicProcedure, serverOnlyProcedure } from "../context";
|
import { protectedProcedure, publicProcedure } from "../context";
|
||||||
import { resumeDto } from "../dto/resume";
|
import { resumeDto } from "../dto/resume";
|
||||||
|
import { resumePasswordRateLimit } from "../rate-limit";
|
||||||
import { resumeService } from "../services/resume";
|
import { resumeService } from "../services/resume";
|
||||||
|
|
||||||
const tagsRouter = {
|
const tagsRouter = {
|
||||||
@@ -22,7 +23,7 @@ const tagsRouter = {
|
|||||||
})
|
})
|
||||||
.output(z.array(z.string()))
|
.output(z.array(z.string()))
|
||||||
.handler(async ({ context }) => {
|
.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 }) => {
|
.handler(async ({ context, input }) => {
|
||||||
return await resumeService.statistics.getById({ id: input.id, userId: context.user.id });
|
return 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);
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +69,7 @@ const analysisRouter = {
|
|||||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||||
.output(storedResumeAnalysisSchema.nullable())
|
.output(storedResumeAnalysisSchema.nullable())
|
||||||
.handler(async ({ context, input }) => {
|
.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" }))
|
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
|
||||||
.output(resumeDto.list.output)
|
.output(resumeDto.list.output)
|
||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
return await resumeService.list({
|
return resumeService.list({
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
tags: input.tags,
|
tags: input.tags,
|
||||||
sort: input.sort,
|
sort: input.sort,
|
||||||
@@ -119,14 +113,22 @@ export const resumeRouter = {
|
|||||||
.input(resumeDto.getById.input)
|
.input(resumeDto.getById.input)
|
||||||
.output(resumeDto.getById.output)
|
.output(resumeDto.getById.output)
|
||||||
.handler(async ({ context, input }) => {
|
.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" })
|
.route({ tags: ["Internal"], operationId: "getResumeForPrinter", summary: "Get resume by ID for printer" })
|
||||||
.input(resumeDto.getById.input)
|
.input(
|
||||||
.handler(async ({ input }) => {
|
resumeDto.getById.input.extend({
|
||||||
return await resumeService.getByIdForPrinter({ id: input.id });
|
token: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
return resumeService.getByIdForPrinter({
|
||||||
|
id: input.id,
|
||||||
|
currentUserId: context.user?.id,
|
||||||
|
printerToken: input.token,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getBySlug: publicProcedure
|
getBySlug: publicProcedure
|
||||||
@@ -143,7 +145,7 @@ export const resumeRouter = {
|
|||||||
.input(resumeDto.getBySlug.input)
|
.input(resumeDto.getBySlug.input)
|
||||||
.output(resumeDto.getBySlug.output)
|
.output(resumeDto.getBySlug.output)
|
||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
return await resumeService.getBySlug({ ...input, currentUserId: context.user?.id });
|
return resumeService.getBySlug({ ...input, currentUserId: context.user?.id });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -166,7 +168,7 @@ export const resumeRouter = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
return await resumeService.create({
|
return resumeService.create({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
tags: input.tags,
|
tags: input.tags,
|
||||||
@@ -199,7 +201,7 @@ export const resumeRouter = {
|
|||||||
const name = generateRandomName();
|
const name = generateRandomName();
|
||||||
const slug = slugify(name);
|
const slug = slugify(name);
|
||||||
|
|
||||||
return await resumeService.create({
|
return resumeService.create({
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -229,7 +231,7 @@ export const resumeRouter = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
return await resumeService.update({
|
return resumeService.update({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -260,7 +262,7 @@ export const resumeRouter = {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
return await resumeService.patch({
|
return resumeService.patch({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
operations: input.operations,
|
operations: input.operations,
|
||||||
@@ -281,7 +283,7 @@ export const resumeRouter = {
|
|||||||
.input(resumeDto.setLocked.input)
|
.input(resumeDto.setLocked.input)
|
||||||
.output(resumeDto.setLocked.output)
|
.output(resumeDto.setLocked.output)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
return await resumeService.setLocked({
|
return resumeService.setLocked({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
isLocked: input.isLocked,
|
isLocked: input.isLocked,
|
||||||
@@ -302,7 +304,7 @@ export const resumeRouter = {
|
|||||||
.input(resumeDto.setPassword.input)
|
.input(resumeDto.setPassword.input)
|
||||||
.output(resumeDto.setPassword.output)
|
.output(resumeDto.setPassword.output)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
return await resumeService.setPassword({
|
return resumeService.setPassword({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
@@ -327,9 +329,10 @@ export const resumeRouter = {
|
|||||||
password: z.string().min(1).describe("The password to verify."),
|
password: z.string().min(1).describe("The password to verify."),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.use(resumePasswordRateLimit)
|
||||||
.output(z.boolean())
|
.output(z.boolean())
|
||||||
.handler(async ({ input }): Promise<boolean> => {
|
.handler(async ({ input }): Promise<boolean> => {
|
||||||
return await resumeService.verifyPassword({
|
return resumeService.verifyPassword({
|
||||||
username: input.username,
|
username: input.username,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
@@ -350,7 +353,7 @@ export const resumeRouter = {
|
|||||||
.input(resumeDto.removePassword.input)
|
.input(resumeDto.removePassword.input)
|
||||||
.output(resumeDto.removePassword.output)
|
.output(resumeDto.removePassword.output)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
return await resumeService.removePassword({
|
return resumeService.removePassword({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
});
|
});
|
||||||
@@ -372,7 +375,7 @@ export const resumeRouter = {
|
|||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
|
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||||
|
|
||||||
return await resumeService.create({
|
return resumeService.create({
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
name: input.name ?? original.name,
|
name: input.name ?? original.name,
|
||||||
slug: input.slug ?? original.slug,
|
slug: input.slug ?? original.slug,
|
||||||
@@ -396,6 +399,6 @@ export const resumeRouter = {
|
|||||||
.input(resumeDto.delete.input)
|
.input(resumeDto.delete.input)
|
||||||
.output(resumeDto.delete.output)
|
.output(resumeDto.delete.output)
|
||||||
.handler(async ({ context, input }) => {
|
.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 { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||||
import { type TailorOutput, tailorOutputSchema } from "@/schema/tailor";
|
import { type TailorOutput, tailorOutputSchema } from "@/schema/tailor";
|
||||||
import { buildAiExtractionTemplate } from "@/utils/ai-template";
|
import { buildAiExtractionTemplate } from "@/utils/ai-template";
|
||||||
|
import { env } from "@/utils/env";
|
||||||
import { isObject } from "@/utils/sanitize";
|
import { isObject } from "@/utils/sanitize";
|
||||||
|
import { isAllowedExternalUrl, parseAllowedHostList } from "@/utils/url-security";
|
||||||
|
|
||||||
const aiExtractionTemplate = buildAiExtractionTemplate();
|
const aiExtractionTemplate = buildAiExtractionTemplate();
|
||||||
|
|
||||||
@@ -97,23 +99,27 @@ function logAndRethrow(context: string, error: unknown): never {
|
|||||||
throw new Error(`An unknown error occurred during ${context}.`);
|
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 {
|
function parseAndValidateResumeJson(resultText: string): ResumeData {
|
||||||
let jsonString = resultText;
|
let jsonString = resultText;
|
||||||
const firstCurly = jsonString.indexOf("{");
|
const { first, last } = getJsonBoundaryIndices(jsonString);
|
||||||
const firstSquare = jsonString.indexOf("[");
|
if (first !== -1 && last !== -1 && last >= first) {
|
||||||
const lastCurly = jsonString.lastIndexOf("}");
|
jsonString = jsonString.substring(first, last + 1);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -203,15 +209,40 @@ type GetModelInput = {
|
|||||||
provider: AIProvider;
|
provider: AIProvider;
|
||||||
model: string;
|
model: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseURL: string;
|
baseURL?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
|
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 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) {
|
function getModel(input: GetModelInput) {
|
||||||
const { provider, model, apiKey } = input;
|
const { provider, model, apiKey } = input;
|
||||||
const baseURL = input.baseURL || undefined;
|
const baseURL = resolveBaseUrl(input);
|
||||||
|
|
||||||
return match(provider)
|
return match(provider)
|
||||||
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
|
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
|
||||||
@@ -226,7 +257,7 @@ export const aiCredentialsSchema = z.object({
|
|||||||
provider: aiProviderSchema,
|
provider: aiProviderSchema,
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
apiKey: z.string(),
|
apiKey: z.string(),
|
||||||
baseURL: z.string(),
|
baseURL: z.string().optional().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fileInputSchema = z.object({
|
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
|
// Close browser on process termination
|
||||||
process.on("exit", async () => {
|
process.on("exit", async () => {
|
||||||
await closeBrowser();
|
await closeBrowser();
|
||||||
@@ -120,70 +134,126 @@ async function doPrintResumeAsPDF(
|
|||||||
try {
|
try {
|
||||||
// Step 4: Connect to the browser and navigate to the printer route
|
// 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
|
// Use an isolated browser context so concurrent requests with different locales don't interfere
|
||||||
const browser = await getBrowser();
|
const browser = await getBrowser().catch((error) =>
|
||||||
context = await browser.createBrowserContext();
|
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)
|
// Wait for the page to become ready without relying on strict network idle.
|
||||||
await page.emulateMediaType("print");
|
await page
|
||||||
await page.setViewport(pageDimensionsAsPixels[format]);
|
.emulateMediaType("print")
|
||||||
await page.goto(url, { waitUntil: "networkidle0" });
|
.catch((error) =>
|
||||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
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)
|
// Step 5a: Prepare the DOM for PDF rendering (background colors, reset margins, print padding)
|
||||||
await page.evaluate(
|
await page
|
||||||
(pagePaddingX: number, pagePaddingY: number, backgroundColor: string) => {
|
.evaluate(
|
||||||
const root = document.documentElement;
|
(pagePaddingX: number, pagePaddingY: number, backgroundColor: string) => {
|
||||||
const body = document.body;
|
const root = document.documentElement;
|
||||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
const body = document.body;
|
||||||
const pageContentElements = document.querySelectorAll(".page-content");
|
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.
|
// Ensure PDF margins inherit the resume background color instead of defaulting to white.
|
||||||
root.style.backgroundColor = backgroundColor;
|
root.style.backgroundColor = backgroundColor;
|
||||||
body.style.backgroundColor = backgroundColor;
|
body.style.backgroundColor = backgroundColor;
|
||||||
root.style.margin = "0";
|
root.style.margin = "0";
|
||||||
body.style.margin = "0";
|
body.style.margin = "0";
|
||||||
root.style.padding = "0";
|
root.style.padding = "0";
|
||||||
body.style.padding = "0";
|
body.style.padding = "0";
|
||||||
|
|
||||||
for (const el of pageElements) {
|
for (const el of pageElements) {
|
||||||
const pageWrapper = el as HTMLElement;
|
const pageWrapper = el as HTMLElement;
|
||||||
const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null;
|
const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null;
|
||||||
|
|
||||||
pageWrapper.style.backgroundColor = backgroundColor;
|
pageWrapper.style.backgroundColor = backgroundColor;
|
||||||
pageWrapper.style.breakInside = "auto";
|
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.
|
// Apply print-only margins as padding inside each page's content surface.
|
||||||
if (pagePaddingX > 0 || pagePaddingY > 0) {
|
if (pagePaddingX > 0 || pagePaddingY > 0) {
|
||||||
for (const el of pageContentElements) {
|
for (const el of pageContentElements) {
|
||||||
const pageContent = el as HTMLElement;
|
const pageContent = el as HTMLElement;
|
||||||
|
|
||||||
pageContent.style.boxSizing = "border-box";
|
pageContent.style.boxSizing = "border-box";
|
||||||
// Ensure padding is repeated on every printed fragment when content
|
// Ensure padding is repeated on every printed fragment when content
|
||||||
// flows across physical PDF pages (not just the first fragment).
|
// flows across physical PDF pages (not just the first fragment).
|
||||||
pageContent.style.boxDecorationBreak = "clone";
|
pageContent.style.boxDecorationBreak = "clone";
|
||||||
pageContent.style.setProperty("-webkit-box-decoration-break", "clone");
|
pageContent.style.setProperty("-webkit-box-decoration-break", "clone");
|
||||||
if (pagePaddingX > 0) {
|
if (pagePaddingX > 0) {
|
||||||
pageContent.style.paddingLeft = `${pagePaddingX}pt`;
|
pageContent.style.paddingLeft = `${pagePaddingX}pt`;
|
||||||
pageContent.style.paddingRight = `${pagePaddingX}pt`;
|
pageContent.style.paddingRight = `${pagePaddingX}pt`;
|
||||||
}
|
}
|
||||||
if (pagePaddingY > 0) {
|
if (pagePaddingY > 0) {
|
||||||
pageContent.style.paddingTop = `${pagePaddingY}pt`;
|
pageContent.style.paddingTop = `${pagePaddingY}pt`;
|
||||||
pageContent.style.paddingBottom = `${pagePaddingY}pt`;
|
pageContent.style.paddingBottom = `${pagePaddingY}pt`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
pagePaddingX,
|
||||||
pagePaddingX,
|
pagePaddingY,
|
||||||
pagePaddingY,
|
data.metadata.design.colors.background,
|
||||||
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
|
// Step 5b: Format-specific layout adjustments
|
||||||
const isFreeForm = format === "free-form";
|
const isFreeForm = format === "free-form";
|
||||||
@@ -191,57 +261,73 @@ async function doPrintResumeAsPDF(
|
|||||||
|
|
||||||
if (isFreeForm) {
|
if (isFreeForm) {
|
||||||
// Free-form: measure actual content height after adding inter-page margins
|
// Free-form: measure actual content height after adding inter-page margins
|
||||||
contentHeight = await page.evaluate(
|
contentHeight = await page
|
||||||
(pagePaddingY: number, minPageHeight: number) => {
|
.evaluate(
|
||||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
(pagePaddingY: number, minPageHeight: number) => {
|
||||||
const numberOfPages = pageElements.length;
|
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||||
|
const numberOfPages = pageElements.length;
|
||||||
|
|
||||||
// Add margin between pages (except the last one)
|
// Add margin between pages (except the last one)
|
||||||
for (let i = 0; i < numberOfPages - 1; i++) {
|
for (let i = 0; i < numberOfPages - 1; i++) {
|
||||||
const pageEl = pageElements[i] as HTMLElement;
|
const pageEl = pageElements[i] as HTMLElement;
|
||||||
if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`;
|
if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measure the total height (margins are now part of the DOM)
|
// Measure the total height (margins are now part of the DOM)
|
||||||
let totalHeight = 0;
|
let totalHeight = 0;
|
||||||
|
|
||||||
for (const el of pageElements) {
|
for (const el of pageElements) {
|
||||||
const pageEl = el as HTMLElement;
|
const pageEl = el as HTMLElement;
|
||||||
const style = getComputedStyle(pageEl);
|
const style = getComputedStyle(pageEl);
|
||||||
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
|
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
|
||||||
totalHeight += pageEl.offsetHeight + marginBottom;
|
totalHeight += pageEl.offsetHeight + marginBottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(totalHeight, minPageHeight);
|
return Math.max(totalHeight, minPageHeight);
|
||||||
},
|
},
|
||||||
pagePaddingY,
|
pagePaddingY,
|
||||||
pageDimensionsAsPixels[format].height,
|
pageDimensionsAsPixels[format].height,
|
||||||
);
|
)
|
||||||
|
.catch((error) =>
|
||||||
|
throwPrinterStepError(
|
||||||
|
"Failed to compute free-form PDF content height",
|
||||||
|
"Check resume layout and page metadata values",
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// A4/Letter: set fixed page height and add page breaks between pages
|
// A4/Letter: set fixed page height and add page breaks between pages
|
||||||
await page.evaluate((pageHeight: number) => {
|
await page
|
||||||
const root = document.documentElement;
|
.evaluate((pageHeight: number) => {
|
||||||
const pageElements = document.querySelectorAll("[data-page-index]");
|
const root = document.documentElement;
|
||||||
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
|
const pageElements = document.querySelectorAll("[data-page-index]");
|
||||||
|
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
|
||||||
|
|
||||||
const newHeight = `${pageHeight}px`;
|
const newHeight = `${pageHeight}px`;
|
||||||
if (container) container.style.setProperty("--page-height", newHeight);
|
if (container) container.style.setProperty("--page-height", newHeight);
|
||||||
root.style.setProperty("--page-height", newHeight);
|
root.style.setProperty("--page-height", newHeight);
|
||||||
|
|
||||||
for (const el of pageElements) {
|
for (const el of pageElements) {
|
||||||
const element = el as HTMLElement;
|
const element = el as HTMLElement;
|
||||||
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
|
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
|
||||||
|
|
||||||
// Force a page break before each page except the first
|
// Force a page break before each page except the first
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
element.style.breakBefore = "page";
|
element.style.breakBefore = "page";
|
||||||
element.style.pageBreakBefore = "always";
|
element.style.pageBreakBefore = "always";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow content within a page to break naturally if it overflows
|
||||||
|
element.style.breakInside = "auto";
|
||||||
}
|
}
|
||||||
|
}, pageDimensionsAsPixels[format].height)
|
||||||
// Allow content within a page to break naturally if it overflows
|
.catch((error) =>
|
||||||
element.style.breakInside = "auto";
|
throwPrinterStepError(
|
||||||
}
|
"Failed to prepare fixed-page PDF layout",
|
||||||
}, pageDimensionsAsPixels[format].height);
|
"Check template/page format configuration",
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Generate the PDF with the specified dimensions and margins
|
// 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
|
// For A4/Letter: use fixed dimensions from pageDimensionsAsPixels
|
||||||
const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height;
|
const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height;
|
||||||
|
|
||||||
const pdfBuffer = await page.pdf({
|
const pdfBuffer = await page
|
||||||
width: `${pageDimensionsAsPixels[format].width}px`,
|
.pdf({
|
||||||
height: `${pdfHeight}px`,
|
width: `${pageDimensionsAsPixels[format].width}px`,
|
||||||
tagged: true, // Adds accessibility tags to the PDF
|
height: `${pdfHeight}px`,
|
||||||
waitForFonts: true, // Ensures all fonts are loaded before rendering
|
tagged: true, // Adds accessibility tags to the PDF
|
||||||
printBackground: true, // Includes background colors and images
|
waitForFonts: true, // Ensures all fonts are loaded before rendering
|
||||||
margin: {
|
printBackground: true, // Includes background colors and images
|
||||||
bottom: 0,
|
margin: {
|
||||||
top: 0,
|
bottom: 0,
|
||||||
right: 0,
|
top: 0,
|
||||||
left: 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
|
// Step 7: Upload the generated PDF to storage
|
||||||
const result = await uploadFile({
|
const result = await uploadFile({
|
||||||
@@ -270,11 +364,21 @@ async function doPrintResumeAsPDF(
|
|||||||
data: new Uint8Array(pdfBuffer),
|
data: new Uint8Array(pdfBuffer),
|
||||||
contentType: "application/pdf",
|
contentType: "application/pdf",
|
||||||
type: "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;
|
return result.url;
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
if (page) await page.close().catch(() => null);
|
if (page) await page.close().catch(() => null);
|
||||||
if (context) await context.close().catch(() => null);
|
if (context) await context.close().catch(() => null);
|
||||||
@@ -295,7 +399,15 @@ async function doGetResumeScreenshot(
|
|||||||
const storageService = getStorageService();
|
const storageService = getStorageService();
|
||||||
const screenshotPrefix = `uploads/${userId}/screenshots/${id}`;
|
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 now = Date.now();
|
||||||
const resumeUpdatedAt = updatedAt.getTime();
|
const resumeUpdatedAt = updatedAt.getTime();
|
||||||
|
|
||||||
@@ -324,7 +436,13 @@ async function doGetResumeScreenshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resume was updated after the screenshot - delete old ones and regenerate
|
// 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;
|
let page: Page | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const browser = await getBrowser();
|
const browser = await getBrowser().catch((error) =>
|
||||||
context = await browser.createBrowserContext();
|
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
|
||||||
await page.goto(url, { waitUntil: "networkidle0" });
|
.setViewport(pageDimensionsAsPixels.a4)
|
||||||
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
|
.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({
|
const result = await uploadFile({
|
||||||
userId,
|
userId,
|
||||||
@@ -359,11 +531,21 @@ async function doGetResumeScreenshot(
|
|||||||
data: new Uint8Array(screenshotBuffer),
|
data: new Uint8Array(screenshotBuffer),
|
||||||
contentType: "image/webp",
|
contentType: "image/webp",
|
||||||
type: "screenshot",
|
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;
|
return result.url;
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
if (page) await page.close().catch(() => null);
|
if (page) await page.close().catch(() => null);
|
||||||
if (context) await context.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 { defaultResumeData } from "@/schema/resume/data";
|
||||||
import { env } from "@/utils/env";
|
import { env } from "@/utils/env";
|
||||||
import { hashPassword, verifyPassword } from "@/utils/password";
|
import { hashPassword, verifyPassword } from "@/utils/password";
|
||||||
|
import { verifyPrinterToken } from "@/utils/printer-token";
|
||||||
import { applyResumePatches, ResumePatchError } from "@/utils/resume/patch";
|
import { applyResumePatches, ResumePatchError } from "@/utils/resume/patch";
|
||||||
import { generateId } from "@/utils/string";
|
import { generateId } from "@/utils/string";
|
||||||
|
import { sanitizeResumePictureUrl } from "@/utils/url-security";
|
||||||
|
|
||||||
import { grantResumeAccess, hasResumeAccess } from "../helpers/resume-access";
|
import { grantResumeAccess, hasResumeAccess } from "../helpers/resume-access";
|
||||||
import { getStorageService } from "./storage";
|
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 = {
|
export const resumeService = {
|
||||||
tags,
|
tags,
|
||||||
statistics,
|
statistics,
|
||||||
@@ -176,7 +222,7 @@ export const resumeService = {
|
|||||||
return resume;
|
return resume;
|
||||||
},
|
},
|
||||||
|
|
||||||
getByIdForPrinter: async (input: { id: string; userId?: string }) => {
|
getByIdForPrinter: async (input: { id: string; currentUserId?: string; printerToken?: string }) => {
|
||||||
const [resume] = await db
|
const [resume] = await db
|
||||||
.select({
|
.select({
|
||||||
id: schema.resume.id,
|
id: schema.resume.id,
|
||||||
@@ -189,27 +235,15 @@ export const resumeService = {
|
|||||||
updatedAt: schema.resume.updatedAt,
|
updatedAt: schema.resume.updatedAt,
|
||||||
})
|
})
|
||||||
.from(schema.resume)
|
.from(schema.resume)
|
||||||
.where(
|
.where(eq(schema.resume.id, input.id));
|
||||||
input.userId
|
|
||||||
? and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId))
|
|
||||||
: eq(schema.resume.id, input.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||||
|
|
||||||
try {
|
const isOwner = !!input.currentUserId && resume.userId === input.currentUserId;
|
||||||
if (!resume.data.picture.url) throw new Error("Picture is not available");
|
|
||||||
|
|
||||||
// Convert picture URL to base64 data, so there's no fetching required on the client.
|
const hasValidToken = hasValidPrinterToken(input.printerToken, input.id);
|
||||||
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"));
|
|
||||||
|
|
||||||
resume.data.picture.url = `data:image/jpeg;base64,${base64}`;
|
if (!isOwner && !hasValidToken) throw new ORPCError("NOT_FOUND");
|
||||||
} catch {
|
|
||||||
// Ignore errors, as the picture is not always available
|
|
||||||
}
|
|
||||||
|
|
||||||
return resume;
|
return resume;
|
||||||
},
|
},
|
||||||
@@ -243,32 +277,12 @@ export const resumeService = {
|
|||||||
|
|
||||||
if (!resume.hasPassword) {
|
if (!resume.hasPassword) {
|
||||||
await resumeService.statistics.increment({ id: resume.id, views: true });
|
await resumeService.statistics.increment({ id: resume.id, views: true });
|
||||||
|
return toSharedResumeResponse(resume, false);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasResumeAccess(resume.id, resume.passwordHash)) {
|
if (hasResumeAccess(resume.id, resume.passwordHash)) {
|
||||||
await resumeService.statistics.increment({ id: resume.id, views: true });
|
await resumeService.statistics.increment({ id: resume.id, views: true });
|
||||||
|
return toSharedResumeResponse(resume, 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ORPCError("NEED_PASSWORD", {
|
throw new ORPCError("NEED_PASSWORD", {
|
||||||
@@ -288,6 +302,7 @@ export const resumeService = {
|
|||||||
const id = generateId();
|
const id = generateId();
|
||||||
|
|
||||||
input.data = input.data ?? defaultResumeData;
|
input.data = input.data ?? defaultResumeData;
|
||||||
|
input.data = sanitizeResumeDataPictureUrl(input.data);
|
||||||
input.data.metadata.page.locale = input.locale;
|
input.data.metadata.page.locale = input.locale;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -333,7 +348,7 @@ export const resumeService = {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
tags: input.tags,
|
tags: input.tags,
|
||||||
data: input.data,
|
data: input.data ? sanitizeResumeDataPictureUrl(input.data) : undefined,
|
||||||
isPublic: input.isPublic,
|
isPublic: input.isPublic,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -383,6 +398,7 @@ export const resumeService = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
patchedData = applyResumePatches(existing.data, input.operations);
|
patchedData = applyResumePatches(existing.data, input.operations);
|
||||||
|
patchedData = sanitizeResumeDataPictureUrl(patchedData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ResumePatchError) {
|
if (error instanceof ResumePatchError) {
|
||||||
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
|
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 passwordHash = resume.password as string;
|
||||||
const isValid = await verifyPassword(input.password, passwordHash);
|
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);
|
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 DotwellKnownOpenidConfigurationRouteImport } from "./routes/[.]well-known/openid-configuration";
|
||||||
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from "./routes/[.]well-known/oauth-protected-resource";
|
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 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 UsernameSlugRouteImport } from "./routes/$username/$slug";
|
||||||
import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route";
|
import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route";
|
||||||
import { Route as DashboardResumesIndexRouteImport } from "./routes/dashboard/resumes/index";
|
import { Route as DashboardResumesIndexRouteImport } from "./routes/dashboard/resumes/index";
|
||||||
@@ -157,6 +158,11 @@ const DotwellKnownOauthAuthorizationServerRoute =
|
|||||||
path: "/.well-known/oauth-authorization-server",
|
path: "/.well-known/oauth-authorization-server",
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any);
|
} as any);
|
||||||
|
const DotwellKnownSplatRoute = DotwellKnownSplatRouteImport.update({
|
||||||
|
id: "/.well-known/$",
|
||||||
|
path: "/.well-known/$",
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any);
|
||||||
const UsernameSlugRoute = UsernameSlugRouteImport.update({
|
const UsernameSlugRoute = UsernameSlugRouteImport.update({
|
||||||
id: "/$username/$slug",
|
id: "/$username/$slug",
|
||||||
path: "/$username/$slug",
|
path: "/$username/$slug",
|
||||||
@@ -269,6 +275,7 @@ export interface FileRoutesByFullPath {
|
|||||||
"/schema.json": typeof SchemaDotjsonRoute;
|
"/schema.json": typeof SchemaDotjsonRoute;
|
||||||
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
|
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
|
||||||
"/$username/$slug": typeof UsernameSlugRoute;
|
"/$username/$slug": typeof UsernameSlugRoute;
|
||||||
|
"/.well-known/$": typeof DotwellKnownSplatRoute;
|
||||||
"/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
"/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
||||||
"/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
"/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
||||||
"/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute;
|
"/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute;
|
||||||
@@ -306,6 +313,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
"/schema.json": typeof SchemaDotjsonRoute;
|
"/schema.json": typeof SchemaDotjsonRoute;
|
||||||
"/$username/$slug": typeof UsernameSlugRoute;
|
"/$username/$slug": typeof UsernameSlugRoute;
|
||||||
|
"/.well-known/$": typeof DotwellKnownSplatRoute;
|
||||||
"/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
"/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
||||||
"/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
"/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
||||||
"/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute;
|
"/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute;
|
||||||
@@ -349,6 +357,7 @@ export interface FileRoutesById {
|
|||||||
"/schema.json": typeof SchemaDotjsonRoute;
|
"/schema.json": typeof SchemaDotjsonRoute;
|
||||||
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
|
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
|
||||||
"/$username/$slug": typeof UsernameSlugRoute;
|
"/$username/$slug": typeof UsernameSlugRoute;
|
||||||
|
"/.well-known/$": typeof DotwellKnownSplatRoute;
|
||||||
"/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
"/.well-known/oauth-authorization-server": typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
||||||
"/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
"/.well-known/oauth-protected-resource": typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
||||||
"/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute;
|
"/.well-known/openid-configuration": typeof DotwellKnownOpenidConfigurationRoute;
|
||||||
@@ -393,6 +402,7 @@ export interface FileRouteTypes {
|
|||||||
| "/schema.json"
|
| "/schema.json"
|
||||||
| "/builder/$resumeId"
|
| "/builder/$resumeId"
|
||||||
| "/$username/$slug"
|
| "/$username/$slug"
|
||||||
|
| "/.well-known/$"
|
||||||
| "/.well-known/oauth-authorization-server"
|
| "/.well-known/oauth-authorization-server"
|
||||||
| "/.well-known/oauth-protected-resource"
|
| "/.well-known/oauth-protected-resource"
|
||||||
| "/.well-known/openid-configuration"
|
| "/.well-known/openid-configuration"
|
||||||
@@ -430,6 +440,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| "/schema.json"
|
| "/schema.json"
|
||||||
| "/$username/$slug"
|
| "/$username/$slug"
|
||||||
|
| "/.well-known/$"
|
||||||
| "/.well-known/oauth-authorization-server"
|
| "/.well-known/oauth-authorization-server"
|
||||||
| "/.well-known/oauth-protected-resource"
|
| "/.well-known/oauth-protected-resource"
|
||||||
| "/.well-known/openid-configuration"
|
| "/.well-known/openid-configuration"
|
||||||
@@ -472,6 +483,7 @@ export interface FileRouteTypes {
|
|||||||
| "/schema.json"
|
| "/schema.json"
|
||||||
| "/builder/$resumeId"
|
| "/builder/$resumeId"
|
||||||
| "/$username/$slug"
|
| "/$username/$slug"
|
||||||
|
| "/.well-known/$"
|
||||||
| "/.well-known/oauth-authorization-server"
|
| "/.well-known/oauth-authorization-server"
|
||||||
| "/.well-known/oauth-protected-resource"
|
| "/.well-known/oauth-protected-resource"
|
||||||
| "/.well-known/openid-configuration"
|
| "/.well-known/openid-configuration"
|
||||||
@@ -515,6 +527,7 @@ export interface RootRouteChildren {
|
|||||||
SchemaDotjsonRoute: typeof SchemaDotjsonRoute;
|
SchemaDotjsonRoute: typeof SchemaDotjsonRoute;
|
||||||
BuilderResumeIdRouteRoute: typeof BuilderResumeIdRouteRouteWithChildren;
|
BuilderResumeIdRouteRoute: typeof BuilderResumeIdRouteRouteWithChildren;
|
||||||
UsernameSlugRoute: typeof UsernameSlugRoute;
|
UsernameSlugRoute: typeof UsernameSlugRoute;
|
||||||
|
DotwellKnownSplatRoute: typeof DotwellKnownSplatRoute;
|
||||||
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren;
|
||||||
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRouteWithChildren;
|
||||||
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute;
|
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute;
|
||||||
@@ -677,6 +690,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport;
|
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport;
|
||||||
parentRoute: typeof rootRouteImport;
|
parentRoute: typeof rootRouteImport;
|
||||||
};
|
};
|
||||||
|
"/.well-known/$": {
|
||||||
|
id: "/.well-known/$";
|
||||||
|
path: "/.well-known/$";
|
||||||
|
fullPath: "/.well-known/$";
|
||||||
|
preLoaderRoute: typeof DotwellKnownSplatRouteImport;
|
||||||
|
parentRoute: typeof rootRouteImport;
|
||||||
|
};
|
||||||
"/$username/$slug": {
|
"/$username/$slug": {
|
||||||
id: "/$username/$slug";
|
id: "/$username/$slug";
|
||||||
path: "/$username/$slug";
|
path: "/$username/$slug";
|
||||||
@@ -932,6 +952,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
SchemaDotjsonRoute: SchemaDotjsonRoute,
|
SchemaDotjsonRoute: SchemaDotjsonRoute,
|
||||||
BuilderResumeIdRouteRoute: BuilderResumeIdRouteRouteWithChildren,
|
BuilderResumeIdRouteRoute: BuilderResumeIdRouteRouteWithChildren,
|
||||||
UsernameSlugRoute: UsernameSlugRoute,
|
UsernameSlugRoute: UsernameSlugRoute,
|
||||||
|
DotwellKnownSplatRoute: DotwellKnownSplatRoute,
|
||||||
DotwellKnownOauthAuthorizationServerRoute:
|
DotwellKnownOauthAuthorizationServerRoute:
|
||||||
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
||||||
DotwellKnownOauthProtectedResourceRoute:
|
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 { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { auth } from "@/integrations/auth/config";
|
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 {
|
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||||
if (request.method !== "GET") return request;
|
if (request.method !== "GET") return request;
|
||||||
@@ -19,15 +53,7 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
|||||||
url.searchParams.set(key, sanitizeValue(value));
|
url.searchParams.set(key, sanitizeValue(value));
|
||||||
};
|
};
|
||||||
|
|
||||||
sanitizeParam("prompt");
|
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
|
||||||
sanitizeParam("redirect_uri");
|
|
||||||
sanitizeParam("client_id");
|
|
||||||
sanitizeParam("code_challenge");
|
|
||||||
sanitizeParam("code_challenge_method");
|
|
||||||
sanitizeParam("response_type");
|
|
||||||
sanitizeParam("scope");
|
|
||||||
sanitizeParam("state");
|
|
||||||
sanitizeParam("resource");
|
|
||||||
|
|
||||||
const redirectUri = url.searchParams.get("redirect_uri");
|
const redirectUri = url.searchParams.get("redirect_uri");
|
||||||
if (redirectUri && !URL.canParse(redirectUri)) {
|
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 }) {
|
async function handler({ request }: { request: Request }) {
|
||||||
|
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
|
||||||
|
if (registrationValidationError) return registrationValidationError;
|
||||||
|
|
||||||
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
|
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
|
||||||
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
|
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);
|
return auth.handler(finalRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { zodValidator } from "@tanstack/zod-adapter";
|
import { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -14,6 +15,12 @@ const searchSchema = z.object({
|
|||||||
token: z.string().catch(""),
|
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")({
|
export const Route = createFileRoute("/printer/$resumeId")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: zodValidator(searchSchema),
|
validateSearch: zodValidator(searchSchema),
|
||||||
@@ -21,22 +28,32 @@ export const Route = createFileRoute("/printer/$resumeId")({
|
|||||||
if (env.FLAG_DEBUG_PRINTER) return;
|
if (env.FLAG_DEBUG_PRINTER) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify the token and ensure it matches the resume ID
|
assertValidPrinterToken(search.token, params.resumeId);
|
||||||
const tokenResumeId = verifyPrinterToken(search.token);
|
|
||||||
if (tokenResumeId !== params.resumeId) throw new Error();
|
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid or missing token - throw error to be caught by error handler
|
|
||||||
throw redirect({ to: "/", search: {}, throw: true });
|
throw redirect({ to: "/", search: {}, throw: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
loader: async ({ params }) => {
|
loaderDeps: ({ search }) => ({ token: search.token }),
|
||||||
|
loader: async ({ params, deps }) => {
|
||||||
const client = getORPCClient();
|
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 };
|
return { resume };
|
||||||
},
|
},
|
||||||
head: ({ loaderData }) => ({
|
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
|
url: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.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
|
size: z
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ResumeData } from "./data";
|
|||||||
export const sampleResumeData: ResumeData = {
|
export const sampleResumeData: ResumeData = {
|
||||||
picture: {
|
picture: {
|
||||||
hidden: false,
|
hidden: false,
|
||||||
url: "https://i.imgur.com/o4Jpt1p.jpeg",
|
url: "",
|
||||||
size: 100,
|
size: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
|
|||||||
+37
-2
@@ -3,8 +3,43 @@ import { FastResponse } from "srvx";
|
|||||||
|
|
||||||
globalThis.Response = FastResponse;
|
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({
|
export default createServerEntry({
|
||||||
fetch(request) {
|
async fetch(request) {
|
||||||
return handler.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_AUTHORIZATION_URL: z.url({ protocol: /https?/ }).optional(),
|
||||||
OAUTH_TOKEN_URL: z.url({ protocol: /https?/ }).optional(),
|
OAUTH_TOKEN_URL: z.url({ protocol: /https?/ }).optional(),
|
||||||
OAUTH_USER_INFO_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
|
OAUTH_SCOPES: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.transform((value) => value.split(" "))
|
.transform((value) => value.split(" "))
|
||||||
.default(["openid", "profile", "email"]),
|
.default(["openid", "profile", "email"]),
|
||||||
|
|
||||||
|
// AI provider URL restrictions
|
||||||
|
AI_ALLOWED_BASE_URLS: z.string().optional(),
|
||||||
|
|
||||||
// Email (SMTP)
|
// Email (SMTP)
|
||||||
SMTP_HOST: z.string().min(1).optional(),
|
SMTP_HOST: z.string().min(1).optional(),
|
||||||
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
|
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: [
|
plugins: [
|
||||||
"eslint",
|
"eslint",
|
||||||
|
"import",
|
||||||
|
"jest",
|
||||||
|
"jsdoc",
|
||||||
|
"jsx-a11y",
|
||||||
|
"node",
|
||||||
|
"oxc",
|
||||||
|
"promise",
|
||||||
|
"react-perf",
|
||||||
|
"react",
|
||||||
"typescript",
|
"typescript",
|
||||||
"unicorn",
|
"unicorn",
|
||||||
"oxc",
|
|
||||||
"react",
|
|
||||||
"react-perf",
|
|
||||||
"import",
|
|
||||||
"jsdoc",
|
|
||||||
"node",
|
|
||||||
"promise",
|
|
||||||
"vitest",
|
"vitest",
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
Reference in New Issue
Block a user