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>
201 lines
6.4 KiB
TypeScript
201 lines
6.4 KiB
TypeScript
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);
|
||
}
|