feat(nextcloud-files): Modul Dateien mit Nextcloud-Adresse, Verbindungsprüfung und gesicherter Verbindungsschicht
- Migration 20261008180000: NextcloudFilesConfig (Organisation) und NextcloudFilesAccount (Mandant UND Benutzer per Zeilenschutz, nur verschlüsseltes App-Passwort) - Transportschicht ncRequest: feste Pfadanfänge, Segmentcodierung, keine Weiterleitungen, keine Cookies, Zeitgrenzen; Aufrufsperre pausiert den Ursprung nach jedem 429 und sperrt Zugangsschlüssel nach dem ersten 401 - Einstellungen: Adresse speichern/prüfen, Adresswechsel läuft alle Konten ab (mit Bestätigung) - Controller mit Verwalten nur auf Einstellungen, Seed, Modul, Registrierung in Web (Ladefunktion, Symbol folder, Navigation, Layout) und Reiter Einstellungen - RLS-Inventar fortgeschrieben, Testaufbau (tessera-nc-test) und E2E-Skripte Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
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 Pfadsegment pruefen (D-H): nicht leer, nicht `.`/`..`, kein
|
||||
* `/`, `\`, NUL oder Steuerzeichen, 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) ||
|
||||
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>,
|
||||
): 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)}`;
|
||||
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>;
|
||||
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;
|
||||
/** 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);
|
||||
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 };
|
||||
}
|
||||
|
||||
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') };
|
||||
}
|
||||
Reference in New Issue
Block a user