import type { ApiSettings } from "../types"; const STORAGE_KEY = "multimailer.apiSettings"; const SESSION_STORAGE_KEY = "multimailer.session"; const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf"; export class ApiError extends Error { readonly status: number; readonly statusText: string; readonly body: string; constructor(status: number, statusText: string, body: string) { super(`${status} ${statusText}: ${body}`); this.name = "ApiError"; this.status = status; this.statusText = statusText; this.body = body; } } export function isApiError(error: unknown, ...statuses: number[]): error is ApiError { return error instanceof ApiError && (statuses.length === 0 || statuses.includes(error.status)); } /** * API endpoint helpers already pass paths beginning with /api/v1. The configured * value is therefore an origin only. Normalize legacy values that included the * API prefix so old localStorage settings cannot produce /api/v1/api/v1 URLs. */ export function normalizeApiBaseUrl(value: string): string { const withoutTrailingSlash = value.trim().replace(/\/+$/, ""); return withoutTrailingSlash.replace(/\/api(?:\/v1)?$/i, ""); } export function apiUrl(settings: ApiSettings, path: string): string { const baseUrl = normalizeApiBaseUrl(settings.apiBaseUrl); const normalizedPath = path.startsWith("/") ? path : `/${path}`; return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath; } export function loadApiSettings(): ApiSettings { const storedBaseUrl = localStorage.getItem(`${STORAGE_KEY}.baseUrl`); const configuredBaseUrl = storedBaseUrl !== null ? storedBaseUrl : import.meta.env.VITE_API_BASE_URL ?? ""; const apiBaseUrl = normalizeApiBaseUrl(configuredBaseUrl); // Repair legacy values once loaded, while keeping an empty value for same-origin use. if (storedBaseUrl !== null && storedBaseUrl !== apiBaseUrl) { localStorage.setItem(`${STORAGE_KEY}.baseUrl`, apiBaseUrl); } localStorage.removeItem(`${STORAGE_KEY}.accessToken`); sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`); return { // Empty base URL means "same origin". In Vite dev, /api is proxied to FastAPI. apiBaseUrl, apiKey: localStorage.getItem(`${STORAGE_KEY}.apiKey`) || "", accessToken: "" }; } export function saveApiSettings(settings: ApiSettings): void { localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl)); localStorage.setItem(`${STORAGE_KEY}.apiKey`, settings.apiKey); localStorage.removeItem(`${STORAGE_KEY}.accessToken`); sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`); } export function clearAccessToken(): void { sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`); localStorage.removeItem(`${STORAGE_KEY}.accessToken`); } function readCookie(name: string): string { const prefix = `${encodeURIComponent(name)}=`; return document.cookie .split(";") .map((part) => part.trim()) .find((part) => part.startsWith(prefix)) ?.slice(prefix.length) ?? ""; } export function csrfToken(): string { const value = readCookie(CSRF_COOKIE_NAME); return value ? decodeURIComponent(value) : ""; } function isUnsafeMethod(method?: string): boolean { const normalized = (method || "GET").toUpperCase(); return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized); } export function authHeaders(settings: ApiSettings): Headers { const headers = new Headers(); if (settings.accessToken) { headers.set("Authorization", `Bearer ${settings.accessToken}`); } else if (settings.apiKey) { headers.set("X-API-Key", settings.apiKey); } return headers; } export async function apiFetch(settings: ApiSettings, path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers || {}); if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } for (const [key, value] of authHeaders(settings)) { headers.set(key, value); } const csrf = csrfToken(); if (csrf && isUnsafeMethod(init?.method) && !headers.has("X-CSRF-Token")) { headers.set("X-CSRF-Token", csrf); } const response = await fetch(apiUrl(settings, path), { ...init, headers, credentials: init?.credentials ?? "include" }); if (!response.ok) { const text = await response.text(); throw new ApiError(response.status, response.statusText, text); } if (response.status === 204) { return undefined as T; } const contentType = response.headers.get("content-type") || ""; if (!contentType.includes("application/json")) { return (await response.text()) as T; } return (await response.json()) as T; } export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise { const response = await fetch(apiUrl(settings, path), { headers: authHeaders(settings), credentials: "include" }); if (!response.ok) { const text = await response.text(); throw new ApiError(response.status, response.statusText, text); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); }