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; export interface NcTransportRequest { url: string; method: string; headers: Record; 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; /** 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])|(? 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, 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; /** Haengt der Adresse einen `/` an (Ordner bei WebDAV). */ trailingSlash?: boolean; method: string; headers?: Record; 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 }; 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 { 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 { const headers: Record = { '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((_, 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 & { destroy?: (err?: Error) => unknown }, maxBytes: number, ): Promise { 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') }; }