initial commit after split
This commit is contained in:
155
webui/src/api/client.ts
Normal file
155
webui/src/api/client.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user