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:
2026-10-08 17:41:46 +02:00
parent 83b842b72c
commit 960696745f
35 changed files with 3282 additions and 2 deletions
@@ -0,0 +1,25 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Nextcloud-Adresse speichern (quick-261008-mzu). `confirmReconnect` bestaetigt
* ausdruecklich, dass beim Wechsel der Adresse alle verbundenen Benutzer
* abgemeldet werden (ihre App-Passwoerter gelten nur fuer die alte Nextcloud).
*/
export class SaveNextcloudFilesSettingsDto {
@IsString()
@IsNotEmpty()
@MaxLength(2048)
baseUrl!: string;
@IsOptional()
@IsBoolean()
confirmReconnect?: boolean;
}
/** Verbindungspruefung einer (noch nicht gespeicherten) Adresse. */
export class TestNextcloudFilesSettingsDto {
@IsString()
@IsNotEmpty()
@MaxLength(2048)
baseUrl!: string;
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import {
DEAD_KEY_TTL_MS,
DEFAULT_PAUSE_SECONDS,
MAX_DEAD_KEYS,
MAX_PAUSE_SECONDS,
NextcloudCallGate,
} from './nextcloud-call-gate';
function makeGate() {
const gate = new NextcloudCallGate();
const clock = { t: 1_000_000 };
gate.now = () => clock.t;
return { gate, clock };
}
describe('NextcloudCallGate — Sperre je Ursprung', () => {
it('nach pause(): 900 Sekunden, nur fuer diesen Ursprung', () => {
const { gate } = makeGate();
expect(gate.isPaused('https://cloud.example')).toEqual({ paused: false, retryAfterSeconds: 0 });
gate.pause('https://cloud.example');
expect(gate.isPaused('https://cloud.example')).toEqual({
paused: true,
retryAfterSeconds: DEFAULT_PAUSE_SECONDS,
});
expect(DEFAULT_PAUSE_SECONDS).toBe(900);
expect(gate.isPaused('https://other.example').paused).toBe(false);
});
it('Retry-After 120 -> 120 Sekunden; 99999 -> auf 3600 gedeckelt; Unsinn -> 900', () => {
const { gate } = makeGate();
gate.pause('https://a.example', '120');
expect(gate.isPaused('https://a.example').retryAfterSeconds).toBe(120);
gate.pause('https://b.example', '99999');
expect(gate.isPaused('https://b.example').retryAfterSeconds).toBe(3600);
expect(MAX_PAUSE_SECONDS).toBe(3600);
gate.pause('https://c.example', 'Wed, 21 Oct 2026 07:28:00 GMT');
expect(gate.isPaused('https://c.example').retryAfterSeconds).toBe(900);
gate.pause('https://d.example', '0');
expect(gate.isPaused('https://d.example').retryAfterSeconds).toBe(900);
});
it('nach Ablauf geht es wieder, die Restzeit sinkt mit der Uhr', () => {
const { gate, clock } = makeGate();
gate.pause('https://cloud.example', '120');
clock.t += 60_000;
expect(gate.isPaused('https://cloud.example')).toEqual({ paused: true, retryAfterSeconds: 60 });
clock.t += 60_000;
expect(gate.isPaused('https://cloud.example').paused).toBe(false);
});
it('eine kuerzere zweite Sperre verkuerzt eine laengere nicht', () => {
const { gate } = makeGate();
gate.pause('https://cloud.example', '600');
gate.pause('https://cloud.example', '60');
expect(gate.isPaused('https://cloud.example').retryAfterSeconds).toBe(600);
});
});
describe('NextcloudCallGate — Sperre je Zugangsschluessel', () => {
it('markDead(): Schluessel tot, andere Schluessel nicht', () => {
const { gate } = makeGate();
expect(gate.isDead('k1')).toBe(false);
gate.markDead('k1');
expect(gate.isDead('k1')).toBe(true);
expect(gate.isDead('k2')).toBe(false);
});
it('markDead() bricht das Signal des Schluessels ab, nicht das eines anderen', () => {
const { gate } = makeGate();
const s1 = gate.signalFor('k1');
const s2 = gate.signalFor('k2');
expect(s1.aborted).toBe(false);
gate.markDead('k1');
expect(s1.aborted).toBe(true);
expect(s2.aborted).toBe(false);
expect(gate.signalFor('k1').aborted).toBe(true);
});
it('tote Schluessel laufen nach 24 Stunden ab', () => {
const { gate, clock } = makeGate();
gate.markDead('k1');
clock.t += DEAD_KEY_TTL_MS - 1;
expect(gate.isDead('k1')).toBe(true);
clock.t += 2;
expect(gate.isDead('k1')).toBe(false);
});
it('haelt nie mehr als 10000 tote Schluessel, die aeltesten fliegen zuerst raus', () => {
const { gate } = makeGate();
for (let i = 0; i < MAX_DEAD_KEYS + 5; i++) gate.markDead(`key-${i}`);
expect(gate.isDead('key-0')).toBe(false);
expect(gate.isDead('key-4')).toBe(false);
expect(gate.isDead('key-5')).toBe(true);
expect(gate.isDead(`key-${MAX_DEAD_KEYS + 4}`)).toBe(true);
// intern gezaehlt: genau MAX_DEAD_KEYS Schluessel sind noch tot
let dead = 0;
for (let i = 0; i < MAX_DEAD_KEYS + 5; i++) if (gate.isDead(`key-${i}`)) dead++;
expect(dead).toBe(MAX_DEAD_KEYS);
});
});
@@ -0,0 +1,122 @@
import { Injectable } from '@nestjs/common';
/** Sperrzeit, wenn Nextcloud 429 ohne `Retry-After` liefert (Standard der Brute-Force-Sperre). */
export const DEFAULT_PAUSE_SECONDS = 15 * 60;
/** Obergrenze fuer eine Sperrzeit, auch wenn Nextcloud mehr verlangt. */
export const MAX_PAUSE_SECONDS = 60 * 60;
/** So lange bleibt ein als widerrufen erkannter Zugangsschluessel gemerkt. */
export const DEAD_KEY_TTL_MS = 24 * 60 * 60 * 1000;
/** Hoechstzahl gemerkter Zugangsschluessel (aelteste fliegen zuerst raus). */
export const MAX_DEAD_KEYS = 10_000;
/**
* Aufrufsperre (quick-261008-mzu, D-O) — ein prozessweites Objekt, das jeder
* Nextcloud-Aufruf in `ncRequest` befragt, damit kein Aufrufer sie umgehen kann.
*
* (a) Sperre je Nextcloud-Ursprung: Die Brute-Force-Sperre der Nextcloud gilt
* je IP-Adresse, und alle Tessera-Benutzer kommen von EINER Server-Adresse.
* Jede weitere Anfrage waehrend der Sperre verlaengert sie oder ist
* verschwendet. Deshalb wird nach JEDEM 429 (egal bei welchem Aufruf) der
* ganze Ursprung angehalten; das Tor wiederholt nichts, es haelt den
* Verkehr an.
*
* (b) Sperre je Zugangsschluessel: Ein widerrufenes App-Passwort darf Nextcloud
* nicht bombardieren — dort zaehlt jedes 401 als Fehlanmeldung. Nach dem
* ersten 401 zu einem gespeicherten App-Passwort gilt der Schluessel als
* tot: spaetere Aufrufe gehen gar nicht erst raus, und laufende Aufrufe
* desselben Schluessels werden abgebrochen.
*
* Der Zustand liegt im Arbeitsspeicher; ein Neustart der API hebt ihn auf (die
* Nextcloud-Sperre selbst bleibt dort bestehen und wird beim naechsten 429
* wieder erkannt).
*/
@Injectable()
export class NextcloudCallGate {
/** Zeitquelle in Millisekunden; Tests ersetzen sie. */
now: () => number = () => Date.now();
private readonly pausedUntil = new Map<string, number>();
private readonly dead = new Map<string, number>();
private readonly controllers = new Map<string, AbortController>();
// --- (a) Sperre je Ursprung --------------------------------------------------
isPaused(origin: string): { paused: boolean; retryAfterSeconds: number } {
const until = this.pausedUntil.get(origin);
if (until === undefined) return { paused: false, retryAfterSeconds: 0 };
const remainingMs = until - this.now();
if (remainingMs <= 0) {
this.pausedUntil.delete(origin);
return { paused: false, retryAfterSeconds: 0 };
}
return { paused: true, retryAfterSeconds: Math.ceil(remainingMs / 1000) };
}
/** Haelt den Ursprung an: `Retry-After` (Sekunden) oder 15 Minuten, hoechstens 60. */
pause(origin: string, retryAfterHeader?: string | null): number {
let seconds = DEFAULT_PAUSE_SECONDS;
if (retryAfterHeader !== undefined && retryAfterHeader !== null) {
const trimmed = String(retryAfterHeader).trim();
if (/^\d{1,9}$/.test(trimmed)) {
const parsed = Number.parseInt(trimmed, 10);
if (parsed > 0) seconds = parsed;
}
}
seconds = Math.min(seconds, MAX_PAUSE_SECONDS);
const until = this.now() + seconds * 1000;
const existing = this.pausedUntil.get(origin);
this.pausedUntil.set(origin, existing !== undefined && existing > until ? existing : until);
return seconds;
}
// --- (b) Sperre je Zugangsschluessel ----------------------------------------
isDead(key: string): boolean {
const expires = this.dead.get(key);
if (expires === undefined) return false;
if (expires <= this.now()) {
this.dead.delete(key);
return false;
}
return true;
}
/** Merkt den Schluessel als tot und bricht alle laufenden Aufrufe damit ab. */
markDead(key: string): void {
this.dead.delete(key); // neu einsortieren: Map behaelt die Einfuegereihenfolge
this.dead.set(key, this.now() + DEAD_KEY_TTL_MS);
while (this.dead.size > MAX_DEAD_KEYS) {
const oldest = this.dead.keys().next().value;
if (oldest === undefined) break;
this.dead.delete(oldest);
}
const controller = this.controllers.get(key);
if (controller) {
controller.abort(new Error('credential-dead'));
this.controllers.delete(key);
}
}
/**
* Signal, das abbricht, sobald der Schluessel stirbt. Fuer einen schon toten
* Schluessel kommt ein bereits abgebrochenes Signal zurueck.
*/
signalFor(key: string): AbortSignal {
if (this.isDead(key)) {
const done = new AbortController();
done.abort(new Error('credential-dead'));
return done.signal;
}
let controller = this.controllers.get(key);
if (!controller) {
controller = new AbortController();
this.controllers.set(key, controller);
while (this.controllers.size > MAX_DEAD_KEYS) {
const oldest = this.controllers.keys().next().value;
if (oldest === undefined) break;
this.controllers.delete(oldest);
}
}
return controller.signal;
}
}
@@ -0,0 +1,255 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
}));
import type { NextcloudCheckResult } from '../nextcloud-status/nextcloud-status-fetch';
import { NextcloudCallGate } from './nextcloud-call-gate';
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
interface Row {
baseUrl: string;
}
function reachable(over: Partial<NextcloudCheckResult> = {}): NextcloudCheckResult {
return {
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '34.0.4',
edition: '',
productName: 'Nextcloud',
errorKind: null,
errorDetail: null,
...over,
};
}
function unreachable(
errorKind: NextcloudCheckResult['errorKind'],
errorDetail: string | null = null,
): NextcloudCheckResult {
return {
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind,
errorDetail,
};
}
function makeService(opts: {
row?: Row | null;
activeAccounts?: number;
check?: NextcloudCheckResult;
}) {
const state = { row: opts.row ?? null };
const upsert = vi.fn(async ({ create, update }: any) => {
state.row = state.row ? { ...state.row, ...update } : { ...create };
return state.row;
});
const updateMany = vi.fn(async (..._a: unknown[]) => ({ count: 0 }));
const count = vi.fn(async (..._a: unknown[]) => opts.activeAccounts ?? 0);
const tenants: string[] = [];
const db = {
__bound: (tenantId: string) => {
tenants.push(tenantId);
return {
nextcloudFilesConfig: { findUnique: vi.fn(async () => state.row), upsert },
nextcloudFilesAccount: { count, updateMany },
};
},
};
const fetcher = vi.fn(async (..._a: unknown[]) => opts.check ?? reachable());
const gate = new NextcloudCallGate();
const service = new NextcloudFilesSettingsService(db as any, gate, fetcher as any);
return { service, state, upsert, updateMany, count, fetcher, gate, tenants };
}
const codeOf = async (p: Promise<unknown>) => {
try {
await p;
} catch (e) {
const body = (e as any).getResponse?.();
return { status: (e as any).getStatus?.(), body };
}
return null;
};
describe('NextcloudFilesSettingsService — Lesen', () => {
it('GET settings ohne Zeile: keine Adresse, keine Konten', async () => {
const { service, count } = makeService({});
expect(await service.getSettings('t1')).toEqual({ baseUrl: null, connectedAccounts: 0 });
expect(count).not.toHaveBeenCalled();
});
it('GET settings mit Zeile zaehlt die aktiven Konten der Organisation', async () => {
const { service, count, tenants } = makeService({
row: { baseUrl: 'https://cloud.example' },
activeAccounts: 3,
});
expect(await service.getSettings('t1')).toEqual({
baseUrl: 'https://cloud.example',
connectedAccounts: 3,
});
expect(count).toHaveBeenCalledWith({ where: { tenantId: 't1', status: 'ACTIVE' } });
expect(tenants.every((t) => t === 't1')).toBe(true);
});
it('getStatus: nicht eingerichtet und eingerichtet', async () => {
expect(await makeService({}).service.getStatus('t1')).toEqual({
configured: false,
serverUrl: null,
host: null,
account: null,
});
const set = makeService({ row: { baseUrl: 'https://cloud.example:8443/nc' } });
expect(await set.service.getStatus('t1')).toEqual({
configured: true,
serverUrl: 'https://cloud.example:8443/nc',
host: 'cloud.example:8443',
account: null,
});
});
});
describe('NextcloudFilesSettingsService — Speichern', () => {
it('normalisiert die Adresse (Gross-/Kleinschreibung des Hosts, /index.php, Leerraum)', async () => {
const { service, upsert } = makeService({});
const view = await service.saveSettings('t1', {
baseUrl: ' https://Cloud.Example/nc/index.php ',
});
expect(upsert).toHaveBeenCalledWith({
where: { tenantId: 't1' },
create: { tenantId: 't1', baseUrl: 'https://cloud.example/nc' },
update: { baseUrl: 'https://cloud.example/nc' },
});
expect(view.baseUrl).toBe('https://cloud.example/nc');
});
it('ftp:// ist 400 invalidUrl', async () => {
const { service, upsert } = makeService({});
const err = await codeOf(service.saveSettings('t1', { baseUrl: 'ftp://x' }));
expect(err?.status).toBe(400);
expect(err?.body.code).toBe('invalidUrl');
expect(upsert).not.toHaveBeenCalled();
});
it('dieselbe Adresse noch einmal: kein Schreiben, keine Ablaufmarkierung', async () => {
const { service, upsert, updateMany } = makeService({
row: { baseUrl: 'https://cloud.example' },
});
const view = await service.saveSettings('t1', { baseUrl: 'https://cloud.example/' });
expect(view.baseUrl).toBe('https://cloud.example');
expect(upsert).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
});
it('neue Adresse mit 2 verbundenen Konten ohne Bestaetigung: 409 confirmReconnect, nichts geschrieben', async () => {
const { service, upsert, updateMany } = makeService({
row: { baseUrl: 'https://alt.example' },
activeAccounts: 2,
});
const err = await codeOf(service.saveSettings('t1', { baseUrl: 'https://neu.example' }));
expect(err?.status).toBe(409);
expect(err?.body.code).toBe('confirmReconnect');
expect(err?.body.connectedAccounts).toBe(2);
expect(upsert).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
});
it('mit confirmReconnect: Adresse gespeichert, alle Konten der Organisation abgelaufen, Zuhoerer gerufen', async () => {
const { service, upsert, updateMany, fetcher } = makeService({
row: { baseUrl: 'https://alt.example' },
activeAccounts: 2,
});
const listener = vi.fn();
const other = vi.fn();
service.onAddressChange(listener);
service.onAddressChange(other);
const view = await service.saveSettings('t1', {
baseUrl: 'https://neu.example',
confirmReconnect: true,
});
expect(upsert).toHaveBeenCalledTimes(1);
expect(updateMany).toHaveBeenCalledWith({
where: { tenantId: 't1' },
data: { status: 'EXPIRED' },
});
expect(listener).toHaveBeenCalledWith('t1');
expect(other).toHaveBeenCalledWith('t1');
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls[0][0]).toBe('https://neu.example');
expect(view.check?.ok).toBe(true);
expect(view.connectedAccounts).toBe(0);
});
it('erste Einrichtung braucht keine Bestaetigung und zaehlt keine Konten', async () => {
const { service, count } = makeService({ activeAccounts: 5 });
await service.saveSettings('t1', { baseUrl: 'https://cloud.example' });
expect(count).not.toHaveBeenCalled();
});
});
describe('NextcloudFilesSettingsService — Verbindungspruefung', () => {
it('erreichbar: ok mit Version', async () => {
const { service, fetcher } = makeService({ check: reachable() });
const res = await service.testAddress('https://cloud.example');
expect(res).toMatchObject({
ok: true,
kind: 'ok',
version: '34.0.4',
productName: 'Nextcloud',
});
expect(res.message).toContain('34.0.4');
expect(fetcher.mock.calls[0][0]).toBe('https://cloud.example');
});
it('HTTP 400 ist der Hinweis auf die vertrauenswuerdigen Domains', async () => {
const { service } = makeService({ check: unreachable('http-status', 'HTTP 400') });
const res = await service.testAddress('https://cloud.example');
expect(res.ok).toBe(false);
expect(res.kind).toBe('trusted-domains');
expect(res.message).toContain('vertrauenswürdigen Domains');
});
it('Zeitueberschreitung hat einen deutschen Text', async () => {
const { service } = makeService({ check: unreachable('timeout') });
const res = await service.testAddress('https://cloud.example');
expect(res).toMatchObject({ ok: false, kind: 'timeout' });
expect(res.message).toContain('nicht rechtzeitig');
});
it('Wartungsmodus ist kein Erfolg', async () => {
const { service } = makeService({ check: reachable({ maintenance: true }) });
expect(await service.testAddress('https://cloud.example')).toMatchObject({
ok: false,
kind: 'maintenance',
});
});
it('eine gesperrte Adresse (Aufrufsperre) fragt Nextcloud gar nicht erst', async () => {
const { service, fetcher, gate } = makeService({});
gate.pause('https://cloud.example');
const res = await service.testAddress('https://cloud.example');
expect(res).toMatchObject({ ok: false, kind: 'paused' });
expect(fetcher).not.toHaveBeenCalled();
});
it('ein 429 aus der Pruefung sperrt den Ursprung fuer alle weiteren Aufrufe', async () => {
const { service, gate } = makeService({ check: unreachable('http-status', 'HTTP 429') });
const res = await service.testAddress('https://cloud.example');
expect(res.kind).toBe('locked');
expect(gate.isPaused('https://cloud.example').paused).toBe(true);
});
it('eine ungueltige Adresse ist 400 invalidUrl', async () => {
const { service } = makeService({});
const err = await codeOf(service.testAddress('nonsense'));
expect(err?.body.code).toBe('invalidUrl');
});
});
@@ -0,0 +1,262 @@
import { Inject, Injectable } from '@nestjs/common';
import { fetch as undiciFetch } from 'undici';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import {
type FetchStatusOptions,
fetchNextcloudStatus,
type NextcloudCheckResult,
normalizeCloudUrl,
} from '../nextcloud-status/nextcloud-status-fetch';
import { NextcloudCallGate } from './nextcloud-call-gate';
import {
type NextcloudFilesCheckView,
type NextcloudFilesSettingsView,
type NextcloudFilesStatusView,
ncErrorDefault,
} from './nextcloud-files.types';
/** Nest-Token fuer den Abruf von `status.php` (Tests setzen eine Attrappe ein). */
export const NEXTCLOUD_STATUS_FETCHER = 'NEXTCLOUD_STATUS_FETCHER';
export type NextcloudStatusFetcher = (
baseUrl: string,
opts?: FetchStatusOptions,
) => Promise<NextcloudCheckResult>;
export const defaultStatusFetcher: NextcloudStatusFetcher = fetchNextcloudStatus;
export type AddressChangeListener = (tenantId: string) => void;
interface ConfigRow {
baseUrl: string;
}
const TRUSTED_DOMAINS_TEXT =
'Nextcloud lehnt diese Adresse ab. Bitte nehmen Sie den Rechnernamen in die vertrauenswürdigen Domains (trusted_domains) der Nextcloud auf.';
function hostOf(baseUrl: string): string | null {
try {
return new URL(baseUrl).host;
} catch {
return null;
}
}
function originOf(baseUrl: string): string | null {
try {
return new URL(baseUrl).origin;
} catch {
return null;
}
}
/**
* Einstellungen des Moduls "Nextcloud-Dateien" (quick-261008-mzu): die EINE
* Nextcloud-Adresse der Organisation. Gesamter Zugriff auf `nextcloudFilesConfig`
* liegt ausschliesslich hier; vom Konto-Modell nur die mandantengebundenen
* Verwaltungsvorgaenge (zaehlen, nach Adresswechsel alle ablaufen lassen).
*
* Wechselt die Adresse, werden alle Konten der Organisation als abgelaufen
* markiert (ihre App-Passwoerter gelten nur fuer die alte Nextcloud) und die
* eingetragenen Zuhoerer benachrichtigt (offene Browser-Anmeldungen, zwischen-
* gespeicherte Serverkennung).
*/
@Injectable()
export class NextcloudFilesSettingsService {
private readonly listeners: AddressChangeListener[] = [];
constructor(
private readonly prisma: PrismaService,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_STATUS_FETCHER) private readonly statusFetcher: NextcloudStatusFetcher,
) {}
/** Meldet einen Zuhoerer an, der nach einem Adresswechsel mit der Organisation aufgerufen wird. */
onAddressChange(listener: AddressChangeListener): void {
this.listeners.push(listener);
}
// --- Lesen -----------------------------------------------------------------
private async loadConfig(tenantId: string): Promise<ConfigRow | null> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.nextcloudFilesConfig.findUnique({ where: { tenantId } });
return (row as ConfigRow | null) ?? null;
}
private async countActiveAccounts(tenantId: string): Promise<number> {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.nextcloudFilesAccount.count({ where: { tenantId, status: 'ACTIVE' } });
}
/** Gespeicherte Adresse der Organisation oder `null`. */
async getBaseUrl(tenantId: string): Promise<string | null> {
return (await this.loadConfig(tenantId))?.baseUrl ?? null;
}
/** Kurzer Stand fuer jeden Benutzer mit Modulzugriff. Das Konto fuellt Aufgabe 2 ein. */
async getStatus(tenantId: string): Promise<NextcloudFilesStatusView> {
const baseUrl = await this.getBaseUrl(tenantId);
return {
configured: baseUrl !== null,
serverUrl: baseUrl,
host: baseUrl ? hostOf(baseUrl) : null,
account: null,
};
}
async getSettings(tenantId: string): Promise<NextcloudFilesSettingsView> {
const baseUrl = await this.getBaseUrl(tenantId);
const connectedAccounts = baseUrl === null ? 0 : await this.countActiveAccounts(tenantId);
return { baseUrl, connectedAccounts };
}
// --- Speichern -------------------------------------------------------------
async saveSettings(
tenantId: string,
dto: { baseUrl: string; confirmReconnect?: boolean },
): Promise<NextcloudFilesSettingsView> {
const baseUrl = normalizeCloudUrl(dto.baseUrl);
if (baseUrl === null) throw ncErrorDefault('invalidUrl');
const current = await this.loadConfig(tenantId);
if (current?.baseUrl === baseUrl) return this.getSettings(tenantId);
if (current) {
const connectedAccounts = await this.countActiveAccounts(tenantId);
if (connectedAccounts > 0 && dto.confirmReconnect !== true) {
throw ncErrorDefault('confirmReconnect', { connectedAccounts });
}
}
const writer = forTenant(this.prisma, tenantId);
await writer.nextcloudFilesConfig.upsert({
where: { tenantId },
create: { tenantId, baseUrl },
update: { baseUrl },
});
const expirer = forTenant(this.prisma, tenantId);
await expirer.nextcloudFilesAccount.updateMany({
where: { tenantId },
data: { status: 'EXPIRED' },
});
for (const listener of this.listeners) listener(tenantId);
const check = await this.testAddress(baseUrl);
return { baseUrl, connectedAccounts: 0, check };
}
// --- Verbindungspruefung -----------------------------------------------------
/**
* Fragt `status.php` der Adresse ab (ohne Zugangsdaten). Antwortet immer mit
* `{ ok, kind, message, version, productName }`; Netz- und HTTP-Probleme sind
* keine Serverfehler. Steht der Ursprung wegen eines 429 auf der Aufrufsperre,
* geht gar keine Anfrage raus.
*/
async testAddress(rawBaseUrl: string): Promise<NextcloudFilesCheckView> {
const baseUrl = normalizeCloudUrl(rawBaseUrl);
if (baseUrl === null) throw ncErrorDefault('invalidUrl');
const origin = originOf(baseUrl);
if (origin) {
const pause = this.gate.isPaused(origin);
if (pause.paused) {
const minutes = Math.max(1, Math.ceil(pause.retryAfterSeconds / 60));
return {
ok: false,
kind: 'paused',
message: `Nextcloud sperrt Anmeldungen vom Tessera-Server zurzeit. Bitte warten Sie etwa ${minutes} Minuten.`,
version: null,
productName: null,
};
}
}
// Merkt, ob die Adresse nur ueber eine Weiterleitung antwortet: im Betrieb folgt Tessera
// keiner Weiterleitung, die Adresse muss dann direkt eingetragen werden.
let redirected = false;
const recordingFetch = (async (...args: Parameters<typeof undiciFetch>) => {
const response = await undiciFetch(...args);
if (response.status >= 300 && response.status < 400) redirected = true;
return response;
}) as typeof undiciFetch;
const result = await this.statusFetcher(baseUrl, { fetchImpl: recordingFetch });
return this.describeCheck(result, redirected, origin);
}
private describeCheck(
result: NextcloudCheckResult,
redirected: boolean,
origin: string | null,
): NextcloudFilesCheckView {
const fail = (kind: string, message: string): NextcloudFilesCheckView => ({
ok: false,
kind,
message,
version: null,
productName: null,
});
if (result.reachable) {
if (result.maintenance) {
return fail('maintenance', 'Nextcloud befindet sich im Wartungsmodus.');
}
const name = result.productName ?? 'Nextcloud';
const version = result.versionString;
const base = version
? `Verbindung erfolgreich: ${name} ${version}.`
: 'Verbindung erfolgreich.';
const hint = redirected
? ' Die Adresse leitet allerdings um. Tessera folgt keiner Weiterleitung, tragen Sie bitte die endgültige Adresse ein (zum Beispiel mit https).'
: '';
return {
ok: true,
kind: redirected ? 'redirected' : 'ok',
message: base + hint,
version,
productName: result.productName,
};
}
switch (result.errorKind) {
case 'timeout':
return fail(
'timeout',
'Nextcloud hat nicht rechtzeitig geantwortet. Bitte prüfen Sie die Adresse und versuchen Sie es erneut.',
);
case 'network':
return fail('network', 'Unter dieser Adresse ist keine Nextcloud erreichbar.');
case 'tls':
return fail(
'tls',
'Das Zertifikat der Nextcloud konnte nicht geprüft werden. Bitte prüfen Sie die Adresse oder hinterlegen Sie die Zertifizierungsstelle auf dem Tessera-Server.',
);
case 'http-status': {
if (result.errorDetail === 'HTTP 400') return fail('trusted-domains', TRUSTED_DOMAINS_TEXT);
if (result.errorDetail === 'HTTP 429') {
if (origin) this.gate.pause(origin);
return fail(
'locked',
'Nextcloud sperrt Anmeldungen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in einigen Minuten erneut.',
);
}
return fail(
'http-status',
`Nextcloud hat unter dieser Adresse mit einem Fehler geantwortet (${result.errorDetail ?? 'unbekannt'}).`,
);
}
case 'redirect':
return fail(
'redirect',
'Die Adresse leitet mehrfach um oder führt nicht zu einer Nextcloud. Bitte prüfen Sie sie.',
);
case 'too-large':
case 'not-nextcloud':
return fail('not-nextcloud', 'Unter dieser Adresse antwortet keine Nextcloud.');
default:
return fail('network', 'Unter dieser Adresse ist keine Nextcloud erreichbar.');
}
}
}
@@ -0,0 +1,120 @@
import 'reflect-metadata';
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { NextcloudFilesController } from './nextcloud-files.controller';
const proto = NextcloudFilesController.prototype as any;
const req = (tenantId?: string) => ({ tenantId }) as any;
/** Verwalten (Administratoren und Freigabestufe Verwalten). Spaetere Aufgaben ergaenzen nichts hier. */
const MANAGE_HANDLERS = ['getSettings', 'saveSettings', 'testSettings'];
/** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */
function routeHandlers(): string[] {
return Object.getOwnPropertyNames(NextcloudFilesController.prototype).filter(
(n) =>
n !== 'constructor' &&
typeof proto[n] === 'function' &&
Reflect.getMetadata('path', proto[n]) !== undefined,
);
}
/**
* Wiederverwendbar (spaetere Aufgaben fuegen nur Handler hinzu): jeder Handler,
* dessen Pfad ein `:` enthaelt, steht NACH allen statischen Handlern.
*/
function expectParamRoutesLast(controller: { prototype: object }): void {
const p = controller.prototype as any;
const names = Object.getOwnPropertyNames(controller.prototype).filter(
(n) =>
n !== 'constructor' &&
typeof p[n] === 'function' &&
Reflect.getMetadata('path', p[n]) !== undefined,
);
const isParam = (n: string) => String(Reflect.getMetadata('path', p[n])).includes(':');
const firstParam = names.findIndex(isParam);
if (firstParam === -1) return;
names.slice(firstParam).forEach((n) => {
expect(isParam(n), `${n} steht nach einer Parameterroute, ist aber statisch`).toBe(true);
});
}
describe('NextcloudFilesController — Metadaten', () => {
it('haengt an modules/nextcloud-files und traegt @UseModule(nextcloud-files)', () => {
expect(Reflect.getMetadata('path', NextcloudFilesController)).toBe('modules/nextcloud-files');
expect(Reflect.getMetadata(MODULE_SLUG_KEY, NextcloudFilesController)).toBe('nextcloud-files');
});
it('Einstellungen und Pruefung verlangen Verwalten, ohne Rollen-Decorator', () => {
for (const name of MANAGE_HANDLERS) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBe(true);
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
}
});
it('alle anderen Handler stehen auf Benutzen-Ebene', () => {
const others = routeHandlers().filter((n) => !MANAGE_HANDLERS.includes(n));
expect(others).toContain('getStatus');
for (const name of others) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
}
});
it('Pfade und Methoden', () => {
const route = (name: string) => [
Reflect.getMetadata('method', proto[name]),
Reflect.getMetadata('path', proto[name]),
];
// RequestMethod: GET 0, POST 1, PUT 2, DELETE 3
expect(route('getStatus')).toEqual([0, 'status']);
expect(route('getSettings')).toEqual([0, 'settings']);
expect(route('saveSettings')).toEqual([2, 'settings']);
expect(route('testSettings')).toEqual([1, 'settings/test']);
});
it('POST settings/test antwortet 200, nicht 201', () => {
expect(Reflect.getMetadata('__httpCode__', proto.testSettings)).toBe(200);
});
});
describe('NextcloudFilesController — Routen-Reihenfolge (statisch vor Parameter)', () => {
it('deklariert jeden Handler mit :-Pfad nach allen statischen Handlern', () => {
expectParamRoutesLast(NextcloudFilesController);
});
});
describe('NextcloudFilesController — Delegation', () => {
function makeSettings() {
return {
getStatus: vi.fn(async (..._a: unknown[]) => ({ configured: true })),
getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })),
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
testAddress: vi.fn(async (..._a: unknown[]) => ({ ok: true })),
};
}
it('reicht den Mandanten aus dem Token weiter, nie aus dem Body', async () => {
const settings = makeSettings();
const controller = new NextcloudFilesController(settings as any);
await controller.getStatus(req('t1'));
await controller.getSettings(req('t1'));
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
await controller.testSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
expect(settings.getStatus).toHaveBeenCalledWith('t1');
expect(settings.getSettings).toHaveBeenCalledWith('t1');
expect(settings.saveSettings).toHaveBeenCalledWith('t1', { baseUrl: 'https://x.example' });
expect(settings.testAddress).toHaveBeenCalledWith('https://x.example');
});
it('ohne Mandantenkontext: ForbiddenException', async () => {
const controller = new NextcloudFilesController(makeSettings() as any);
await expect(controller.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getSettings(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.testSettings(req(undefined), { baseUrl: 'https://x.example' } as any),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
@@ -0,0 +1,87 @@
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
Post,
Put,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import {
SaveNextcloudFilesSettingsDto,
TestNextcloudFilesSettingsDto,
} from './dto/nextcloud-files-settings.dto';
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
/**
* `@UseModule('nextcloud-files')` auf Klassenebene — Aktivierung UND Freigabe.
* `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query;
* die Benutzerkennung (spaetere Aufgaben) ausschliesslich aus dem Token.
*
* Rechte je Route (quick-261008-mzu, D-N):
* Verwalten (`@ModuleManage('nextcloud-files')`, Administratoren und Benutzer
* mit der Freigabestufe Verwalten): GET settings, PUT settings,
* POST settings/test.
* Benutzen (nur Klassen-`@UseModule`): alles andere — GET status, GET server,
* GET server/logo, POST connect/password, POST connect/flow,
* DELETE connect, GET files, DELETE files, POST folders, POST move,
* GET preview, GET download, GET download/zip, POST uploads, PUT
* uploads/file; danach die Parameterrouten am ENDE: GET/DELETE
* connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
* uploads/:uploadId. Jeder Benutzer arbeitet nur im eigenen Konto.
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
* RolesGuard wuerde Verwalter sonst aussperren.
*
* REIHENFOLGE: alle statischen Routen stehen VOR jeder Route mit `:param`, sonst
* faengt die Parameterroute sie ab (404-Shadowing, Unit-Tests ohne die
* Reihenfolge-Pruefung fangen das nicht). Jede Aufgabe haengt ihre statischen
* Routen vor den Parameterblock und ihre Parameterrouten ans ENDE;
* `nextcloud-files.controller.spec.ts` prueft die Deklarationsreihenfolge.
*
* FEHLER: bei einem Nextcloud-seitigen Problem antwortet die API NIE mit 401
* oder 403 (die Weboberflaeche liest beides als Tessera-Sitzung bzw.
* Tessera-Recht), sondern mit `{ code, message }` und einem anderen Status
* (siehe `nextcloud-files.types.ts`).
*/
@Controller('modules/nextcloud-files')
@UseModule('nextcloud-files')
export class NextcloudFilesController {
constructor(private readonly settings: NextcloudFilesSettingsService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get('status')
async getStatus(@Req() req: AuthenticatedRequest) {
return this.settings.getStatus(this.requireTenantId(req));
}
@Get('settings')
@ModuleManage('nextcloud-files')
async getSettings(@Req() req: AuthenticatedRequest) {
return this.settings.getSettings(this.requireTenantId(req));
}
@Put('settings')
@ModuleManage('nextcloud-files')
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: SaveNextcloudFilesSettingsDto) {
return this.settings.saveSettings(this.requireTenantId(req), dto);
}
@Post('settings/test')
@HttpCode(200)
@ModuleManage('nextcloud-files')
async testSettings(@Req() req: AuthenticatedRequest, @Body() dto: TestNextcloudFilesSettingsDto) {
this.requireTenantId(req);
return this.settings.testAddress(dto.baseUrl);
}
}
@@ -0,0 +1,45 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { NextcloudCallGate } from './nextcloud-call-gate';
import { NextcloudFilesController } from './nextcloud-files.controller';
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
import {
defaultStatusFetcher,
NEXTCLOUD_STATUS_FETCHER,
NextcloudFilesSettingsService,
} from './nextcloud-files-settings.service';
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
/**
* Modul "Dateien" (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in
* Tessera. Traegt sich beim Start in die Modulverwaltung ein; aktiviert wird per
* Marktplatz. `CryptoService` kommt aus dem globalen `CryptoModule`,
* `PrismaService` ist global. Die Aufrufsperre ist ein einziges Objekt fuer den
* ganzen Prozess (alle Benutzer teilen die Server-Adresse gegenueber Nextcloud).
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [NextcloudFilesController],
providers: [
NextcloudFilesSettingsService,
NextcloudCallGate,
{ provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport },
{ provide: NEXTCLOUD_STATUS_FETCHER, useValue: defaultStatusFetcher },
],
exports: [NextcloudCallGate, NextcloudFilesSettingsService, NEXTCLOUD_TRANSPORT],
})
export class NextcloudFilesModule implements OnModuleInit {
private readonly logger = new Logger(NextcloudFilesModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedNextcloudFilesModule(this.moduleRegistryService);
this.logger.log('Nextcloud files module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed nextcloud files module', error);
}
}
}
@@ -0,0 +1,23 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service';
/**
* Traegt das Modul "Dateien" (Nextcloud-Dateien) in die Modulverwaltung ein
* (quick-261008-mzu). Kategorie `infrastructure` neben Nextcloud-Status —
* Administratoren koennen es umhaengen. Der Slug ist zugleich der Wert in
* `@UseModule`.
*/
export async function seedNextcloudFilesModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'nextcloud-files',
name: 'Dateien',
version: '1.0.0',
category: 'infrastructure',
description: {
de: 'Dateien Ihrer Nextcloud ansehen, hochladen, herunterladen und ordnen',
en: 'View, upload, download and organise the files in your Nextcloud',
},
isSystem: true,
});
}
@@ -0,0 +1,251 @@
import { HttpException } from '@nestjs/common';
/**
* Gemeinsame Typen des Moduls "Nextcloud-Dateien" (quick-261008-mzu).
*
* Fehlervertrag (D-D): die API antwortet bei einem Nextcloud-seitigen Fehler
* NIE mit 401 oder 403 — die Weboberflaeche liest beides als Problem mit der
* Tessera-Sitzung bzw. den Tessera-Rechten und wuerde den Benutzer abmelden
* oder aussperren. Jeder Fehlerkoerper ist `{ code, message }` mit deutschem
* Text; die Oberflaeche ordnet den Code einem Text zu und faellt auf `message`
* zurueck.
*/
export type NcErrorCode =
| 'notConfigured'
| 'notConnected'
| 'connectionExpired'
| 'accountBroken'
| 'credentialsOrTwoFactor'
| 'useBrowserLogin'
| 'tooManyAttempts'
| 'nextcloudLocked'
| 'nextcloudMaintenance'
| 'nextcloudRedirect'
| 'nextcloudUnavailable'
| 'nextcloudError'
| 'notFound'
| 'nameTaken'
| 'changedMeanwhile'
| 'pathConflict'
| 'moveIntoItself'
| 'locked'
| 'notAllowed'
| 'invalidName'
| 'invalidPath'
| 'quotaExceeded'
| 'lengthRequired'
| 'chunkTooLarge'
| 'fileTooLarge'
| 'flowExpired'
| 'tooManyFlows'
| 'invalidUrl'
| 'confirmReconnect';
interface ErrorDefault {
status: number;
message: string;
}
/** Vorgabe-Statuscode und deutscher Text je Fehlercode (D-D). */
export const NC_ERROR_DEFAULTS: Record<NcErrorCode, ErrorDefault> = {
notConfigured: {
status: 409,
message: 'Für Ihre Organisation ist noch keine Nextcloud-Adresse hinterlegt.',
},
notConnected: {
status: 409,
message: 'Sie sind noch nicht mit Ihrer Nextcloud verbunden.',
},
connectionExpired: {
status: 409,
message: 'Die Verbindung zu Ihrer Nextcloud ist abgelaufen. Bitte verbinden Sie sich neu.',
},
accountBroken: {
status: 500,
message:
'Die gespeicherte Verbindung ließ sich nicht entschlüsseln. Bitte trennen Sie die Verbindung und verbinden Sie sich neu.',
},
credentialsOrTwoFactor: {
status: 422,
message:
'Die Anmeldung ist nicht gelungen. Entweder stimmen Benutzername und Passwort nicht, oder Ihr Konto nutzt Zwei-Faktor-Anmeldung. In dem Fall melden Sie sich bitte im Browser an.',
},
useBrowserLogin: {
status: 422,
message:
'Nextcloud erlaubt für dieses Konto keine Anmeldung mit Passwort. Bitte melden Sie sich im Browser an.',
},
tooManyAttempts: {
status: 429,
message:
'Zu viele fehlgeschlagene Anmeldungen. Bitte warten Sie einige Minuten, bevor Sie es erneut versuchen.',
},
nextcloudLocked: {
status: 503,
message:
'Nextcloud sperrt Anmeldungen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in einigen Minuten erneut.',
},
nextcloudMaintenance: {
status: 503,
message: 'Nextcloud befindet sich im Wartungsmodus. Bitte versuchen Sie es später erneut.',
},
nextcloudRedirect: {
status: 502,
message:
'Nextcloud leitet um. Bitte korrigieren Sie die Adresse in den Einstellungen (zum Beispiel auf https).',
},
nextcloudUnavailable: {
status: 504,
message: 'Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.',
},
nextcloudError: {
status: 502,
message: 'Nextcloud hat eine unerwartete Antwort geliefert.',
},
notFound: {
status: 404,
message: 'Der Eintrag wurde nicht gefunden.',
},
nameTaken: {
status: 409,
message: 'Ein Eintrag mit diesem Namen existiert bereits.',
},
changedMeanwhile: {
status: 409,
message: 'Der Eintrag wurde zwischenzeitlich geändert.',
},
pathConflict: {
status: 409,
message: 'Der Zielordner existiert nicht oder der Vorgang ist an dieser Stelle nicht möglich.',
},
moveIntoItself: {
status: 400,
message: 'Ein Ordner kann nicht in sich selbst verschoben werden.',
},
locked: {
status: 409,
message: 'Der Eintrag ist gerade gesperrt, weil ihn jemand bearbeitet.',
},
notAllowed: {
status: 422,
message: 'Nextcloud erlaubt diese Aktion für diesen Eintrag nicht.',
},
invalidName: {
status: 400,
message: 'Dieser Name ist nicht erlaubt.',
},
invalidPath: {
status: 400,
message: 'Dieser Pfad ist nicht gültig.',
},
quotaExceeded: {
status: 507,
message: 'Der Speicherplatz in Nextcloud ist erschöpft.',
},
lengthRequired: {
status: 411,
message: 'Die Größe der Übertragung fehlt.',
},
chunkTooLarge: {
status: 413,
message: 'Das Teilstück ist zu groß.',
},
fileTooLarge: {
status: 413,
message: 'Die Datei ist zu groß für die Übertragung.',
},
flowExpired: {
status: 410,
message: 'Die Anmeldung im Browser ist abgelaufen. Bitte starten Sie sie erneut.',
},
tooManyFlows: {
status: 503,
message:
'Zurzeit laufen zu viele Anmeldungen im Browser. Bitte versuchen Sie es gleich erneut.',
},
invalidUrl: {
status: 400,
message: 'Das ist keine gültige Adresse. Bitte geben Sie sie mit http:// oder https:// an.',
},
confirmReconnect: {
status: 409,
message:
'Bei einem Wechsel der Adresse müssen sich alle verbundenen Benutzer neu anmelden. Bitte bestätigen Sie den Wechsel.',
},
};
/** Baut die HttpException mit dem Koerper `{ code, message, ...extra }`. */
export function ncError(
code: NcErrorCode,
httpStatus: number,
message: string,
extra?: Record<string, unknown>,
): HttpException {
return new HttpException({ code, message, ...(extra ?? {}) }, httpStatus);
}
/** Wie `ncError`, aber mit Statuscode und Text aus `NC_ERROR_DEFAULTS`. */
export function ncErrorDefault(
code: NcErrorCode,
extra?: Record<string, unknown>,
message?: string,
): HttpException {
const def = NC_ERROR_DEFAULTS[code];
return ncError(code, def.status, message ?? def.message, extra);
}
/** Art eines fehlgeschlagenen Nextcloud-Aufrufs (Transportschicht). */
export type NcFailureKind =
| 'redirect'
| 'timeout'
| 'network'
| 'tls'
| 'too-large'
| 'invalid-response'
| 'http'
| 'paused'
| 'credential-dead'
| 'aborted';
/** Zugangsdaten eines Benutzers fuer einen Aufruf; das App-Passwort steht nur hier. */
export interface NcSession {
baseUrl: string;
ncUserId: string;
/** `Basic base64(ncUserId:appPassword)` — nie loggen, nie zurueckgeben. */
authorization: string;
/** Erste 16 Hex-Zeichen von sha256 ueber den verschluesselten Wert. */
credentialKey: string;
}
// --- Ansichten fuer die Weboberflaeche ---------------------------------------
export interface NextcloudFilesAccountView {
connected: boolean;
/** true, wenn das Konto abgelaufen ist (Adresswechsel, widerrufen, 401). */
expired: boolean;
ncUserId: string | null;
displayName: string | null;
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null;
}
export interface NextcloudFilesStatusView {
configured: boolean;
serverUrl: string | null;
host: string | null;
account: NextcloudFilesAccountView | null;
}
export interface NextcloudFilesCheckView {
ok: boolean;
kind: string;
message: string;
version: string | null;
productName: string | null;
}
export interface NextcloudFilesSettingsView {
baseUrl: string | null;
connectedAccounts: number;
check?: NextcloudFilesCheckView;
}
@@ -0,0 +1,397 @@
import { Readable } from 'node:stream';
import { describe, expect, it, vi } from 'vitest';
import { NextcloudCallGate } from './nextcloud-call-gate';
import {
basicAuth,
buildNcUrl,
encodeSegments,
type NcTransportRequest,
type NcTransportResponse,
type NextcloudTransport,
ncRequest,
parseUserPath,
readCappedText,
validateNewName,
validateSegment,
} from './nextcloud-http';
const BASE = 'https://cloud.example/nc';
function bodyOf(text = ''): Readable {
return Readable.from(text === '' ? [] : [Buffer.from(text)]);
}
function reply(
statusCode: number,
text = '',
headers: Record<string, string> = {},
): NcTransportResponse {
return { statusCode, headers, body: bodyOf(text) };
}
function fakeTransport(handler: (req: NcTransportRequest) => Promise<NcTransportResponse>) {
const calls: NcTransportRequest[] = [];
const transport: NextcloudTransport = async (req) => {
calls.push(req);
return handler(req);
};
return { transport, calls };
}
function errCode(code: string): Error & { code: string } {
return Object.assign(new Error('boom'), { code });
}
function codeOf(fn: () => unknown): string | undefined {
try {
fn();
} catch (e) {
return (e as { getResponse?: () => { code?: string } }).getResponse?.()?.code;
}
return undefined;
}
describe('buildNcUrl / encodeSegments', () => {
it('codiert Umlaute, Leerzeichen, & und % segmentweise (Literale)', () => {
expect(buildNcUrl(BASE, '/remote.php/dav/files/', ['anna', 'Ärger & Ölpreis 100%.txt'])).toBe(
'https://cloud.example/nc/remote.php/dav/files/anna/%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt',
);
expect(encodeSegments(['a b#c?d.txt'])).toBe('a%20b%23c%3Fd.txt');
expect(encodeSegments(['50%25.txt'])).toBe('50%2525.txt');
});
it('haengt eine Query codiert an', () => {
expect(buildNcUrl(BASE, '/index.php/core/preview', [], { fileId: '123', x: '256' })).toBe(
'https://cloud.example/nc/index.php/core/preview?fileId=123&x=256',
);
});
it('ein Pfadanfang ausserhalb der Liste wirft vor jedem Aufruf', () => {
expect(() => buildNcUrl(BASE, '/admin/secret' as never, [])).toThrow();
expect(() => buildNcUrl(BASE, '/status.php', ['x'])).toThrow();
});
it('eine unnormalisierte Basis wirft', () => {
expect(() => buildNcUrl('ftp://cloud.example', '/status.php')).toThrow();
expect(() => buildNcUrl('https://user:pw@cloud.example', '/status.php')).toThrow();
});
});
describe('Pfade', () => {
it('parseUserPath: Wurzel und normale Pfade', () => {
expect(parseUserPath('')).toEqual([]);
expect(parseUserPath('/')).toEqual([]);
expect(parseUserPath(undefined)).toEqual([]);
expect(parseUserPath('/Projekte/2026/')).toEqual(['Projekte', '2026']);
expect(parseUserPath('Projekte/Ärger & Ölpreis.txt')).toEqual([
'Projekte',
'Ärger & Ölpreis.txt',
]);
});
it.each([
['/a//b', 'leeres Segment'],
['/a/../b', '..'],
['/./a', '.'],
['a\\b', 'Rueckwaertsstrich'],
['a\u0000b', 'NUL'],
['a\u0007b', 'Steuerzeichen'],
[`/${'ä'.repeat(128)}`, '256 Byte'],
[`/${Array.from({ length: 101 }, () => 'a').join('/')}`, '101 Segmente'],
[`/${'a'.repeat(4100)}`, 'zu lang'],
])('parseUserPath lehnt %s ab (%s)', (raw) => {
expect(codeOf(() => parseUserPath(raw))).toBe('invalidPath');
});
it('ein Segment mit genau 255 Byte ist erlaubt, validateSegment("..") wirft', () => {
expect(() => validateSegment('a'.repeat(255))).not.toThrow();
expect(codeOf(() => validateSegment('..'))).toBe('invalidPath');
});
it('validateNewName: .part, Leerraum und Segmentverstoesse sind invalidName', () => {
expect(validateNewName('Bericht.pdf')).toBe('Bericht.pdf');
expect(codeOf(() => validateNewName('x.part'))).toBe('invalidName');
expect(codeOf(() => validateNewName(' '))).toBe('invalidName');
expect(codeOf(() => validateNewName('a/b'))).toBe('invalidName');
});
});
describe('ncRequest — Transport', () => {
it('sendet User-Agent, nie ein Cookie, und den Authorization-Literal fuer anna/geheim', async () => {
const { transport, calls } = fakeTransport(async () => reply(200, '{}'));
const gate = new NextcloudCallGate();
expect(basicAuth('anna', 'geheim')).toBe('Basic YW5uYTpnZWhlaW0=');
const res = await ncRequest(transport, gate, {
baseUrl: BASE,
prefix: '/ocs/v2.php/',
segments: ['cloud', 'user'],
method: 'GET',
authorization: basicAuth('anna', 'geheim'),
ocs: true,
});
expect(res.ok).toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('https://cloud.example/nc/ocs/v2.php/cloud/user');
expect(calls[0].method).toBe('GET');
expect(calls[0].headers).toEqual({
'user-agent': 'Tessera (Nextcloud-Dateien)',
'ocs-apirequest': 'true',
accept: 'application/json',
authorization: 'Basic YW5uYTpnZWhlaW0=',
});
expect(Object.keys(calls[0].headers)).not.toContain('cookie');
});
it('verweigert freie cookie/host/authorization-Kopfzeilen', async () => {
const { transport } = fakeTransport(async () => reply(200));
for (const name of ['Cookie', 'host', 'Authorization']) {
await expect(
ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
headers: { [name]: 'x' },
}),
).rejects.toThrow();
}
});
it('3xx ist Fehlerart redirect, der Transport wird genau einmal aufgerufen', async () => {
const { transport, calls } = fakeTransport(async () =>
reply(302, '', { location: 'https://evil.example/' }),
);
const res = await ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
});
expect(res).toEqual({ ok: false, kind: 'redirect', status: 302 });
expect(calls).toHaveLength(1);
});
it('haengender Transport mit 20 ms Zeitgrenze ist timeout', async () => {
const { transport } = fakeTransport(() => new Promise(() => {}));
const res = await ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
headersTimeoutMs: 20,
});
expect(res).toEqual({ ok: false, kind: 'timeout' });
});
it('ENOTFOUND ist network, CERT_HAS_EXPIRED ist tls, undici-Zeitgrenze ist timeout', async () => {
const run = async (code: string) => {
const { transport } = fakeTransport(async () => {
throw errCode(code);
});
return ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
});
};
expect(await run('ENOTFOUND')).toEqual({ ok: false, kind: 'network', detail: 'ENOTFOUND' });
expect(await run('CERT_HAS_EXPIRED')).toEqual({
ok: false,
kind: 'tls',
detail: 'CERT_HAS_EXPIRED',
});
expect(await run('UND_ERR_HEADERS_TIMEOUT')).toEqual({
ok: false,
kind: 'timeout',
detail: 'UND_ERR_HEADERS_TIMEOUT',
});
});
it('ein Abbruch durch den Aufrufer ist aborted', async () => {
const ctrl = new AbortController();
const { transport } = fakeTransport(() => new Promise(() => {}));
const p = ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
signal: ctrl.signal,
});
ctrl.abort();
expect(await p).toEqual({ ok: false, kind: 'aborted' });
});
it('JSON eines Fehlers enthaelt weder "Basic " noch das Passwort', async () => {
const { transport } = fakeTransport(async () => {
throw errCode('ECONNREFUSED');
});
const res = await ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/ocs/v2.php/',
segments: ['core', 'getapppassword'],
method: 'GET',
authorization: basicAuth('anna', 'geheim'),
});
const json = JSON.stringify(res);
expect(json).not.toContain('Basic ');
expect(json).not.toContain('geheim');
expect(json).not.toContain('YW5uYTpnZWhlaW0=');
});
});
describe('ncRequest — Aufrufsperre', () => {
function gateWithClock() {
const gate = new NextcloudCallGate();
const clock = { t: 5_000_000 };
gate.now = () => clock.t;
return { gate, clock };
}
it('ein 429 haelt den ganzen Ursprung an, ohne Transportaufruf; ein anderer Ursprung geht raus', async () => {
const { gate } = gateWithClock();
const { transport, calls } = fakeTransport(async () => reply(429, '{}'));
const first = await ncRequest(transport, gate, {
baseUrl: 'https://cloud.example',
prefix: '/ocs/v2.php/',
segments: ['core', 'getapppassword'],
method: 'GET',
});
expect(first).toEqual({ ok: false, kind: 'http', status: 429, retryAfterSeconds: 900 });
expect(calls).toHaveLength(1);
const second = await ncRequest(transport, gate, {
baseUrl: 'https://cloud.example',
prefix: '/remote.php/dav/files/',
segments: ['anna'],
method: 'PROPFIND',
});
expect(second).toEqual({ ok: false, kind: 'paused', retryAfterSeconds: 900 });
expect(calls).toHaveLength(1);
const other = fakeTransport(async () => reply(200, '{}'));
const third = await ncRequest(other.transport, gate, {
baseUrl: 'https://other.example',
prefix: '/status.php',
method: 'GET',
});
expect(third.ok).toBe(true);
expect(other.calls).toHaveLength(1);
});
it('Retry-After 120 -> 120 s, 99999 -> 3600 s, danach geht es wieder', async () => {
const { gate, clock } = gateWithClock();
const answers = [reply(429, '', { 'retry-after': '120' }), reply(200, '{}')];
const { transport, calls } = fakeTransport(async () => answers.shift() as NcTransportResponse);
const opts = {
baseUrl: 'https://cloud.example',
prefix: '/status.php',
method: 'GET',
} as const;
expect(await ncRequest(transport, gate, opts)).toMatchObject({ retryAfterSeconds: 120 });
expect(await ncRequest(transport, gate, opts)).toMatchObject({ kind: 'paused' });
clock.t += 121_000;
expect((await ncRequest(transport, gate, opts)).ok).toBe(true);
expect(calls).toHaveLength(2);
const big = fakeTransport(async () => reply(429, '', { 'retry-after': '99999' }));
const { gate: gate2 } = gateWithClock();
expect(await ncRequest(big.transport, gate2, opts)).toMatchObject({ retryAfterSeconds: 3600 });
});
it('ein 401 mit credentialKey: der Schluessel stirbt, laufende Aufrufe brechen ab, spaetere gehen nicht raus', async () => {
const { gate } = gateWithClock();
let release: (() => void) | undefined;
let abortedSeen = false;
const transport: NextcloudTransport = (req) => {
if (req.url.endsWith('/slow')) {
return new Promise<NcTransportResponse>((_resolve, reject) => {
req.signal.addEventListener('abort', () => {
abortedSeen = true;
reject(new Error('aborted'));
});
release = () => reject(new Error('never'));
});
}
if (req.url.endsWith('/revoked')) return Promise.resolve(reply(401, '{}'));
return Promise.resolve(reply(200, '{}'));
};
const base = { baseUrl: 'https://cloud.example', method: 'GET' } as const;
const slow = ncRequest(transport, gate, {
...base,
prefix: '/remote.php/dav/files/',
segments: ['anna', 'slow'],
credentialKey: 'k1',
});
const killed = await ncRequest(transport, gate, {
...base,
prefix: '/remote.php/dav/files/',
segments: ['anna', 'revoked'],
credentialKey: 'k1',
});
expect(killed).toEqual({ ok: false, kind: 'credential-dead', status: 401 });
expect(await slow).toEqual({ ok: false, kind: 'credential-dead' });
expect(abortedSeen).toBe(true);
expect(release).toBeTypeOf('function');
const calls = vi.fn(transport);
const later = await ncRequest(calls, gate, {
...base,
prefix: '/remote.php/dav/files/',
segments: ['anna', 'ok'],
credentialKey: 'k1',
});
expect(later).toEqual({ ok: false, kind: 'credential-dead' });
expect(calls).not.toHaveBeenCalled();
const other = await ncRequest(calls, gate, {
...base,
prefix: '/remote.php/dav/files/',
segments: ['anna', 'ok'],
credentialKey: 'k2',
});
expect(other.ok).toBe(true);
expect(calls).toHaveBeenCalledTimes(1);
});
it('ein 401 ohne credentialKey (Passwortanmeldung) markiert nichts', async () => {
const { gate } = gateWithClock();
const { transport, calls } = fakeTransport(async () => reply(401, '{}'));
const opts = {
baseUrl: 'https://cloud.example',
prefix: '/ocs/v2.php/',
segments: ['core', 'getapppassword'],
method: 'GET',
} as const;
const first = await ncRequest(transport, gate, opts);
expect(first.ok && first.status).toBe(401);
await ncRequest(transport, gate, opts);
expect(calls).toHaveLength(2);
expect(gate.isDead('k1')).toBe(false);
});
});
describe('readCappedText', () => {
it('liest bis zur Grenze', async () => {
expect(
await readCappedText(Readable.from([Buffer.from('abc'), Buffer.from('def')]), 6),
).toEqual({
ok: true,
text: 'abcdef',
});
});
it('ueber der Grenze ist too-large', async () => {
const res = await readCappedText(Readable.from([Buffer.from('abcd'), Buffer.from('efg')]), 6);
expect(res).toEqual({ ok: false, kind: 'too-large' });
});
it('ein Lesefehler wird zur Fehlerart', async () => {
const stream = new Readable({
read() {
this.destroy(errCode('ECONNRESET'));
},
});
expect(await readCappedText(stream, 100)).toEqual({
ok: false,
kind: 'network',
detail: 'ECONNRESET',
});
});
});
@@ -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') };
}