syncova-backup/apps/web/src/api/client.ts
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

300 lines
10 KiB
TypeScript

/**
* HTTP-Client fuer die Syncova-API.
*
* Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als
* ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
* (PROMPT.md §140: keine stillen Fehler).
*/
import type { ErrorResponse, SuccessResponse } from '../types/api';
/** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */
const API_BASE_PATH = '/api/v1';
/** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */
const CORRELATION_ID_HEADER = 'X-Correlation-ID';
/**
* Fehler einer API-Anfrage.
*
* Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
* einen Vorfall gegenueber dem Betreiber eindeutig benennen kann.
*/
export class ApiError extends Error {
/** Stabiler maschinenlesbarer Fehlercode. */
public readonly code: string;
/** HTTP-Statuscode der Antwort. */
public readonly statusCode: number;
/** Kennung des Requests zur Zuordnung im Serverlog. */
public readonly requestId: string;
/** Optionale unbedenkliche Zusatzinformationen. */
public readonly details: Record<string, unknown> | undefined;
constructor(parameters: {
code: string;
message: string;
statusCode: number;
requestId: string;
details?: Record<string, unknown>;
}) {
super(parameters.message);
this.name = 'ApiError';
this.code = parameters.code;
this.statusCode = parameters.statusCode;
this.requestId = parameters.requestId;
this.details = parameters.details;
}
}
/**
* Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht.
*
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber
* diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und
* dem Anmeldemodul, das seinerseits den Client verwendet.
*/
let accessTokenProvider: () => string | null = () => null;
/** Hinterlegt die Funktion, die das aktuelle Zugriffstoken liefert. */
export function setAccessTokenProvider(tokenProvider: () => string | null): void {
accessTokenProvider = tokenProvider;
}
/** Fehlercode fuer eine nicht erreichbare API. */
export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE';
/** Fehlercode fuer eine unverstaendliche Antwort. */
export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE';
/**
* Erzeugt eine Correlation ID fuer einen Request.
*
* crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der
* Rueckfall deckt aeltere Testumgebungen ab.
*/
function createCorrelationId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein
// der Nachvollziehbarkeit, nicht der Sicherheit.
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
}
/** Optionen einer API-Anfrage. */
export interface RequestOptions {
/** HTTP-Methode; Standard ist GET. */
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
/** Optionaler Anfragekoerper, der als JSON gesendet wird. */
body?: unknown;
/** Signal zum Abbrechen der Anfrage. */
signal?: AbortSignal;
}
/**
* Fuehrt eine Anfrage gegen die Syncova-API aus.
*
* @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health".
* @returns Die Nutzlast der Antwort.
* @throws ApiError bei jedem Fehlerfall.
*/
export async function requestApi<TPayload>(
endpointPath: string,
requestOptions: RequestOptions = {},
): Promise<TPayload> {
const correlationId = createCorrelationId();
const requestMethod = requestOptions.method ?? 'GET';
const requestHeaders: Record<string, string> = {
Accept: 'application/json',
[CORRELATION_ID_HEADER]: correlationId,
};
if (requestOptions.body !== undefined) {
requestHeaders['Content-Type'] = 'application/json';
}
// Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen
// unauthentifiziert - der Server entscheidet dann ueber den Zugriff.
const accessToken = accessTokenProvider();
if (accessToken !== null) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
}
let httpResponse: Response;
try {
httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, {
method: requestMethod,
headers: requestHeaders,
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
// Die Sitzung laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs.
credentials: 'same-origin',
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
});
} catch (networkError) {
// Ein Abbruch durch den Aufrufer ist kein Fehlerfall und wird durchgereicht.
if (networkError instanceof DOMException && networkError.name === 'AbortError') {
throw networkError;
}
throw new ApiError({
code: NETWORK_ERROR_CODE,
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
statusCode: 0,
requestId: correlationId,
});
}
// 204 traegt per Definition keinen Koerper.
if (httpResponse.status === 204) {
return undefined as TPayload;
}
let parsedBody: unknown;
try {
parsedBody = await httpResponse.json();
} catch {
throw new ApiError({
code: MALFORMED_RESPONSE_CODE,
message: 'Die Antwort des Servers war unverstaendlich.',
statusCode: httpResponse.status,
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
});
}
// Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status.
//
// Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den
// Betriebszustand, die Huelle den Inhalt. GET /api/v1/health nutzt genau diese
// Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei
// aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden
// Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
const errorResponse = parsedBody as Partial<ErrorResponse>;
if (errorResponse.error) {
throw new ApiError({
code: errorResponse.error.code,
message: errorResponse.error.message,
statusCode: httpResponse.status,
requestId: errorResponse.error.request_id,
...(errorResponse.error.details ? { details: errorResponse.error.details } : {}),
});
}
const successResponse = parsedBody as Partial<SuccessResponse<TPayload>>;
if (successResponse.data === undefined) {
throw new ApiError({
code: MALFORMED_RESPONSE_CODE,
message: httpResponse.ok
? 'Die Antwort des Servers enthielt keine Daten.'
: 'Der Server meldete einen Fehler ohne verwertbare Beschreibung.',
statusCode: httpResponse.status,
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
});
}
return successResponse.data;
}
/**
* Laedt eine Datei von der API herunter.
*
* Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine**
* Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi
* schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine
* unverstaendliche Antwort — obwohl alles in Ordnung ist.
*
* Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage,
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die
* Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als
* „bericht.pdf" im Download-Ordner, und der Anwender saehe statt einer Meldung
* eine kaputte Datei.
*/
export async function downloadApiFile(
endpointPath: string,
requestOptions: RequestOptions = {},
): Promise<{ blob: Blob; fileName: string }> {
const correlationId = createCorrelationId();
const requestHeaders: Record<string, string> = {
[CORRELATION_ID_HEADER]: correlationId,
};
if (requestOptions.body !== undefined) {
requestHeaders['Content-Type'] = 'application/json';
}
const accessToken = accessTokenProvider();
if (accessToken !== null) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
}
let httpResponse: Response;
try {
httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, {
method: requestOptions.method ?? 'POST',
headers: requestHeaders,
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
credentials: 'same-origin',
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
});
} catch (networkError) {
if (networkError instanceof DOMException && networkError.name === 'AbortError') {
throw networkError;
}
throw new ApiError({
code: NETWORK_ERROR_CODE,
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
statusCode: 0,
requestId: correlationId,
});
}
const responseContentType = httpResponse.headers.get('Content-Type') ?? '';
// Eine JSON-Antwort auf eine Dateianfrage ist immer ein Fehler.
if (responseContentType.includes('application/json')) {
const parsedBody = (await httpResponse.json()) as Partial<ErrorResponse>;
throw new ApiError({
code: parsedBody.error?.code ?? MALFORMED_RESPONSE_CODE,
message: parsedBody.error?.message ?? 'Die Datei konnte nicht erzeugt werden.',
statusCode: httpResponse.status,
requestId: parsedBody.error?.request_id ?? correlationId,
});
}
if (!httpResponse.ok) {
throw new ApiError({
code: MALFORMED_RESPONSE_CODE,
message: 'Die Datei konnte nicht erzeugt werden.',
statusCode: httpResponse.status,
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
});
}
return {
blob: await httpResponse.blob(),
fileName: parseFileNameFromDisposition(httpResponse.headers.get('Content-Disposition')),
};
}
/**
* Liest den Dateinamen aus dem Content-Disposition-Kopf.
*
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen —
* ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner.
*/
function parseFileNameFromDisposition(dispositionHeader: string | null): string {
if (dispositionHeader === null) {
return '';
}
const fileNameMatch = /filename="([^"]+)"/.exec(dispositionHeader);
return fileNameMatch === null ? '' : (fileNameMatch[1] ?? '');
}