From 371ff866c7bc8628a7c3fc2af2f933c80f10b633 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 16 Jun 2026 04:56:23 +0530 Subject: [PATCH] refactor(web): replace BACKEND_URL with buildBackendUrl for dynamic URL construction --- .../new-chat/[[...chat_id]]/page.tsx | 11 ++-- .../views/connector-edit-view.tsx | 15 ++--- .../views/connector-accounts-list-view.tsx | 15 ++--- .../components/editor-panel/editor-panel.tsx | 16 +++-- .../layout/ui/tabs/DocumentTabContent.tsx | 16 +++-- surfsense_web/eslint.config.mjs | 28 ++++++++- .../hooks/use-search-source-connectors.ts | 61 ++++++++----------- surfsense_web/lib/apis/base-api.service.ts | 17 ++---- surfsense_web/lib/env-config.ts | 38 ++++++++++++ 9 files changed, 138 insertions(+), 79 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 0c4fa63ec..3594e15eb 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -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", diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index 011eeec96..1fc555471 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -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 = ({ 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."); diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx index 05b684397..f53537cdc 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx @@ -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 = ({ 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."); diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx index 01983cbe1..7e1e429e2 100644 --- a/surfsense_web/components/editor-panel/editor-panel.tsx +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -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"); diff --git a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx index 61b8c3e25..d50d28a3c 100644 --- a/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx +++ b/surfsense_web/components/layout/ui/tabs/DocumentTabContent.tsx @@ -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"); diff --git a/surfsense_web/eslint.config.mjs b/surfsense_web/eslint.config.mjs index 530b65478..6a3f3f4c6 100644 --- a/surfsense_web/eslint.config.mjs +++ b/surfsense_web/eslint.config.mjs @@ -9,6 +9,32 @@ 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-syntax": [ + "error", + { + selector: + "NewExpression[callee.name='URL'] TemplateLiteral Identifier[name='BACKEND_URL']", + message: + "Use buildBackendUrl(path, params) for backend URLs. BACKEND_URL may be empty in proxy mode, and new URL('/relative') throws without a base.", + }, + { + selector: + "NewExpression[callee.name='URL'] TemplateLiteral Identifier[name='backendUrl']", + message: + "Use buildBackendUrl(path, params) for backend URLs instead of aliasing BACKEND_URL into new URL().", + }, + { + selector: "VariableDeclarator[id.name='backendUrl'][init.name='BACKEND_URL']", + message: + "Do not alias BACKEND_URL for URL construction. Use buildBackendUrl(path, params) instead.", + }, + ], + }, + }, +]; export default eslintConfig; diff --git a/surfsense_web/hooks/use-search-source-connectors.ts b/surfsense_web/hooks/use-search-source-connectors.ts index ad0db3de6..30083dcc3 100644 --- a/surfsense_web/hooks/use-search-source-connectors.ts +++ b/surfsense_web/hooks/use-search-source-connectors.ts @@ -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" }, diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index fbef77dfe..678293d8e 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -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 } @@ -102,8 +97,7 @@ class BaseApiService { throw new AuthenticationError("You are not authenticated. Please login again."); } - // Construct the full URL. Empty baseUrl is valid for same-origin proxy mode. - const fullUrl = this.baseUrl ? new URL(url, this.baseUrl).toString() : url; + const fullUrl = buildBackendUrl(url); // Prepare fetch options const fetchOptions: RequestInit = { @@ -379,7 +373,8 @@ class BaseApiService { options?: Omit & { 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", @@ -394,4 +389,4 @@ class BaseApiService { } } -export const baseApiService = new BaseApiService(BACKEND_URL); +export const baseApiService = new BaseApiService(); diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index 55e042815..e3c46e5bc 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -16,6 +16,44 @@ export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "G // same-origin relative requests (e.g. /api/v1/... and /auth/...). export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? ""; +type BackendUrlParam = string | number | boolean | null | undefined; + +/** + * 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 { + 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 =