08abfef8e2
- WR-09: ein einzelnes UTF-16-Ersatzzeichen in einem Pfadsegment ist 400 invalidPath (bzw. invalidName) statt eines URIError mit 500; die Weboberflaeche wiederholt keine 4xx - IN-02: Vorschaubilder nur als Rasterbild, image/svg+xml wird wie ein fehlendes Vorschaubild behandelt (CSP-Sandbox und nosniff bleiben) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
489 lines
18 KiB
TypeScript
489 lines
18 KiB
TypeScript
import type { Readable } from 'node:stream';
|
|
import { request } from 'undici';
|
|
import { normalizeCloudUrl } from '../nextcloud-status/nextcloud-status-fetch';
|
|
import type { NextcloudCallGate } from './nextcloud-call-gate';
|
|
import { type NcFailureKind, ncErrorDefault } from './nextcloud-files.types';
|
|
|
|
/**
|
|
* Einzige Transportschicht des Moduls "Nextcloud-Dateien" (quick-261008-mzu).
|
|
* Jeder Aufruf an eine Nextcloud geht durch `ncRequest` — dort sitzen die
|
|
* Schutzregeln, damit sie kein Aufrufer umgehen kann.
|
|
*
|
|
* Warum interne Adressen erlaubt sind (L-01): die Nextcloud steht oft im Haus,
|
|
* und die Adresse setzt allein ein Verwalter. Der gemeinsame Schutz
|
|
* `isPublicHttpUrl` (nur oeffentliche Adressen) wird deshalb hier mit Absicht
|
|
* NICHT verwendet. Stattdessen wird die Reichweite so eingegrenzt:
|
|
* - Es gibt genau EINE Basisadresse je Organisation (Normalform von
|
|
* `normalizeCloudUrl`). Jede URL ist `Basis + fester Pfadanfang +
|
|
* einzeln codierte Segmente`; der Pfadanfang kommt aus einer festen Liste.
|
|
* - Benutzereingaben sind nur Pfadsegmente. Sie laufen durch
|
|
* `validateSegment` und werden Stueck fuer Stueck mit `encodeURIComponent`
|
|
* codiert (nie ein ganzer Pfad auf einmal).
|
|
* - Es werden KEINE Weiterleitungen befolgt (jedes 3xx ist ein Fehler), und
|
|
* nie wird eine URL aus einer Nextcloud-Antwort aufgerufen.
|
|
* - Es werden nie Cookies gesendet; `set-cookie` wird nie weitergereicht.
|
|
* - Zeit- und Groessengrenzen auf jedem Aufruf. Zertifikate werden immer
|
|
* geprueft (es gibt keinen Schalter, das abzustellen; eine interne CA
|
|
* gehoert in `NODE_EXTRA_CA_CERTS` des api-Containers).
|
|
* - Ein Adresswechsel laesst alle gespeicherten App-Passwoerter ablaufen,
|
|
* das Konto fuehrt die Adresse mit, fuer die das Passwort galt.
|
|
*
|
|
* Warum undici `request` und nicht `fetch`: Node-Streams als Anfragekoerper
|
|
* ohne Umweg, kein Weiterleitungsfolgen von sich aus, die Antwort kommt als
|
|
* lesbarer Strom, der mit `stream.pipeline` weitergereicht werden kann.
|
|
*
|
|
* Nie loggen: Kopfzeilen, Zugangsdaten, App-Passwoerter. Fehlerergebnisse
|
|
* tragen nur Art, Statuscode und eine kurze Kennung (z. B. `ENOTFOUND`).
|
|
*/
|
|
|
|
export const NC_USER_AGENT = 'Tessera (Nextcloud-Dateien)';
|
|
|
|
/** Nest-Token fuer den einsetzbaren Transport (Tests setzen eine Attrappe ein). */
|
|
export const NEXTCLOUD_TRANSPORT = 'NEXTCLOUD_TRANSPORT';
|
|
|
|
/** Erlaubte feste Pfadanfaenge (D-C). Alles andere wirft vor jedem Aufruf. */
|
|
export const ALLOWED_PREFIXES = [
|
|
'/status.php',
|
|
'/ocs/v2.php/',
|
|
'/index.php/login/v2',
|
|
'/index.php/login/v2/poll',
|
|
'/index.php/core/preview',
|
|
'/remote.php/dav/files/',
|
|
'/remote.php/dav/uploads/',
|
|
'/index.php/apps/theming/image/logo',
|
|
'/core/img/logo/logo.svg',
|
|
] as const;
|
|
export type NcPrefix = (typeof ALLOWED_PREFIXES)[number];
|
|
|
|
export const DEFAULT_HEADERS_TIMEOUT_MS = 15_000;
|
|
export const DEFAULT_BODY_TIMEOUT_MS = 15_000;
|
|
|
|
// --- Transport -------------------------------------------------------------------
|
|
|
|
export type NcHeaders = Record<string, string | string[] | undefined>;
|
|
|
|
export interface NcTransportRequest {
|
|
url: string;
|
|
method: string;
|
|
headers: Record<string, string>;
|
|
body?: Readable | string | Buffer | null;
|
|
headersTimeoutMs: number;
|
|
bodyTimeoutMs: number;
|
|
signal: AbortSignal;
|
|
}
|
|
|
|
export interface NcTransportResponse {
|
|
statusCode: number;
|
|
headers: NcHeaders;
|
|
body: Readable;
|
|
}
|
|
|
|
export type NextcloudTransport = (req: NcTransportRequest) => Promise<NcTransportResponse>;
|
|
|
|
/** Standardtransport auf Basis von undici `request` (keine Cookies, keine Weiterleitungen). */
|
|
export const undiciTransport: NextcloudTransport = async (req) => {
|
|
const res = await request(req.url, {
|
|
// undici kennt PROPFIND/MKCOL/MOVE zur Laufzeit; die Typen nennen nur die Standardmethoden.
|
|
method: req.method as never,
|
|
headers: req.headers,
|
|
body: (req.body ?? undefined) as never,
|
|
headersTimeout: req.headersTimeoutMs,
|
|
bodyTimeout: req.bodyTimeoutMs,
|
|
signal: req.signal,
|
|
});
|
|
return {
|
|
statusCode: res.statusCode,
|
|
headers: res.headers as NcHeaders,
|
|
body: res.body as unknown as Readable,
|
|
};
|
|
};
|
|
|
|
// --- Pfade und URLs -----------------------------------------------------------------
|
|
|
|
const MAX_SEGMENT_BYTES = 255;
|
|
const MAX_SEGMENTS = 100;
|
|
const MAX_PATH_CHARS = 4096;
|
|
|
|
/** Ein einzelnes UTF-16-Ersatzzeichen ohne Partner (z. B. `"\uD800"` aus einem JSON-Koerper). */
|
|
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
|
|
/**
|
|
* Ein einzelnes Pfadsegment pruefen (D-H): nicht leer, nicht `.`/`..`, kein
|
|
* `/`, `\`, NUL oder Steuerzeichen, kein einzelnes Ersatzzeichen (WR-09: daran
|
|
* scheitert `encodeURIComponent` mit einem URIError, der sonst als 500 endete),
|
|
* hoechstens 255 UTF-8-Byte.
|
|
*/
|
|
export function validateSegment(segment: string): string {
|
|
if (
|
|
typeof segment !== 'string' ||
|
|
segment === '' ||
|
|
segment === '.' ||
|
|
segment === '..' ||
|
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Prueffall
|
|
/[\\/\u0000-\u001f\u007f]/.test(segment) ||
|
|
LONE_SURROGATE.test(segment) ||
|
|
Buffer.byteLength(segment, 'utf8') > MAX_SEGMENT_BYTES
|
|
) {
|
|
throw ncErrorDefault('invalidPath');
|
|
}
|
|
return segment;
|
|
}
|
|
|
|
/** Jedes Segment einzeln mit `encodeURIComponent` codieren und mit `/` verbinden. */
|
|
export function encodeSegments(segments: readonly string[]): string {
|
|
return segments.map((s) => encodeURIComponent(validateSegment(s))).join('/');
|
|
}
|
|
|
|
/**
|
|
* Pfad aus Query/Body in Segmente zerlegen. `''` und `/` sind die Wurzel; ein
|
|
* fuehrender und ein abschliessender `/` werden entfernt; jedes Segment laeuft
|
|
* durch `validateSegment`; mehr als 100 Segmente oder 4096 Zeichen sind
|
|
* ungueltig. Namen bleiben unveraendert (keine Unicode-Normalisierung).
|
|
*/
|
|
export function parseUserPath(raw: string | undefined | null): string[] {
|
|
if (raw === undefined || raw === null) return [];
|
|
if (typeof raw !== 'string' || raw.length > MAX_PATH_CHARS) throw ncErrorDefault('invalidPath');
|
|
let path = raw;
|
|
if (path.startsWith('/')) path = path.slice(1);
|
|
if (path.endsWith('/')) path = path.slice(0, -1);
|
|
if (path === '') return [];
|
|
const segments = path.split('/');
|
|
if (segments.length > MAX_SEGMENTS) throw ncErrorDefault('invalidPath');
|
|
return segments.map(validateSegment);
|
|
}
|
|
|
|
/** Neue Namen (Ordner anlegen, Umbenennen): Segmentregeln plus `.part` und Leerraum. */
|
|
export function validateNewName(name: string): string {
|
|
try {
|
|
validateSegment(name);
|
|
} catch {
|
|
throw ncErrorDefault('invalidName');
|
|
}
|
|
if (name.trim() === '' || name.toLowerCase().endsWith('.part')) {
|
|
throw ncErrorDefault('invalidName');
|
|
}
|
|
return name;
|
|
}
|
|
|
|
/**
|
|
* Baut die URL `Basis + Pfadanfang + codierte Segmente (+ Query)`. Die Basis
|
|
* muss die Normalform von `normalizeCloudUrl` haben; der Pfadanfang muss in
|
|
* `ALLOWED_PREFIXES` stehen. Verstoesse sind Programmierfehler und werfen.
|
|
*/
|
|
export function buildNcUrl(
|
|
baseUrl: string,
|
|
prefix: NcPrefix,
|
|
segments: readonly string[] = [],
|
|
query?: Record<string, string>,
|
|
trailingSlash = false,
|
|
): string {
|
|
if (!(ALLOWED_PREFIXES as readonly string[]).includes(prefix)) {
|
|
throw new Error(`Nextcloud-Pfadanfang nicht erlaubt: ${prefix}`);
|
|
}
|
|
const base = normalizeCloudUrl(baseUrl);
|
|
if (base === null) throw new Error('Nextcloud-Basisadresse ist ungültig');
|
|
if (segments.length > 0 && !prefix.endsWith('/')) {
|
|
throw new Error(`Nextcloud-Pfadanfang ${prefix} nimmt keine Segmente`);
|
|
}
|
|
let url = `${base}${prefix}${encodeSegments(segments)}`;
|
|
// Ordner-Adressen enden fuer WebDAV mit einem Schraegstrich (PROPFIND auf Ordner).
|
|
if (trailingSlash && !url.endsWith('/')) url += '/';
|
|
if (query) {
|
|
const pairs = Object.entries(query).map(
|
|
([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
|
|
);
|
|
if (pairs.length > 0) url += `?${pairs.join('&')}`;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/** `Basic base64(benutzer:geheimnis)` — das Ergebnis nie loggen oder zurueckgeben. */
|
|
export function basicAuth(user: string, secret: string): string {
|
|
return `Basic ${Buffer.from(`${user}:${secret}`, 'utf8').toString('base64')}`;
|
|
}
|
|
|
|
// --- Aufruf -----------------------------------------------------------------------------
|
|
|
|
export interface NcRequestOptions {
|
|
baseUrl: string;
|
|
prefix: NcPrefix;
|
|
segments?: readonly string[];
|
|
query?: Record<string, string>;
|
|
/** Haengt der Adresse einen `/` an (Ordner bei WebDAV). */
|
|
trailingSlash?: boolean;
|
|
method: string;
|
|
headers?: Record<string, string>;
|
|
body?: Readable | string | Buffer | null;
|
|
/** Fertiger `Authorization`-Wert (siehe `basicAuth`). */
|
|
authorization?: string;
|
|
/** Gesetzt bei Aufrufen mit gespeichertem App-Passwort (siehe Aufrufsperre). */
|
|
credentialKey?: string;
|
|
/**
|
|
* Nicht unter die Grenze gleichzeitiger Aufrufe je Schluessel (WR-03) fallen: nur fuer
|
|
* Uebertragungen, deren Antwort erst nach dem ganzen Koerper kommt (PUT eines Stuecks,
|
|
* Zusammenbau). Sie laufen ohnehin nacheinander je Datei.
|
|
*/
|
|
unthrottled?: boolean;
|
|
/** OCS-Aufruf: setzt `OCS-APIRequest` und `Accept: application/json`. */
|
|
ocs?: boolean;
|
|
headersTimeoutMs?: number;
|
|
bodyTimeoutMs?: number;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export interface NcFailure {
|
|
ok: false;
|
|
kind: NcFailureKind;
|
|
status?: number;
|
|
retryAfterSeconds?: number;
|
|
/** Kurze eigene Kennung wie `ENOTFOUND` — nie ein Antworttext. */
|
|
detail?: string;
|
|
}
|
|
|
|
export type NcResult = { ok: true; status: number; headers: NcHeaders; body: Readable } | NcFailure;
|
|
|
|
const TLS_CODES = new Set([
|
|
'CERT_HAS_EXPIRED',
|
|
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
'ERR_TLS_CERT_ALTNAME_INVALID_ALTERNATE',
|
|
'CERT_NOT_YET_VALID',
|
|
'CERT_UNTRUSTED',
|
|
'CERT_REVOKED',
|
|
'CERT_SIGNATURE_FAILURE',
|
|
'HOSTNAME_MISMATCH',
|
|
]);
|
|
const TIMEOUT_CODES = new Set([
|
|
'UND_ERR_HEADERS_TIMEOUT',
|
|
'UND_ERR_BODY_TIMEOUT',
|
|
'UND_ERR_CONNECT_TIMEOUT',
|
|
'ETIMEDOUT',
|
|
]);
|
|
|
|
function errorCode(err: unknown): string | null {
|
|
const e = err as { code?: unknown; cause?: { code?: unknown } } | null;
|
|
const code = e?.cause?.code ?? e?.code;
|
|
return typeof code === 'string' && /^[A-Z0-9_]{2,80}$/.test(code) ? code : null;
|
|
}
|
|
|
|
/** Ordnet einen Transportfehler einer Fehlerart zu (ohne den Fehlertext zu uebernehmen). */
|
|
export function classifyTransportError(err: unknown): NcFailure {
|
|
const code = errorCode(err);
|
|
if (code && TLS_CODES.has(code)) return { ok: false, kind: 'tls', detail: code };
|
|
if (code && TIMEOUT_CODES.has(code)) return { ok: false, kind: 'timeout', detail: code };
|
|
return { ok: false, kind: 'network', ...(code ? { detail: code } : {}) };
|
|
}
|
|
|
|
function headerOne(value: string | string[] | undefined): string | undefined {
|
|
if (Array.isArray(value)) return value[0];
|
|
return value;
|
|
}
|
|
|
|
/** Antwortkoerper verwerfen, ohne einen Fehler nach aussen zu geben. */
|
|
export function discardBody(body: Readable | null | undefined): void {
|
|
if (!body) return;
|
|
try {
|
|
const dumpable = body as unknown as { dump?: () => Promise<void> };
|
|
if (typeof dumpable.dump === 'function') {
|
|
dumpable.dump().catch(() => {});
|
|
} else {
|
|
body.destroy();
|
|
}
|
|
} catch {
|
|
// schon verbraucht
|
|
}
|
|
}
|
|
|
|
const FORBIDDEN_CUSTOM_HEADERS = new Set(['cookie', 'host', 'authorization']);
|
|
|
|
/**
|
|
* Fuehrt einen Aufruf aus. Wirft nie bei Netz- oder HTTP-Problemen; das
|
|
* Ergebnis ist `{ ok: true, status, headers, body }` oder `{ ok: false, kind, ... }`.
|
|
* HTTP-Fehlerstatus (404, 412, ...) kommen als `ok: true` zurueck, damit der
|
|
* Aufrufer sie deuten kann. Ausnahmen, die hier schon entschieden werden:
|
|
* - 429: haelt den ganzen Ursprung an (Aufrufsperre) -> `{ ok: false, kind: 'http', status: 429 }`
|
|
* - 401 mit `credentialKey`: Schluessel stirbt -> `kind: 'credential-dead'`
|
|
* - 3xx: `kind: 'redirect'` (es wird nie gefolgt)
|
|
* Vor dem Transport prueft sie die Aufrufsperre (`paused`, `credential-dead`).
|
|
*/
|
|
export async function ncRequest(
|
|
transport: NextcloudTransport,
|
|
gate: NextcloudCallGate,
|
|
opts: NcRequestOptions,
|
|
): Promise<NcResult> {
|
|
const url = buildNcUrl(
|
|
opts.baseUrl,
|
|
opts.prefix,
|
|
opts.segments ?? [],
|
|
opts.query,
|
|
opts.trailingSlash ?? false,
|
|
);
|
|
const origin = new URL(url).origin;
|
|
|
|
if (opts.credentialKey && gate.isDead(opts.credentialKey)) {
|
|
return { ok: false, kind: 'credential-dead' };
|
|
}
|
|
const pause = gate.isPaused(origin);
|
|
if (pause.paused) {
|
|
return { ok: false, kind: 'paused', retryAfterSeconds: pause.retryAfterSeconds };
|
|
}
|
|
|
|
// WR-03: hoechstens 4 gleichzeitige Aufrufe je Zugangsschluessel bis zur Antwort. Ein
|
|
// widerrufener Zugang erzeugt so hoechstens ein paar 401, bevor er als tot gilt.
|
|
let releaseSlot: (() => void) | undefined;
|
|
if (opts.credentialKey && !opts.unthrottled) {
|
|
try {
|
|
releaseSlot = await gate.acquireSlot(opts.credentialKey, opts.signal);
|
|
} catch {
|
|
return { ok: false, kind: 'aborted' };
|
|
}
|
|
// Waehrend des Wartens kann der Schluessel gestorben oder der Ursprung gesperrt worden sein.
|
|
if (gate.isDead(opts.credentialKey)) {
|
|
releaseSlot();
|
|
return { ok: false, kind: 'credential-dead' };
|
|
}
|
|
const later = gate.isPaused(origin);
|
|
if (later.paused) {
|
|
releaseSlot();
|
|
return { ok: false, kind: 'paused', retryAfterSeconds: later.retryAfterSeconds };
|
|
}
|
|
}
|
|
try {
|
|
return await sendNcRequest(transport, gate, opts, url, origin);
|
|
} finally {
|
|
// Erst NACH der Auswertung (401 -> tot, 429 -> Sperre) freigeben: der naechste Wartende
|
|
// sieht den neuen Zustand, bevor er sendet.
|
|
releaseSlot?.();
|
|
}
|
|
}
|
|
|
|
async function sendNcRequest(
|
|
transport: NextcloudTransport,
|
|
gate: NextcloudCallGate,
|
|
opts: NcRequestOptions,
|
|
url: string,
|
|
origin: string,
|
|
): Promise<NcResult> {
|
|
const headers: Record<string, string> = { 'user-agent': NC_USER_AGENT };
|
|
if (opts.ocs) {
|
|
headers['ocs-apirequest'] = 'true';
|
|
headers.accept = 'application/json';
|
|
}
|
|
for (const [name, value] of Object.entries(opts.headers ?? {})) {
|
|
const lower = name.toLowerCase();
|
|
if (FORBIDDEN_CUSTOM_HEADERS.has(lower)) {
|
|
throw new Error(`Kopfzeile ${lower} darf nicht frei gesetzt werden`);
|
|
}
|
|
headers[lower] = value;
|
|
}
|
|
if (opts.authorization) headers.authorization = opts.authorization;
|
|
|
|
const own = new AbortController();
|
|
const signals: AbortSignal[] = [own.signal];
|
|
if (opts.signal) signals.push(opts.signal);
|
|
if (opts.credentialKey) signals.push(gate.signalFor(opts.credentialKey));
|
|
const signal = AbortSignal.any(signals);
|
|
|
|
const headersTimeoutMs = opts.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS;
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
own.abort(new Error('timeout'));
|
|
}, headersTimeoutMs);
|
|
|
|
// Auch ein Transport, der das Signal nicht beachtet, soll nicht haengen bleiben.
|
|
let onAbort: (() => void) | undefined;
|
|
const aborted = new Promise<never>((_, reject) => {
|
|
onAbort = () => reject(signal.reason ?? new Error('aborted'));
|
|
if (signal.aborted) onAbort();
|
|
else signal.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
aborted.catch(() => {});
|
|
|
|
const pending = Promise.resolve().then(() =>
|
|
transport({
|
|
url,
|
|
method: opts.method,
|
|
headers,
|
|
body: opts.body ?? null,
|
|
headersTimeoutMs,
|
|
bodyTimeoutMs: opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS,
|
|
signal,
|
|
}),
|
|
);
|
|
pending.catch(() => {});
|
|
|
|
let res: NcTransportResponse;
|
|
try {
|
|
res = await Promise.race([pending, aborted]);
|
|
} catch (err) {
|
|
// Eine spaet eintreffende Antwort des abgebrochenen Aufrufs wegwerfen.
|
|
pending.then((late) => discardBody(late.body)).catch(() => {});
|
|
if (opts.credentialKey && gate.isDead(opts.credentialKey)) {
|
|
return { ok: false, kind: 'credential-dead' };
|
|
}
|
|
if (timedOut) return { ok: false, kind: 'timeout' };
|
|
if (opts.signal?.aborted) return { ok: false, kind: 'aborted' };
|
|
return classifyTransportError(err);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
if (onAbort) signal.removeEventListener('abort', onAbort);
|
|
}
|
|
|
|
const status = res.statusCode;
|
|
if (status === 429) {
|
|
const seconds = gate.pause(origin, headerOne(res.headers['retry-after']));
|
|
discardBody(res.body);
|
|
return { ok: false, kind: 'http', status, retryAfterSeconds: seconds };
|
|
}
|
|
if (status === 401 && opts.credentialKey) {
|
|
gate.markDead(opts.credentialKey);
|
|
discardBody(res.body);
|
|
return { ok: false, kind: 'credential-dead', status };
|
|
}
|
|
if (status >= 300 && status < 400) {
|
|
discardBody(res.body);
|
|
return { ok: false, kind: 'redirect', status };
|
|
}
|
|
return { ok: true, status, headers: res.headers, body: res.body };
|
|
}
|
|
|
|
export type CappedText =
|
|
| { ok: true; text: string }
|
|
| { ok: false; kind: 'too-large' | 'timeout' | 'network' | 'tls'; detail?: string };
|
|
|
|
/** Liest einen Antwortkoerper als Text, hoechstens `maxBytes` (sonst `too-large`). */
|
|
export async function readCappedText(
|
|
body: AsyncIterable<Uint8Array | string> & { destroy?: (err?: Error) => unknown },
|
|
maxBytes: number,
|
|
): Promise<CappedText> {
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
try {
|
|
for await (const chunk of body) {
|
|
const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk);
|
|
total += buf.byteLength;
|
|
if (total > maxBytes) {
|
|
try {
|
|
body.destroy?.();
|
|
} catch {
|
|
// schon beendet
|
|
}
|
|
return { ok: false, kind: 'too-large' };
|
|
}
|
|
chunks.push(buf);
|
|
}
|
|
} catch (err) {
|
|
const failure = classifyTransportError(err);
|
|
return {
|
|
ok: false,
|
|
kind: failure.kind as 'timeout' | 'network' | 'tls',
|
|
...(failure.detail ? { detail: failure.detail } : {}),
|
|
};
|
|
}
|
|
return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
|
|
}
|