Files
nebula/src/lib/git/http.ts
T
florian.wildeandClaude Opus 5 e1d435f10a feat(tasks): Aufgabe als Git-Issue anlegen, Zustand wandert mit
Die Zuordnung stand im Schema laengst – sourceIssue, sourceUrl und
sourceRepositoryId samt Eindeutigkeit je Repository, gefuellt von der
Uebernahme aus Issues seit v0.9.0. Es fehlte die Gegenrichtung.

Ein Knopf im Aufgabendialog legt das Issue an; Nummer und Adresse landen in
denselben Feldern, der Dublettenschutz gilt also fuer beide Wege. In den
Rumpf kommt ein Rueckverweis auf das Projekt.

Ausdruecklich per Knopf und nie nebenbei: was in ein fremdes System
geschrieben wird, soll jemand gewollt haben. Ist die Aufgabe erst
verknuepft, wandert der Zustand allerdings mit – erledigt schliesst das
Issue, ein Zurueckziehen oeffnet es wieder. Fehlertolerant: ein nicht
erreichbares Git-System darf das Abhaken nicht verhindern.

Die Zustaende passen nicht aufeinander, und das bleibt so. Nebula kennt
vier, ein Issue zwei; nach aussen geht nur die Grenze um ERLEDIGT. Ein
Rundlauf verliert damit IN ARBEIT und BLOCKIERT. Labels waeren die
Alternative, wuerden aber den Issue-Tracker mit Zustaenden fuellen, die
dort niemand pflegt.

Kein Widerspruch zu "keine eigenen Git-Operationen": Issues laufen ueber
die HTTP-API der Provider. Dafuer gibt es jetzt sendJson neben getJson –
durch dieselbe SSRF-Schleuse, aber ohne Umleitungen zu folgen. Ein 307 auf
eine interne Adresse naehme den Rumpf und den Token im Header mit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:35:11 +02:00

201 lines
6.4 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 { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { GitApiError } from "./types";
const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000);
export interface FetchOptions {
headers?: Record<string, string>;
timeoutMs?: number;
/** Wenn true, liefert die Funktion bei 404 null statt zu werfen. */
allow404?: boolean;
}
/** Entfernt einen abschliessenden Slash, damit URLs sauber zusammengesetzt werden. */
export function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, "");
}
function friendlyMessage(status: number, url: string, body: string): string {
switch (status) {
case 401:
return "Nicht autorisiert (401) – Token fehlt, ist abgelaufen oder ungueltig.";
case 403:
return body.toLowerCase().includes("rate limit")
? "Rate-Limit erreicht (403) – bitte spaeter erneut versuchen oder Token hinterlegen."
: "Zugriff verweigert (403) – Token hat nicht die noetigen Scopes.";
case 404:
return "Nicht gefunden (404) – Repo-Pfad pruefen oder Token hat keinen Lesezugriff.";
case 429:
return "Zu viele Anfragen (429) – Rate-Limit des Git-Systems.";
default:
if (status >= 500) return `Serverfehler ${status} beim Git-System.`;
return `Anfrage fehlgeschlagen (${status}) auf ${url}`;
}
}
/** GET mit Timeout, JSON-Parsing und sprechenden Fehlern. */
export async function getJson<T>(
url: string,
options: FetchOptions = {},
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.timeoutMs ?? DEFAULT_TIMEOUT,
);
try {
// Vor jedem ausgehenden Aufruf pruefen, wohin er geht. Die Basis-URL
// stammt aus einem Formular, also aus Benutzereingabe.
await assertAllowedUrl(url);
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "Nebula-Dashboard",
...options.headers,
},
signal: controller.signal,
cache: "no-store",
redirect: "follow",
});
// Ein Redirect koennte auf eine interne Adresse zeigen (etwa bei einem
// umbenannten GitHub-Repo, aber eben auch boesartig). Deshalb das
// tatsaechliche Ziel nachtraeglich noch einmal pruefen.
if (response.redirected && response.url && response.url !== url) {
await assertAllowedUrl(response.url);
}
if (response.status === 404 && options.allow404) {
return null as T;
}
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new GitApiError(friendlyMessage(response.status, url, body), response.status, url);
}
const text = await response.text();
if (!text) return null as T;
return JSON.parse(text) as T;
} catch (error) {
if (error instanceof GitApiError) throw error;
if (error instanceof BlockedHostError) {
throw new GitApiError(error.message, 403, url);
}
if (error instanceof Error && error.name === "AbortError") {
throw new GitApiError(
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
408,
url,
);
}
const reason = error instanceof Error ? error.message : String(error);
throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url);
} finally {
clearTimeout(timeout);
}
}
/**
* Schreibender Aufruf – POST, PATCH oder PUT.
*
* Bewusst dieselbe Schleuse wie getJson: die Basis-URL stammt aus einem
* Formular, und ohne die Pruefung waere ein schreibender Aufruf der weitaus
* unangenehmere Weg nach innen. Redirects werden hier **nicht** verfolgt.
* Ein 307 auf eine interne Adresse wuerde den Rumpf mitnehmen, und der
* enthaelt bei uns immer den Token im Header – lieber ein Fehler als eine
* Anfrage, die woanders landet.
*/
export async function sendJson<T>(
url: string,
method: "POST" | "PATCH" | "PUT",
body: unknown,
options: FetchOptions = {},
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.timeoutMs ?? DEFAULT_TIMEOUT,
);
try {
await assertAllowedUrl(url);
const response = await fetch(url, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "Nebula-Dashboard",
...options.headers,
},
body: JSON.stringify(body),
signal: controller.signal,
cache: "no-store",
redirect: "manual",
});
// Bei redirect: "manual" kommt der Umleitungsstatus durch, statt dass
// fetch ihm folgt. Das ist hier gewollt und ein Fehlerfall.
if (response.status >= 300 && response.status < 400) {
throw new GitApiError(
"Das Git-System hat umgeleitet. Schreibende Aufrufe folgen dem nicht – " +
"bitte die Basis-URL der Verbindung auf die endgueltige Adresse setzen.",
response.status,
url,
);
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url);
}
const text = await response.text();
if (!text) return null as T;
return JSON.parse(text) as T;
} catch (error) {
if (error instanceof GitApiError) throw error;
if (error instanceof BlockedHostError) {
throw new GitApiError(error.message, 403, url);
}
if (error instanceof Error && error.name === "AbortError") {
throw new GitApiError(
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
408,
url,
);
}
const reason = error instanceof Error ? error.message : String(error);
throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url);
} finally {
clearTimeout(timeout);
}
}
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
export async function getJsonSafe<T>(
url: string,
fallback: T,
options: FetchOptions = {},
): Promise<T> {
try {
const result = await getJson<T>(url, options);
return (result ?? fallback) as T;
} catch {
return fallback;
}
}
export function toIso(value: unknown): string | null {
if (!value) return null;
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
export function shortSha(sha: string | null | undefined): string {
return (sha ?? "").slice(0, 7);
}