Merge pull request #1499 from AnishSarkar22/feat/reverse-proxy

feat: Add single-origin reverse proxy deployment with runtime web config
This commit is contained in:
Rohan Verma 2026-06-16 14:03:27 -07:00 committed by GitHub
commit b6d25d3828
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 5599 additions and 5315 deletions

View file

@ -95,10 +95,12 @@ jobs:
run: pnpm build run: pnpm build
working-directory: surfsense_web working-directory: surfsense_web
env: env:
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${{ vars.NEXT_PUBLIC_FASTAPI_BACKEND_URL }} NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${{ vars.HOSTED_BACKEND_URL }}
SURFSENSE_BACKEND_INTERNAL_URL: ${{ vars.HOSTED_BACKEND_URL }}
NEXT_PUBLIC_ZERO_CACHE_URL: ${{ vars.NEXT_PUBLIC_ZERO_CACHE_URL }} NEXT_PUBLIC_ZERO_CACHE_URL: ${{ vars.NEXT_PUBLIC_ZERO_CACHE_URL }}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${{ vars.NEXT_PUBLIC_DEPLOYMENT_MODE }} NEXT_PUBLIC_DEPLOYMENT_MODE: ${{ vars.NEXT_PUBLIC_DEPLOYMENT_MODE }}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${{ vars.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE }} NEXT_PUBLIC_AUTH_TYPE: ${{ vars.NEXT_PUBLIC_AUTH_TYPE }}
NEXT_PUBLIC_ETL_SERVICE: ${{ vars.NEXT_PUBLIC_ETL_SERVICE }}
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
- name: Install desktop dependencies - name: Install desktop dependencies
@ -109,6 +111,7 @@ jobs:
run: pnpm build run: pnpm build
working-directory: surfsense_desktop working-directory: surfsense_desktop
env: env:
HOSTED_BACKEND_URL: ${{ vars.HOSTED_BACKEND_URL }}
HOSTED_FRONTEND_URL: ${{ vars.HOSTED_FRONTEND_URL }} HOSTED_FRONTEND_URL: ${{ vars.HOSTED_FRONTEND_URL }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}

View file

