- Четыре копии «ресайз → FormData → CSRF из cookie → fetch → разбор конверта ошибки» (аватар, фото партии, фото из админки, иконка ачивки) сведены в lib/upload.ts. Иконка по-прежнему уходит оригиналом: ресайз в JPEG убил бы прозрачность герба. - Три копии readCsrfToken и вторая копия resizeImage удалены — берём readCookie из api/client.ts и resizeImage из lib/image.ts с параметром размера. - Ключи, протухающие от партии, собраны в matchAffectedKeys: раньше они были написаны строками мимо реестра qk в двух местах, и переименование ключа сломало бы инвалидацию молча. - Различение «нет сессии» и «нет связи» вынесено в authProbeRetry и применено к обеим пробам. Прошлый заход чинил только игроцкие гварды, и RequireAdmin по-прежнему выкидывал админа на страницу входа при обрыве связи. - ToastContext больше не пересоздаёт значение контекста: провайдер обёрнут вокруг всего приложения, и каждый тост перерисовывал всех потребителей. - PlaceEditor не трогает DOM, пока цель подсветки не изменилась (было querySelectorAll на каждый pointermove). #8 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
66 lines
2.2 KiB
TypeScript
66 lines
2.2 KiB
TypeScript
import createClient from "openapi-fetch";
|
|
import type { paths } from "./schema";
|
|
|
|
export function readCookie(name: string): string | null {
|
|
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
|
return m ? decodeURIComponent(m[1]) : null;
|
|
}
|
|
|
|
// Пути в схеме уже содержат /api; baseUrl — origin (пусто в dev/prod).
|
|
export const api = createClient<paths>({
|
|
baseUrl: import.meta.env.VITE_API_BASE_URL || "",
|
|
credentials: "include",
|
|
});
|
|
|
|
// CSRF double-submit: дублируем cookie csrf_token в заголовок на всех запросах.
|
|
api.use({
|
|
onRequest({ request }) {
|
|
const csrf = readCookie("csrf_token");
|
|
if (csrf) request.headers.set("X-CSRF-Token", csrf);
|
|
return request;
|
|
},
|
|
});
|
|
|
|
export class ApiError extends Error {
|
|
code: string;
|
|
status: number;
|
|
details?: unknown;
|
|
constructor(message: string, code: string, status: number, details?: unknown) {
|
|
super(message);
|
|
this.code = code;
|
|
this.status = status;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
interface FetchResult<T> {
|
|
data?: T;
|
|
error?: unknown;
|
|
response: Response;
|
|
}
|
|
|
|
/** Разворачивает ответ openapi-fetch: возвращает данные или бросает ApiError. */
|
|
export function unwrap<T>(res: FetchResult<T>): T {
|
|
if (res.error || !res.response.ok) {
|
|
const env = (res.error as { error?: { code?: string; message?: string; details?: unknown } })
|
|
?.error;
|
|
throw new ApiError(
|
|
env?.message || "Ошибка запроса",
|
|
env?.code || "ERROR",
|
|
res.response.status,
|
|
env?.details,
|
|
);
|
|
}
|
|
return res.data as T;
|
|
}
|
|
|
|
/**
|
|
* Повторы для проб сессии (`/users/me`, `/admin/me`). «Не авторизован» — это
|
|
* только ответ сервера (401/403, обрабатывается в самих хуках); обрыв связи
|
|
* ответом не является, и без повторов гварды приняли бы его за разлогин и
|
|
* увели на страницу входа.
|
|
*/
|
|
export const authProbeRetry = (count: number, err: unknown): boolean =>
|
|
!(err instanceof ApiError) && count < 2;
|
|
|