diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index bdc6a9a..d7c0adf 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -28,6 +28,7 @@ import { useDataSource } from "@/lib/data-source" import { listServers } from "@/shared/api/servers" import { toFrontendServer } from "@/entities/server/model/mappers" import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups" +import { requestBlob } from "@/shared/api/http-client" import { toast } from "sonner" import { Stepper, @@ -276,8 +277,7 @@ export default function BackupsPage() { } async function handleDownload(id: string, fallbackFilename: string) { - const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`) - if (!res.ok) throw new Error("Не удалось скачать файл") + const res = await requestBlob(backendUrl, `/api/backups/${id}/download`) const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement("a") diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx index ebd5153..a2d5d6a 100644 --- a/app/(main)/bgp/page.tsx +++ b/app/(main)/bgp/page.tsx @@ -23,6 +23,7 @@ import { XIcon, AlertCircleIcon, } from "lucide-react" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" // ─── types ──────────────────────────────────────────────────────────────────── @@ -621,11 +622,7 @@ export default function BgpPage() { if (cancelled) return setLoading(true) setLiveError(null) - fetch(`${backendUrl}/api/bgp/sessions`) - .then(r => { - if (!r.ok) throw new Error(`HTTP ${r.status}`) - return r.json() as Promise - }) + void requestJson(backendUrl, "/api/bgp/sessions") .then(data => { if (cancelled) return setLiveSessions(data.map(backendToFrontend)) diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 1a22801..a06f5f5 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -16,6 +16,7 @@ import { } from "lucide-react" import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" import { Flag } from "@/components/flag" import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data" @@ -733,13 +734,14 @@ function InterfacesTab({ const ra = readStoredRouteOptimizerSettings() setOptimizing(true) try { - const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pingWeight: ra.pingWeight }), - }) - if (!r.ok) throw new Error(`HTTP ${r.status}`) - const data = await r.json() as BackendOspfOptimizeResponse + const data = await requestJson( + backendUrl, + `/api/servers/${filterServerId}/ospf/optimize`, + { + method: "POST", + body: JSON.stringify({ pingWeight: ra.pingWeight }), + }, + ) const byKey: Record = {} data.interfaces.forEach((row) => { byKey[`${data.serverId}-${row.id}`] = row.optimalCost @@ -1120,8 +1122,7 @@ export default function OspfPage() { if (cancelled) return setLoading(true) setLiveError(null) - fetch(`${backendUrl}/api/ospf/all`) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise }) + void requestJson(backendUrl, "/api/ospf/all") .then(data => { if (cancelled) return setLiveData(data); setFetchedAt(new Date()); setLoading(false) diff --git a/app/(main)/terminal/page.tsx b/app/(main)/terminal/page.tsx index 92b6f76..200abde 100644 --- a/app/(main)/terminal/page.tsx +++ b/app/(main)/terminal/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button" import { servers as mockServers } from "@/lib/data" import { Flag } from "@/components/flag" import { useDataSource } from "@/lib/data-source" +import { requestJson } from "@/shared/api/http-client" import { TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon, } from "lucide-react" @@ -259,12 +260,14 @@ function Terminal({ if (isLive && server.backendId !== null) { setExecuting(true) try { - const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ command: cmd }), - }) - const data = await res.json() as { output?: string; error?: string } + const data = await requestJson<{ output?: string; error?: string }>( + backendUrl, + `/api/servers/${server.backendId}/exec`, + { + method: "POST", + body: JSON.stringify({ command: cmd }), + }, + ) const text = data.output ?? data.error ?? "(empty response)" const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output" text.split("\n").forEach(line => @@ -427,7 +430,7 @@ interface BackendServer { } export default function TerminalPage() { - const { mode, backendUrl } = useDataSource() + const { mode, backendUrl, prefsHydrated } = useDataSource() const isLive = mode === "live" // Server list state @@ -437,14 +440,13 @@ export default function TerminalPage() { // Load servers from backend when in live mode useEffect(() => { - if (!isLive) return + if (!isLive || !prefsHydrated) return let cancelled = false queueMicrotask(() => { if (cancelled) return setServersLoading(true) - fetch(`${backendUrl}/api/servers`) - .then(r => r.json() as Promise) - .then(data => { + void requestJson(backendUrl, "/api/servers") + .then((data) => { if (cancelled) return setLiveServers(data.map(s => ({ uid: String(s.id), @@ -462,7 +464,7 @@ export default function TerminalPage() { .catch(() => { if (!cancelled) setServersLoading(false) }) }) return () => { cancelled = true } - }, [isLive, backendUrl, refreshKey]) + }, [isLive, backendUrl, refreshKey, prefsHydrated]) const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers() diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 34f678e..7f9f397 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -38,6 +38,7 @@ import { } from "lucide-react" import { useDataSource } from "@/lib/data-source" import { useEvoBGP } from "@/lib/evobgp-context" +import { requestJson } from "@/shared/api/http-client" import { formatSidebarBadgeCount, mockSidebarBadgesByUrl, @@ -104,7 +105,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [ type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number } export function AppSidebar({ ...props }: React.ComponentProps) { - const { mode, backendUrl } = useDataSource() + const { mode, backendUrl, prefsHydrated } = useDataSource() const evo = useEvoBGP() const [mounted, setMounted] = React.useState(false) const [liveCounts, setLiveCounts] = React.useState(null) @@ -118,30 +119,21 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }, []) React.useEffect(() => { - if (mode !== "live") { - setLiveCounts(null) + if (!prefsHydrated || mode !== "live") { + if (mode !== "live") setLiveCounts(null) return } let cancelled = false const load = async () => { try { - const base = backendUrl.replace(/\/$/, "") - const [cRes, gRes] = await Promise.all([ - fetch(`${base}/api/sidebar-counts`), - fetch(`${base}/api/filters/gre-tunnels`), + const [cJson, gJson] = await Promise.all([ + requestJson(backendUrl, "/api/sidebar-counts"), + requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch( + () => ({ tunnels: [] as unknown[] }), + ), ]) if (cancelled) return - if (!cRes.ok) { - setLiveCounts(null) - return - } - const cJson = (await cRes.json()) as SidebarCountsDto - let greN = 0 - if (gRes.ok) { - const gJson = (await gRes.json()) as { tunnels?: unknown[] } - greN = (gJson.tunnels ?? []).length - } - setLiveCounts({ ...cJson, greTunnels: greN }) + setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length }) } catch { if (!cancelled) setLiveCounts(null) } @@ -152,7 +144,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { cancelled = true window.clearInterval(id) } - }, [mode, backendUrl]) + }, [mode, backendUrl, prefsHydrated]) const navGroups = React.useMemo((): NavGroup[] => { function badgeFor(url: string): string | undefined { diff --git a/components/system-monitor-popover.tsx b/components/system-monitor-popover.tsx index c278808..0f14c40 100644 --- a/components/system-monitor-popover.tsx +++ b/components/system-monitor-popover.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" import { filters, pingProbes, servers } from "@/lib/data" import type { SidebarCountsDto } from "@/lib/sidebar-badges" +import { resolveApiUrl, requestJson } from "@/shared/api/http-client" type MonitorMetric = { id: string @@ -78,11 +79,12 @@ function MetricCell({ metric }: { metric: MonitorMetric }) { /** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */ export function SystemMonitorPopover() { - const { mode, backendUrl } = useDataSource() + const { mode, backendUrl, prefsHydrated } = useDataSource() const [healthOk, setHealthOk] = useState(null) const [counts, setCounts] = useState(null) useEffect(() => { + if (!prefsHydrated) return if (mode !== "live") { setHealthOk(true) setCounts({ @@ -98,11 +100,10 @@ export function SystemMonitorPopover() { let cancelled = false const load = async () => { - const base = backendUrl.replace(/\/$/, "") try { - const [hRes, cRes] = await Promise.all([ - fetch(`${base}/health`), - fetch(`${base}/api/sidebar-counts`), + const [hRes, counts] = await Promise.all([ + fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }), + requestJson(backendUrl, "/api/sidebar-counts"), ]) if (cancelled) return if (hRes.ok) { @@ -111,11 +112,7 @@ export function SystemMonitorPopover() { } else { setHealthOk(false) } - if (cRes.ok) { - setCounts((await cRes.json()) as SidebarCountsDto) - } else { - setCounts(null) - } + setCounts(counts) } catch { if (!cancelled) { setHealthOk(false) @@ -129,7 +126,7 @@ export function SystemMonitorPopover() { cancelled = true window.clearInterval(id) } - }, [mode, backendUrl]) + }, [mode, backendUrl, prefsHydrated]) const serversCount = counts?.servers ?? 0 const filtersCount = counts?.filterRules ?? 0 diff --git a/lib/backend-url.ts b/lib/backend-url.ts index 557007e..09ead17 100644 --- a/lib/backend-url.ts +++ b/lib/backend-url.ts @@ -26,12 +26,26 @@ export function isBackendUrlLocked(): boolean { return cfg.kind === "same-origin" || cfg.kind === "fixed" } +function isLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" +} + +/** Prefer same-origin when the UI is not on loopback — never point the browser at localhost. */ export function resolveStoredBackendUrl(stored: string | null): string { const cfg = configuredBackendUrl() if (cfg.kind === "fixed") return cfg.url - if (cfg.kind === "same-origin" && typeof window !== "undefined") { + if (cfg.kind === "same-origin") { + if (typeof window !== "undefined") return window.location.origin + return "" + } + if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) { return window.location.origin } const trimmed = stored?.trim().replace(/\/$/, "") + if (trimmed && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimmed)) { + if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) { + return window.location.origin + } + } return trimmed || LOCAL_DEFAULT_BACKEND_URL } diff --git a/lib/data-source.tsx b/lib/data-source.tsx index 878ca98..c3e9744 100644 --- a/lib/data-source.tsx +++ b/lib/data-source.tsx @@ -9,6 +9,7 @@ import { LOCAL_DEFAULT_BACKEND_URL, resolveStoredBackendUrl, } from "@/lib/backend-url" +import { resolveApiUrl } from "@/shared/api/http-client" // ── types ───────────────────────────────────────────────────────────────────── @@ -49,8 +50,13 @@ function readStoredMode(): DataSourceMode { return defaultDataSourceMode() } -function readStoredBackendUrl(): string { - if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL +function initialBackendUrl(): string { + if (typeof window === "undefined") { + const cfg = configuredBackendUrl() + if (cfg.kind === "same-origin") return "" + if (cfg.kind === "fixed") return cfg.url + return LOCAL_DEFAULT_BACKEND_URL + } return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND)) } @@ -60,7 +66,7 @@ function normalizeBackendUrl(url: string): string { export function DataSourceProvider({ children }: { children: React.ReactNode }) { const [mode, setModeState] = useState(defaultDataSourceMode) - const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL) + const [backendUrl, setBackendUrlState] = useState(initialBackendUrl) const [prefsHydrated, setPrefsHydrated] = useState(false) const [backendStatus, setBackendStatus] = useState(undefined) const backendUrlLocked = isBackendUrlLocked() @@ -68,10 +74,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode }) useEffect(() => { const storedMode = readStoredMode() - let url = readStoredBackendUrl() - if (configuredBackendUrl().kind === "same-origin") { - url = window.location.origin - } + const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND)) setModeState(storedMode) setBackendUrlState(url) setPrefsHydrated(true) @@ -91,10 +94,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode }) }, [backendUrlLocked]) const checkBackend = useCallback(async () => { - const healthUrl = - configuredBackendUrl().kind === "same-origin" - ? "/health" - : `${normalizeBackendUrl(backendUrl)}/health` + const healthUrl = resolveApiUrl(backendUrl, "/health") try { const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) }) setBackendStatus(res.ok) diff --git a/shared/api/http-client.ts b/shared/api/http-client.ts index 48b0a25..2df2a88 100644 --- a/shared/api/http-client.ts +++ b/shared/api/http-client.ts @@ -21,38 +21,72 @@ function trimBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/$/, "") } -function resolveRequestUrl(baseUrl: string, path: string): string { +/** Absolute or same-origin-relative URL for backend API paths. */ +export function resolveApiUrl(baseUrl: string, path: string): string { if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") { return path } + // Safety: never call browser localhost when the UI is served from a remote host + if (typeof window !== "undefined") { + const host = window.location.hostname + const remoteUi = host !== "localhost" && host !== "127.0.0.1" + const baseIsLocal = + /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimBaseUrl(baseUrl)) + if (remoteUi && (baseIsLocal || !baseUrl.trim())) { + return path.startsWith("/") ? path : `/${path}` + } + } return trimBaseUrl(baseUrl) + path } +/** Attach portal JWT when present. */ +export function withAuthHeaders(init?: HeadersInit): Headers { + const headers = new Headers(init) + const token = typeof window !== "undefined" ? getToken() : null + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`) + } + return headers +} + +function handleUnauthorized(): never { + if (typeof window !== "undefined" && isAuthEnabled()) { + const ok = redirectToPortalLogin() + if (!ok) redirectToPortalLoginInteractive() + } + throw new ApiClientError("Unauthorized", 401) +} + +async function parseErrorMessage(res: Response): Promise { + const payload = await res.json().catch(() => undefined) + if ( + typeof payload === "object" && + payload !== null && + "error" in payload && + typeof (payload as { error?: unknown }).error === "string" + ) { + return (payload as { error: string }).error + } + return res.statusText || `HTTP ${res.status}` +} + export async function requestJson( baseUrl: string, path: string, init?: RequestInit, ): Promise { const hasBody = init?.body != null - const headers = new Headers(init?.headers) + const headers = withAuthHeaders(init?.headers) if (hasBody && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json") } - const token = typeof window !== "undefined" ? getToken() : null - if (token && !headers.has("Authorization")) { - headers.set("Authorization", `Bearer ${token}`) - } - const res = await fetch(resolveRequestUrl(baseUrl, path), { + const res = await fetch(resolveApiUrl(baseUrl, path), { ...init, headers, }) - if (res.status === 401 && typeof window !== "undefined" && isAuthEnabled()) { - const ok = redirectToPortalLogin() - if (!ok) redirectToPortalLoginInteractive() - throw new ApiClientError("Unauthorized", 401) - } + if (res.status === 401) handleUnauthorized() if (res.status === 204) return undefined as T @@ -70,3 +104,21 @@ export async function requestJson( return payload as T } + +/** Binary/download endpoints (backup, backup file) with the same auth + URL rules. */ +export async function requestBlob( + baseUrl: string, + path: string, + init?: RequestInit, +): Promise { + const headers = withAuthHeaders(init?.headers) + const res = await fetch(resolveApiUrl(baseUrl, path), { + ...init, + headers, + }) + if (res.status === 401) handleUnauthorized() + if (!res.ok) { + throw new ApiClientError(await parseErrorMessage(res), res.status) + } + return res +} diff --git a/shared/api/system-database.ts b/shared/api/system-database.ts index eca80e0..838c986 100644 --- a/shared/api/system-database.ts +++ b/shared/api/system-database.ts @@ -1,19 +1,7 @@ -import { ApiClientError } from "@/shared/api/http-client" -import { configuredBackendUrl } from "@/lib/backend-url" +import { ApiClientError, requestBlob } from "@/shared/api/http-client" const MAX_RESTORE_BYTES = 512 * 1024 * 1024 -function trimBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/$/, "") -} - -function resolveDatabaseApiUrl(baseUrl: string, path: string): string { - if (configuredBackendUrl().kind === "same-origin") { - return path - } - return `${trimBaseUrl(baseUrl)}${path}` -} - function parseFilename(contentDisposition: string | null, fallback: string): string { if (!contentDisposition) return fallback const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition) @@ -32,18 +20,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str export async function downloadSystemDatabaseBackup( baseUrl: string, ): Promise<{ blob: Blob; filename: string }> { - const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/backup")) - if (!res.ok) { - const payload = await res.json().catch(() => undefined) - const msg = - typeof payload === "object" && - payload !== null && - "error" in payload && - typeof (payload as { error?: unknown }).error === "string" - ? (payload as { error: string }).error - : res.statusText - throw new ApiClientError(msg, res.status, payload) - } + const res = await requestBlob(baseUrl, "/api/system/database/backup") const blob = await res.blob() const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db") return { blob, filename } @@ -56,20 +33,9 @@ export async function restoreSystemDatabaseBackup(baseUrl: string, file: File): 413, ) } - const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), { + await requestBlob(baseUrl, "/api/system/database/restore", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: file, }) - if (!res.ok) { - const payload = await res.json().catch(() => undefined) - const msg = - typeof payload === "object" && - payload !== null && - "error" in payload && - typeof (payload as { error?: unknown }).error === "string" - ? (payload as { error: string }).error - : res.statusText - throw new ApiClientError(msg, res.status, payload) - } }