diff --git a/surfsense_web/app/(home)/free/[model_slug]/page.tsx b/surfsense_web/app/(home)/free/[model_slug]/page.tsx index e72c3d6e3..71fc925e4 100644 --- a/surfsense_web/app/(home)/free/[model_slug]/page.tsx +++ b/surfsense_web/app/(home)/free/[model_slug]/page.tsx @@ -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 { 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 { async function getAllModels(): Promise { 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 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) { diff --git a/surfsense_web/app/(home)/free/page.tsx b/surfsense_web/app/(home)/free/page.tsx index 375d44b7e..b754502f6 100644 --- a/surfsense_web/app/(home)/free/page.tsx +++ b/surfsense_web/app/(home)/free/page.tsx @@ -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 { 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 []; diff --git a/surfsense_web/app/api/zero/query/route.ts b/surfsense_web/app/api/zero/query/route.ts index 35ef51fb5..f08b012e7 100644 --- a/surfsense_web/app/api/zero/query/route.ts +++ b/surfsense_web/app/api/zero/query/route.ts @@ -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 diff --git a/surfsense_web/app/sitemap.ts b/surfsense_web/app/sitemap.ts index 82ec405f9..d18d0440f 100644 --- a/surfsense_web/app/sitemap.ts +++ b/surfsense_web/app/sitemap.ts @@ -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 { 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 []; diff --git a/surfsense_web/components/editor-panel/memory.ts b/surfsense_web/components/editor-panel/memory.ts index aa5b1f68d..7f939772c 100644 --- a/surfsense_web/components/editor-panel/memory.ts +++ b/surfsense_web/components/editor-panel/memory.ts @@ -1,6 +1,7 @@ "use client"; import { authenticatedFetch } from "@/lib/auth-utils"; +import { BACKEND_URL } from "@/lib/env-config"; export type MemoryScope = "user" | "team"; @@ -30,7 +31,7 @@ function getMemoryPath(scope: MemoryScope, searchSpaceId?: number | null) { } function getBackendUrl(path: string) { - return `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${path}`; + return `${BACKEND_URL}${path}`; } export function getMemoryLimitState(length: number, limits?: MemoryLimits | null) { diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 6c6668319..d62de0f2a 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -820,8 +820,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`; + ? `${BACKEND_URL}/api/v1/users/me/memory` + : `${BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory`; const response = await authenticatedFetch(endpoint, { method: "GET" }); if (!response.ok) { const errorData = await response.json().catch(() => ({ detail: "Export failed" })); @@ -1028,8 +1028,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`; + ? `${BACKEND_URL}/api/v1/users/me/memory/reset` + : `${BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory/reset`; try { const response = await authenticatedFetch(endpoint, { method: "POST" }); if (!response.ok) { diff --git a/surfsense_web/components/providers/ZeroProvider.tsx b/surfsense_web/components/providers/ZeroProvider.tsx index 5bb43db99..35d51311a 100644 --- a/surfsense_web/components/providers/ZeroProvider.tsx +++ b/surfsense_web/components/providers/ZeroProvider.tsx @@ -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 ( diff --git a/surfsense_web/docker-entrypoint.js b/surfsense_web/docker-entrypoint.js index 8323f5652..1dc6883ed 100644 --- a/surfsense_web/docker-entrypoint.js +++ b/surfsense_web/docker-entrypoint.js @@ -12,21 +12,31 @@ const fs = require("fs"); const path = require("path"); +function envValue(name, fallback, { allowEmpty = false } = {}) { + if (Object.hasOwn(process.env, name)) { + const value = process.env[name]; + if (allowEmpty || value) { + return value ?? ""; + } + } + return fallback; +} + const replacements = [ [ "__NEXT_PUBLIC_FASTAPI_BACKEND_URL__", - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000", + envValue("NEXT_PUBLIC_FASTAPI_BACKEND_URL", "http://localhost:8000", { allowEmpty: true }), ], [ "__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__", - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL", + envValue("NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE", "LOCAL"), ], - ["__NEXT_PUBLIC_ETL_SERVICE__", process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"], + ["__NEXT_PUBLIC_ETL_SERVICE__", envValue("NEXT_PUBLIC_ETL_SERVICE", "DOCLING")], [ "__NEXT_PUBLIC_ZERO_CACHE_URL__", - process.env.NEXT_PUBLIC_ZERO_CACHE_URL || "http://localhost:4848", + envValue("NEXT_PUBLIC_ZERO_CACHE_URL", "http://localhost:4848", { allowEmpty: true }), ], - ["__NEXT_PUBLIC_DEPLOYMENT_MODE__", process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"], + ["__NEXT_PUBLIC_DEPLOYMENT_MODE__", envValue("NEXT_PUBLIC_DEPLOYMENT_MODE", "self-hosted")], ]; let filesProcessed = 0; diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index a0039b63a..fbef77dfe 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -93,11 +93,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 +102,8 @@ class BaseApiService { throw new AuthenticationError("You are not authenticated. Please login again."); } - // Construct the full URL - const fullUrl = new URL(url, this.baseUrl).toString(); + // Construct the full URL. Empty baseUrl is valid for same-origin proxy mode. + const fullUrl = this.baseUrl ? new URL(url, this.baseUrl).toString() : url; // Prepare fetch options const fetchOptions: RequestInit = { diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index 80db395c6..e8f4a7e1a 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -15,9 +15,18 @@ import packageJson from "../package.json"; // Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__ export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; -// Backend API URL +// Backend API URL. An empty string is valid in proxy mode and means +// same-origin relative requests (e.g. /api/v1/... and /auth/...). // Placeholder: __NEXT_PUBLIC_FASTAPI_BACKEND_URL__ -export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; +export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "http://localhost:8000"; + +// 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.FASTAPI_BACKEND_INTERNAL_URL || + process.env.BACKEND_URL || + BACKEND_URL || + "http://localhost:8000"; // ETL Service: "DOCLING", "UNSTRUCTURED", or "LLAMACLOUD" // Placeholder: __NEXT_PUBLIC_ETL_SERVICE__ diff --git a/surfsense_web/playwright.config.ts b/surfsense_web/playwright.config.ts index ef066a9be..96757a72d 100644 --- a/surfsense_web/playwright.config.ts +++ b/surfsense_web/playwright.config.ts @@ -2,12 +2,17 @@ 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_URL ??= backendURL; process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE ??= "LOCAL"; +process.env.NEXT_PUBLIC_ZERO_CACHE_URL ??= zeroCacheURL; /** * Playwright configuration for SurfSense web E2E tests. @@ -68,6 +73,7 @@ export default defineConfig({ 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, + NEXT_PUBLIC_ZERO_CACHE_URL: process.env.NEXT_PUBLIC_ZERO_CACHE_URL, }, }, });