Files
VoCat/web/src/api.ts
T

196 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ApiErrorBody,
LoggingSettings,
LoginResponse,
SecuritySettings,
Session,
} from "./types";
import { tl } from "./lib/i18n";
const CSRF_KEY = "vocat.csrf";
// Authenticated pages and same-origin plugin frames share this signal. Clear
// the mutation token immediately so a revoked session cannot leave stale auth
// state behind in the browser.
export function notifyUnauthorized() {
try {
sessionStorage.removeItem(CSRF_KEY);
} catch {
/* ignore unavailable storage */
}
window.dispatchEvent(new Event("vocat:unauthorized"));
}
function isMutation(method: string) {
return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase());
}
function camelizeKey(key: string) {
return key.replace(/_([a-z0-9])/g, (_, char: string) => char.toUpperCase());
}
function snakeizeKey(key: string) {
return key
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/-/g, "_")
.toLowerCase();
}
export function camelize<T>(value: unknown): T {
if (Array.isArray(value)) return value.map((item) => camelize(item)) as T;
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
camelizeKey(key),
camelize(item),
]),
) as T;
}
return value as T;
}
function snakeize(value: unknown): unknown {
if (Array.isArray(value)) return value.map((item) => snakeize(item));
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
snakeizeKey(key),
snakeize(item),
]),
);
}
return value;
}
export class ApiError extends Error {
status: number;
code: string;
requestId: string;
detail: ApiErrorBody;
constructor(status: number, detail: ApiErrorBody) {
super(detail.message || detail.error || `${tl("请求失败")}HTTP ${status}`);
this.name = "ApiError";
this.status = status;
this.code = detail.code || "";
this.requestId = detail.requestId || "";
this.detail = detail;
}
}
export interface RequestOptions extends Omit<RequestInit, "body"> {
body?: unknown;
raw?: boolean;
}
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
const method = (options.method || "GET").toUpperCase();
const headers = new Headers(options.headers);
const formBody = typeof FormData !== "undefined" && options.body instanceof FormData;
headers.set("Accept", options.raw ? "*/*" : "application/json");
if (options.body !== undefined && !formBody) headers.set("Content-Type", "application/json");
if (isMutation(method)) {
const csrf = sessionStorage.getItem(CSRF_KEY);
if (csrf) headers.set("X-CSRF-Token", csrf);
}
const response = await fetch(path.startsWith("/api") ? path : `/api${path}`, {
...options,
method,
headers,
credentials: "include",
body: options.body === undefined
? undefined
: formBody
? options.body as FormData
: JSON.stringify(snakeize(options.body)),
});
if (options.raw) {
if (response.status === 401) notifyUnauthorized();
return response as T;
}
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json")
? await response.json()
: { message: await response.text() };
const normalized = camelize<Record<string, unknown>>(payload);
if (!response.ok) {
if (response.status === 401) notifyUnauthorized();
const nested = normalized.error;
const detail = nested && typeof nested === "object"
? {
...(nested as ApiErrorBody),
requestId: (normalized.requestId as string | undefined) || (nested as ApiErrorBody).requestId,
}
: normalized as ApiErrorBody;
throw new ApiError(response.status, detail);
}
return (Object.prototype.hasOwnProperty.call(normalized, "data") ? normalized.data : normalized) as T;
}
export async function login(username: string, password: string) {
const result = await api<LoginResponse & { user?: { username?: string } }>("/auth/login", {
method: "POST",
body: { username, password },
});
if (result.csrfToken) sessionStorage.setItem(CSRF_KEY, result.csrfToken);
return result;
}
export async function session() {
const result = await api<Session & { user?: { username?: string } }>("/auth/session");
if (result.csrfToken) sessionStorage.setItem(CSRF_KEY, result.csrfToken);
return {
...result,
username: result.username || result.user?.username || "",
role: result.role || "Administrator",
};
}
export async function logout() {
try {
await api("/auth/logout", { method: "POST" });
} finally {
sessionStorage.removeItem(CSRF_KEY);
}
}
export function getSecuritySettings() {
return api<SecuritySettings>("/settings/security");
}
export function updateSecuritySettings(settings: {
mode: SecuritySettings["mode"];
allowedCidrs: string[];
trustProxyHeaders: boolean;
}) {
return api<SecuritySettings>("/settings/security", { method: "PUT", body: settings });
}
export function getLoggingSettings() {
return api<LoggingSettings>("/settings/logging");
}
export function updateLoggingSettings(settings: {
mode: LoggingSettings["mode"];
count: number;
days: number;
}) {
return api<LoggingSettings>("/settings/logging", { method: "PUT", body: settings });
}
export function apiMessage(error: unknown) {
if (error instanceof ApiError) {
const suffix = error.requestId ? `${tl("请求")} ${error.requestId}` : "";
return `${error.message}${suffix}`;
}
if (error instanceof Error) return error.message;
return tl("请求未完成,检查服务状态后重试");
}
export function eventStreamURL(path: string, params?: URLSearchParams) {
const suffix = params?.toString();
return `${path.startsWith("/api") ? path : `/api${path}`}${suffix ? `?${suffix}` : ""}`;
}