Oberflaeche, Fehlermeldungen und Kommentare schrieben durchgehend ae, oe, ue und ss. Dafuer gab es keinen Grund - die Anwendung ist deutschsprachig und laeuft durchgehend in UTF-8. Betroffen waren 55 Dateien, darunter alle sichtbaren Texte: Guthaben-Zaehler, Import, Nutzerverwaltung, Passwortwechsel und die Zwei-Faktor-Einrichtung. Der Name einer Migration bleibt im README-Beispiel bewusst bei ASCII, weil daraus ein Verzeichnisname wird.
103 lines
2.6 KiB
TypeScript
103 lines
2.6 KiB
TypeScript
const CSRF_COOKIE = 'ccm_csrf';
|
|
const CSRF_HEADER = 'x-csrf-token';
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
function readCookie(name: string): string | undefined {
|
|
const match = document.cookie
|
|
.split('; ')
|
|
.find((entry) => entry.startsWith(`${name}=`));
|
|
return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
|
|
}
|
|
|
|
interface RequestOptions {
|
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
body?: unknown;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
const method = options.method ?? 'GET';
|
|
const headers: Record<string, string> = {};
|
|
|
|
if (options.body !== undefined) {
|
|
headers['content-type'] = 'application/json';
|
|
}
|
|
if (method !== 'GET') {
|
|
const token = readCookie(CSRF_COOKIE);
|
|
if (token) headers[CSRF_HEADER] = token;
|
|
}
|
|
|
|
const response = await fetch(path, {
|
|
method,
|
|
headers,
|
|
credentials: 'same-origin',
|
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
...(options.signal ? { signal: options.signal } : {}),
|
|
});
|
|
|
|
if (response.status === 204) return undefined as T;
|
|
|
|
const text = await response.text();
|
|
const payload = text ? safeParse(text) : null;
|
|
|
|
if (!response.ok) {
|
|
const error = payload as { error?: string; message?: string } | null;
|
|
throw new ApiError(
|
|
response.status,
|
|
error?.error ?? 'UNKNOWN',
|
|
error?.message ?? `Fehler ${response.status}`,
|
|
);
|
|
}
|
|
|
|
return payload as T;
|
|
}
|
|
|
|
/** Datei-Upload läuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
|
|
export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
|
|
const headers: Record<string, string> = {};
|
|
const token = readCookie(CSRF_COOKIE);
|
|
if (token) headers[CSRF_HEADER] = token;
|
|
|
|
const response = await fetch(path, {
|
|
method: 'POST',
|
|
headers,
|
|
credentials: 'same-origin',
|
|
body: form,
|
|
});
|
|
|
|
const text = await response.text();
|
|
const payload = text ? safeParse(text) : null;
|
|
|
|
if (!response.ok) {
|
|
const error = payload as { error?: string; message?: string } | null;
|
|
throw new ApiError(
|
|
response.status,
|
|
error?.error ?? 'UNKNOWN',
|
|
error?.message ?? `Fehler ${response.status}`,
|
|
);
|
|
}
|
|
|
|
return payload as T;
|
|
}
|
|
|
|
function safeParse(text: string): unknown {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return { message: text };
|
|
}
|
|
}
|