@ -199,11 +199,6 @@ jobs:
build-args: | build-args: |
${{ matrix.image == 'backend' && format('USE_CUDA={0}', matrix.use_cuda) || '' }} ${{ matrix.image == 'backend' && format('USE_CUDA={0}', matrix.use_cuda) || '' }}
${{ matrix.image == 'backend' && format('CUDA_EXTRA={0}', matrix.cuda_extra) || '' }} ${{ matrix.image == 'backend' && format('CUDA_EXTRA={0}', matrix.cuda_extra) || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_FASTAPI_BACKEND_URL=__NEXT_PUBLIC_FASTAPI_BACKEND_URL__' || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__' || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_ETL_SERVICE=__NEXT_PUBLIC_ETL_SERVICE__' || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_ZERO_CACHE_URL=__NEXT_PUBLIC_ZERO_CACHE_URL__' || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__' || '' }}
- name: Export digest - name: Export digest
run: | run: |

View file

@ -27,9 +27,10 @@ jobs:
PLAYWRIGHT_TEST_EMAIL: e2e-test@surfsense.net PLAYWRIGHT_TEST_EMAIL: e2e-test@surfsense.net
PLAYWRIGHT_TEST_PASSWORD: E2eTestPassword123! PLAYWRIGHT_TEST_PASSWORD: E2eTestPassword123!
# Frontend env: Playwright's webServer (surfsense_web/playwright.config.ts) # Frontend env: Playwright's webServer (surfsense_web/playwright.config.ts)
# spawns `pnpm build && pnpm start` in CI; these get baked into the build. # spawns `pnpm build && pnpm start` in CI.
NEXT_PUBLIC_FASTAPI_BACKEND_URL: http://localhost:8000 NEXT_PUBLIC_FASTAPI_BACKEND_URL: http://localhost:8000
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: LOCAL SURFSENSE_BACKEND_INTERNAL_URL: http://localhost:8000
AUTH_TYPE: LOCAL
# Shared secret for the test-only POST /__e2e__/auth/token endpoint. # Shared secret for the test-only POST /__e2e__/auth/token endpoint.
# Must match docker-compose.e2e.yml's backend env (x-backend-env). # Must match docker-compose.e2e.yml's backend env (x-backend-env).
E2E_MINT_SECRET: e2e-mint-secret-not-for-production E2E_MINT_SECRET: e2e-mint-secret-not-for-production

View file

@ -30,6 +30,9 @@ SECRET_KEY=replace_me_with_a_random_string
# Auth type: LOCAL (email/password) or GOOGLE (OAuth) # Auth type: LOCAL (email/password) or GOOGLE (OAuth)
AUTH_TYPE=LOCAL AUTH_TYPE=LOCAL
# Deployment mode: self-hosted enables local filesystem connectors; cloud hides them.
DEPLOYMENT_MODE=self-hosted
# Allow new user registrations (TRUE or FALSE) # Allow new user registrations (TRUE or FALSE)
# REGISTRATION_ENABLED=TRUE # REGISTRATION_ENABLED=TRUE
@ -43,51 +46,47 @@ ETL_SERVICE=DOCLING
EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Ports (change to avoid conflicts with other services on your machine) # How You Access SurfSense
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# One public URL. Browser traffic stays same-origin and Caddy routes internally.
# BACKEND_PORT=8929 SURFSENSE_PUBLIC_URL=http://localhost:3929
# FRONTEND_PORT=3929
# ZERO_CACHE_PORT=5929
# SEARXNG_PORT=8888
# FLOWER_PORT=5555
# ==============================================================================
# DEV COMPOSE ONLY (docker-compose.dev.yml)
# You only need them only if you are running `docker-compose.dev.yml`.
# ==============================================================================
# -- pgAdmin (database GUI) --
# PGADMIN_PORT=5050
# PGADMIN_DEFAULT_EMAIL=admin@surfsense.com
# PGADMIN_DEFAULT_PASSWORD=surfsense
# -- Redis exposed port (dev only; Redis is internal-only in prod) --
# REDIS_PORT=6379
# -- WhatsApp bridge exposed port (dev/hybrid only; prod keeps it Docker-internal) --
# WHATSAPP_BRIDGE_PORT=9929
# -- Frontend Build Args --
# In dev, the frontend is built from source and these are passed as build args.
# In prod, they are automatically derived from AUTH_TYPE, ETL_SERVICE, and the port settings above.
# NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL
# NEXT_PUBLIC_ETL_SERVICE=DOCLING
# NEXT_PUBLIC_DEPLOYMENT_MODE=self-hosted
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Custom Domain / Reverse Proxy # Public Ports
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# ONLY set these if you are serving SurfSense on a real domain via a reverse # Production Docker exposes only Caddy to your machine. Caddy then routes
# proxy (e.g. Caddy, Nginx, Cloudflare Tunnel). # frontend, backend, and zero-cache traffic internally.
# For standard localhost deployments, leave all of these commented out.
# they are automatically derived from the port settings above.
# #
# NEXT_FRONTEND_URL=https://app.yourdomain.com # Local default: LISTEN_HTTP_PORT=3929
# BACKEND_URL=https://api.yourdomain.com # Domain default: LISTEN_HTTP_PORT=80 and LISTEN_HTTPS_PORT=443
# NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.yourdomain.com LISTEN_HTTP_PORT=3929
# NEXT_PUBLIC_ZERO_CACHE_URL=https://zero.yourdomain.com LISTEN_HTTPS_PORT=443
# FASTAPI_BACKEND_INTERNAL_URL=http://backend:8000
# ------------------------------------------------------------------------------
# Custom Domain / HTTPS
# ------------------------------------------------------------------------------
# Leave SURFSENSE_SITE_ADDRESS as :80 for local HTTP.
# Set it to your domain to enable automatic HTTPS:
# SURFSENSE_SITE_ADDRESS=surf.example.com
# CERT_EMAIL=you@example.com
SURFSENSE_SITE_ADDRESS=:80
CERT_EMAIL=
# ------------------------------------------------------------------------------
# Advanced Reverse Proxy Settings
# ------------------------------------------------------------------------------
# Usually do not change these. They are for custom certificate setup, CDNs/load
# balancers, trusted proxy IPs, or changing upload limits.
#
# CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
# CERT_ACME_DNS=
# If a CDN/load balancer sits in front of Caddy, narrow this to that proxy's CIDRs.
# TRUSTED_PROXIES=0.0.0.0/0
# SURFSENSE_MAX_BODY_SIZE=5GB
#
# Browser API and Zero URLs are same-origin relative behind bundled Caddy.
# Next.js server-side calls use Docker DNS through SURFSENSE_BACKEND_INTERNAL_URL
# set internally by docker-compose.yml. Usually do not override it.
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Zero-cache (real-time sync) # Zero-cache (real-time sync)
@ -108,10 +107,9 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# Sync worker tuning. zero-cache defaults ZERO_NUM_SYNC_WORKERS to the number # Sync worker tuning. zero-cache defaults ZERO_NUM_SYNC_WORKERS to the number
# of CPU cores, which can exceed the connection pool limits on high-core machines. # of CPU cores, which can exceed the connection pool limits on high-core machines.
# Each sync worker needs at least 1 connection from both the UPSTREAM and CVR # Each sync worker needs at least 1 connection from both the UPSTREAM and CVR pools.
# pools, so these constraints must hold: # Keep ZERO_UPSTREAM_MAX_CONNS and ZERO_CVR_MAX_CONNS greater than or equal to
# ZERO_UPSTREAM_MAX_CONNS >= ZERO_NUM_SYNC_WORKERS # ZERO_NUM_SYNC_WORKERS.
# ZERO_CVR_MAX_CONNS >= ZERO_NUM_SYNC_WORKERS
# Default of 4 workers is sufficient for self-hosted / personal use. # Default of 4 workers is sufficient for self-hosted / personal use.
# ZERO_NUM_SYNC_WORKERS=4 # ZERO_NUM_SYNC_WORKERS=4
# ZERO_UPSTREAM_MAX_CONNS=20 # ZERO_UPSTREAM_MAX_CONNS=20
@ -125,16 +123,16 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# ZERO_QUERY_URL: where zero-cache forwards query requests for resolution. # ZERO_QUERY_URL: where zero-cache forwards query requests for resolution.
# ZERO_MUTATE_URL: required by zero-cache when auth tokens are used, even though # ZERO_MUTATE_URL: required by zero-cache when auth tokens are used, even though
# SurfSense does not use Zero mutators. Setting both URLs tells zero-cache to # SurfSense does not use Zero mutators. Setting both URLs tells zero-cache to
# skip its own JWT verification and let the app endpoints handle auth instead. # skip its own JWT verification and let the app endpoints handle auth instead.
# The mutate endpoint is a no-op that returns an empty response. # The mutate endpoint is a no-op that returns an empty response.
# Default: Docker service networking (http://frontend:3000/api/zero/...). # Default: Docker service networking (http://frontend:3000/api/zero/...).
# Override when running the frontend outside Docker: # Override when running the frontend outside Docker:
# ZERO_QUERY_URL=http://host.docker.internal:3000/api/zero/query # ZERO_QUERY_URL=http://host.docker.internal:3000/api/zero/query
# ZERO_MUTATE_URL=http://host.docker.internal:3000/api/zero/mutate # ZERO_MUTATE_URL=http://host.docker.internal:3000/api/zero/mutate
# Override for custom domain: # Override for custom domain only when zero-cache is not in the bundled Docker network:
# ZERO_QUERY_URL=https://app.yourdomain.com/api/zero/query # ZERO_QUERY_URL=https://surf.example.com/api/zero/query
# ZERO_MUTATE_URL=https://app.yourdomain.com/api/zero/mutate # ZERO_MUTATE_URL=https://surf.example.com/api/zero/mutate
# ZERO_QUERY_URL=http://frontend:3000/api/zero/query # ZERO_QUERY_URL=http://frontend:3000/api/zero/query
# ZERO_MUTATE_URL=http://frontend:3000/api/zero/mutate # ZERO_MUTATE_URL=http://frontend:3000/api/zero/mutate
@ -222,73 +220,74 @@ STT_SERVICE=local/base
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# -- Google Connectors -- # -- Google Connectors --
# GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/connector/callback # GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/calendar/connector/callback
# GOOGLE_GMAIL_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/gmail/connector/callback # GOOGLE_GMAIL_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/gmail/connector/callback
# GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/drive/connector/callback # GOOGLE_DRIVE_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/drive/connector/callback
# -- Notion -- # -- Notion --
# NOTION_CLIENT_ID= # NOTION_CLIENT_ID=
# NOTION_CLIENT_SECRET= # NOTION_CLIENT_SECRET=
# NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback # NOTION_REDIRECT_URI=http://localhost:3929/api/v1/auth/notion/connector/callback
# -- Slack -- # -- Slack --
# SLACK_CLIENT_ID= # SLACK_CLIENT_ID=
# SLACK_CLIENT_SECRET= # SLACK_CLIENT_SECRET=
# SLACK_REDIRECT_URI=http://localhost:8000/api/v1/auth/slack/connector/callback # SLACK_REDIRECT_URI=http://localhost:3929/api/v1/auth/slack/connector/callback
# -- Discord -- # -- Discord --
# DISCORD_CLIENT_ID= # DISCORD_CLIENT_ID=
# DISCORD_CLIENT_SECRET= # DISCORD_CLIENT_SECRET=
# DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback # DISCORD_REDIRECT_URI=http://localhost:3929/api/v1/auth/discord/connector/callback
# DISCORD_BOT_TOKEN= # DISCORD_BOT_TOKEN=
# -- Atlassian (Jira & Confluence) -- # -- Atlassian (Jira & Confluence) --
# ATLASSIAN_CLIENT_ID= # ATLASSIAN_CLIENT_ID=
# ATLASSIAN_CLIENT_SECRET= # ATLASSIAN_CLIENT_SECRET=
# JIRA_REDIRECT_URI=http://localhost:8000/api/v1/auth/jira/connector/callback # JIRA_REDIRECT_URI=http://localhost:3929/api/v1/auth/jira/connector/callback
# CONFLUENCE_REDIRECT_URI=http://localhost:8000/api/v1/auth/confluence/connector/callback # CONFLUENCE_REDIRECT_URI=http://localhost:3929/api/v1/auth/confluence/connector/callback
# -- Linear -- # -- Linear --
# LINEAR_CLIENT_ID= # LINEAR_CLIENT_ID=
# LINEAR_CLIENT_SECRET= # LINEAR_CLIENT_SECRET=
# LINEAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/linear/connector/callback # LINEAR_REDIRECT_URI=http://localhost:3929/api/v1/auth/linear/connector/callback
# -- ClickUp -- # -- ClickUp --
# CLICKUP_CLIENT_ID= # CLICKUP_CLIENT_ID=
# CLICKUP_CLIENT_SECRET= # CLICKUP_CLIENT_SECRET=
# CLICKUP_REDIRECT_URI=http://localhost:8000/api/v1/auth/clickup/connector/callback # CLICKUP_REDIRECT_URI=http://localhost:3929/api/v1/auth/clickup/connector/callback
# -- Airtable -- # -- Airtable --
# AIRTABLE_CLIENT_ID= # AIRTABLE_CLIENT_ID=
# AIRTABLE_CLIENT_SECRET= # AIRTABLE_CLIENT_SECRET=
# AIRTABLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/airtable/connector/callback # AIRTABLE_REDIRECT_URI=http://localhost:3929/api/v1/auth/airtable/connector/callback
# -- Microsoft OAuth (Teams & OneDrive) -- # -- Microsoft OAuth (Teams & OneDrive) --
# MICROSOFT_CLIENT_ID= # MICROSOFT_CLIENT_ID=
# MICROSOFT_CLIENT_SECRET= # MICROSOFT_CLIENT_SECRET=
# TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback # TEAMS_REDIRECT_URI=http://localhost:3929/api/v1/auth/teams/connector/callback
# ONEDRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/onedrive/connector/callback # ONEDRIVE_REDIRECT_URI=http://localhost:3929/api/v1/auth/onedrive/connector/callback
# -- Dropbox -- # -- Dropbox --
# DROPBOX_APP_KEY= # DROPBOX_APP_KEY=
# DROPBOX_APP_SECRET= # DROPBOX_APP_SECRET=
# DROPBOX_REDIRECT_URI=http://localhost:8000/api/v1/auth/dropbox/connector/callback # DROPBOX_REDIRECT_URI=http://localhost:3929/api/v1/auth/dropbox/connector/callback
# -- Composio -- # -- Composio --
# COMPOSIO_API_KEY= # COMPOSIO_API_KEY=
# COMPOSIO_ENABLED=TRUE # COMPOSIO_ENABLED=TRUE
# COMPOSIO_REDIRECT_URI=http://localhost:8000/api/v1/auth/composio/connector/callback # COMPOSIO_REDIRECT_URI=http://localhost:3929/api/v1/auth/composio/connector/callback
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Messaging Channels (optional) # Messaging Channels (optional)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Configure only the external chat channels you want to use. # Configure only the external chat channels you want to use.
# GATEWAY_ENABLED=TRUE
# -- Telegram -- # -- Telegram --
# TELEGRAM_SHARED_BOT_TOKEN= # TELEGRAM_SHARED_BOT_TOKEN=
# TELEGRAM_SHARED_BOT_USERNAME= # TELEGRAM_SHARED_BOT_USERNAME=
# TELEGRAM_WEBHOOK_SECRET= # TELEGRAM_WEBHOOK_SECRET=
# GATEWAY_BASE_URL=http://localhost:8929 # GATEWAY_BASE_URL=http://localhost:3929
# GATEWAY_TELEGRAM_INTAKE_MODE=webhook # GATEWAY_TELEGRAM_INTAKE_MODE=webhook
# -- WhatsApp -- # -- WhatsApp --
@ -307,20 +306,20 @@ STT_SERVICE=local/base
# #
# GATEWAY_SLACK_ENABLED=FALSE # GATEWAY_SLACK_ENABLED=FALSE
# GATEWAY_SLACK_SIGNING_SECRET= # GATEWAY_SLACK_SIGNING_SECRET=
# GATEWAY_SLACK_REDIRECT_URI=http://localhost:8929/api/v1/gateway/slack/callback # GATEWAY_SLACK_REDIRECT_URI=http://localhost:3929/api/v1/gateway/slack/callback
# -- Discord -- # -- Discord --
# Uses DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, and DISCORD_BOT_TOKEN from the # Uses DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, and DISCORD_BOT_TOKEN from the
# Discord connector section. # Discord connector section.
# #
# GATEWAY_DISCORD_ENABLED=FALSE # GATEWAY_DISCORD_ENABLED=FALSE
# GATEWAY_DISCORD_REDIRECT_URI=http://localhost:8929/api/v1/gateway/discord/callback # GATEWAY_DISCORD_REDIRECT_URI=http://localhost:3929/api/v1/gateway/discord/callback
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# SearXNG (bundled web search, works out of the box with no config needed) # SearXNG (bundled web search, works out of the box with no config needed)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# SearXNG provides web search to all search spaces automatically. # SearXNG provides web search to all search spaces automatically.
# To access the SearXNG UI directly: http://localhost:8888 # To access the SearXNG UI directly in dev/deps-only compose: http://localhost:8888
# To disable the service entirely: docker compose up --scale searxng=0 # To disable the service entirely: docker compose up --scale searxng=0
# To point at your own SearXNG instance instead of the bundled one: # To point at your own SearXNG instance instead of the bundled one:
# SEARXNG_DEFAULT_HOST=http://your-searxng:8080 # SEARXNG_DEFAULT_HOST=http://your-searxng:8080
@ -457,3 +456,36 @@ NOLOGIN_MODE_ENABLED=FALSE
# RESIDENTIAL_PROXY_HOSTNAME= # RESIDENTIAL_PROXY_HOSTNAME=
# RESIDENTIAL_PROXY_LOCATION= # RESIDENTIAL_PROXY_LOCATION=
# RESIDENTIAL_PROXY_TYPE=1 # RESIDENTIAL_PROXY_TYPE=1
# ==============================================================================
# DEV / DEPS-ONLY COMPOSE OVERRIDES
# These are only needed for docker-compose.dev.yml or docker-compose.deps-only.yml.
# Production Docker exposes Caddy only; raw app ports below do not affect
# docker-compose.yml.
# ==============================================================================
# -- pgAdmin (database GUI, dev/deps-only only) --
# PGADMIN_PORT=5050
# PGADMIN_DEFAULT_EMAIL=admin@surfsense.com
# PGADMIN_DEFAULT_PASSWORD=surfsense
# -- Redis exposed port (dev/deps-only only; Redis is internal-only in prod) --
# REDIS_PORT=6379
# -- SearXNG exposed port (dev/deps-only only; internal-only in prod) --
# SEARXNG_PORT=8888
# -- WhatsApp bridge exposed port (dev/hybrid only; prod keeps it Docker-internal) --
# WHATSAPP_BRIDGE_PORT=9929
# -- Raw app ports (dev/deps-only only; prod exposes Caddy instead) --
# BACKEND_PORT=8000
# FRONTEND_PORT=3000
# ZERO_CACHE_PORT=4848
# -- Frontend runtime flags (prod and dev compose) --
# The frontend reads these at request time in Docker; no NEXT_PUBLIC_* rebuild
# or startup substitution is required.
# AUTH_TYPE=LOCAL
# ETL_SERVICE=DOCLING
# DEPLOYMENT_MODE=self-hosted

View file

@ -253,16 +253,15 @@ services:
frontend: frontend:
build: build:
context: ../surfsense_web context: ../surfsense_web
args:
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:8000}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_ETL_SERVICE: ${NEXT_PUBLIC_ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_ZERO_CACHE_URL: ${NEXT_PUBLIC_ZERO_CACHE_URL:-http://localhost:${ZERO_CACHE_PORT:-4848}}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${NEXT_PUBLIC_DEPLOYMENT_MODE:-self-hosted}
ports: ports:
- "${FRONTEND_PORT:-3000}:3000" - "${FRONTEND_PORT:-3000}:3000"
env_file: env_file:
- ../surfsense_web/.env - ../surfsense_web/.env
environment:
AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
SURFSENSE_BACKEND_INTERNAL_URL: http://backend:8000
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy

View file

@ -0,0 +1,54 @@
# =============================================================================
# SurfSense — Optional Caddy reverse-proxy overlay
# =============================================================================
# Usage (from docker/):
# PROXY_HTTP_PORT=8080 SURFSENSE_PUBLIC_URL=http://localhost:8080 \
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
#
# This overlay is for validation and custom deployments. The production
# docker-compose.yml includes Caddy by default.
# =============================================================================
services:
backend:
ports:
- "${BACKEND_PORT:-8929}:8000"
zero-cache:
ports:
- "${ZERO_CACHE_PORT:-5929}:4848"
frontend:
ports:
- "${FRONTEND_PORT:-3929}:3000"
proxy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "${PROXY_HTTP_PORT:-8080}:80"
- "${PROXY_HTTPS_PORT:-8443}:443"
volumes:
- ./proxy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
SURFSENSE_SITE_ADDRESS: ${SURFSENSE_SITE_ADDRESS:-:80}
CERT_EMAIL: ${CERT_EMAIL:-}
CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
CERT_ACME_DNS: ${CERT_ACME_DNS:-}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0}
SURFSENSE_MAX_BODY_SIZE: ${SURFSENSE_MAX_BODY_SIZE:-5GB}
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
zero-cache:
condition: service_healthy
volumes:
caddy_data:
name: surfsense-caddy-data
caddy_config:
name: surfsense-caddy-config

View file

@ -94,10 +94,39 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# Single public entry point for the Docker stack. Comment this service out
# only if you front SurfSense with your own reverse proxy.
proxy:
image: caddy:2-alpine
# For DNS-01/wildcard certificates, replace image with:
# build: ./proxy
restart: unless-stopped
ports:
- "${LISTEN_HTTP_PORT:-3929}:80"
- "${LISTEN_HTTPS_PORT:-443}:443"
volumes:
- ./proxy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
SURFSENSE_SITE_ADDRESS: ${SURFSENSE_SITE_ADDRESS:-:80}
CERT_EMAIL: ${CERT_EMAIL:-}
CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
CERT_ACME_DNS: ${CERT_ACME_DNS:-}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0}
SURFSENSE_MAX_BODY_SIZE: ${SURFSENSE_MAX_BODY_SIZE:-5GB}
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
zero-cache:
condition: service_healthy
backend: backend:
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}} image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}}
ports: expose:
- "${BACKEND_PORT:-8929}:8000" - "8000"
volumes: volumes:
- shared_temp:/shared_tmp - shared_temp:/shared_tmp
env_file: env_file:
@ -113,7 +142,8 @@ services:
PYTHONPATH: /app PYTHONPATH: /app
UVICORN_LOOP: asyncio UVICORN_LOOP: asyncio
UNSTRUCTURED_HAS_PATCHED_LOOP: "1" UNSTRUCTURED_HAS_PATCHED_LOOP: "1"
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}} NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-${SURFSENSE_PUBLIC_URL:-http://localhost:${LISTEN_HTTP_PORT:-3929}}}
BACKEND_URL: ${BACKEND_URL:-${SURFSENSE_PUBLIC_URL:-http://localhost:${LISTEN_HTTP_PORT:-3929}}}
SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080} SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
WHATSAPP_BRIDGE_URL: ${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929} WHATSAPP_BRIDGE_URL: ${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
# Daytona Sandbox uncomment and set credentials to enable cloud code execution # Daytona Sandbox uncomment and set credentials to enable cloud code execution
@ -217,8 +247,8 @@ services:
zero-cache: zero-cache:
image: rocicorp/zero:1.4.0 image: rocicorp/zero:1.4.0
ports: expose:
- "${ZERO_CACHE_PORT:-5929}:4848" - "4848"
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
environment: environment:
@ -252,16 +282,13 @@ services:
frontend: frontend:
image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest} image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest}
ports: expose:
- "${FRONTEND_PORT:-3929}:3000" - "3000"
environment: environment:
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8929}} AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_ZERO_CACHE_URL: ${NEXT_PUBLIC_ZERO_CACHE_URL:-http://localhost:${ZERO_CACHE_PORT:-5929}} ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL} DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING} SURFSENSE_BACKEND_INTERNAL_URL: http://backend:8000
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
NEXT_PUBLIC_WHATSAPP_DISPLAY_PHONE_NUMBER: ${WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER:-}
FASTAPI_BACKEND_INTERNAL_URL: ${FASTAPI_BACKEND_INTERNAL_URL:-http://backend:8000}
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"
depends_on: depends_on:
@ -280,5 +307,9 @@ volumes:
name: surfsense-shared-temp name: surfsense-shared-temp
zero_cache_data: zero_cache_data:
name: surfsense-zero-cache name: surfsense-zero-cache
caddy_data:
name: surfsense-caddy-data
caddy_config:
name: surfsense-caddy-config
whatsapp_sessions: whatsapp_sessions:
name: surfsense-whatsapp-sessions name: surfsense-whatsapp-sessions

45
docker/proxy/Caddyfile Normal file
View file

@ -0,0 +1,45 @@
{
# Optional ACME/global settings. These are harmless in the default :80
# localhost mode and become active when SURFSENSE_SITE_ADDRESS is a domain.
{$CERT_EMAIL}
acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory}
{$CERT_ACME_DNS}
servers {
client_ip_headers X-Forwarded-For X-Real-IP
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
}
}
(surfsense_proxy) {
request_body {
max_size {$SURFSENSE_MAX_BODY_SIZE:5GB}
}
# Frontend-owned auth page (the post-login token handler). More specific than
# /auth/*, so Caddy's matcher-specificity sort routes it here, not to backend.
reverse_proxy /auth/callback* frontend:3000
# Backend auth routes (FastAPI Users + OAuth helpers).
reverse_proxy /auth/* backend:8000
# Backend user profile routes (FastAPI Users users router, mounted at /users).
reverse_proxy /users/* backend:8000
# Backend REST, streaming, connector OAuth, and messaging gateway endpoints.
# FastAPI already serves /api/v1, so the path is forwarded unchanged.
reverse_proxy /api/v1/* backend:8000 {
flush_interval -1
}
# Zero accepts a single path-component base URL (Zero >= 0.6).
# Preserve /zero so browser cacheURL can be ${SURFSENSE_PUBLIC_URL}/zero.
reverse_proxy /zero/* zero-cache:4848
# Next.js app and frontend-owned API routes:
# /api/zero/*, /api/search, /api/contact, etc.
reverse_proxy /* frontend:3000
}
{$SURFSENSE_SITE_ADDRESS::80} {
import surfsense_proxy
}

10
docker/proxy/Dockerfile Normal file
View file

@ -0,0 +1,10 @@
FROM caddy:2-builder-alpine AS builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/caddy-dns/digitalocean
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile /etc/caddy/Caddyfile

View file

@ -333,11 +333,13 @@ step "Downloading SurfSense files"
info "Installation directory: ${INSTALL_DIR}" info "Installation directory: ${INSTALL_DIR}"
mkdir -p "${INSTALL_DIR}/scripts" mkdir -p "${INSTALL_DIR}/scripts"
mkdir -p "${INSTALL_DIR}/searxng" mkdir -p "${INSTALL_DIR}/searxng"
mkdir -p "${INSTALL_DIR}/proxy"
FILES=( FILES=(
"docker/docker-compose.yml:docker-compose.yml" "docker/docker-compose.yml:docker-compose.yml"
"docker/docker-compose.gpu.yml:docker-compose.gpu.yml" "docker/docker-compose.gpu.yml:docker-compose.gpu.yml"
"docker/.env.example:.env.example" "docker/.env.example:.env.example"
"docker/proxy/Caddyfile:proxy/Caddyfile"
"docker/postgresql.conf:postgresql.conf" "docker/postgresql.conf:postgresql.conf"
"docker/scripts/migrate-database.sh:scripts/migrate-database.sh" "docker/scripts/migrate-database.sh:scripts/migrate-database.sh"
"docker/searxng/settings.yml:searxng/settings.yml" "docker/searxng/settings.yml:searxng/settings.yml"
@ -532,9 +534,12 @@ _variant_display=$(grep '^SURFSENSE_VARIANT=' "${INSTALL_DIR}/.env" 2>/dev/null
_variant_display="${_variant_display:-cpu}" _variant_display="${_variant_display:-cpu}"
step "SurfSense is now installed [${_version_display}]" step "SurfSense is now installed [${_version_display}]"
info " Frontend: http://localhost:3929" _public_url=$(grep '^SURFSENSE_PUBLIC_URL=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | head -1 || true)
info " Backend: http://localhost:8929" _public_url="${_public_url:-http://localhost:3929}"
info " API Docs: http://localhost:8929/docs"
info " SurfSense: ${_public_url}"
info " Backend: ${_public_url}/api/v1"
info " Zero sync: ${_public_url}/zero"
info "" info ""
info " Config: ${INSTALL_DIR}/.env" info " Config: ${INSTALL_DIR}/.env"
info " Variant: ${_variant_display}" info " Variant: ${_variant_display}"

View file

@ -15,12 +15,9 @@ CELERY_TASK_DEFAULT_QUEUE=surfsense
# Optional: TTL in seconds for connector indexing lock key # Optional: TTL in seconds for connector indexing lock key
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800 # CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
# Messaging Gateway (global) # Messaging Gateway: disabled by default; set TRUE to enable chat integrations.
# GATEWAY_ENABLED: master switch for ALL messaging gateway channels (Telegram, WhatsApp, # Supported messaging gateways: WhatsApp, Telegram, Discord, Slack
# Slack, Discord). When FALSE, no gateway background workers/supervisors start and all # GATEWAY_ENABLED=TRUE
# gateway HTTP routes (webhooks, OAuth callbacks, pairing) return 404. Set per-channel
# flags below to control individual platforms once the gateway is enabled.
GATEWAY_ENABLED=TRUE
# Telegram Gateway # Telegram Gateway
# TELEGRAM_WEBHOOK_SECRET must be 1-256 chars and contain only A-Z, a-z, 0-9, _ or - # TELEGRAM_WEBHOOK_SECRET must be 1-256 chars and contain only A-Z, a-z, 0-9, _ or -
@ -386,7 +383,9 @@ LANGSMITH_PROJECT=surfsense
# SURFSENSE_ENABLE_LLM_TOOL_SELECTOR=false # adds a per-turn LLM call # SURFSENSE_ENABLE_LLM_TOOL_SELECTOR=false # adds a per-turn LLM call
# Observability - OTel # Observability - OTel
# SURFSENSE_ENABLE_OTEL=false # Disabled by default. Uncomment to enable OpenTelemetry.
# SURFSENSE_ENABLE_OTEL=true
# OpenTelemetry - endpoint enables export; absent = no-op. # OpenTelemetry - endpoint enables export; absent = no-op.
# Production should point at an OTel Collector. For local docker-compose.dev.yml, # Production should point at an OTel Collector. For local docker-compose.dev.yml,
# use http://otel-lgtm:4317 instead. # use http://otel-lgtm:4317 instead.

View file

@ -535,14 +535,15 @@ class Config:
# Platform web search (SearXNG) # Platform web search (SearXNG)
SEARXNG_DEFAULT_HOST = os.getenv("SEARXNG_DEFAULT_HOST") SEARXNG_DEFAULT_HOST = os.getenv("SEARXNG_DEFAULT_HOST")
NEXT_FRONTEND_URL = os.getenv("NEXT_FRONTEND_URL") SURFSENSE_PUBLIC_URL = os.getenv("SURFSENSE_PUBLIC_URL")
NEXT_FRONTEND_URL = os.getenv("NEXT_FRONTEND_URL") or SURFSENSE_PUBLIC_URL
# Backend URL to override the http to https in the OAuth redirect URI # Backend URL to override the http to https in the OAuth redirect URI
BACKEND_URL = os.getenv("BACKEND_URL") BACKEND_URL = os.getenv("BACKEND_URL") or SURFSENSE_PUBLIC_URL
# Messaging gateway (Telegram v1) # Messaging gateway
# Global master switch: when FALSE, no gateway supervisors/workers start and all # Global master switch: when FALSE, no gateway supervisors/workers start and all
# gateway HTTP routes return 404, regardless of the per-channel flags below. # gated gateway HTTP routes return 404, regardless of the per-channel flags below.
GATEWAY_ENABLED = os.getenv("GATEWAY_ENABLED", "TRUE").upper() == "TRUE" GATEWAY_ENABLED = os.getenv("GATEWAY_ENABLED", "FALSE").upper() == "TRUE"
TELEGRAM_SHARED_BOT_TOKEN = os.getenv("TELEGRAM_SHARED_BOT_TOKEN") TELEGRAM_SHARED_BOT_TOKEN = os.getenv("TELEGRAM_SHARED_BOT_TOKEN")
TELEGRAM_SHARED_BOT_USERNAME = os.getenv("TELEGRAM_SHARED_BOT_USERNAME") TELEGRAM_SHARED_BOT_USERNAME = os.getenv("TELEGRAM_SHARED_BOT_USERNAME")
TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET") TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET")

View file

@ -8,7 +8,7 @@ from app.config import config
def require_gateway_enabled() -> None: def require_gateway_enabled() -> None:
"""FastAPI dependency that gates all gateway HTTP routes on the global flag. """FastAPI dependency that gates gateway operational routes on the global flag.
Returns 404 (rather than 503) when ``GATEWAY_ENABLED`` is FALSE so that Returns 404 (rather than 503) when ``GATEWAY_ENABLED`` is FALSE so that
disabling the gateway makes its webhook/OAuth/pairing surface indistinguishable disabling the gateway makes its webhook/OAuth/pairing surface indistinguishable

View file

@ -24,7 +24,10 @@ from .dropbox_add_connector_route import router as dropbox_add_connector_router
from .editor_routes import router as editor_router from .editor_routes import router as editor_router
from .export_routes import router as export_router from .export_routes import router as export_router
from .folders_routes import router as folders_router from .folders_routes import router as folders_router
from .gateway_webhook_routes import router as gateway_router from .gateway_webhook_routes import (
config_router as gateway_config_router,
router as gateway_router,
)
from .gateway_whatsapp_baileys_routes import router as gateway_whatsapp_baileys_router from .gateway_whatsapp_baileys_routes import router as gateway_whatsapp_baileys_router
from .gateway_whatsapp_webhook_routes import router as gateway_whatsapp_webhook_router from .gateway_whatsapp_webhook_routes import router as gateway_whatsapp_webhook_router
from .google_calendar_add_connector_route import ( from .google_calendar_add_connector_route import (
@ -74,6 +77,7 @@ router.include_router(export_router)
router.include_router(documents_router) router.include_router(documents_router)
router.include_router(folders_router) router.include_router(folders_router)
_gateway_enabled_dep = [Depends(require_gateway_enabled)] _gateway_enabled_dep = [Depends(require_gateway_enabled)]
router.include_router(gateway_config_router)
router.include_router(gateway_router, dependencies=_gateway_enabled_dep) router.include_router(gateway_router, dependencies=_gateway_enabled_dep)
router.include_router( router.include_router(
gateway_whatsapp_webhook_router, dependencies=_gateway_enabled_dep gateway_whatsapp_webhook_router, dependencies=_gateway_enabled_dep

View file

@ -56,6 +56,7 @@ from app.utils.oauth_security import OAuthStateManager, TokenEncryption
from app.utils.rbac import check_search_space_access from app.utils.rbac import check_search_space_access
router = APIRouter(prefix="/gateway", tags=["gateway"]) router = APIRouter(prefix="/gateway", tags=["gateway"])
config_router = APIRouter(prefix="/gateway", tags=["gateway"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SLACK_AUTHORIZATION_URL = "https://slack.com/oauth/v2/authorize" SLACK_AUTHORIZATION_URL = "https://slack.com/oauth/v2/authorize"
@ -967,11 +968,20 @@ async def list_platforms(
] ]
@router.get("/config") @config_router.get("/config")
async def get_gateway_config( async def get_gateway_config(
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
) -> dict[str, bool | str]: ) -> dict[str, bool | str]:
if not config.GATEWAY_ENABLED:
return {
"enabled": False,
"telegram_enabled": False,
"whatsapp_intake_mode": "disabled",
"slack_enabled": False,
"discord_enabled": False,
}
return { return {
"enabled": True,
"telegram_enabled": _telegram_gateway_enabled(), "telegram_enabled": _telegram_gateway_enabled(),
"whatsapp_intake_mode": config.GATEWAY_WHATSAPP_INTAKE_MODE, "whatsapp_intake_mode": config.GATEWAY_WHATSAPP_INTAKE_MODE,
"slack_enabled": _slack_gateway_enabled(), "slack_enabled": _slack_gateway_enabled(),

View file

@ -41,7 +41,6 @@ dependencies = [
"elasticsearch>=9.1.1", "elasticsearch>=9.1.1",
"faster-whisper>=1.1.0", "faster-whisper>=1.1.0",
"celery[redis]>=5.5.3", "celery[redis]>=5.5.3",
"flower>=2.0.1",
"redis>=5.2.1", "redis>=5.2.1",
"firecrawl-py>=4.9.0", "firecrawl-py>=4.9.0",
"boto3>=1.35.0", "boto3>=1.35.0",

9163
surfsense_backend/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -108,8 +108,11 @@ async function buildElectron() {
sourcemap: true, sourcemap: true,
minify: false, minify: false,
define: { define: {
'process.env.HOSTED_BACKEND_URL': JSON.stringify(
process.env.HOSTED_BACKEND_URL || desktopEnv.HOSTED_BACKEND_URL || ''
),
'process.env.HOSTED_FRONTEND_URL': JSON.stringify( 'process.env.HOSTED_FRONTEND_URL': JSON.stringify(
process.env.HOSTED_FRONTEND_URL || desktopEnv.HOSTED_FRONTEND_URL || 'https://surfsense.net' process.env.HOSTED_FRONTEND_URL || desktopEnv.HOSTED_FRONTEND_URL || 'https://surfsense.com'
), ),
'process.env.POSTHOG_KEY': JSON.stringify( 'process.env.POSTHOG_KEY': JSON.stringify(
process.env.POSTHOG_KEY || desktopEnv.POSTHOG_KEY || '' process.env.POSTHOG_KEY || desktopEnv.POSTHOG_KEY || ''

View file

@ -43,11 +43,13 @@ export async function startNextServer(): Promise<void> {
const standalonePath = getStandalonePath(); const standalonePath = getStandalonePath();
const serverScript = path.join(standalonePath, 'server.js'); const serverScript = path.join(standalonePath, 'server.js');
const backendInternalUrl = process.env.SURFSENSE_BACKEND_INTERNAL_URL || process.env.HOSTED_BACKEND_URL;
const child = utilityProcess.fork(serverScript, [], { const child = utilityProcess.fork(serverScript, [], {
cwd: standalonePath, cwd: standalonePath,
env: { env: {
...process.env, ...process.env,
...(backendInternalUrl ? { SURFSENSE_BACKEND_INTERNAL_URL: backendInternalUrl } : {}),
PORT: String(serverPort), PORT: String(serverPort),
// Loopback bind: avoids 0.0.0.0 leaking into request.url and redirect origins. // Loopback bind: avoids 0.0.0.0 leaking into request.url and redirect origins.
HOSTNAME: SERVER_HOST, HOSTNAME: SERVER_HOST,

View file

@ -1,18 +1,17 @@
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000 # Optional packaged-client override. Leave unset in Docker so browser requests
# use same-origin relative URLs behind Caddy.
# NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
# Server-only. Internal backend URL used by Next.js server code. # Server-only. Internal backend URL used by Next.js server code.
FASTAPI_BACKEND_INTERNAL_URL=https://your-internal-backend.example.com SURFSENSE_BACKEND_INTERNAL_URL=http://backend:8000
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE AUTH_TYPE=LOCAL
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING ETL_SERVICE=DOCLING
NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848 DEPLOYMENT_MODE=self-hosted
# Contact Form Vars (optional) # Contact Form Vars (optional)
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres
# Deployment mode (optional)
NEXT_PUBLIC_DEPLOYMENT_MODE="self-hosted" or "cloud"
# PostHog analytics (optional, leave empty to disable) # PostHog analytics (optional, leave empty to disable)
NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_KEY=

View file

@ -35,21 +35,6 @@ RUN apk add --no-cache git
# Enable pnpm # Enable pnpm
RUN corepack enable pnpm RUN corepack enable pnpm
# Build with placeholder values for NEXT_PUBLIC_* variables.
# These are replaced at container startup by docker-entrypoint.js
# with real values from the container's environment variables.
ARG NEXT_PUBLIC_FASTAPI_BACKEND_URL=__NEXT_PUBLIC_FASTAPI_BACKEND_URL__
ARG NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
ARG NEXT_PUBLIC_ETL_SERVICE=__NEXT_PUBLIC_ETL_SERVICE__
ARG NEXT_PUBLIC_ZERO_CACHE_URL=__NEXT_PUBLIC_ZERO_CACHE_URL__
ARG NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__
ENV NEXT_PUBLIC_FASTAPI_BACKEND_URL=$NEXT_PUBLIC_FASTAPI_BACKEND_URL
ENV NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=$NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE
ENV NEXT_PUBLIC_ETL_SERVICE=$NEXT_PUBLIC_ETL_SERVICE
ENV NEXT_PUBLIC_ZERO_CACHE_URL=$NEXT_PUBLIC_ZERO_CACHE_URL
ENV NEXT_PUBLIC_DEPLOYMENT_MODE=$NEXT_PUBLIC_DEPLOYMENT_MODE
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
@ -78,10 +63,6 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/app/ ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/app/ ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Entrypoint scripts for runtime env var substitution
COPY --chown=nextjs:nodejs docker-entrypoint.js ./docker-entrypoint.js
COPY --chown=nextjs:nodejs --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
@ -91,4 +72,4 @@ ENV PORT=3000
# server.js is created by next build from the standalone output # server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output # https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"] CMD ["node", "server.js"]

View file

@ -7,7 +7,7 @@ import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import type { AnonModel } from "@/contracts/types/anonymous-chat.types"; import type { AnonModel } from "@/contracts/types/anonymous-chat.types";
import { BACKEND_URL } from "@/lib/env-config"; import { SERVER_BACKEND_URL } from "@/lib/env-config";
interface PageProps { interface PageProps {
params: Promise<{ model_slug: string }>; params: Promise<{ model_slug: string }>;
@ -16,7 +16,7 @@ interface PageProps {
async function getModel(slug: string): Promise<AnonModel | null> { async function getModel(slug: string): Promise<AnonModel | null> {
try { try {
const res = await fetch( const res = await fetch(
`${BACKEND_URL}/api/v1/public/anon-chat/models/${encodeURIComponent(slug)}`, `${SERVER_BACKEND_URL}/api/v1/public/anon-chat/models/${encodeURIComponent(slug)}`,
{ next: { revalidate: 300 } } { next: { revalidate: 300 } }
); );
if (!res.ok) return null; if (!res.ok) return null;
@ -28,7 +28,7 @@ async function getModel(slug: string): Promise<AnonModel | null> {
async function getAllModels(): Promise<AnonModel[]> { async function getAllModels(): Promise<AnonModel[]> {
try { try {
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/models`, { const res = await fetch(`${SERVER_BACKEND_URL}/api/v1/public/anon-chat/models`, {
next: { revalidate: 300 }, next: { revalidate: 300 },
}); });
if (!res.ok) return []; if (!res.ok) return [];
@ -136,7 +136,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
export async function generateStaticParams() { export async function generateStaticParams() {
const models = await getAllModels(); const models = await getAllModels();
return models.filter((m) => m.seo_slug).map((m) => ({ model_slug: m.seo_slug! })); return models.flatMap((m) => (m.seo_slug ? [{ model_slug: m.seo_slug }] : []));
} }
export default async function FreeModelPage({ params }: PageProps) { export default async function FreeModelPage({ params }: PageProps) {

View file

@ -16,7 +16,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { AnonModel } from "@/contracts/types/anonymous-chat.types"; import type { AnonModel } from "@/contracts/types/anonymous-chat.types";
import { BACKEND_URL } from "@/lib/env-config"; import { SERVER_BACKEND_URL } from "@/lib/env-config";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Free AI Chat, No Login Required | SurfSense", title: "Free AI Chat, No Login Required | SurfSense",
@ -94,7 +94,7 @@ export const metadata: Metadata = {
async function getModels(): Promise<AnonModel[]> { async function getModels(): Promise<AnonModel[]> {
try { try {
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/models`, { const res = await fetch(`${SERVER_BACKEND_URL}/api/v1/public/anon-chat/models`, {
next: { revalidate: 300 }, next: { revalidate: 300 },
}); });
if (!res.ok) return []; if (!res.ok) return [];

View file

@ -3,7 +3,7 @@ import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events"; import { trackLoginAttempt } from "@/lib/posthog/events";
import { AmbientBackground } from "./AmbientBackground"; import { AmbientBackground } from "./AmbientBackground";
@ -51,7 +51,7 @@ export function GoogleLoginButton() {
// cross-origin fetch requests may not be sent on subsequent redirects. // cross-origin fetch requests may not be sent on subsequent redirects.
// The authorize-redirect endpoint does a server-side redirect to Google // The authorize-redirect endpoint does a server-side redirect to Google
// and sets the CSRF cookie properly for same-site context. // and sets the CSRF cookie properly for same-site context.
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; window.location.href = buildBackendUrl("/auth/google/authorize-redirect");
}; };
return ( return (
<div className="relative w-full overflow-hidden"> <div className="relative w-full overflow-hidden">

View file

@ -7,10 +7,10 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { ValidationError } from "@/lib/error"; import { ValidationError } from "@/lib/error";
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events"; import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
@ -26,7 +26,7 @@ export function LocalLoginForm() {
title: null, title: null,
message: null, message: null,
}); });
const authType = AUTH_TYPE; const { authType } = useRuntimeConfig();
const router = useRouter(); const router = useRouter();
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);

View file

@ -0,0 +1,5 @@
import { RuntimeConfig } from "@/components/providers/runtime-config.server";
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return <RuntimeConfig>{children}</RuntimeConfig>;
}

View file

@ -6,11 +6,10 @@ import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { setRedirectPath } from "@/lib/auth-utils"; import { setRedirectPath } from "@/lib/auth-utils";
import { AUTH_TYPE } from "@/lib/env-config";
import { AmbientBackground } from "./AmbientBackground"; import { AmbientBackground } from "./AmbientBackground";
import { GoogleLoginButton } from "./GoogleLoginButton"; import { GoogleLoginButton } from "./GoogleLoginButton";
import { LocalLoginForm } from "./LocalLoginForm"; import { LocalLoginForm } from "./LocalLoginForm";
@ -19,8 +18,7 @@ function LoginContent() {
const t = useTranslations("auth"); const t = useTranslations("auth");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const router = useRouter(); const router = useRouter();
const [authType, setAuthType] = useState<string | null>(null); const { authType } = useRuntimeConfig();
const [isLoading, setIsLoading] = useState(true);
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null); const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -96,19 +94,7 @@ function LoginContent() {
duration: 4000, duration: 4000,
}); });
} }
}, [router, searchParams, t, tCommon]);
// Get the auth type from centralized config
setAuthType(AUTH_TYPE);
setIsLoading(false);
}, [searchParams, t, tCommon]);
// Use global loading screen for auth type determination - spinner animation won't reset
useGlobalLoadingEffect(isLoading);
// Show nothing while loading - the GlobalLoadingProvider handles the loading UI
if (isLoading) {
return null;
}
if (authType === "GOOGLE") { if (authType === "GOOGLE") {
return <GoogleLoginButton />; return <GoogleLoginButton />;

View file

@ -0,0 +1,5 @@
import { RuntimeConfig } from "@/components/providers/runtime-config.server";
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
return <RuntimeConfig>{children}</RuntimeConfig>;
}

View file

@ -9,11 +9,11 @@ import { useEffect, useState } from "react";
import { type ExternalToast, toast } from "sonner"; import { type ExternalToast, toast } from "sonner";
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
import { AUTH_TYPE } from "@/lib/env-config";
import { AppError, ValidationError } from "@/lib/error"; import { AppError, ValidationError } from "@/lib/error";
import { import {
trackRegistrationAttempt, trackRegistrationAttempt,
@ -25,6 +25,7 @@ import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() { export default function RegisterPage() {
const t = useTranslations("auth"); const t = useTranslations("auth");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const { authType } = useRuntimeConfig();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@ -44,10 +45,10 @@ export default function RegisterPage() {
router.replace("/dashboard"); router.replace("/dashboard");
return; return;
} }
if (AUTH_TYPE !== "LOCAL") { if (authType !== "LOCAL") {
router.push("/login"); router.push("/login");
} }
}, [router]); }, [authType, router]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();

View file

@ -14,7 +14,11 @@ const HOP_BY_HOP_HEADERS = new Set([
]); ]);
function getBackendBaseUrl() { function getBackendBaseUrl() {
const base = process.env.FASTAPI_BACKEND_INTERNAL_URL || "http://localhost:8000"; const base =
process.env.SURFSENSE_BACKEND_INTERNAL_URL ||
// TODO: Remove FASTAPI_BACKEND_INTERNAL_URL after the post-Caddy env migration window.
process.env.FASTAPI_BACKEND_INTERNAL_URL ||
"http://backend:8000";
return base.endsWith("/") ? base.slice(0, -1) : base; return base.endsWith("/") ? base.slice(0, -1) : base;
} }

View file

@ -1,7 +1,7 @@
import { mustGetQuery } from "@rocicorp/zero"; import { mustGetQuery } from "@rocicorp/zero";
import { handleQueryRequest } from "@rocicorp/zero/server"; import { handleQueryRequest } from "@rocicorp/zero/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { BACKEND_URL } from "@/lib/env-config"; import { SERVER_BACKEND_URL } from "@/lib/env-config";
import type { Context } from "@/types/zero"; import type { Context } from "@/types/zero";
import { queries } from "@/zero/queries"; import { queries } from "@/zero/queries";
import { schema } from "@/zero/schema"; import { schema } from "@/zero/schema";
@ -11,11 +11,7 @@ import { schema } from "@/zero/schema";
// (e.g. http://backend:8000). The browser-facing NEXT_PUBLIC_FASTAPI_BACKEND_URL // (e.g. http://backend:8000). The browser-facing NEXT_PUBLIC_FASTAPI_BACKEND_URL
// (e.g. http://localhost:8929) does NOT resolve from inside the frontend // (e.g. http://localhost:8929) does NOT resolve from inside the frontend
// container and would make every authenticated Zero query fail with a 503. // container and would make every authenticated Zero query fail with a 503.
const backendURL = ( const backendURL = SERVER_BACKEND_URL.replace(/\/$/, "");
process.env.FASTAPI_BACKEND_INTERNAL_URL ||
process.env.BACKEND_URL ||
"http://localhost:8000"
).replace(/\/$/, "");
async function authenticateRequest( async function authenticateRequest(
request: Request request: Request

View file

@ -0,0 +1,74 @@
import type { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
function getBackendBaseUrl() {
const base =
process.env.SURFSENSE_BACKEND_INTERNAL_URL ||
// TODO: Remove FASTAPI_BACKEND_INTERNAL_URL after the post-Caddy env migration window.
process.env.FASTAPI_BACKEND_INTERNAL_URL ||
"http://backend:8000";
return base.endsWith("/") ? base.slice(0, -1) : base;
}
function toUpstreamHeaders(headers: Headers) {
const nextHeaders = new Headers(headers);
nextHeaders.delete("host");
nextHeaders.delete("content-length");
return nextHeaders;
}
function toClientHeaders(headers: Headers) {
const nextHeaders = new Headers(headers);
for (const header of HOP_BY_HOP_HEADERS) {
nextHeaders.delete(header);
}
return nextHeaders;
}
async function proxy(request: NextRequest, context: { params: Promise<{ path?: string[] }> }) {
const params = await context.params;
const path = params.path?.join("/") || "";
const upstreamUrl = new URL(`${getBackendBaseUrl()}/auth/${path}`);
upstreamUrl.search = request.nextUrl.search;
const hasBody = request.method !== "GET" && request.method !== "HEAD";
const response = await fetch(upstreamUrl, {
method: request.method,
headers: toUpstreamHeaders(request.headers),
body: hasBody ? request.body : undefined,
// `duplex: "half"` is required by the Fetch spec when streaming a
// ReadableStream as the request body. Avoids buffering uploads in heap.
// @ts-expect-error - `duplex` is not yet in lib.dom RequestInit types.
duplex: hasBody ? "half" : undefined,
redirect: "manual",
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: toClientHeaders(response.headers),
});
}
export {
proxy as GET,
proxy as POST,
proxy as PUT,
proxy as PATCH,
proxy as DELETE,
proxy as OPTIONS,
proxy as HEAD,
};

View file

@ -16,9 +16,12 @@ export async function GET(
}; };
const result = JSON.stringify(payload); const result = JSON.stringify(payload);
const redirectUrl = new URL(`/dashboard/${search_space_id}/new-chat`, request.url); const response = new NextResponse(null, {
status: 302,
const response = NextResponse.redirect(redirectUrl, { status: 302 }); headers: {
Location: `/dashboard/${search_space_id}/new-chat`,
},
});
response.cookies.set(OAUTH_RESULT_COOKIE, result, { response.cookies.set(OAUTH_RESULT_COOKIE, result, {
path: "/", path: "/",
maxAge: 60, maxAge: 60,

View file

@ -106,7 +106,7 @@ import {
extractUserTurnForNewChatApi, extractUserTurnForNewChatApi,
type NewChatUserImagePayload, type NewChatUserImagePayload,
} from "@/lib/chat/user-turn-api-parts"; } from "@/lib/chat/user-turn-api-parts";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { NotFoundError } from "@/lib/error"; import { NotFoundError } from "@/lib/error";
import { import {
trackChatBlocked, trackChatBlocked,
@ -919,10 +919,9 @@ export default function NewChatPage() {
if (threadId) { if (threadId) {
const token = getBearerToken(); const token = getBearerToken();
if (token) { if (token) {
const backendUrl = BACKEND_URL;
try { try {
const response = await fetch( const response = await fetch(
`${backendUrl}/api/v1/threads/${threadId}/cancel-active-turn`, buildBackendUrl(`/api/v1/threads/${threadId}/cancel-active-turn`),
{ {
method: "POST", method: "POST",
headers: { headers: {
@ -1110,7 +1109,6 @@ export default function NewChatPage() {
let streamBatcher: FrameBatchedUpdater | null = null; let streamBatcher: FrameBatchedUpdater | null = null;
try { try {
const backendUrl = BACKEND_URL;
const selection = await getAgentFilesystemSelection(searchSpaceId, { const selection = await getAgentFilesystemSelection(searchSpaceId, {
localFilesystemEnabled, localFilesystemEnabled,
}); });
@ -1147,7 +1145,7 @@ export default function NewChatPage() {
} }
const response = await fetchWithTurnCancellingRetry(() => const response = await fetchWithTurnCancellingRetry(() =>
fetch(`${backendUrl}/api/v1/new_chat`, { fetch(buildBackendUrl("/api/v1/new_chat"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -1642,12 +1640,11 @@ export default function NewChatPage() {
} }
try { try {
const backendUrl = BACKEND_URL;
const selection = await getAgentFilesystemSelection(searchSpaceId, { const selection = await getAgentFilesystemSelection(searchSpaceId, {
localFilesystemEnabled, localFilesystemEnabled,
}); });
const response = await fetchWithTurnCancellingRetry(() => const response = await fetchWithTurnCancellingRetry(() =>
fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, { fetch(buildBackendUrl(`/api/v1/threads/${resumeThreadId}/resume`), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View file

@ -1,10 +1,11 @@
"use client"; "use client";
import { RefreshCw, ShieldAlert } from "lucide-react"; import { AlertTriangle, RefreshCw, ShieldAlert } from "lucide-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
@ -19,7 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import type { SearchSpace } from "@/contracts/types/search-space.types"; import type { SearchSpace } from "@/contracts/types/search-space.types";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type GatewayConnection = { type GatewayConnection = {
@ -39,6 +40,7 @@ type GatewayConnection = {
}; };
type GatewayConfig = { type GatewayConfig = {
enabled: boolean;
telegram_enabled: boolean; telegram_enabled: boolean;
whatsapp_intake_mode: "disabled" | "cloud" | "baileys"; whatsapp_intake_mode: "disabled" | "cloud" | "baileys";
slack_enabled: boolean; slack_enabled: boolean;
@ -47,6 +49,14 @@ type GatewayConfig = {
type GatewayConfigState = GatewayConfig | null; type GatewayConfigState = GatewayConfig | null;
const DISABLED_GATEWAY_CONFIG: GatewayConfig = {
enabled: false,
telegram_enabled: false,
whatsapp_intake_mode: "disabled",
slack_enabled: false,
discord_enabled: false,
};
type Pairing = { type Pairing = {
binding_id: number; binding_id: number;
code: string; code: string;
@ -80,16 +90,26 @@ export function MessagingChannelsContent() {
const whatsappMode = gatewayConfig?.whatsapp_intake_mode ?? "disabled"; const whatsappMode = gatewayConfig?.whatsapp_intake_mode ?? "disabled";
const slackGatewayEnabled = gatewayConfig?.slack_enabled ?? false; const slackGatewayEnabled = gatewayConfig?.slack_enabled ?? false;
const discordGatewayEnabled = gatewayConfig?.discord_enabled ?? false; const discordGatewayEnabled = gatewayConfig?.discord_enabled ?? false;
const gatewayDisabled = gatewayConfig?.enabled === false;
const fetchConnections = useCallback(async (platform?: GatewayPlatform) => { const fetchConnections = useCallback(async (platform?: GatewayPlatform) => {
const query = platform ? `?platform=${encodeURIComponent(platform)}` : ""; const res = await authenticatedFetch(
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/connections${query}`); buildBackendUrl("/api/v1/gateway/connections", platform ? { platform } : undefined)
return (await res.json()) as GatewayConnection[]; );
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as GatewayConnection[]) : [];
}, []); }, []);
const fetchGatewayConfig = useCallback(async () => { const fetchGatewayConfig = useCallback(async (): Promise<GatewayConfig> => {
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/config`); const res = await authenticatedFetch(buildBackendUrl("/api/v1/gateway/config"));
return (await res.json()) as GatewayConfig; if (!res.ok) return DISABLED_GATEWAY_CONFIG;
const data = (await res.json()) as Partial<GatewayConfig>;
return {
...DISABLED_GATEWAY_CONFIG,
...data,
enabled: data.enabled ?? true,
};
}, []); }, []);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@ -125,7 +145,9 @@ export function MessagingChannelsContent() {
const refreshBaileysHealth = useCallback(async () => { const refreshBaileysHealth = useCallback(async () => {
if (whatsappMode !== "baileys") return; if (whatsappMode !== "baileys") return;
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/whatsapp/baileys/health`); const res = await authenticatedFetch(
buildBackendUrl("/api/v1/gateway/whatsapp/baileys/health")
);
if (!res.ok) return; if (!res.ok) return;
const data = (await res.json()) as BaileysHealth; const data = (await res.json()) as BaileysHealth;
setBaileysHealth(data); setBaileysHealth(data);
@ -136,7 +158,7 @@ export function MessagingChannelsContent() {
}, [refreshBaileysHealth]); }, [refreshBaileysHealth]);
async function startPairing(platform: PairingPlatform) { async function startPairing(platform: PairingPlatform) {
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/start`, { const res = await authenticatedFetch(buildBackendUrl("/api/v1/gateway/bindings/start"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform, search_space_id: searchSpaceId }), body: JSON.stringify({ platform, search_space_id: searchSpaceId }),
@ -148,7 +170,7 @@ export function MessagingChannelsContent() {
async function installSlackGateway() { async function installSlackGateway() {
const res = await authenticatedFetch( const res = await authenticatedFetch(
`${BACKEND_URL}/api/v1/gateway/slack/install?search_space_id=${searchSpaceId}` buildBackendUrl("/api/v1/gateway/slack/install", { search_space_id: searchSpaceId })
); );
if (!res.ok) return; if (!res.ok) return;
const data = (await res.json()) as { auth_url?: string }; const data = (await res.json()) as { auth_url?: string };
@ -159,7 +181,7 @@ export function MessagingChannelsContent() {
async function installDiscordGateway() { async function installDiscordGateway() {
const res = await authenticatedFetch( const res = await authenticatedFetch(
`${BACKEND_URL}/api/v1/gateway/discord/install?search_space_id=${searchSpaceId}` buildBackendUrl("/api/v1/gateway/discord/install", { search_space_id: searchSpaceId })
); );
if (!res.ok) return; if (!res.ok) return;
const data = (await res.json()) as { auth_url?: string }; const data = (await res.json()) as { auth_url?: string };
@ -181,8 +203,8 @@ export function MessagingChannelsContent() {
async function revoke(connection: GatewayConnection) { async function revoke(connection: GatewayConnection) {
const url = const url =
connection.route_type === "account" && connection.account_id connection.route_type === "account" && connection.account_id
? `${BACKEND_URL}/api/v1/gateway/accounts/${connection.account_id}` ? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}`)
: `${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}`; : buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}`);
await authenticatedFetch(url, { await authenticatedFetch(url, {
method: "DELETE", method: "DELETE",
}); });
@ -205,8 +227,8 @@ export function MessagingChannelsContent() {
); );
const url = const url =
connection.route_type === "account" && connection.account_id connection.route_type === "account" && connection.account_id
? `${BACKEND_URL}/api/v1/gateway/accounts/${connection.account_id}/search-space` ? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}/search-space`)
: `${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}/search-space`; : buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}/search-space`);
const res = await authenticatedFetch(url, { const res = await authenticatedFetch(url, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -222,7 +244,7 @@ export function MessagingChannelsContent() {
} }
async function resume(connection: GatewayConnection) { async function resume(connection: GatewayConnection) {
await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}/resume`, { await authenticatedFetch(buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}/resume`), {
method: "POST", method: "POST",
}); });
await refreshPlatform(connection.platform as GatewayPlatform); await refreshPlatform(connection.platform as GatewayPlatform);
@ -381,7 +403,21 @@ export function MessagingChannelsContent() {
<div className="grid items-stretch gap-3 sm:grid-cols-2"> <div className="grid items-stretch gap-3 sm:grid-cols-2">
{isGatewayConfigLoading ? renderGatewaySkeletons() : null} {isGatewayConfigLoading ? renderGatewaySkeletons() : null}
{!isGatewayConfigLoading && !hasEnabledGateway ? ( {!isGatewayConfigLoading && gatewayDisabled ? (
<Alert className="col-span-full" variant="warning">
<AlertTriangle aria-hidden />
<AlertTitle>Messaging Channels coming soon</AlertTitle>
<AlertDescription>
<p>
Soon you'll be able to connect WhatsApp, Telegram, Slack, and Discord to your
SurfSense agent so you can ask questions, route messages to search spaces, and get
answers from your knowledge base without leaving your chat app.
</p>
</AlertDescription>
</Alert>
) : null}
{!isGatewayConfigLoading && !gatewayDisabled && !hasEnabledGateway ? (
<Card className="col-span-full border-accent bg-accent/20"> <Card className="col-span-full border-accent bg-accent/20">
<CardHeader className="space-y-1.5 p-4"> <CardHeader className="space-y-1.5 p-4">
<CardTitle className="text-sm">No messaging gateways enabled</CardTitle> <CardTitle className="text-sm">No messaging gateways enabled</CardTitle>
@ -389,7 +425,7 @@ export function MessagingChannelsContent() {
</Card> </Card>
) : null} ) : null}
{telegramGatewayEnabled ? ( {!gatewayDisabled && telegramGatewayEnabled ? (
<Card className="order-1 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md"> <Card className="order-1 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-1.5 p-4 pb-2"> <CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@ -425,7 +461,7 @@ export function MessagingChannelsContent() {
</Card> </Card>
) : null} ) : null}
{slackGatewayEnabled ? ( {!gatewayDisabled && slackGatewayEnabled ? (
<Card className="order-4 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md"> <Card className="order-4 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-1.5 p-4 pb-2"> <CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@ -457,7 +493,7 @@ export function MessagingChannelsContent() {
</Card> </Card>
) : null} ) : null}
{discordGatewayEnabled ? ( {!gatewayDisabled && discordGatewayEnabled ? (
<Card className="order-3 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md"> <Card className="order-3 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-1.5 p-4 pb-2"> <CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@ -489,7 +525,7 @@ export function MessagingChannelsContent() {
</Card> </Card>
) : null} ) : null}
{whatsappMode !== "disabled" ? ( {!gatewayDisabled && whatsappMode !== "disabled" ? (
<Card className="order-2 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md"> <Card className="order-2 group relative h-full overflow-hidden border-accent bg-accent/20 transition-all duration-200 hover:shadow-md">
<CardHeader className="space-y-1.5 p-4 pb-2"> <CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">

View file

@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { ensureTokensFromElectron, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { queryClient } from "@/lib/query-client/client";
export function DashboardShell({ children }: { children: React.ReactNode }) {
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
// Use the global loading screen - spinner animation won't reset
useGlobalLoadingEffect(isCheckingAuth);
useEffect(() => {
async function checkAuth() {
let token = getBearerToken();
if (!token) {
const synced = await ensureTokensFromElectron();
if (synced) token = getBearerToken();
}
if (!token) {
redirectToLogin();
return;
}
queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
setIsCheckingAuth(false);
}
checkAuth();
}, []);
// Return null while loading - the global provider handles the loading UI
if (isCheckingAuth) {
return null;
}
return (
<div className="h-full flex flex-col ">
<div className="flex-1 min-h-0">{children}</div>
</div>
);
}

View file

@ -1,46 +1,14 @@
"use client"; import { RuntimeConfig } from "@/components/providers/runtime-config.server";
import { DashboardShell } from "./dashboard-shell";
import { useEffect, useState } from "react";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { ensureTokensFromElectron, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { queryClient } from "@/lib/query-client/client";
interface DashboardLayoutProps { interface DashboardLayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
export default function DashboardLayout({ children }: DashboardLayoutProps) { export default function DashboardLayout({ children }: DashboardLayoutProps) {
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
// Use the global loading screen - spinner animation won't reset
useGlobalLoadingEffect(isCheckingAuth);
useEffect(() => {
async function checkAuth() {
let token = getBearerToken();
if (!token) {
const synced = await ensureTokensFromElectron();
if (synced) token = getBearerToken();
}
if (!token) {
redirectToLogin();
return;
}
queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
setIsCheckingAuth(false);
}
checkAuth();
}, []);
// Return null while loading - the global provider handles the loading UI
if (isCheckingAuth) {
return null;
}
return ( return (
<div className="h-full flex flex-col "> <RuntimeConfig>
<div className="flex-1 min-h-0">{children}</div> <DashboardShell>{children}</DashboardShell>
</div> </RuntimeConfig>
); );
} }

View file

@ -0,0 +1,5 @@
import { RuntimeConfig } from "@/components/providers/runtime-config.server";
export default function DesktopLoginLayout({ children }: { children: React.ReactNode }) {
return <RuntimeConfig>{children}</RuntimeConfig>;
}

View file

@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder"; import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
import { useIsGoogleAuth } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -17,9 +18,8 @@ import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { setBearerToken } from "@/lib/auth-utils"; import { setBearerToken } from "@/lib/auth-utils";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist"; type ShortcutKey = "generalAssist" | "quickAsk" | "screenshotAssist";
type ShortcutMap = typeof DEFAULT_SHORTCUTS; type ShortcutMap = typeof DEFAULT_SHORTCUTS;
@ -189,6 +189,7 @@ function HotkeyRow({
export default function DesktopLoginPage() { export default function DesktopLoginPage() {
const router = useRouter(); const router = useRouter();
const api = useElectronAPI(); const api = useElectronAPI();
const isGoogleAuth = useIsGoogleAuth();
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -239,7 +240,7 @@ export default function DesktopLoginPage() {
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
if (isGoogleRedirecting) return; if (isGoogleRedirecting) return;
setIsGoogleRedirecting(true); setIsGoogleRedirecting(true);
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; window.location.href = buildBackendUrl("/auth/google/authorize-redirect");
}; };
const autoSetSearchSpace = async () => { const autoSetSearchSpace = async () => {

View file

@ -2,6 +2,7 @@ import { loader } from "fumadocs-core/source";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { blog, changelog } from "@/.source/server"; import { blog, changelog } from "@/.source/server";
import { source as docsSource } from "@/lib/source"; import { source as docsSource } from "@/lib/source";
import { SERVER_BACKEND_URL } from "@/lib/env-config";
const blogSource = loader({ const blogSource = loader({
baseUrl: "/blog", baseUrl: "/blog",
@ -14,11 +15,10 @@ const changelogSource = loader({
}); });
const BASE_URL = "https://www.surfsense.com"; const BASE_URL = "https://www.surfsense.com";
const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
async function getFreeModelSlugs(): Promise<string[]> { async function getFreeModelSlugs(): Promise<string[]> {
try { try {
const res = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/models`, { const res = await fetch(`${SERVER_BACKEND_URL}/api/v1/public/anon-chat/models`, {
next: { revalidate: 3600 }, next: { revalidate: 3600 },
}); });
if (!res.ok) return []; if (!res.ok) return [];

View file

@ -1,12 +1,16 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace( function getBackendBaseUrl() {
/\/+$/, const base =
"" process.env.SURFSENSE_BACKEND_INTERNAL_URL ||
); // TODO: Remove FASTAPI_BACKEND_INTERNAL_URL after the post-Caddy env migration window.
process.env.FASTAPI_BACKEND_INTERNAL_URL ||
"http://backend:8000";
return base.replace(/\/+$/, "");
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const response = await fetch(`${backendBaseUrl}/verify-token`, { const response = await fetch(`${getBackendBaseUrl()}/verify-token`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: request.headers.get("authorization") || "", Authorization: request.headers.get("authorization") || "",

View file

@ -6,7 +6,6 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import { useApiKey } from "@/hooks/use-api-key"; import { useApiKey } from "@/hooks/use-api-key";
import { BACKEND_URL } from "@/lib/env-config";
import { getConnectorBenefits } from "../connector-benefits"; import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index"; import type { ConnectFormProps } from "../index";

View file

@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import type { ConnectorConfigProps } from "../index"; import type { ConnectorConfigProps } from "../index";
export interface CirclebackConfigProps extends ConnectorConfigProps { export interface CirclebackConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void; onNameChange?: (name: string) => void;
@ -42,17 +42,10 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
const doFetch = async () => { const doFetch = async () => {
if (!connector.search_space_id) return; if (!connector.search_space_id) return;
const baseUrl = BACKEND_URL;
if (!baseUrl) {
console.error("NEXT_PUBLIC_FASTAPI_BACKEND_URL is not configured");
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`, buildBackendUrl(`/api/v1/webhooks/circleback/${connector.search_space_id}/info`),
{ signal: controller.signal } { signal: controller.signal }
); );
if (controller.signal.aborted) return; if (controller.signal.aborted) return;

View file

@ -13,7 +13,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { getReauthEndpoint } from "@/lib/connector-telemetry"; import { getReauthEndpoint } from "@/lib/connector-telemetry";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector"; import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
@ -95,12 +95,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
if (!spaceId || !reauthEndpoint) return; if (!spaceId || !reauthEndpoint) return;
setReauthing(true); setReauthing(true);
try { try {
const backendUrl = BACKEND_URL; const response = await authenticatedFetch(
const url = new URL(`${backendUrl}${reauthEndpoint}`); buildBackendUrl(reauthEndpoint, {
url.searchParams.set("connector_id", String(connector.id)); connector_id: connector.id,
url.searchParams.set("space_id", String(spaceId)); space_id: spaceId,
url.searchParams.set("return_url", window.location.pathname); return_url: window.location.pathname,
const response = await authenticatedFetch(url.toString()); })
);
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication."); toast.error(data.detail ?? "Failed to initiate re-authentication.");

View file

@ -16,7 +16,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { searchSourceConnector } from "@/contracts/types/connector.types"; import { searchSourceConnector } from "@/contracts/types/connector.types";
import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types/oauth.types"; import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types/oauth.types";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { import {
trackConnectorConnected, trackConnectorConnected,
trackConnectorDeleted, trackConnectorDeleted,
@ -351,9 +351,7 @@ export const useConnectorDialog = () => {
trackConnectorSetupStarted(Number(searchSpaceId), connector.connectorType, "oauth_click"); trackConnectorSetupStarted(Number(searchSpaceId), connector.connectorType, "oauth_click");
try { try {
// Check if authEndpoint already has query parameters const url = buildBackendUrl(connector.authEndpoint, { space_id: searchSpaceId });
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
const url = `${BACKEND_URL}${connector.authEndpoint}${separator}space_id=${searchSpaceId}`;
const response = await authenticatedFetch(url, { method: "GET" }); const response = await authenticatedFetch(url, { method: "GET" });

View file

@ -5,7 +5,7 @@ import type { FC } from "react";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { usePlatform } from "@/hooks/use-platform"; import { usePlatform } from "@/hooks/use-platform";
import { isSelfHosted } from "@/lib/env-config"; import { useIsSelfHosted } from "@/components/providers/runtime-config";
import { ConnectorCard } from "../components/connector-card"; import { ConnectorCard } from "../components/connector-card";
import { import {
COMPOSIO_CONNECTORS, COMPOSIO_CONNECTORS,
@ -22,6 +22,11 @@ type OAuthConnector = (typeof OAUTH_CONNECTORS)[number];
type ComposioConnector = (typeof COMPOSIO_CONNECTORS)[number]; type ComposioConnector = (typeof COMPOSIO_CONNECTORS)[number];
type OtherConnector = (typeof OTHER_CONNECTORS)[number]; type OtherConnector = (typeof OTHER_CONNECTORS)[number];
type CrawlerConnector = (typeof CRAWLERS)[number]; type CrawlerConnector = (typeof CRAWLERS)[number];
type DeploymentFilterableConnector = {
readonly id: string;
readonly selfHostedOnly?: boolean;
readonly desktopOnly?: boolean;
};
/** /**
* Extract the display name from a full connector name. * Extract the display name from a full connector name.
@ -66,14 +71,14 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onManage, onManage,
onViewAccountsList, onViewAccountsList,
}) => { }) => {
const selfHosted = isSelfHosted(); const selfHosted = useIsSelfHosted();
const { isDesktop } = usePlatform(); const { isDesktop } = usePlatform();
const matchesSearch = (title: string, description: string) => const matchesSearch = (title: string, description: string) =>
title.toLowerCase().includes(searchQuery.toLowerCase()) || title.toLowerCase().includes(searchQuery.toLowerCase()) ||
description.toLowerCase().includes(searchQuery.toLowerCase()); description.toLowerCase().includes(searchQuery.toLowerCase());
const passesDeploymentFilter = (c: { selfHostedOnly?: boolean; desktopOnly?: boolean }) => const passesDeploymentFilter = (c: DeploymentFilterableConnector) =>
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop); (!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
// Filter connectors based on search and deployment mode // Filter connectors based on search and deployment mode

View file

@ -12,7 +12,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { getReauthEndpoint } from "@/lib/connector-telemetry"; import { getReauthEndpoint } from "@/lib/connector-telemetry";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { formatRelativeDate } from "@/lib/format-date"; import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants"; import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
@ -61,12 +61,13 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
if (!searchSpaceId || !endpoint) return; if (!searchSpaceId || !endpoint) return;
setReauthingId(connector.id); setReauthingId(connector.id);
try { try {
const backendUrl = BACKEND_URL; const response = await authenticatedFetch(
const url = new URL(`${backendUrl}${endpoint}`); buildBackendUrl(endpoint, {
url.searchParams.set("connector_id", String(connector.id)); connector_id: connector.id,
url.searchParams.set("space_id", String(searchSpaceId)); space_id: searchSpaceId,
url.searchParams.set("return_url", window.location.pathname); return_url: window.location.pathname,
const response = await authenticatedFetch(url.toString()); })
);
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication."); toast.error(data.detail ?? "Failed to initiate re-authentication.");

View file

@ -1,40 +1,6 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
// Official Google "G" logo with brand colors
const GoogleLogo = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Google logo"
>
<title>Google logo</title>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
interface SignInButtonProps { interface SignInButtonProps {
/** /**
@ -46,51 +12,17 @@ interface SignInButtonProps {
} }
export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => { export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => {
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
const [isRedirecting, setIsRedirecting] = useState(false);
const handleGoogleLogin = () => {
if (isRedirecting) return;
setIsRedirecting(true);
trackLoginAttempt("google");
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
const getClassName = () => { const getClassName = () => {
if (variant === "desktop") { if (variant === "desktop") {
return isGoogleAuth return "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black";
? "hidden rounded-full border border-white bg-white px-5 py-2 text-sm font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] md:flex dark:border-white"
: "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black";
} }
if (variant === "compact") { if (variant === "compact") {
return isGoogleAuth return "rounded-full bg-black px-6 py-1.5 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black";
? "rounded-full border border-white bg-white px-4 py-1.5 text-sm font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white"
: "rounded-full bg-black px-6 py-1.5 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black";
} }
// mobile // mobile
return isGoogleAuth return "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation";
? "w-full rounded-lg border border-white bg-white px-8 py-2.5 font-medium text-[#1f1f1f] shadow-sm hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-white touch-manipulation"
: "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation";
}; };
if (isGoogleAuth) {
return (
<Button
type="button"
variant="ghost"
onClick={handleGoogleLogin}
disabled={isRedirecting}
className={cn(
"flex items-center justify-center gap-2 transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-50",
getClassName()
)}
>
<GoogleLogo className="h-4 w-4" />
<span>Sign In</span>
</Button>
);
}
return ( return (
<Link href="/login" className={getClassName()}> <Link href="/login" className={getClassName()}>
Sign In Sign In

View file

@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
interface DownloadOriginalButtonProps { interface DownloadOriginalButtonProps {
documentId: number; documentId: number;
@ -41,7 +41,7 @@ export function DownloadOriginalButton({ documentId }: DownloadOriginalButtonPro
setDownloading(true); setDownloading(true);
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/documents/${documentId}/download-original`, buildBackendUrl(`/api/v1/documents/${documentId}/download-original`),
{ method: "GET" } { method: "GET" }
); );
if (!response.ok) throw new Error("Download failed"); if (!response.ok) throw new Error("Download failed");

View file

@ -34,7 +34,7 @@ import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language"; import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
const PlateEditor = dynamic( const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })), () => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
@ -260,10 +260,12 @@ export function EditorPanelContent({
return; return;
} }
const url = new URL( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content` buildBackendUrl(
`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
),
{ method: "GET" }
); );
const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
@ -402,7 +404,7 @@ export function EditorPanelContent({
return; return;
} }
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`, buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`),
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -496,7 +498,9 @@ export function EditorPanelContent({
setDownloading(true); setDownloading(true);
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`, buildBackendUrl(
`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`
),
{ method: "GET" } { method: "GET" }
); );
if (!response.ok) throw new Error("Download failed"); if (!response.ok) throw new Error("Download failed");

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { buildBackendUrl } from "@/lib/env-config";
export type MemoryScope = "user" | "team"; export type MemoryScope = "user" | "team";
@ -29,10 +30,6 @@ function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) {
return `/api/v1/searchspaces/${searchSpaceId}/memory`; return `/api/v1/searchspaces/${searchSpaceId}/memory`;
} }
function getBackendUrl(path: string) {
return `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${path}`;
}
export function getMemoryLimitState(length: number, limits?: MemoryLimits | null) { export function getMemoryLimitState(length: number, limits?: MemoryLimits | null) {
if (!limits) { if (!limits) {
return { return {
@ -65,7 +62,7 @@ export async function fetchMemoryEditorDocument({
title?: string | null; title?: string | null;
signal?: AbortSignal; signal?: AbortSignal;
}) { }) {
const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), { const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, searchSpaceId)), {
method: "GET", method: "GET",
signal, signal,
}); });
@ -97,7 +94,7 @@ export async function saveMemoryMarkdown({
searchSpaceId?: number | null; searchSpaceId?: number | null;
markdown: string; markdown: string;
}) { }) {
const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), { const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, searchSpaceId)), {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ memory_md: markdown }), body: JSON.stringify({ memory_md: markdown }),

View file

@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types"; import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { readSSEStream } from "@/lib/chat/streaming-state"; import { readSSEStream } from "@/lib/chat/streaming-state";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { QuotaBar } from "./quota-bar"; import { QuotaBar } from "./quota-bar";
@ -81,7 +81,7 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
content: m.content, content: m.content,
})); }));
const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, { const response = await fetch(buildBackendUrl("/api/v1/public/anon-chat/stream"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",

View file

@ -33,7 +33,7 @@ import {
updateThinkingSteps, updateThinkingSteps,
updateToolCall, updateToolCall,
} from "@/lib/chat/streaming-state"; } from "@/lib/chat/streaming-state";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { FreeThread } from "./free-thread"; import { FreeThread } from "./free-thread";
import { RemoveAdsBanner } from "./remove-ads-banner"; import { RemoveAdsBanner } from "./remove-ads-banner";
@ -176,7 +176,7 @@ export function FreeChatPage() {
if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"]; if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"];
if (turnstileToken) reqBody.turnstile_token = turnstileToken; if (turnstileToken) reqBody.turnstile_token = turnstileToken;
const response = await fetch(`${BACKEND_URL}/api/v1/public/anon-chat/stream`, { const response = await fetch(buildBackendUrl("/api/v1/public/anon-chat/stream"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",

View file

@ -37,38 +37,8 @@ import {
getAssetLabel, getAssetLabel,
usePrimaryDownload, usePrimaryDownload,
} from "@/lib/desktop-download-utils"; } from "@/lib/desktop-download-utils";
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
import { trackLoginAttempt } from "@/lib/posthog/events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const GoogleLogo = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Google logo"
>
<title>Google logo</title>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
type HeroUseCase = { type HeroUseCase = {
id: string; id: string;
title: string; title: string;
@ -314,31 +284,6 @@ export function HeroSection() {
} }
function GetStartedButton() { function GetStartedButton() {
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
const [isRedirecting, setIsRedirecting] = useState(false);
const handleGoogleLogin = () => {
if (isRedirecting) return;
setIsRedirecting(true);
trackLoginAttempt("google");
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
if (isGoogleAuth) {
return (
<Button
type="button"
variant="ghost"
onClick={handleGoogleLogin}
disabled={isRedirecting}
className="h-14 w-full cursor-pointer gap-3 rounded-lg border border-white bg-white text-center text-base font-medium text-[#1f1f1f] shadow-sm transition duration-150 hover:bg-zinc-100 hover:text-[#1f1f1f] sm:w-56 dark:border-white"
>
<GoogleLogo className="h-5 w-5" />
<span>Continue with Google</span>
</Button>
);
}
return ( return (
<Button <Button
asChild asChild

View file

@ -72,13 +72,14 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform"; import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { uploadFolderScan } from "@/lib/folder-sync-upload"; import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index"; import { queries } from "@/zero/queries/index";
@ -226,6 +227,7 @@ function AuthenticatedDocumentsSidebarBase({
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
const platformElectronAPI = useElectronAPI(); const platformElectronAPI = useElectronAPI();
const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null; const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null;
const { etlService } = useRuntimeConfig();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const openEditorPanel = useSetAtom(openEditorPanelAtom); const openEditorPanel = useSetAtom(openEditorPanelAtom);
@ -618,7 +620,8 @@ function AuthenticatedDocumentsSidebarBase({
folderName: matched.name, folderName: matched.name,
searchSpaceId, searchSpaceId,
excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS, excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: matched.fileExtensions ?? Array.from(getSupportedExtensionsSet()), fileExtensions:
matched.fileExtensions ?? Array.from(getSupportedExtensionsSet(undefined, etlService)),
rootFolderId: folder.id, rootFolderId: folder.id,
}); });
toast.success(`Re-scan complete: ${matched.name}`); toast.success(`Re-scan complete: ${matched.name}`);
@ -626,7 +629,7 @@ function AuthenticatedDocumentsSidebarBase({
toast.error((err as Error)?.message || "Failed to re-scan folder"); toast.error((err as Error)?.message || "Failed to re-scan folder");
} }
}, },
[searchSpaceId, electronAPI] [searchSpaceId, electronAPI, etlService]
); );
const handleStopWatching = useCallback( const handleStopWatching = useCallback(
@ -748,7 +751,9 @@ function AuthenticatedDocumentsSidebarBase({
.trim() .trim()
.slice(0, 80) || "folder"; .slice(0, 80) || "folder";
await doExport( await doExport(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`, buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/export`, {
folder_id: ctx.folder.id,
}),
`${safeName}.zip` `${safeName}.zip`
); );
toast.success(`Folder "${ctx.folder.name}" exported`); toast.success(`Folder "${ctx.folder.name}" exported`);
@ -800,7 +805,9 @@ function AuthenticatedDocumentsSidebarBase({
.trim() .trim()
.slice(0, 80) || "folder"; .slice(0, 80) || "folder";
await doExport( await doExport(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${folder.id}`, buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/export`, {
folder_id: folder.id,
}),
`${safeName}.zip` `${safeName}.zip`
); );
toast.success(`Folder "${folder.name}" exported`); toast.success(`Folder "${folder.name}" exported`);
@ -820,8 +827,8 @@ function AuthenticatedDocumentsSidebarBase({
try { try {
const endpoint = const endpoint =
doc.document_type === "USER_MEMORY" doc.document_type === "USER_MEMORY"
? `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/users/me/memory` ? buildBackendUrl("/api/v1/users/me/memory")
: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory`; : buildBackendUrl(`/api/v1/searchspaces/${searchSpaceId}/memory`);
const response = await authenticatedFetch(endpoint, { method: "GET" }); const response = await authenticatedFetch(endpoint, { method: "GET" });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Export failed" })); const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
@ -849,7 +856,9 @@ function AuthenticatedDocumentsSidebarBase({
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${format}`, buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export`, {
format,
}),
{ method: "GET" } { method: "GET" }
); );
@ -1028,8 +1037,8 @@ function AuthenticatedDocumentsSidebarBase({
} }
const endpoint = const endpoint =
doc.document_type === "USER_MEMORY" doc.document_type === "USER_MEMORY"
? `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/users/me/memory/reset` ? buildBackendUrl("/api/v1/users/me/memory/reset")
: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory/reset`; : buildBackendUrl(`/api/v1/searchspaces/${searchSpaceId}/memory/reset`);
try { try {
const response = await authenticatedFetch(endpoint, { method: "POST" }); const response = await authenticatedFetch(endpoint, { method: "POST" });
if (!response.ok) { if (!response.ok) {

View file

@ -11,7 +11,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
@ -108,10 +108,12 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
} }
try { try {
const url = new URL( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content` buildBackendUrl(
`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
),
{ method: "GET" }
); );
const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
@ -165,7 +167,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setSaving(true); setSaving(true);
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`, buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`),
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -323,7 +325,9 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setDownloading(true); setDownloading(true);
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`, buildBackendUrl(
`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`
),
{ method: "GET" } { method: "GET" }
); );
if (!response.ok) throw new Error("Download failed"); if (!response.ok) throw new Error("Download failed");

View file

@ -12,7 +12,15 @@ import { getBearerToken, handleUnauthorized, refreshAccessToken } from "@/lib/au
import { queries } from "@/zero/queries"; import { queries } from "@/zero/queries";
import { schema } from "@/zero/schema"; import { schema } from "@/zero/schema";
const cacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL || "http://localhost:4848"; const configuredCacheURL = process.env.NEXT_PUBLIC_ZERO_CACHE_URL;
function getCacheURL() {
if (configuredCacheURL) return configuredCacheURL;
if (typeof window !== "undefined") {
return `${window.location.origin}/zero`;
}
return "http://localhost:4848";
}
function ZeroAuthSync() { function ZeroAuthSync() {
const zero = useZero(); const zero = useZero();
@ -42,6 +50,7 @@ function ZeroAuthSync() {
export function ZeroProvider({ children }: { children: React.ReactNode }) { export function ZeroProvider({ children }: { children: React.ReactNode }) {
const { data: user } = useAtomValue(currentUserAtom); const { data: user } = useAtomValue(currentUserAtom);
const cacheURL = useMemo(() => getCacheURL(), []);
const userId = user?.id; const userId = user?.id;
const hasUser = !!userId; const hasUser = !!userId;
@ -65,7 +74,7 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) {
cacheURL, cacheURL,
auth, auth,
}), }),
[userID, context, auth] [userID, context, cacheURL, auth]
); );
return ( return (

View file

@ -0,0 +1,19 @@
import { connection } from "next/server";
import { RuntimeConfigProvider } from "@/components/providers/runtime-config";
import {
BUILD_TIME_AUTH_TYPE,
BUILD_TIME_DEPLOYMENT_MODE,
BUILD_TIME_ETL_SERVICE,
} from "@/lib/env-config";
export async function RuntimeConfig({ children }: { children: React.ReactNode }) {
await connection();
const value = {
authType: process.env.AUTH_TYPE ?? BUILD_TIME_AUTH_TYPE,
etlService: process.env.ETL_SERVICE ?? BUILD_TIME_ETL_SERVICE,
deploymentMode: process.env.DEPLOYMENT_MODE ?? BUILD_TIME_DEPLOYMENT_MODE,
};
return <RuntimeConfigProvider value={value}>{children}</RuntimeConfigProvider>;
}

View file

@ -0,0 +1,48 @@
"use client";
import { createContext, useContext } from "react";
export type AuthType = "LOCAL" | "GOOGLE" | string;
export type DeploymentMode = "self-hosted" | "cloud" | string;
export interface RuntimeConfigValue {
authType: AuthType;
etlService: string;
deploymentMode: DeploymentMode;
}
const RuntimeConfigContext = createContext<RuntimeConfigValue | null>(null);
export function RuntimeConfigProvider({
value,
children,
}: {
value: RuntimeConfigValue;
children: React.ReactNode;
}) {
return <RuntimeConfigContext.Provider value={value}>{children}</RuntimeConfigContext.Provider>;
}
export function useRuntimeConfig() {
const context = useContext(RuntimeConfigContext);
if (!context) {
throw new Error("useRuntimeConfig must be used within RuntimeConfigProvider");
}
return context;
}
export function useIsLocalAuth() {
return useRuntimeConfig().authType === "LOCAL";
}
export function useIsGoogleAuth() {
return useRuntimeConfig().authType === "GOOGLE";
}
export function useIsSelfHosted() {
return useRuntimeConfig().deploymentMode === "self-hosted";
}
export function useIsCloud() {
return useRuntimeConfig().deploymentMode === "cloud";
}

View file

@ -22,7 +22,7 @@ import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service"; import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
function ReportPanelSkeleton() { function ReportPanelSkeleton() {
return ( return (
@ -245,7 +245,7 @@ export function ReportPanelContent({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} else { } else {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`, buildBackendUrl(`/api/v1/reports/${activeReportId}/export`, { format }),
{ method: "GET" } { method: "GET" }
); );
@ -278,7 +278,7 @@ export function ReportPanelContent({
setSaving(true); setSaving(true);
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/reports/${activeReportId}/content`, buildBackendUrl(`/api/v1/reports/${activeReportId}/content`),
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -506,7 +506,11 @@ export function ReportPanelContent({
</div> </div>
) : reportContent.content_type === "typst" ? ( ) : reportContent.content_type === "typst" ? (
<PdfViewer <PdfViewer
pdfUrl={`${BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`} pdfUrl={buildBackendUrl(
shareToken
? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview`
: `/api/v1/reports/${activeReportId}/preview`
)}
isPublic={isPublic} isPublic={isPublic}
toolbarActions={ toolbarActions={
<> <>

View file

@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner"; import { Spinner } from "../ui/spinner";
@ -49,7 +49,7 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
setIsExporting(true); setIsExporting(true);
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`, buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/export`),
{ method: "GET" } { method: "GET" }
); );
if (!response.ok) { if (!response.ok) {

View file

@ -8,6 +8,7 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } f
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { toast } from "sonner"; import { toast } from "sonner";
import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@ -136,6 +137,7 @@ export function DocumentUploadTab({
onAccordionStateChange, onAccordionStateChange,
}: DocumentUploadTabProps) { }: DocumentUploadTabProps) {
const t = useTranslations("upload_documents"); const t = useTranslations("upload_documents");
const { etlService } = useRuntimeConfig();
const [files, setFiles] = useState<FileWithId[]>([]); const [files, setFiles] = useState<FileWithId[]>([]);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [accordionValue, setAccordionValue] = useState<string>(""); const [accordionValue, setAccordionValue] = useState<string>("");
@ -160,7 +162,7 @@ export function DocumentUploadTab({
const electronAPI = useElectronAPI(); const electronAPI = useElectronAPI();
const isElectron = !!electronAPI?.browseFiles; const isElectron = !!electronAPI?.browseFiles;
const acceptedFileTypes = useMemo(() => getAcceptedFileTypes(), []); const acceptedFileTypes = useMemo(() => getAcceptedFileTypes(etlService), [etlService]);
const supportedExtensions = useMemo( const supportedExtensions = useMemo(
() => getSupportedExtensions(acceptedFileTypes), () => getSupportedExtensions(acceptedFileTypes),
[acceptedFileTypes] [acceptedFileTypes]

View file

@ -3,6 +3,7 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -48,6 +49,7 @@ export function FolderWatchDialog({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState<FolderSyncProgress | null>(null); const [progress, setProgress] = useState<FolderSyncProgress | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const { etlService } = useRuntimeConfig();
useEffect(() => { useEffect(() => {
if (open && initialFolder) { if (open && initialFolder) {
@ -55,7 +57,10 @@ export function FolderWatchDialog({
} }
}, [open, initialFolder]); }, [open, initialFolder]);
const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []); const supportedExtensions = useMemo(
() => Array.from(getSupportedExtensionsSet(undefined, etlService)),
[etlService]
);
const handleSelectFolder = useCallback(async () => { const handleSelectFolder = useCallback(async () => {
const api = window.electronAPI; const api = window.electronAPI;

View file

@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service"; import { baseApiService } from "@/lib/apis/base-api.service";
import { getAuthHeaders } from "@/lib/auth-utils"; import { getAuthHeaders } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs", "pdfjs-dist/build/pdf.worker.min.mjs",
@ -223,7 +223,7 @@ function ResumeCard({
const previewPath = shareToken const previewPath = shareToken
? `/api/v1/public/${shareToken}/reports/${reportId}/preview` ? `/api/v1/public/${shareToken}/reports/${reportId}/preview`
: `/api/v1/reports/${reportId}/preview`; : `/api/v1/reports/${reportId}/preview`;
setPdfUrl(`${BACKEND_URL}${previewPath}`); setPdfUrl(buildBackendUrl(previewPath));
if (autoOpen && isDesktop && !autoOpenedRef.current) { if (autoOpen && isDesktop && !autoOpenedRef.current) {
autoOpenedRef.current = true; autoOpenedRef.current = true;

View file

@ -14,7 +14,7 @@ import {
import { baseApiService } from "@/lib/apis/base-api.service"; import { baseApiService } from "@/lib/apis/base-api.service";
import { podcastsApiService } from "@/lib/apis/podcasts-api.service"; import { podcastsApiService } from "@/lib/apis/podcasts-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { speakerLabel } from "./schema"; import { speakerLabel } from "./schema";
// Public snapshots predate the transcript.turns shape and keep their own. // Public snapshots predate the transcript.turns shape and keep their own.
@ -121,7 +121,7 @@ export function PodcastPlayer({
); );
} else { } else {
const [audioResponse, detail] = await Promise.all([ const [audioResponse, detail] = await Promise.all([
authenticatedFetch(`${BACKEND_URL}/api/v1/podcasts/${podcastId}/stream`, { authenticatedFetch(buildBackendUrl(`/api/v1/podcasts/${podcastId}/stream`), {
method: "GET", method: "GET",
signal: controller.signal, signal: controller.signal,
}), }),

View file

@ -17,7 +17,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// ============================================================================ // ============================================================================
@ -158,7 +158,9 @@ function truncateCommand(command: string, maxLen = 80): string {
async function downloadSandboxFile(threadId: string, filePath: string, fileName: string) { async function downloadSandboxFile(threadId: string, filePath: string, fileName: string) {
const token = getBearerToken(); const token = getBearerToken();
const url = `${BACKEND_URL}/api/v1/threads/${threadId}/sandbox/download?path=${encodeURIComponent(filePath)}`; const url = buildBackendUrl(`/api/v1/threads/${threadId}/sandbox/download`, {
path: filePath,
});
const res = await fetch(url, { const res = await fetch(url, {
headers: { Authorization: `Bearer ${token || ""}` }, headers: { Authorization: `Bearer ${token || ""}` },
}); });

View file

@ -10,7 +10,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { baseApiService } from "@/lib/apis/base-api.service"; import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { compileCheck, compileToComponent } from "@/lib/remotion/compile-check"; import { compileCheck, compileToComponent } from "@/lib/remotion/compile-check";
import { FPS } from "@/lib/remotion/constants"; import { FPS } from "@/lib/remotion/constants";
import { import {
@ -137,7 +137,6 @@ function VideoPresentationPlayer({
const [isPptxExporting, setIsPptxExporting] = useState(false); const [isPptxExporting, setIsPptxExporting] = useState(false);
const [pptxProgress, setPptxProgress] = useState<string | null>(null); const [pptxProgress, setPptxProgress] = useState<string | null>(null);
const backendUrl = BACKEND_URL ?? "";
const audioBlobUrlsRef = useRef<string[]>([]); const audioBlobUrlsRef = useRef<string[]>([]);
const loadPresentation = useCallback(async () => { const loadPresentation = useCallback(async () => {
@ -177,7 +176,7 @@ function VideoPresentationPlayer({
title: scene.title ?? slide.title, title: scene.title ?? slide.title,
code: scene.code, code: scene.code,
durationInFrames, durationInFrames,
audioUrl: slide.audio_url ? `${backendUrl}${slide.audio_url}` : undefined, audioUrl: slide.audio_url ? buildBackendUrl(slide.audio_url) : undefined,
}); });
} }
@ -222,7 +221,7 @@ function VideoPresentationPlayer({
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [presentationId, backendUrl, shareToken]); }, [presentationId, shareToken]);
useEffect(() => { useEffect(() => {
loadPresentation(); loadPresentation();

View file

@ -10,7 +10,11 @@ cd SurfSense/docker
docker compose -f docker-compose.dev.yml up --build docker compose -f docker-compose.dev.yml up --build
``` ```
This file builds the backend and frontend from your local source code (instead of pulling prebuilt images) and includes pgAdmin for database inspection at [http://localhost:5050](http://localhost:5050). Use the production `docker-compose.yml` for all other cases. This file builds the backend and frontend from your local source code (instead
of pulling prebuilt images) and includes pgAdmin for database inspection at
[http://localhost:5050](http://localhost:5050). It intentionally keeps raw
frontend, backend, and zero-cache ports published for debugging. Use the
production `docker-compose.yml` for the default Caddy single-origin setup.
## Dev-Only Environment Variables ## Dev-Only Environment Variables
@ -22,9 +26,14 @@ The following `.env` variables are **only used by the dev compose file** (they h
| `PGADMIN_DEFAULT_EMAIL` | pgAdmin login email | `admin@surfsense.com` | | `PGADMIN_DEFAULT_EMAIL` | pgAdmin login email | `admin@surfsense.com` |
| `PGADMIN_DEFAULT_PASSWORD` | pgAdmin login password | `surfsense` | | `PGADMIN_DEFAULT_PASSWORD` | pgAdmin login password | `surfsense` |
| `REDIS_PORT` | Exposed Redis port (internal-only in prod) | `6379` | | `REDIS_PORT` | Exposed Redis port (internal-only in prod) | `6379` |
| `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` | Frontend build arg for auth type | `LOCAL` | | `AUTH_TYPE` | Runtime auth mode | `LOCAL` |
| `NEXT_PUBLIC_ETL_SERVICE` | Frontend build arg for ETL service | `DOCLING` | | `ETL_SERVICE` | Runtime document parsing service | `DOCLING` |
| `NEXT_PUBLIC_ZERO_CACHE_URL` | Frontend build arg for Zero-cache URL | `http://localhost:4848` | | `DEPLOYMENT_MODE` | Runtime deployment mode | `self-hosted` |
| `NEXT_PUBLIC_DEPLOYMENT_MODE` | Frontend build arg for deployment mode | `self-hosted` | | `ZERO_CACHE_PORT` | Exposed zero-cache port for debugging | `4848` |
In the production compose file, the `NEXT_PUBLIC_*` frontend variables are automatically derived from `AUTH_TYPE`, `ETL_SERVICE`, and the port settings. In the dev compose file, they are passed as build args since the frontend is built from source. In the production compose file, the frontend reads `AUTH_TYPE`, `ETL_SERVICE`,
and `DEPLOYMENT_MODE` at request time. Browser API and Zero traffic are
same-origin relative through bundled Caddy.
Production Docker exposes only the bundled Caddy proxy by default; dev compose
keeps direct service ports so contributors can inspect and restart individual
services without going through the proxy.

View file

@ -15,9 +15,9 @@ docker compose up -d
After starting, access SurfSense at: After starting, access SurfSense at:
- **Frontend**: [http://localhost:3929](http://localhost:3929) - **SurfSense**: [http://localhost:3929](http://localhost:3929)
- **Backend API**: [http://localhost:8929](http://localhost:8929) - **Backend API**: [http://localhost:3929/api/v1](http://localhost:3929/api/v1)
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs) - **Zero sync**: `ws://localhost:3929/zero`
--- ---
## Configuration ## Configuration
@ -99,24 +99,59 @@ docker run -d --name watchtower \
SurfSense containers are labeled for Watchtower, so `--label-enable` limits updates to the SurfSense services. SurfSense containers are labeled for Watchtower, so `--label-enable` limits updates to the SurfSense services.
### Ports ### Public URL and Ports
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `FRONTEND_PORT` | Frontend service port | `3929` | | `SURFSENSE_PUBLIC_URL` | Public origin used by the frontend, backend OAuth callbacks, and Zero browser URL | `http://localhost:3929` |
| `BACKEND_PORT` | Backend API service port | `8929` | | `SURFSENSE_SITE_ADDRESS` | Caddy site address. `:80` means local plain HTTP; a hostname enables automatic HTTPS | `:80` |
| `ZERO_CACHE_PORT` | Zero-cache real-time sync port | `5929` | | `LISTEN_HTTP_PORT` | Host port mapped to Caddy's HTTP listener | `3929` |
| `LISTEN_HTTPS_PORT` | Host port mapped to Caddy's HTTPS listener for domain mode | `443` |
### Custom Domain / Reverse Proxy SurfSense includes Caddy by default. The `frontend`, `backend`, and
`zero-cache` containers are internal-only in the production compose file; the
browser reaches them through Caddy path routing.
Only set these if serving SurfSense on a real domain via a reverse proxy (Caddy, Nginx, Cloudflare Tunnel, etc.). Leave commented out for standard localhost deployments. ### Custom Domain / Automatic HTTPS
For a real domain, point DNS at the Docker host and set:
```dotenv
SURFSENSE_SITE_ADDRESS=surf.example.com
LISTEN_HTTP_PORT=80
LISTEN_HTTPS_PORT=443
CERT_EMAIL=you@example.com
SURFSENSE_PUBLIC_URL=https://surf.example.com
```
Caddy will issue and renew Let's Encrypt certificates automatically. Ports 80
and 443 must be reachable from the internet for the default HTTP-01 challenge.
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `NEXT_FRONTEND_URL` | Public frontend URL (e.g. `https://app.yourdomain.com`) | | `CERT_EMAIL` | Optional ACME contact email |
| `BACKEND_URL` | Public backend URL for OAuth callbacks (e.g. `https://api.yourdomain.com`) | | `CERT_ACME_CA` | ACME directory URL; use Let's Encrypt staging when testing cert issuance |
| `NEXT_PUBLIC_FASTAPI_BACKEND_URL` | Backend URL used by the frontend (e.g. `https://api.yourdomain.com`) | | `CERT_ACME_DNS` | DNS-01 challenge config; requires the custom Caddy build |
| `NEXT_PUBLIC_ZERO_CACHE_URL` | Zero-cache URL used by the frontend (e.g. `https://zero.yourdomain.com`) | | `TRUSTED_PROXIES` | CIDR ranges trusted for forwarded client IP headers |
| `SURFSENSE_MAX_BODY_SIZE` | Upload limit enforced at the proxy |
### Bring Your Own Proxy
If you already run nginx, Traefik, Cloudflare Tunnel, or another ingress, you
can comment out the `proxy` service and route traffic to the internal services
with the same path contract:
| Public path | Upstream |
|-------------|----------|
| `/auth/*` | `backend:8000` |
| `/api/v1/*` | `backend:8000` |
| `/zero/*` | `zero-cache:4848` |
| `/*` | `frontend:3000` |
Alternative proxies must preserve WebSocket upgrades for `/zero`, avoid
buffering streaming responses, allow long-running requests, and support large
uploads. For DNS-01 or wildcard certificates with Caddy, build
`docker/proxy/Dockerfile` and set `CERT_ACME_DNS` for your DNS provider.
### Zero-cache (Real-Time Sync) ### Zero-cache (Real-Time Sync)
@ -165,7 +200,10 @@ Create credentials at the [Google Cloud Console](https://console.cloud.google.co
### Connector OAuth Keys ### Connector OAuth Keys
Uncomment the connectors you want to use. Redirect URIs follow the pattern `http://localhost:8000/api/v1/auth/<connector>/connector/callback`. Uncomment the connectors you want to use. Redirect URIs follow the single-origin
pattern `${SURFSENSE_PUBLIC_URL}/api/v1/auth/<connector>/connector/callback`.
For local Docker defaults, that means
`http://localhost:3929/api/v1/auth/<connector>/connector/callback`.
| Connector | Variables | | Connector | Variables |
|-----------|-----------| |-----------|-----------|
@ -218,6 +256,7 @@ for full setup.
| Service | Description | | Service | Description |
|---------|-------------| |---------|-------------|
| `proxy` | Caddy reverse proxy; the only public ingress in production Docker |
| `db` | PostgreSQL with pgvector extension | | `db` | PostgreSQL with pgvector extension |
| `migrations` | Short-lived: runs `alembic upgrade head` and verifies `zero_publication`, then exits | | `migrations` | Short-lived: runs `alembic upgrade head` and verifies `zero_publication`, then exits |
| `redis` | Message broker for Celery | | `redis` | Message broker for Celery |
@ -226,7 +265,7 @@ for full setup.
| `celery_worker` | Background task processing (document indexing, etc.) | | `celery_worker` | Background task processing (document indexing, etc.) |
| `celery_beat` | Periodic task scheduler (connector sync) | | `celery_beat` | Periodic task scheduler (connector sync) |
| `zero-cache` | Rocicorp Zero real-time sync (replicates Postgres to clients) | | `zero-cache` | Rocicorp Zero real-time sync (replicates Postgres to clients) |
| `frontend` | Next.js web application | | `frontend` | Next.js web application, internal behind Caddy |
All services start automatically with `docker compose up -d`. All services start automatically with `docker compose up -d`.
@ -292,9 +331,9 @@ docker compose down -v
## Troubleshooting ## Troubleshooting
- **Ports already in use**: Change the relevant `*_PORT` variable in `.env` and restart. - **Port already in use**: Change `LISTEN_HTTP_PORT` in `.env` and restart. In domain mode, use ports `80` and `443` so Caddy can complete certificate issuance.
- **Permission errors on Linux**: You may need to prefix `docker` commands with `sudo`. - **Permission errors on Linux**: You may need to prefix `docker` commands with `sudo`.
- **Real-time updates not working**: Open DevTools → Console and check for WebSocket errors. Verify `NEXT_PUBLIC_ZERO_CACHE_URL` matches the running zero-cache address. - **Real-time updates not working**: Open DevTools → Console and check for WebSocket errors. In production Docker the expected URL is `${SURFSENSE_PUBLIC_URL}/zero`.
- **Line ending issues on Windows**: Run `git config --global core.autocrlf true` before cloning. - **Line ending issues on Windows**: Run `git config --global core.autocrlf true` before cloning.
### Migration service exited non-zero ### Migration service exited non-zero

View file

@ -74,7 +74,27 @@ If Watchtower is enabled, it preserves the running image variant tag automatical
After starting, access SurfSense at: After starting, access SurfSense at:
- **Frontend**: [http://localhost:3929](http://localhost:3929) - **SurfSense**: [http://localhost:3929](http://localhost:3929)
- **Backend API**: [http://localhost:8929](http://localhost:8929) - **Backend API**: [http://localhost:3929/api/v1](http://localhost:3929/api/v1)
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs) - **Zero sync**: `ws://localhost:3929/zero`
- **Zero-cache**: [http://localhost:5929](http://localhost:5929)
The installer uses the bundled Caddy reverse proxy by default. The backend and
zero-cache containers are not published on separate host ports in the production
stack.
For a custom domain, edit `surfsense/.env` after installation:
```dotenv
SURFSENSE_SITE_ADDRESS=surf.example.com
LISTEN_HTTP_PORT=80
LISTEN_HTTPS_PORT=443
CERT_EMAIL=you@example.com
SURFSENSE_PUBLIC_URL=https://surf.example.com
```
Then run:
```bash
cd surfsense
docker compose up -d --wait
```

View file

@ -32,10 +32,10 @@ zero-cache is included in the Docker Compose setup. The key environment variable
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `ZERO_CACHE_PORT` | Port for the zero-cache service | `5929` (prod) / `4848` (dev) | | `SURFSENSE_PUBLIC_URL` | Public SurfSense origin used by the browser | `http://localhost:3929` |
| `ZERO_ADMIN_PASSWORD` | Password for the zero-cache admin UI and `/statz` endpoint | `surfsense-zero-admin` | | `ZERO_ADMIN_PASSWORD` | Password for the zero-cache admin UI and `/statz` endpoint | `surfsense-zero-admin` |
| `ZERO_UPSTREAM_DB` | PostgreSQL connection URL for replication | Built from `DB_*` vars | | `ZERO_UPSTREAM_DB` | PostgreSQL connection URL for replication | Built from `DB_*` vars |
| `NEXT_PUBLIC_ZERO_CACHE_URL` | URL the frontend uses to connect to zero-cache | `http://localhost:<ZERO_CACHE_PORT>` | | `/zero` | Same-origin browser path Caddy routes to zero-cache | `${SURFSENSE_PUBLIC_URL}/zero` |
| `ZERO_APP_PUBLICATIONS` | PostgreSQL publication restricting which tables are replicated | `zero_publication` | | `ZERO_APP_PUBLICATIONS` | PostgreSQL publication restricting which tables are replicated | `zero_publication` |
| `ZERO_NUM_SYNC_WORKERS` | Number of view-sync worker processes. Must be ≤ `ZERO_UPSTREAM_MAX_CONNS` and ≤ `ZERO_CVR_MAX_CONNS` | `4` | | `ZERO_NUM_SYNC_WORKERS` | Number of view-sync worker processes. Must be ≤ `ZERO_UPSTREAM_MAX_CONNS` and ≤ `ZERO_CVR_MAX_CONNS` | `4` |
| `ZERO_UPSTREAM_MAX_CONNS` | Max connections to upstream PostgreSQL for mutations | `20` | | `ZERO_UPSTREAM_MAX_CONNS` | Max connections to upstream PostgreSQL for mutations | `20` |
@ -64,14 +64,18 @@ If running the frontend outside Docker (e.g. `pnpm dev`), you need:
``` ```
Run `uv run alembic upgrade head` from `surfsense_backend/` **before** starting this container so the `zero_publication` exists. Run `uv run alembic upgrade head` from `surfsense_backend/` **before** starting this container so the `zero_publication` exists.
2. **`NEXT_PUBLIC_ZERO_CACHE_URL`** set in `surfsense_web/.env` (default: `http://localhost:4848`). 2. If the frontend is not behind bundled Caddy, set `NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848` before building/running the frontend so the browser connects directly to zero-cache.
3. **`wal_level = logical`** in your PostgreSQL config (see [Manual Installation → Configure PostgreSQL for Zero Sync](/docs/manual-installation#3-configure-postgresql-for-zero-sync)). 3. **`wal_level = logical`** in your PostgreSQL config (see [Manual Installation → Configure PostgreSQL for Zero Sync](/docs/manual-installation#3-configure-postgresql-for-zero-sync)).
For the full manual setup walkthrough, see the [Manual Installation guide](/docs/manual-installation). For the full manual setup walkthrough, see the [Manual Installation guide](/docs/manual-installation).
### Custom Domain / Reverse Proxy ### Custom Domain / Reverse Proxy
When deploying behind a reverse proxy, set `NEXT_PUBLIC_ZERO_CACHE_URL` to your public zero-cache URL (e.g., `https://zero.yourdomain.com`). The zero-cache service must be accessible via WebSocket from the browser. The production Docker stack includes Caddy by default. Zero is exposed under the
same public origin as the app at `${SURFSENSE_PUBLIC_URL}/zero`, for example
`https://surf.example.com/zero`. Zero accepts this single path-component base
URL, so Caddy forwards `/zero/*` to the internal `zero-cache:4848` service
without stripping the prefix.
### Database Requirements ### Database Requirements
@ -110,7 +114,7 @@ Zero syncs the following tables for real-time features:
- **zero-cache not starting**: Check `docker compose logs zero-cache`. Ensure PostgreSQL has `wal_level=logical` (configured in `postgresql.conf`). - **zero-cache not starting**: Check `docker compose logs zero-cache`. Ensure PostgreSQL has `wal_level=logical` (configured in `postgresql.conf`).
- **"Insufficient upstream connections" error**: zero-cache defaults `ZERO_NUM_SYNC_WORKERS` to the number of CPU cores, which can exceed connection pool limits on high-core machines. Lower `ZERO_NUM_SYNC_WORKERS` or raise `ZERO_UPSTREAM_MAX_CONNS` / `ZERO_CVR_MAX_CONNS` in your `.env`. - **"Insufficient upstream connections" error**: zero-cache defaults `ZERO_NUM_SYNC_WORKERS` to the number of CPU cores, which can exceed connection pool limits on high-core machines. Lower `ZERO_NUM_SYNC_WORKERS` or raise `ZERO_UPSTREAM_MAX_CONNS` / `ZERO_CVR_MAX_CONNS` in your `.env`.
- **Frontend not syncing**: Open DevTools → Console and check for WebSocket connection errors. Verify `NEXT_PUBLIC_ZERO_CACHE_URL` matches the running zero-cache address. - **Frontend not syncing**: Open DevTools → Console and check for WebSocket connection errors. In production Docker, verify Caddy serves `${SURFSENSE_PUBLIC_URL}/zero`. In manual local development, verify `NEXT_PUBLIC_ZERO_CACHE_URL` points at the running zero-cache port.
- **Stale data after restart**: zero-cache rebuilds its SQLite replica from PostgreSQL on startup. This may take a moment for large databases. - **Stale data after restart**: zero-cache rebuilds its SQLite replica from PostgreSQL on startup. This may take a moment for large databases.
## Learn More ## Learn More

View file

@ -546,7 +546,10 @@ cd ../docker
docker compose -f docker-compose.deps-only.yml up -d docker compose -f docker-compose.deps-only.yml up -d
``` ```
The deps-only stack exposes zero-cache on port `4848` by default. Keep `NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848` in your `surfsense_web/.env`. The deps-only stack exposes zero-cache on port `4848` by default. If your
frontend is not behind a reverse proxy that serves `/zero`, set
`NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848` before building/running the
frontend.
## Frontend Setup ## Frontend Setup
@ -577,12 +580,13 @@ Copy-Item -Path .env.example -Destination .env
Edit the `.env` file and set: Edit the `.env` file and set:
| ENV VARIABLE | DESCRIPTION | | ENV VARIABLE | DESCRIPTION |
| ------------------------------- | ------------------------------------------- | | --- | --- |
| NEXT_PUBLIC_FASTAPI_BACKEND_URL | Backend URL (e.g., `http://localhost:8000`) | | `SURFSENSE_BACKEND_INTERNAL_URL` | Backend URL used by Next.js server routes, e.g. `http://localhost:8000` or `http://backend:8000` in Docker |
| NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE | Same value as set in backend AUTH_TYPE i.e `GOOGLE` for OAuth with Google, `LOCAL` for email/password authentication | | `AUTH_TYPE` | Same value as backend auth type: `GOOGLE` for OAuth with Google, `LOCAL` for email/password authentication |
| NEXT_PUBLIC_ETL_SERVICE | Document parsing service (should match backend ETL_SERVICE): `UNSTRUCTURED`, `LLAMACLOUD`, or `DOCLING` - affects supported file formats in upload interface | | `ETL_SERVICE` | Document parsing service (should match backend ETL_SERVICE): `UNSTRUCTURED`, `LLAMACLOUD`, or `DOCLING`; affects supported file formats in the upload interface |
| NEXT_PUBLIC_ZERO_CACHE_URL | URL for Zero-cache real-time sync service (e.g., `http://localhost:4848`) | | `DEPLOYMENT_MODE` | `self-hosted` or `cloud`; controls self-hosted-only connector visibility |
| `NEXT_PUBLIC_ZERO_CACHE_URL` | Only needed when the browser cannot reach Zero through same-origin `/zero`, e.g. manual local dev at `http://localhost:4848` |
### 2. Install Dependencies ### 2. Install Dependencies
@ -693,7 +697,7 @@ To verify your installation:
- **Authentication Problems**: Check your Google OAuth configuration and ensure redirect URIs are set correctly - **Authentication Problems**: Check your Google OAuth configuration and ensure redirect URIs are set correctly
- **LLM Errors**: Confirm your LLM API keys are valid and the selected models are accessible - **LLM Errors**: Confirm your LLM API keys are valid and the selected models are accessible
- **File Upload Failures**: Validate your ETL service API key (Unstructured.io or LlamaCloud) or ensure Docling is properly configured - **File Upload Failures**: Validate your ETL service API key (Unstructured.io or LlamaCloud) or ensure Docling is properly configured
- **Real-time updates not working / stale UI**: Verify zero-cache is running (`curl http://localhost:4848/keepalive` returns 200). Open browser DevTools → Console and look for WebSocket errors. Confirm `NEXT_PUBLIC_ZERO_CACHE_URL` in `surfsense_web/.env` matches the running zero-cache address. - **Real-time updates not working / stale UI**: Verify zero-cache is running (`curl http://localhost:4848/keepalive` returns 200). Open browser DevTools → Console and look for WebSocket errors. In default Docker, confirm `/zero` is routed by Caddy. In manual local development, confirm `NEXT_PUBLIC_ZERO_CACHE_URL` in `surfsense_web/.env` matches the running zero-cache address.
- **Zero-cache stuck on `Unknown or invalid publications. Specified: [zero_publication]`**: You skipped (or never ran) `uv run alembic upgrade head` from `surfsense_backend/`. Run it, then restart the zero-cache container with `docker restart surfsense-zero-cache`. - **Zero-cache stuck on `Unknown or invalid publications. Specified: [zero_publication]`**: You skipped (or never ran) `uv run alembic upgrade head` from `surfsense_backend/`. Run it, then restart the zero-cache container with `docker restart surfsense-zero-cache`.
- **Zero-cache crashes with `_zero.tableMetadata` errors**: A previous run left a half-built SQLite replica behind. Stop the container, remove the volume, and start fresh: `docker rm -f surfsense-zero-cache && docker volume rm surfsense-zero-cache && docker run -d ...` (re-run the command from [Zero-Cache Setup](#zero-cache-setup)). - **Zero-cache crashes with `_zero.tableMetadata` errors**: A previous run left a half-built SQLite replica behind. Stop the container, remove the volume, and start fresh: `docker rm -f surfsense-zero-cache && docker volume rm surfsense-zero-cache && docker run -d ...` (re-run the command from [Zero-Cache Setup](#zero-cache-setup)).
- **`wal_level` is not set to `logical`**: zero-cache requires logical replication. Set `wal_level = logical` in `postgresql.conf`, restart PostgreSQL, and verify with `SHOW wal_level;` in psql. - **`wal_level` is not set to `logical`**: zero-cache requires logical replication. Set `wal_level = logical` in `postgresql.conf`, restart PostgreSQL, and verify with `SHOW wal_level;` in psql.

View file

@ -15,17 +15,19 @@ wired by Compose.
## Public URLs ## Public URLs
For localhost-only testing, the defaults are enough for the SurfSense UI, but For localhost-only testing, the defaults are enough for the SurfSense UI, but
public webhooks from Telegram, WhatsApp, and Slack require a public HTTPS backend public webhooks from Telegram, WhatsApp, and Slack require a public HTTPS
URL. Use your deployed backend URL or a tunnel such as Cloudflare Tunnel or SurfSense URL. Use your deployed domain or a tunnel such as Cloudflare Tunnel
ngrok. or ngrok.
When using a custom domain or tunnel, set: When using a custom domain or tunnel with the bundled Caddy proxy, set:
```bash ```bash
BACKEND_URL=https://api.example.com SURFSENSE_PUBLIC_URL=https://surf.example.com
GATEWAY_BASE_URL=https://api.example.com SURFSENSE_SITE_ADDRESS=surf.example.com
NEXT_FRONTEND_URL=https://app.example.com LISTEN_HTTP_PORT=80
NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.example.com LISTEN_HTTPS_PORT=443
CERT_EMAIL=you@example.com
GATEWAY_BASE_URL=https://surf.example.com
``` ```
## Environment Variables ## Environment Variables

View file

@ -1,88 +0,0 @@
/**
* Runtime environment variable substitution for Next.js Docker images.
*
* Next.js inlines NEXT_PUBLIC_* values at build time. The Docker image is built
* with unique placeholder strings (e.g. __NEXT_PUBLIC_FASTAPI_BACKEND_URL__).
* This script replaces those placeholders with real values from the container's
* environment variables before the server starts.
*
* Runs once at container startup via docker-entrypoint.sh.
*/
const fs = require("fs");
const path = require("path");
const replacements = [
[
"__NEXT_PUBLIC_FASTAPI_BACKEND_URL__",
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000",
],
[
"__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__",
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL",
],
["__NEXT_PUBLIC_ETL_SERVICE__", process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"],
[
"__NEXT_PUBLIC_ZERO_CACHE_URL__",
process.env.NEXT_PUBLIC_ZERO_CACHE_URL || "http://localhost:4848",
],
["__NEXT_PUBLIC_DEPLOYMENT_MODE__", process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"],
];
let filesProcessed = 0;
let filesModified = 0;
function walk(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.name.endsWith(".js")) {
filesProcessed++;
let content = fs.readFileSync(full, "utf8");
let changed = false;
for (const [placeholder, value] of replacements) {
if (content.includes(placeholder)) {
content = content.replaceAll(placeholder, value);
changed = true;
}
}
if (changed) {
fs.writeFileSync(full, content);
filesModified++;
}
}
}
}
console.log("[entrypoint] Replacing environment variable placeholders...");
for (const [placeholder, value] of replacements) {
console.log(` ${placeholder} -> ${value}`);
}
walk(path.join(__dirname, ".next"));
const serverJs = path.join(__dirname, "server.js");
if (fs.existsSync(serverJs)) {
let content = fs.readFileSync(serverJs, "utf8");
let changed = false;
filesProcessed++;
for (const [placeholder, value] of replacements) {
if (content.includes(placeholder)) {
content = content.replaceAll(placeholder, value);
changed = true;
}
}
if (changed) {
fs.writeFileSync(serverJs, content);
filesModified++;
}
}
console.log(`[entrypoint] Done. Scanned ${filesProcessed} files, modified ${filesModified}.`);

View file

@ -1,6 +0,0 @@
#!/bin/sh
set -e
node /app/docker-entrypoint.js
exec node server.js

View file

@ -9,6 +9,33 @@ const compat = new FlatCompat({
baseDirectory: __dirname, baseDirectory: __dirname,
}); });
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")]; const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@/lib/env-config",
importNames: ["BACKEND_URL"],
message:
"Use buildBackendUrl(path, params) for browser-facing backend URLs. BACKEND_URL is empty in proxy mode; importing it bypasses the single URL seam.",
},
],
patterns: [
{
group: ["**/env-config", "**/env-config.ts"],
importNames: ["BACKEND_URL"],
message:
"Use buildBackendUrl(path, params). Import BACKEND_URL only inside lib/env-config.ts.",
},
],
},
],
},
},
];
export default eslintConfig; export default eslintConfig;

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
export interface SearchSourceConnector { export interface SearchSourceConnector {
id: number; id: number;
name: string; name: string;
@ -106,16 +106,15 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
// Build URL with optional search_space_id query parameter const response = await authenticatedFetch(
const url = new URL(`${BACKEND_URL}/api/v1/search-source-connectors`); buildBackendUrl("/api/v1/search-source-connectors", {
if (spaceId !== undefined) { search_space_id: spaceId,
url.searchParams.append("search_space_id", spaceId.toString()); }),
} {
method: "GET",
const response = await authenticatedFetch(url.toString(), { headers: { "Content-Type": "application/json" },
method: "GET", }
headers: { "Content-Type": "application/json" }, );
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch connectors: ${response.statusText}`); throw new Error(`Failed to fetch connectors: ${response.statusText}`);
@ -166,15 +165,16 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
spaceId: number spaceId: number
) => { ) => {
try { try {
// Add search_space_id as a query parameter const response = await authenticatedFetch(
const url = new URL(`${BACKEND_URL}/api/v1/search-source-connectors`); buildBackendUrl("/api/v1/search-source-connectors", {
url.searchParams.append("search_space_id", spaceId.toString()); search_space_id: spaceId,
}),
const response = await authenticatedFetch(url.toString(), { {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(connectorData), body: JSON.stringify(connectorData),
}); }
);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to create connector: ${response.statusText}`); throw new Error(`Failed to create connector: ${response.statusText}`);
@ -204,7 +204,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
) => { ) => {
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, buildBackendUrl(`/api/v1/search-source-connectors/${connectorId}`),
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -235,7 +235,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
const deleteConnector = async (connectorId: number) => { const deleteConnector = async (connectorId: number) => {
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, buildBackendUrl(`/api/v1/search-source-connectors/${connectorId}`),
{ {
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -267,19 +267,12 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
endDate?: string endDate?: string
) => { ) => {
try { try {
// Build query parameters
const params = new URLSearchParams({
search_space_id: searchSpaceId.toString(),
});
if (startDate) {
params.append("start_date", startDate);
}
if (endDate) {
params.append("end_date", endDate);
}
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?${params.toString()}`, buildBackendUrl(`/api/v1/search-source-connectors/${connectorId}/index`, {
search_space_id: searchSpaceId,
start_date: startDate,
end_date: endDate,
}),
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

View file

@ -7,7 +7,7 @@ import {
getAnonModelResponse, getAnonModelResponse,
getAnonModelsResponse, getAnonModelsResponse,
} from "@/contracts/types/anonymous-chat.types"; } from "@/contracts/types/anonymous-chat.types";
import { BACKEND_URL } from "../env-config"; import { buildBackendUrl } from "../env-config";
import { ValidationError } from "../error"; import { ValidationError } from "../error";
const BASE = "/api/v1/public/anon-chat"; const BASE = "/api/v1/public/anon-chat";
@ -17,14 +17,8 @@ export type AnonUploadResult =
| { ok: false; reason: "quota_exceeded" }; | { ok: false; reason: "quota_exceeded" };
class AnonymousChatApiService { class AnonymousChatApiService {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private fullUrl(path: string): string { private fullUrl(path: string): string {
return `${this.baseUrl}${BASE}${path}`; return buildBackendUrl(`${BASE}${path}`);
} }
getModels = async (): Promise<AnonModel[]> => { getModels = async (): Promise<AnonModel[]> => {
@ -102,4 +96,4 @@ class AnonymousChatApiService {
}; };
} }
export const anonymousChatApiService = new AnonymousChatApiService(BACKEND_URL); export const anonymousChatApiService = new AnonymousChatApiService();

View file

@ -1,5 +1,5 @@
import type { ZodType } from "zod"; import type { ZodType } from "zod";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
import { getClientPlatform } from "../agent-filesystem"; import { getClientPlatform } from "../agent-filesystem";
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils"; import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
import { import {
@ -31,8 +31,6 @@ export type RequestOptions = {
}; };
class BaseApiService { class BaseApiService {
baseUrl: string;
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"];
// Prefixes that don't require auth (checked with startsWith) // Prefixes that don't require auth (checked with startsWith)
@ -44,12 +42,9 @@ class BaseApiService {
return typeof window !== "undefined" ? getBearerToken() || "" : ""; return typeof window !== "undefined" ? getBearerToken() || "" : "";
} }
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// Keep for backward compatibility, but token is now always read from localStorage // Keep for backward compatibility, but token is now always read from localStorage
setBearerToken(_bearerToken: string) { setBearerToken(_bearerToken: string) {
void _bearerToken;
// No-op: token is now always read fresh from localStorage via the getter // No-op: token is now always read fresh from localStorage via the getter
} }
@ -93,11 +88,6 @@ class BaseApiService {
}, },
}; };
// Validate the base URL
if (!this.baseUrl) {
throw new AppError("Base URL is not set.");
}
// Validate the bearer token // Validate the bearer token
const isNoAuthEndpoint = const isNoAuthEndpoint =
this.noAuthEndpoints.includes(url) || this.noAuthEndpoints.includes(url) ||
@ -107,8 +97,7 @@ class BaseApiService {
throw new AuthenticationError("You are not authenticated. Please login again."); throw new AuthenticationError("You are not authenticated. Please login again.");
} }
// Construct the full URL const fullUrl = buildBackendUrl(url);
const fullUrl = new URL(url, this.baseUrl).toString();
// Prepare fetch options // Prepare fetch options
const fetchOptions: RequestInit = { const fetchOptions: RequestInit = {
@ -384,7 +373,8 @@ class BaseApiService {
options?: Omit<RequestOptions, "method" | "responseType" | "body"> & { body: FormData } options?: Omit<RequestOptions, "method" | "responseType" | "body"> & { body: FormData }
) { ) {
// Remove Content-Type from options headers if present // Remove Content-Type from options headers if present
const { "Content-Type": _, ...headersWithoutContentType } = options?.headers ?? {}; const headersWithoutContentType = { ...(options?.headers ?? {}) };
delete headersWithoutContentType["Content-Type"];
return this.request(url, responseSchema, { return this.request(url, responseSchema, {
method: "POST", method: "POST",
@ -399,4 +389,4 @@ class BaseApiService {
} }
} }
export const baseApiService = new BaseApiService(BACKEND_URL); export const baseApiService = new BaseApiService();

View file

@ -1,7 +1,7 @@
/** /**
* Authentication utilities for handling token expiration and redirects * Authentication utilities for handling token expiration and redirects
*/ */
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
const REDIRECT_PATH_KEY = "surfsense_redirect_path"; const REDIRECT_PATH_KEY = "surfsense_redirect_path";
const BEARER_TOKEN_KEY = "surfsense_bearer_token"; const BEARER_TOKEN_KEY = "surfsense_bearer_token";
@ -195,7 +195,7 @@ export async function logout(): Promise<boolean> {
// Call backend to revoke the refresh token // Call backend to revoke the refresh token
if (refreshToken) { if (refreshToken) {
try { try {
const response = await fetch(`${BACKEND_URL}/auth/jwt/revoke`, { const response = await fetch(buildBackendUrl("/auth/jwt/revoke"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -273,7 +273,7 @@ export async function refreshAccessToken(): Promise<string | null> {
isRefreshing = true; isRefreshing = true;
refreshPromise = (async () => { refreshPromise = (async () => {
try { try {
const response = await fetch(`${BACKEND_URL}/auth/jwt/refresh`, { const response = await fetch(buildBackendUrl("/auth/jwt/refresh"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View file

@ -4,7 +4,7 @@
*/ */
import { baseApiService } from "@/lib/apis/base-api.service"; import { baseApiService } from "@/lib/apis/base-api.service";
import { BACKEND_URL } from "@/lib/env-config"; import { buildBackendUrl } from "@/lib/env-config";
// ============================================================================= // =============================================================================
// Types matching backend schemas // Types matching backend schemas
// ============================================================================= // =============================================================================
@ -227,5 +227,5 @@ export interface RegenerateParams {
* Get the URL for the regenerate endpoint (for streaming fetch) * Get the URL for the regenerate endpoint (for streaming fetch)
*/ */
export function getRegenerateUrl(threadId: number): string { export function getRegenerateUrl(threadId: number): string {
return `${BACKEND_URL}/api/v1/threads/${threadId}/regenerate`; return buildBackendUrl(`/api/v1/threads/${threadId}/regenerate`);
} }

View file

@ -1,47 +1,75 @@
/** /**
* Environment configuration for the frontend. * Environment configuration for the frontend.
* *
* This file centralizes access to NEXT_PUBLIC_* environment variables. * Docker deployments use same-origin relative browser URLs behind Caddy.
* For Docker deployments, these placeholders are replaced at container startup * NEXT_PUBLIC_* values remain only as build-time fallbacks for packaged clients
* via sed in the entrypoint script. * like Electron, where there is no bundled Caddy origin.
*
* IMPORTANT: Do not use template literals or complex expressions with these values
* as it may prevent the sed replacement from working correctly.
*/ */
import packageJson from "../package.json"; import packageJson from "../package.json";
// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth // Build-time fallback for packaged clients. Docker runtime reads plain AUTH_TYPE
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__ // through the runtime config provider first, then falls back to this baked value.
export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; export const BUILD_TIME_AUTH_TYPE = process.env.NEXT_PUBLIC_AUTH_TYPE || "GOOGLE";
// Backend API URL // Backend API URL. An empty string is valid in proxy mode and means
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_URL__ // same-origin relative requests (e.g. /api/v1/... and /auth/...).
export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "";
// ETL Service: "DOCLING", "UNSTRUCTURED", or "LLAMACLOUD" type BackendUrlParam = string | number | boolean | null | undefined;
// Placeholder: __NEXT_PUBLIC_ETL_SERVICE__
export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING";
// Deployment Mode: "self-hosted" or "cloud" /**
// Matches backend's SURFSENSE_DEPLOYMENT_MODE - defaults to "self-hosted" * Build browser-facing backend URLs without breaking proxy mode.
// self-hosted: Full access to local file system connectors (Obsidian, etc.) *
// cloud: Only cloud-based connectors available * In proxy mode BACKEND_URL intentionally stays empty, so callers must keep
// Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__ * same-origin relative URLs ("/api/v1/...") and let Caddy route them. When
export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"; * BACKEND_URL is explicitly configured, the same path resolves against that
* absolute backend origin.
*/
export function buildBackendUrl(
path: string,
params?: Record<string, BackendUrlParam>
): string {
const backendPath = path.startsWith("/") ? path : `/${path}`;
const queryParams = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined) {
queryParams.append(key, String(value));
}
}
}
if (BACKEND_URL) {
const url = new URL(backendPath, BACKEND_URL);
for (const [key, value] of queryParams) {
url.searchParams.append(key, value);
}
return url.toString();
}
const queryString = queryParams.toString();
if (!queryString) return backendPath;
return `${backendPath}${backendPath.includes("?") ? "&" : "?"}${queryString}`;
}
// Server-side backend URL. Relative browser URLs do not work from RSC/API route
// code, so server callers should use Docker DNS or an explicit public backend.
export const SERVER_BACKEND_URL =
process.env.SURFSENSE_BACKEND_INTERNAL_URL ||
// TODO: Remove FASTAPI_BACKEND_INTERNAL_URL after the post-Caddy env migration window.
process.env.FASTAPI_BACKEND_INTERNAL_URL ||
"http://backend:8000";
// Build-time fallback for packaged clients. Docker runtime reads plain ETL_SERVICE
// through the runtime config provider first, then falls back to this baked value.
export const BUILD_TIME_ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING";
// Build-time fallback for packaged clients. Docker runtime reads plain
// DEPLOYMENT_MODE through the runtime config provider first, then falls back to this baked value.
export const BUILD_TIME_DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted";
// App version - defaults to package.json version // App version - defaults to package.json version
// Can be overridden at build time with NEXT_PUBLIC_APP_VERSION for full git tag version // Can be overridden at build time with NEXT_PUBLIC_APP_VERSION for full git tag version
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version; export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version;
// Helper to check if local auth is enabled
export const isLocalAuth = () => AUTH_TYPE === "LOCAL";
// Helper to check if Google auth is enabled
export const isGoogleAuth = () => AUTH_TYPE === "GOOGLE";
// Helper to check if running in self-hosted mode
export const isSelfHosted = () => DEPLOYMENT_MODE === "self-hosted";
// Helper to check if running in cloud mode
export const isCloud = () => DEPLOYMENT_MODE === "cloud";

View file

@ -75,18 +75,21 @@ export const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
}, },
}; };
export function getAcceptedFileTypes(): Record<string, string[]> { export function getAcceptedFileTypes(etlService?: string): Record<string, string[]> {
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
return FILE_TYPE_CONFIG[etlService || "default"] || FILE_TYPE_CONFIG.default; return FILE_TYPE_CONFIG[etlService || "default"] || FILE_TYPE_CONFIG.default;
} }
export function getSupportedExtensions(acceptedFileTypes?: Record<string, string[]>): string[] { export function getSupportedExtensions(
const types = acceptedFileTypes ?? getAcceptedFileTypes(); acceptedFileTypes?: Record<string, string[]>,
etlService?: string
): string[] {
const types = acceptedFileTypes ?? getAcceptedFileTypes(etlService);
return Array.from(new Set(Object.values(types).flat())).sort(); return Array.from(new Set(Object.values(types).flat())).sort();
} }
export function getSupportedExtensionsSet( export function getSupportedExtensionsSet(
acceptedFileTypes?: Record<string, string[]> acceptedFileTypes?: Record<string, string[]>,
etlService?: string
): Set<string> { ): Set<string> {
return new Set(getSupportedExtensions(acceptedFileTypes).map((ext) => ext.toLowerCase())); return new Set(getSupportedExtensions(acceptedFileTypes, etlService).map((ext) => ext.toLowerCase()));
} }

View file

@ -2,12 +2,18 @@ import { defineConfig, devices } from "@playwright/test";
const PORT = process.env.PORT || "3000"; const PORT = process.env.PORT || "3000";
const BACKEND_PORT = process.env.BACKEND_PORT || "8000"; const BACKEND_PORT = process.env.BACKEND_PORT || "8000";
const ZERO_CACHE_PORT = process.env.ZERO_CACHE_PORT || "4848";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`; const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`;
const useProxyOrigin = process.env.PLAYWRIGHT_USE_PROXY_ORIGIN === "true";
const backendURL = useProxyOrigin ? baseURL : `http://localhost:${BACKEND_PORT}`;
const zeroCacheURL = useProxyOrigin ? `${baseURL}/zero` : `http://localhost:${ZERO_CACHE_PORT}`;
process.env.PLAYWRIGHT_TEST_EMAIL ??= "e2e-test@surfsense.net"; process.env.PLAYWRIGHT_TEST_EMAIL ??= "e2e-test@surfsense.net";
process.env.PLAYWRIGHT_TEST_PASSWORD ??= "E2eTestPassword123!"; process.env.PLAYWRIGHT_TEST_PASSWORD ??= "E2eTestPassword123!";
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ??= `http://localhost:${BACKEND_PORT}`; process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ??= backendURL;
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE ??= "LOCAL"; process.env.SURFSENSE_BACKEND_INTERNAL_URL ??= backendURL;
process.env.AUTH_TYPE ??= "LOCAL";
process.env.NEXT_PUBLIC_ZERO_CACHE_URL ??= zeroCacheURL;
/** /**
* Playwright configuration for SurfSense web E2E tests. * Playwright configuration for SurfSense web E2E tests.
@ -67,7 +73,9 @@ export default defineConfig({
stderr: "pipe", stderr: "pipe",
env: { env: {
NEXT_PUBLIC_FASTAPI_BACKEND_URL: process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL, NEXT_PUBLIC_FASTAPI_BACKEND_URL: process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL,
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE, SURFSENSE_BACKEND_INTERNAL_URL: process.env.SURFSENSE_BACKEND_INTERNAL_URL,
AUTH_TYPE: process.env.AUTH_TYPE,
NEXT_PUBLIC_ZERO_CACHE_URL: process.env.NEXT_PUBLIC_ZERO_CACHE_URL,
}, },
}, },
}); });