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
working-directory: surfsense_web
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_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 }}
- name: Install desktop dependencies
@ -109,6 +111,7 @@ jobs:
run: pnpm build
working-directory: surfsense_desktop
env:
HOSTED_BACKEND_URL: ${{ vars.HOSTED_BACKEND_URL }}
HOSTED_FRONTEND_URL: ${{ vars.HOSTED_FRONTEND_URL }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}

View file

@ -199,11 +199,6 @@ jobs:
build-args: |
${{ matrix.image == 'backend' && format('USE_CUDA={0}', matrix.use_cuda) || '' }}
${{ 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
run: |

View file

@ -27,9 +27,10 @@ jobs:
PLAYWRIGHT_TEST_EMAIL: e2e-test@surfsense.net
PLAYWRIGHT_TEST_PASSWORD: E2eTestPassword123!
# 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_AUTH_TYPE: LOCAL
SURFSENSE_BACKEND_INTERNAL_URL: http://localhost:8000
AUTH_TYPE: LOCAL
# Shared secret for the test-only POST /__e2e__/auth/token endpoint.
# Must match docker-compose.e2e.yml's backend env (x-backend-env).
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
# Deployment mode: self-hosted enables local filesystem connectors; cloud hides them.
DEPLOYMENT_MODE=self-hosted
# Allow new user registrations (TRUE or FALSE)
# REGISTRATION_ENABLED=TRUE
@ -43,51 +46,47 @@ ETL_SERVICE=DOCLING
EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# ------------------------------------------------------------------------------
# Ports (change to avoid conflicts with other services on your machine)
# How You Access SurfSense
# ------------------------------------------------------------------------------
# BACKEND_PORT=8929
# 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
# One public URL. Browser traffic stays same-origin and Caddy routes internally.
SURFSENSE_PUBLIC_URL=http://localhost:3929
# ------------------------------------------------------------------------------
# Custom Domain / Reverse Proxy
# Public Ports
# ------------------------------------------------------------------------------
# ONLY set these if you are serving SurfSense on a real domain via a reverse
# proxy (e.g. Caddy, Nginx, Cloudflare Tunnel).
# For standard localhost deployments, leave all of these commented out.
# they are automatically derived from the port settings above.
# Production Docker exposes only Caddy to your machine. Caddy then routes
# frontend, backend, and zero-cache traffic internally.
#
# NEXT_FRONTEND_URL=https://app.yourdomain.com
# BACKEND_URL=https://api.yourdomain.com
# NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.yourdomain.com
# NEXT_PUBLIC_ZERO_CACHE_URL=https://zero.yourdomain.com
# FASTAPI_BACKEND_INTERNAL_URL=http://backend:8000
# Local default: LISTEN_HTTP_PORT=3929
# Domain default: LISTEN_HTTP_PORT=80 and LISTEN_HTTPS_PORT=443
LISTEN_HTTP_PORT=3929
LISTEN_HTTPS_PORT=443
# ------------------------------------------------------------------------------
# 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)
@ -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
# 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
# pools, so these constraints must hold:
# ZERO_UPSTREAM_MAX_CONNS >= ZERO_NUM_SYNC_WORKERS
# ZERO_CVR_MAX_CONNS >= ZERO_NUM_SYNC_WORKERS
# Each sync worker needs at least 1 connection from both the UPSTREAM and CVR pools.
# Keep ZERO_UPSTREAM_MAX_CONNS and ZERO_CVR_MAX_CONNS greater than or equal to
# ZERO_NUM_SYNC_WORKERS.
# Default of 4 workers is sufficient for self-hosted / personal use.
# ZERO_NUM_SYNC_WORKERS=4
# 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_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
# 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.
# 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.
# The mutate endpoint is a no-op that returns an empty response.
# Default: Docker service networking (http://frontend:3000/api/zero/...).
# Override when running the frontend outside Docker:
# ZERO_QUERY_URL=http://host.docker.internal:3000/api/zero/query
# ZERO_MUTATE_URL=http://host.docker.internal:3000/api/zero/mutate
# Override for custom domain:
# ZERO_QUERY_URL=https://app.yourdomain.com/api/zero/query
# ZERO_MUTATE_URL=https://app.yourdomain.com/api/zero/mutate
# ZERO_QUERY_URL=http://host.docker.internal:3000/api/zero/query
# ZERO_MUTATE_URL=http://host.docker.internal:3000/api/zero/mutate
# Override for custom domain only when zero-cache is not in the bundled Docker network:
# ZERO_QUERY_URL=https://surf.example.com/api/zero/query
# ZERO_MUTATE_URL=https://surf.example.com/api/zero/mutate
# ZERO_QUERY_URL=http://frontend:3000/api/zero/query
# ZERO_MUTATE_URL=http://frontend:3000/api/zero/mutate
@ -222,73 +220,74 @@ STT_SERVICE=local/base
# ------------------------------------------------------------------------------
# -- Google Connectors --
# GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/connector/callback
# GOOGLE_GMAIL_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/gmail/connector/callback
# GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/drive/connector/callback
# GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/calendar/connector/callback
# GOOGLE_GMAIL_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/gmail/connector/callback
# GOOGLE_DRIVE_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/drive/connector/callback
# -- Notion --
# NOTION_CLIENT_ID=
# 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_CLIENT_ID=
# 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_CLIENT_ID=
# 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=
# -- Atlassian (Jira & Confluence) --
# ATLASSIAN_CLIENT_ID=
# ATLASSIAN_CLIENT_SECRET=
# JIRA_REDIRECT_URI=http://localhost:8000/api/v1/auth/jira/connector/callback
# CONFLUENCE_REDIRECT_URI=http://localhost:8000/api/v1/auth/confluence/connector/callback
# JIRA_REDIRECT_URI=http://localhost:3929/api/v1/auth/jira/connector/callback
# CONFLUENCE_REDIRECT_URI=http://localhost:3929/api/v1/auth/confluence/connector/callback
# -- Linear --
# LINEAR_CLIENT_ID=
# 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_CLIENT_ID=
# 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_CLIENT_ID=
# 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_CLIENT_ID=
# MICROSOFT_CLIENT_SECRET=
# TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback
# ONEDRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/onedrive/connector/callback
# TEAMS_REDIRECT_URI=http://localhost:3929/api/v1/auth/teams/connector/callback
# ONEDRIVE_REDIRECT_URI=http://localhost:3929/api/v1/auth/onedrive/connector/callback
# -- Dropbox --
# DROPBOX_APP_KEY=
# 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_API_KEY=
# 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)
# ------------------------------------------------------------------------------
# Configure only the external chat channels you want to use.
# GATEWAY_ENABLED=TRUE
# -- Telegram --
# TELEGRAM_SHARED_BOT_TOKEN=
# TELEGRAM_SHARED_BOT_USERNAME=
# TELEGRAM_WEBHOOK_SECRET=
# GATEWAY_BASE_URL=http://localhost:8929
# GATEWAY_BASE_URL=http://localhost:3929
# GATEWAY_TELEGRAM_INTAKE_MODE=webhook
# -- WhatsApp --
@ -307,20 +306,20 @@ STT_SERVICE=local/base
#
# GATEWAY_SLACK_ENABLED=FALSE
# 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 --
# Uses DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, and DISCORD_BOT_TOKEN from the
# Discord connector section.
#
# 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 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 point at your own SearXNG instance instead of the bundled one:
# SEARXNG_DEFAULT_HOST=http://your-searxng:8080
@ -457,3 +456,36 @@ NOLOGIN_MODE_ENABLED=FALSE
# RESIDENTIAL_PROXY_HOSTNAME=
# RESIDENTIAL_PROXY_LOCATION=
# 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:
build:
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:
- "${FRONTEND_PORT:-3000}:3000"
env_file:
- ../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:
backend:
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
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:
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}}
ports:
- "${BACKEND_PORT:-8929}:8000"
expose:
- "8000"
volumes:
- shared_temp:/shared_tmp
env_file:
@ -113,7 +142,8 @@ services:
PYTHONPATH: /app
UVICORN_LOOP: asyncio
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}
WHATSAPP_BRIDGE_URL: ${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
# Daytona Sandbox uncomment and set credentials to enable cloud code execution
@ -217,8 +247,8 @@ services:
zero-cache:
image: rocicorp/zero:1.4.0
ports:
- "${ZERO_CACHE_PORT:-5929}:4848"
expose:
- "4848"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
@ -252,16 +282,13 @@ services:
frontend:
image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest}
ports:
- "${FRONTEND_PORT:-3929}:3000"
expose:
- "3000"
environment:
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8929}}
NEXT_PUBLIC_ZERO_CACHE_URL: ${NEXT_PUBLIC_ZERO_CACHE_URL:-http://localhost:${ZERO_CACHE_PORT:-5929}}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
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}
AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
SURFSENSE_BACKEND_INTERNAL_URL: http://backend:8000
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
@ -280,5 +307,9 @@ volumes:
name: surfsense-shared-temp
zero_cache_data:
name: surfsense-zero-cache
caddy_data:
name: surfsense-caddy-data
caddy_config:
name: surfsense-caddy-config
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}"
mkdir -p "${INSTALL_DIR}/scripts"
mkdir -p "${INSTALL_DIR}/searxng"
mkdir -p "${INSTALL_DIR}/proxy"
FILES=(
"docker/docker-compose.yml:docker-compose.yml"
"docker/docker-compose.gpu.yml:docker-compose.gpu.yml"
"docker/.env.example:.env.example"
"docker/proxy/Caddyfile:proxy/Caddyfile"
"docker/postgresql.conf:postgresql.conf"
"docker/scripts/migrate-database.sh:scripts/migrate-database.sh"
"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}"
step "SurfSense is now installed [${_version_display}]"
info " Frontend: http://localhost:3929"
info " Backend: http://localhost:8929"
info " API Docs: http://localhost:8929/docs"
_public_url=$(grep '^SURFSENSE_PUBLIC_URL=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | head -1 || true)
_public_url="${_public_url:-http://localhost:3929}"
info " SurfSense: ${_public_url}"
info " Backend: ${_public_url}/api/v1"
info " Zero sync: ${_public_url}/zero"
info ""
info " Config: ${INSTALL_DIR}/.env"
info " Variant: ${_variant_display}"

View file

@ -15,12 +15,9 @@ CELERY_TASK_DEFAULT_QUEUE=surfsense
# Optional: TTL in seconds for connector indexing lock key
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
# Messaging Gateway (global)
# GATEWAY_ENABLED: master switch for ALL messaging gateway channels (Telegram, WhatsApp,
# Slack, Discord). When FALSE, no gateway background workers/supervisors start and all
# 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
# Messaging Gateway: disabled by default; set TRUE to enable chat integrations.
# Supported messaging gateways: WhatsApp, Telegram, Discord, Slack
# GATEWAY_ENABLED=TRUE
# Telegram Gateway
# 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
# Observability - OTel
# SURFSENSE_ENABLE_OTEL=false
# Disabled by default. Uncomment to enable OpenTelemetry.
# SURFSENSE_ENABLE_OTEL=true
# OpenTelemetry - endpoint enables export; absent = no-op.
# Production should point at an OTel Collector. For local docker-compose.dev.yml,
# use http://otel-lgtm:4317 instead.

View file

@ -535,14 +535,15 @@ class Config:
# Platform web search (SearXNG)
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 = 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
# gateway HTTP routes return 404, regardless of the per-channel flags below.
GATEWAY_ENABLED = os.getenv("GATEWAY_ENABLED", "TRUE").upper() == "TRUE"
# gated gateway HTTP routes return 404, regardless of the per-channel flags below.
GATEWAY_ENABLED = os.getenv("GATEWAY_ENABLED", "FALSE").upper() == "TRUE"
TELEGRAM_SHARED_BOT_TOKEN = os.getenv("TELEGRAM_SHARED_BOT_TOKEN")
TELEGRAM_SHARED_BOT_USERNAME = os.getenv("TELEGRAM_SHARED_BOT_USERNAME")
TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET")

View file

@ -8,7 +8,7 @@ from app.config import config
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
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 .export_routes import router as export_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_webhook_routes import router as gateway_whatsapp_webhook_router
from .google_calendar_add_connector_route import (
@ -74,6 +77,7 @@ router.include_router(export_router)
router.include_router(documents_router)
router.include_router(folders_router)
_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_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
router = APIRouter(prefix="/gateway", tags=["gateway"])
config_router = APIRouter(prefix="/gateway", tags=["gateway"])
logger = logging.getLogger(__name__)
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(
user: User = Depends(current_active_user),
) -> 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 {
"enabled": True,
"telegram_enabled": _telegram_gateway_enabled(),
"whatsapp_intake_mode": config.GATEWAY_WHATSAPP_INTAKE_MODE,
"slack_enabled": _slack_gateway_enabled(),

View file

@ -41,7 +41,6 @@ dependencies = [
"elasticsearch>=9.1.1",
"faster-whisper>=1.1.0",
"celery[redis]>=5.5.3",
"flower>=2.0.1",
"redis>=5.2.1",
"firecrawl-py>=4.9.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,
minify: false,
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 || 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 || desktopEnv.POSTHOG_KEY || ''

View file

@ -43,11 +43,13 @@ export async function startNextServer(): Promise<void> {
const standalonePath = getStandalonePath();
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, [], {
cwd: standalonePath,
env: {
...process.env,
...(backendInternalUrl ? { SURFSENSE_BACKEND_INTERNAL_URL: backendInternalUrl } : {}),
PORT: String(serverPort),
// Loopback bind: avoids 0.0.0.0 leaking into request.url and redirect origins.
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.
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
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848
AUTH_TYPE=LOCAL
ETL_SERVICE=DOCLING
DEPLOYMENT_MODE=self-hosted
# Contact Form Vars (optional)
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)
NEXT_PUBLIC_POSTHOG_KEY=

View file

@ -35,21 +35,6 @@ RUN apk add --no-cache git
# 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 . .
@ -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/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
EXPOSE 3000
@ -91,4 +72,4 @@ ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
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 { Separator } from "@/components/ui/separator";
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 {
params: Promise<{ model_slug: string }>;
@ -16,7 +16,7 @@ interface PageProps {
async function getModel(slug: string): Promise<AnonModel | null> {
try {
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 } }
);
if (!res.ok) return null;
@ -28,7 +28,7 @@ async function getModel(slug: string): Promise<AnonModel | null> {
async function getAllModels(): Promise<AnonModel[]> {
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 },
});
if (!res.ok) return [];
@ -136,7 +136,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
export async function generateStaticParams() {
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) {

View file

@ -16,7 +16,7 @@ import {
TableRow,
} from "@/components/ui/table";
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 = {
title: "Free AI Chat, No Login Required | SurfSense",
@ -94,7 +94,7 @@ export const metadata: Metadata = {
async function getModels(): Promise<AnonModel[]> {
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 },
});
if (!res.ok) return [];

View file

@ -3,7 +3,7 @@ import { useTranslations } from "next-intl";
import { useState } from "react";
import { Logo } from "@/components/Logo";
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 { AmbientBackground } from "./AmbientBackground";
@ -51,7 +51,7 @@ export function GoogleLoginButton() {
// cross-origin fetch requests may not be sent on subsequent redirects.
// The authorize-redirect endpoint does a server-side redirect to Google
// 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 (
<div className="relative w-full overflow-hidden">

View file

@ -7,10 +7,10 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { ValidationError } from "@/lib/error";
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
@ -26,7 +26,7 @@ export function LocalLoginForm() {
title: null,
message: null,
});
const authType = AUTH_TYPE;
const { authType } = useRuntimeConfig();
const router = useRouter();
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 { toast } from "sonner";
import { Logo } from "@/components/Logo";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { setRedirectPath } from "@/lib/auth-utils";
import { AUTH_TYPE } from "@/lib/env-config";
import { AmbientBackground } from "./AmbientBackground";
import { GoogleLoginButton } from "./GoogleLoginButton";
import { LocalLoginForm } from "./LocalLoginForm";
@ -19,8 +18,7 @@ function LoginContent() {
const t = useTranslations("auth");
const tCommon = useTranslations("common");
const router = useRouter();
const [authType, setAuthType] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const { authType } = useRuntimeConfig();
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
const searchParams = useSearchParams();
@ -96,19 +94,7 @@ function LoginContent() {
duration: 4000,
});
}
// 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;
}
}, [router, searchParams, t, tCommon]);
if (authType === "GOOGLE") {
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 { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo";
import { useRuntimeConfig } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { getBearerToken } from "@/lib/auth-utils";
import { AUTH_TYPE } from "@/lib/env-config";
import { AppError, ValidationError } from "@/lib/error";
import {
trackRegistrationAttempt,
@ -25,6 +25,7 @@ import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
const t = useTranslations("auth");
const tCommon = useTranslations("common");
const { authType } = useRuntimeConfig();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@ -44,10 +45,10 @@ export default function RegisterPage() {
router.replace("/dashboard");
return;
}
if (AUTH_TYPE !== "LOCAL") {
if (authType !== "LOCAL") {
router.push("/login");
}
}, [router]);
}, [authType, router]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();

View file

@ -14,7 +14,11 @@ const HOP_BY_HOP_HEADERS = new Set([
]);
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;
}

View file

@ -1,7 +1,7 @@
import { mustGetQuery } from "@rocicorp/zero";
import { handleQueryRequest } from "@rocicorp/zero/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 { queries } from "@/zero/queries";
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://localhost:8929) does NOT resolve from inside the frontend
// container and would make every authenticated Zero query fail with a 503.
const backendURL = (
process.env.FASTAPI_BACKEND_INTERNAL_URL ||
process.env.BACKEND_URL ||
"http://localhost:8000"
).replace(/\/$/, "");
const backendURL = SERVER_BACKEND_URL.replace(/\/$/, "");
async function authenticateRequest(
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 redirectUrl = new URL(`/dashboard/${search_space_id}/new-chat`, request.url);
const response = NextResponse.redirect(redirectUrl, { status: 302 });
const response = new NextResponse(null, {
status: 302,
headers: {
Location: `/dashboard/${search_space_id}/new-chat`,
},
});
response.cookies.set(OAUTH_RESULT_COOKIE, result, {
path: "/",
maxAge: 60,

View file

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

View file

@ -1,10 +1,11 @@
"use client";
import { RefreshCw, ShieldAlert } from "lucide-react";
import { AlertTriangle, RefreshCw, ShieldAlert } from "lucide-react";
import { useParams } from "next/navigation";
import { QRCodeSVG } from "qrcode.react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@ -19,7 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import type { SearchSpace } from "@/contracts/types/search-space.types";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
import { cn } from "@/lib/utils";
type GatewayConnection = {
@ -39,6 +40,7 @@ type GatewayConnection = {
};
type GatewayConfig = {
enabled: boolean;
telegram_enabled: boolean;
whatsapp_intake_mode: "disabled" | "cloud" | "baileys";
slack_enabled: boolean;
@ -47,6 +49,14 @@ type GatewayConfig = {
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 = {
binding_id: number;
code: string;
@ -80,16 +90,26 @@ export function MessagingChannelsContent() {
const whatsappMode = gatewayConfig?.whatsapp_intake_mode ?? "disabled";
const slackGatewayEnabled = gatewayConfig?.slack_enabled ?? false;
const discordGatewayEnabled = gatewayConfig?.discord_enabled ?? false;
const gatewayDisabled = gatewayConfig?.enabled === false;
const fetchConnections = useCallback(async (platform?: GatewayPlatform) => {
const query = platform ? `?platform=${encodeURIComponent(platform)}` : "";
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/connections${query}`);
return (await res.json()) as GatewayConnection[];
const res = await authenticatedFetch(
buildBackendUrl("/api/v1/gateway/connections", platform ? { platform } : undefined)
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as GatewayConnection[]) : [];
}, []);
const fetchGatewayConfig = useCallback(async () => {
const res = await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/config`);
return (await res.json()) as GatewayConfig;
const fetchGatewayConfig = useCallback(async (): Promise<GatewayConfig> => {
const res = await authenticatedFetch(buildBackendUrl("/api/v1/gateway/config"));
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 () => {
@ -125,7 +145,9 @@ export function MessagingChannelsContent() {
const refreshBaileysHealth = useCallback(async () => {
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;
const data = (await res.json()) as BaileysHealth;
setBaileysHealth(data);
@ -136,7 +158,7 @@ export function MessagingChannelsContent() {
}, [refreshBaileysHealth]);
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform, search_space_id: searchSpaceId }),
@ -148,7 +170,7 @@ export function MessagingChannelsContent() {
async function installSlackGateway() {
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;
const data = (await res.json()) as { auth_url?: string };
@ -159,7 +181,7 @@ export function MessagingChannelsContent() {
async function installDiscordGateway() {
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;
const data = (await res.json()) as { auth_url?: string };
@ -181,8 +203,8 @@ export function MessagingChannelsContent() {
async function revoke(connection: GatewayConnection) {
const url =
connection.route_type === "account" && connection.account_id
? `${BACKEND_URL}/api/v1/gateway/accounts/${connection.account_id}`
: `${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}`;
? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}`)
: buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}`);
await authenticatedFetch(url, {
method: "DELETE",
});
@ -205,8 +227,8 @@ export function MessagingChannelsContent() {
);
const url =
connection.route_type === "account" && connection.account_id
? `${BACKEND_URL}/api/v1/gateway/accounts/${connection.account_id}/search-space`
: `${BACKEND_URL}/api/v1/gateway/bindings/${connection.id}/search-space`;
? buildBackendUrl(`/api/v1/gateway/accounts/${connection.account_id}/search-space`)
: buildBackendUrl(`/api/v1/gateway/bindings/${connection.id}/search-space`);
const res = await authenticatedFetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@ -222,7 +244,7 @@ export function MessagingChannelsContent() {
}
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",
});
await refreshPlatform(connection.platform as GatewayPlatform);
@ -381,7 +403,21 @@ export function MessagingChannelsContent() {
<div className="grid items-stretch gap-3 sm:grid-cols-2">
{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">
<CardHeader className="space-y-1.5 p-4">
<CardTitle className="text-sm">No messaging gateways enabled</CardTitle>
@ -389,7 +425,7 @@ export function MessagingChannelsContent() {
</Card>
) : 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">
<CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3">
@ -425,7 +461,7 @@ export function MessagingChannelsContent() {
</Card>
) : 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">
<CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3">
@ -457,7 +493,7 @@ export function MessagingChannelsContent() {
</Card>
) : 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">
<CardHeader className="space-y-1.5 p-4 pb-2">
<div className="flex items-center justify-between gap-3">
@ -489,7 +525,7 @@ export function MessagingChannelsContent() {
</Card>
) : 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">
<CardHeader className="space-y-1.5 p-4 pb-2">
<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 { 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";
import { RuntimeConfig } from "@/components/providers/runtime-config.server";
import { DashboardShell } from "./dashboard-shell";
interface DashboardLayoutProps {
children: React.ReactNode;
}
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 (
<div className="h-full flex flex-col ">
<div className="flex-1 min-h-0">{children}</div>
</div>
<RuntimeConfig>
<DashboardShell>{children}</DashboardShell>
</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 { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { DEFAULT_SHORTCUTS, keyEventToAccelerator } from "@/components/desktop/shortcut-recorder";
import { useIsGoogleAuth } from "@/components/providers/runtime-config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -17,9 +18,8 @@ import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
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 ShortcutMap = typeof DEFAULT_SHORTCUTS;
@ -189,6 +189,7 @@ function HotkeyRow({
export default function DesktopLoginPage() {
const router = useRouter();
const api = useElectronAPI();
const isGoogleAuth = useIsGoogleAuth();
const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom);
const [email, setEmail] = useState("");
@ -239,7 +240,7 @@ export default function DesktopLoginPage() {
const handleGoogleLogin = () => {
if (isGoogleRedirecting) return;
setIsGoogleRedirecting(true);
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
window.location.href = buildBackendUrl("/auth/google/authorize-redirect");
};
const autoSetSearchSpace = async () => {

View file

@ -2,6 +2,7 @@ import { loader } from "fumadocs-core/source";
import type { MetadataRoute } from "next";
import { blog, changelog } from "@/.source/server";
import { source as docsSource } from "@/lib/source";
import { SERVER_BACKEND_URL } from "@/lib/env-config";
const blogSource = loader({
baseUrl: "/blog",
@ -14,11 +15,10 @@ const changelogSource = loader({
});
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[]> {
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 },
});
if (!res.ok) return [];

View file

@ -1,12 +1,16 @@
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) {
const response = await fetch(`${backendBaseUrl}/verify-token`, {
const response = await fetch(`${getBackendBaseUrl()}/verify-token`, {
method: "GET",
headers: {
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 { EnumConnectorName } from "@/contracts/enums/connector";
import { useApiKey } from "@/hooks/use-api-key";
import { BACKEND_URL } from "@/lib/env-config";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";

View file

@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
import type { ConnectorConfigProps } from "../index";
export interface CirclebackConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
@ -42,17 +42,10 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
const doFetch = async () => {
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);
try {
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 }
);
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 { authenticatedFetch } from "@/lib/auth-utils";
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 { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
@ -95,12 +95,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
if (!spaceId || !reauthEndpoint) return;
setReauthing(true);
try {
const backendUrl = BACKEND_URL;
const url = new URL(`${backendUrl}${reauthEndpoint}`);
url.searchParams.set("connector_id", String(connector.id));
url.searchParams.set("space_id", String(spaceId));
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
const response = await authenticatedFetch(
buildBackendUrl(reauthEndpoint, {
connector_id: connector.id,
space_id: spaceId,
return_url: window.location.pathname,
})
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
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 { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types/oauth.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
import {
trackConnectorConnected,
trackConnectorDeleted,
@ -351,9 +351,7 @@ export const useConnectorDialog = () => {
trackConnectorSetupStarted(Number(searchSpaceId), connector.connectorType, "oauth_click");
try {
// Check if authEndpoint already has query parameters
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
const url = `${BACKEND_URL}${connector.authEndpoint}${separator}space_id=${searchSpaceId}`;
const url = buildBackendUrl(connector.authEndpoint, { space_id: searchSpaceId });
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 type { SearchSourceConnector } from "@/contracts/types/connector.types";
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 {
COMPOSIO_CONNECTORS,
@ -22,6 +22,11 @@ type OAuthConnector = (typeof OAUTH_CONNECTORS)[number];
type ComposioConnector = (typeof COMPOSIO_CONNECTORS)[number];
type OtherConnector = (typeof OTHER_CONNECTORS)[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.
@ -66,14 +71,14 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onManage,
onViewAccountsList,
}) => {
const selfHosted = isSelfHosted();
const selfHosted = useIsSelfHosted();
const { isDesktop } = usePlatform();
const matchesSearch = (title: string, description: string) =>
title.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);
// 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 { authenticatedFetch } from "@/lib/auth-utils";
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 { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
@ -61,12 +61,13 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
if (!searchSpaceId || !endpoint) return;
setReauthingId(connector.id);
try {
const backendUrl = BACKEND_URL;
const url = new URL(`${backendUrl}${endpoint}`);
url.searchParams.set("connector_id", String(connector.id));
url.searchParams.set("space_id", String(searchSpaceId));
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
const response = await authenticatedFetch(
buildBackendUrl(endpoint, {
connector_id: connector.id,
space_id: searchSpaceId,
return_url: window.location.pathname,
})
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication.");

View file

@ -1,40 +1,6 @@
"use client";
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 {
/**
@ -46,51 +12,17 @@ interface 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 = () => {
if (variant === "desktop") {
return isGoogleAuth
? "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";
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";
}
if (variant === "compact") {
return isGoogleAuth
? "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";
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";
}
// mobile
return isGoogleAuth
? "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";
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";
};
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 (
<Link href="/login" className={getClassName()}>
Sign In

View file

@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
interface DownloadOriginalButtonProps {
documentId: number;
@ -41,7 +41,7 @@ export function DownloadOriginalButton({ documentId }: DownloadOriginalButtonPro
setDownloading(true);
try {
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/documents/${documentId}/download-original`,
buildBackendUrl(`/api/v1/documents/${documentId}/download-original`),
{ method: "GET" }
);
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 { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
@ -260,10 +260,12 @@ export function EditorPanelContent({
return;
}
const url = new URL(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
const response = await authenticatedFetch(
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;
@ -402,7 +404,7 @@ export function EditorPanelContent({
return;
}
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",
headers: { "Content-Type": "application/json" },
@ -496,7 +498,9 @@ export function EditorPanelContent({
setDownloading(true);
try {
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" }
);
if (!response.ok) throw new Error("Download failed");

View file

@ -1,6 +1,7 @@
"use client";
import { authenticatedFetch } from "@/lib/auth-utils";
import { buildBackendUrl } from "@/lib/env-config";
export type MemoryScope = "user" | "team";
@ -29,10 +30,6 @@ function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) {
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) {
if (!limits) {
return {
@ -65,7 +62,7 @@ export async function fetchMemoryEditorDocument({
title?: string | null;
signal?: AbortSignal;
}) {
const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), {
const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, searchSpaceId)), {
method: "GET",
signal,
});
@ -97,7 +94,7 @@ export async function saveMemoryMarkdown({
searchSpaceId?: number | null;
markdown: string;
}) {
const response = await authenticatedFetch(getBackendUrl(getMemoryPath(scope, searchSpaceId)), {
const response = await authenticatedFetch(buildBackendUrl(getMemoryPath(scope, searchSpaceId)), {
method: "PUT",
headers: { "Content-Type": "application/json" },
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 { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
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 { cn } from "@/lib/utils";
import { QuotaBar } from "./quota-bar";
@ -81,7 +81,7 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
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",
headers: { "Content-Type": "application/json" },
credentials: "include",

View file

@ -33,7 +33,7 @@ import {
updateThinkingSteps,
updateToolCall,
} 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 { FreeThread } from "./free-thread";
import { RemoveAdsBanner } from "./remove-ads-banner";
@ -176,7 +176,7 @@ export function FreeChatPage() {
if (!webSearchEnabled) reqBody.disabled_tools = ["web_search"];
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",
headers: { "Content-Type": "application/json" },
credentials: "include",

View file

@ -37,38 +37,8 @@ import {
getAssetLabel,
usePrimaryDownload,
} 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";
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 = {
id: string;
title: string;
@ -314,31 +284,6 @@ export function HeroSection() {
}
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 (
<Button
asChild

View file

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

View file

@ -11,7 +11,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
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
@ -108,10 +108,12 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
}
try {
const url = new URL(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
const response = await authenticatedFetch(
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;
@ -165,7 +167,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setSaving(true);
try {
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",
headers: { "Content-Type": "application/json" },
@ -323,7 +325,9 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setDownloading(true);
try {
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" }
);
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 { 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() {
const zero = useZero();
@ -42,6 +50,7 @@ function ZeroAuthSync() {
export function ZeroProvider({ children }: { children: React.ReactNode }) {
const { data: user } = useAtomValue(currentUserAtom);
const cacheURL = useMemo(() => getCacheURL(), []);
const userId = user?.id;
const hasUser = !!userId;
@ -65,7 +74,7 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) {
cacheURL,
auth,
}),
[userID, context, auth]
[userID, context, cacheURL, auth]
);
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 { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
function ReportPanelSkeleton() {
return (
@ -245,7 +245,7 @@ export function ReportPanelContent({
URL.revokeObjectURL(url);
} else {
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`,
buildBackendUrl(`/api/v1/reports/${activeReportId}/export`, { format }),
{ method: "GET" }
);
@ -278,7 +278,7 @@ export function ReportPanelContent({
setSaving(true);
try {
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/reports/${activeReportId}/content`,
buildBackendUrl(`/api/v1/reports/${activeReportId}/content`),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@ -506,7 +506,11 @@ export function ReportPanelContent({
</div>
) : reportContent.content_type === "typst" ? (
<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}
toolbarActions={
<>

View file

@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
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 { Spinner } from "../ui/spinner";
@ -49,7 +49,7 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
setIsExporting(true);
try {
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/export`),
{ method: "GET" }
);
if (!response.ok) {

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getBearerToken } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
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) {
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, {
headers: { Authorization: `Bearer ${token || ""}` },
});

View file

@ -10,7 +10,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { baseApiService } from "@/lib/apis/base-api.service";
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 { FPS } from "@/lib/remotion/constants";
import {
@ -137,7 +137,6 @@ function VideoPresentationPlayer({
const [isPptxExporting, setIsPptxExporting] = useState(false);
const [pptxProgress, setPptxProgress] = useState<string | null>(null);
const backendUrl = BACKEND_URL ?? "";
const audioBlobUrlsRef = useRef<string[]>([]);
const loadPresentation = useCallback(async () => {
@ -177,7 +176,7 @@ function VideoPresentationPlayer({
title: scene.title ?? slide.title,
code: scene.code,
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 {
setIsLoading(false);
}
}, [presentationId, backendUrl, shareToken]);
}, [presentationId, shareToken]);
useEffect(() => {
loadPresentation();

View file

@ -10,7 +10,11 @@ cd SurfSense/docker
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
@ -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_PASSWORD` | pgAdmin login password | `surfsense` |
| `REDIS_PORT` | Exposed Redis port (internal-only in prod) | `6379` |
| `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` | Frontend build arg for auth type | `LOCAL` |
| `NEXT_PUBLIC_ETL_SERVICE` | Frontend build arg for ETL service | `DOCLING` |
| `NEXT_PUBLIC_ZERO_CACHE_URL` | Frontend build arg for Zero-cache URL | `http://localhost:4848` |
| `NEXT_PUBLIC_DEPLOYMENT_MODE` | Frontend build arg for deployment mode | `self-hosted` |
| `AUTH_TYPE` | Runtime auth mode | `LOCAL` |
| `ETL_SERVICE` | Runtime document parsing service | `DOCLING` |
| `DEPLOYMENT_MODE` | Runtime 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:
- **Frontend**: [http://localhost:3929](http://localhost:3929)
- **Backend API**: [http://localhost:8929](http://localhost:8929)
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs)
- **SurfSense**: [http://localhost:3929](http://localhost:3929)
- **Backend API**: [http://localhost:3929/api/v1](http://localhost:3929/api/v1)
- **Zero sync**: `ws://localhost:3929/zero`
---
## 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.
### Ports
### Public URL and Ports
| Variable | Description | Default |
|----------|-------------|---------|
| `FRONTEND_PORT` | Frontend service port | `3929` |
| `BACKEND_PORT` | Backend API service port | `8929` |
| `ZERO_CACHE_PORT` | Zero-cache real-time sync port | `5929` |
| `SURFSENSE_PUBLIC_URL` | Public origin used by the frontend, backend OAuth callbacks, and Zero browser URL | `http://localhost:3929` |
| `SURFSENSE_SITE_ADDRESS` | Caddy site address. `:80` means local plain HTTP; a hostname enables automatic HTTPS | `:80` |
| `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 |
|----------|-------------|
| `NEXT_FRONTEND_URL` | Public frontend URL (e.g. `https://app.yourdomain.com`) |
| `BACKEND_URL` | Public backend URL for OAuth callbacks (e.g. `https://api.yourdomain.com`) |
| `NEXT_PUBLIC_FASTAPI_BACKEND_URL` | Backend URL used by the frontend (e.g. `https://api.yourdomain.com`) |
| `NEXT_PUBLIC_ZERO_CACHE_URL` | Zero-cache URL used by the frontend (e.g. `https://zero.yourdomain.com`) |
| `CERT_EMAIL` | Optional ACME contact email |
| `CERT_ACME_CA` | ACME directory URL; use Let's Encrypt staging when testing cert issuance |
| `CERT_ACME_DNS` | DNS-01 challenge config; requires the custom Caddy build |
| `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)
@ -165,7 +200,10 @@ Create credentials at the [Google Cloud Console](https://console.cloud.google.co
### 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 |
|-----------|-----------|
@ -218,6 +256,7 @@ for full setup.
| Service | Description |
|---------|-------------|
| `proxy` | Caddy reverse proxy; the only public ingress in production Docker |
| `db` | PostgreSQL with pgvector extension |
| `migrations` | Short-lived: runs `alembic upgrade head` and verifies `zero_publication`, then exits |
| `redis` | Message broker for Celery |
@ -226,7 +265,7 @@ for full setup.
| `celery_worker` | Background task processing (document indexing, etc.) |
| `celery_beat` | Periodic task scheduler (connector sync) |
| `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`.
@ -292,9 +331,9 @@ docker compose down -v
## 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`.
- **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.
### 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:
- **Frontend**: [http://localhost:3929](http://localhost:3929)
- **Backend API**: [http://localhost:8929](http://localhost:8929)
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs)
- **Zero-cache**: [http://localhost:5929](http://localhost:5929)
- **SurfSense**: [http://localhost:3929](http://localhost:3929)
- **Backend API**: [http://localhost:3929/api/v1](http://localhost:3929/api/v1)
- **Zero sync**: `ws://localhost:3929/zero`
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 |
|----------|-------------|---------|
| `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_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_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` |
@ -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.
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)).
For the full manual setup walkthrough, see the [Manual Installation guide](/docs/manual-installation).
### 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
@ -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`).
- **"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.
## Learn More

View file

@ -546,7 +546,10 @@ cd ../docker
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
@ -577,12 +580,13 @@ Copy-Item -Path .env.example -Destination .env
Edit the `.env` file and set:
| ENV VARIABLE | DESCRIPTION |
| ------------------------------- | ------------------------------------------- |
| NEXT_PUBLIC_FASTAPI_BACKEND_URL | Backend URL (e.g., `http://localhost:8000`) |
| 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 |
| NEXT_PUBLIC_ETL_SERVICE | Document parsing service (should match backend ETL_SERVICE): `UNSTRUCTURED`, `LLAMACLOUD`, or `DOCLING` - affects supported file formats in upload interface |
| NEXT_PUBLIC_ZERO_CACHE_URL | URL for Zero-cache real-time sync service (e.g., `http://localhost:4848`) |
| ENV VARIABLE | DESCRIPTION |
| --- | --- |
| `SURFSENSE_BACKEND_INTERNAL_URL` | Backend URL used by Next.js server routes, e.g. `http://localhost:8000` or `http://backend:8000` in Docker |
| `AUTH_TYPE` | Same value as backend auth type: `GOOGLE` for OAuth with Google, `LOCAL` for email/password authentication |
| `ETL_SERVICE` | Document parsing service (should match backend ETL_SERVICE): `UNSTRUCTURED`, `LLAMACLOUD`, or `DOCLING`; affects supported file formats in the upload interface |
| `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
@ -693,7 +697,7 @@ To verify your installation:
- **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
- **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 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.

View file

@ -15,17 +15,19 @@ wired by Compose.
## Public URLs
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
URL. Use your deployed backend URL or a tunnel such as Cloudflare Tunnel or
ngrok.
public webhooks from Telegram, WhatsApp, and Slack require a public HTTPS
SurfSense URL. Use your deployed domain or a tunnel such as Cloudflare Tunnel
or ngrok.
When using a custom domain or tunnel, set:
When using a custom domain or tunnel with the bundled Caddy proxy, set:
```bash
BACKEND_URL=https://api.example.com
GATEWAY_BASE_URL=https://api.example.com
NEXT_FRONTEND_URL=https://app.example.com
NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.example.com
SURFSENSE_PUBLIC_URL=https://surf.example.com
SURFSENSE_SITE_ADDRESS=surf.example.com
LISTEN_HTTP_PORT=80
LISTEN_HTTPS_PORT=443
CERT_EMAIL=you@example.com
GATEWAY_BASE_URL=https://surf.example.com
```
## 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,
});
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;

View file

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

View file

@ -7,7 +7,7 @@ import {
getAnonModelResponse,
getAnonModelsResponse,
} from "@/contracts/types/anonymous-chat.types";
import { BACKEND_URL } from "../env-config";
import { buildBackendUrl } from "../env-config";
import { ValidationError } from "../error";
const BASE = "/api/v1/public/anon-chat";
@ -17,14 +17,8 @@ export type AnonUploadResult =
| { ok: false; reason: "quota_exceeded" };
class AnonymousChatApiService {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private fullUrl(path: string): string {
return `${this.baseUrl}${BASE}${path}`;
return buildBackendUrl(`${BASE}${path}`);
}
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 { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
import { getClientPlatform } from "../agent-filesystem";
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
import {
@ -31,8 +31,6 @@ export type RequestOptions = {
};
class BaseApiService {
baseUrl: string;
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"];
// Prefixes that don't require auth (checked with startsWith)
@ -44,12 +42,9 @@ class BaseApiService {
return typeof window !== "undefined" ? getBearerToken() || "" : "";
}
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// Keep for backward compatibility, but token is now always read from localStorage
setBearerToken(_bearerToken: string) {
void _bearerToken;
// 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
const isNoAuthEndpoint =
this.noAuthEndpoints.includes(url) ||
@ -107,8 +97,7 @@ class BaseApiService {
throw new AuthenticationError("You are not authenticated. Please login again.");
}
// Construct the full URL
const fullUrl = new URL(url, this.baseUrl).toString();
const fullUrl = buildBackendUrl(url);
// Prepare fetch options
const fetchOptions: RequestInit = {
@ -384,7 +373,8 @@ class BaseApiService {
options?: Omit<RequestOptions, "method" | "responseType" | "body"> & { body: FormData }
) {
// 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, {
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
*/
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
@ -195,7 +195,7 @@ export async function logout(): Promise<boolean> {
// Call backend to revoke the refresh token
if (refreshToken) {
try {
const response = await fetch(`${BACKEND_URL}/auth/jwt/revoke`, {
const response = await fetch(buildBackendUrl("/auth/jwt/revoke"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -273,7 +273,7 @@ export async function refreshAccessToken(): Promise<string | null> {
isRefreshing = true;
refreshPromise = (async () => {
try {
const response = await fetch(`${BACKEND_URL}/auth/jwt/refresh`, {
const response = await fetch(buildBackendUrl("/auth/jwt/refresh"), {
method: "POST",
headers: {
"Content-Type": "application/json",

View file

@ -4,7 +4,7 @@
*/
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
// =============================================================================
@ -227,5 +227,5 @@ export interface RegenerateParams {
* Get the URL for the regenerate endpoint (for streaming fetch)
*/
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.
*
* This file centralizes access to NEXT_PUBLIC_* environment variables.
* For Docker deployments, these placeholders are replaced at container startup
* via sed in the entrypoint script.
*
* IMPORTANT: Do not use template literals or complex expressions with these values
* as it may prevent the sed replacement from working correctly.
* Docker deployments use same-origin relative browser URLs behind Caddy.
* NEXT_PUBLIC_* values remain only as build-time fallbacks for packaged clients
* like Electron, where there is no bundled Caddy origin.
*/
import packageJson from "../package.json";
// Auth type: "LOCAL" for email/password, "GOOGLE" for OAuth
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__
export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
// Build-time fallback for packaged clients. Docker runtime reads plain AUTH_TYPE
// through the runtime config provider first, then falls back to this baked value.
export const BUILD_TIME_AUTH_TYPE = process.env.NEXT_PUBLIC_AUTH_TYPE || "GOOGLE";
// Backend API URL
// Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_URL__
export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
// Backend API URL. An empty string is valid in proxy mode and means
// same-origin relative requests (e.g. /api/v1/... and /auth/...).
export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "";
// ETL Service: "DOCLING", "UNSTRUCTURED", or "LLAMACLOUD"
// Placeholder: __NEXT_PUBLIC_ETL_SERVICE__
export const ETL_SERVICE = process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING";
type BackendUrlParam = string | number | boolean | null | undefined;
// Deployment Mode: "self-hosted" or "cloud"
// Matches backend's SURFSENSE_DEPLOYMENT_MODE - defaults to "self-hosted"
// self-hosted: Full access to local file system connectors (Obsidian, etc.)
// cloud: Only cloud-based connectors available
// Placeholder: __NEXT_PUBLIC_DEPLOYMENT_MODE__
export const DEPLOYMENT_MODE = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted";
/**
* Build browser-facing backend URLs without breaking proxy mode.
*
* In proxy mode BACKEND_URL intentionally stays empty, so callers must keep
* same-origin relative URLs ("/api/v1/...") and let Caddy route them. When
* 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
// 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;
// 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[]> {
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
export function getAcceptedFileTypes(etlService?: string): Record<string, string[]> {
return FILE_TYPE_CONFIG[etlService || "default"] || FILE_TYPE_CONFIG.default;
}
export function getSupportedExtensions(acceptedFileTypes?: Record<string, string[]>): string[] {
const types = acceptedFileTypes ?? getAcceptedFileTypes();
export function getSupportedExtensions(
acceptedFileTypes?: Record<string, string[]>,
etlService?: string
): string[] {
const types = acceptedFileTypes ?? getAcceptedFileTypes(etlService);
return Array.from(new Set(Object.values(types).flat())).sort();
}
export function getSupportedExtensionsSet(
acceptedFileTypes?: Record<string, string[]>
acceptedFileTypes?: Record<string, string[]>,
etlService?: 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 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 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_PASSWORD ??= "E2eTestPassword123!";
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ??= `http://localhost:${BACKEND_PORT}`;
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE ??= "LOCAL";
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ??= backendURL;
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.
@ -67,7 +73,9 @@ export default defineConfig({
stderr: "pipe",
env: {
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,
},
},
});