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,105 @@
-- 261008-mzu — Modul "Nextcloud-Dateien" (Etappe 1).
--
-- Zweck: zwei Tabellen und zwei Aufzaehlungen.
-- * "NextcloudFilesConfig": Einstellungen der Organisation (Singleton), genau
-- eine Nextcloud-Adresse (`baseUrl`, Normalform von normalizeCloudUrl).
-- * "NextcloudFilesAccount": das persoenliche Konto eines Benutzers in dieser
-- Nextcloud. Gespeichert wird NUR das App-Passwort, AES-256-GCM-verschluesselt
-- ueber den CryptoService (`encryptedAppPassword`); das echte Passwort des
-- Benutzers wird nie abgelegt. `ncUserId` ist die Kennung in der Nextcloud
-- (nicht der eingegebene Anmeldename), `connectedVia` merkt sich den Weg
-- (Passwort oder Login Flow v2 fuer Zwei-Faktor-Konten).
--
-- Warum das Konto die Adresse mitfuehrt ("baseUrl"): ein App-Passwort gilt nur
-- fuer die Nextcloud, die es ausgestellt hat. Es wird nie an einen anderen Host
-- geschickt; wechselt die Adresse der Organisation, laufen alle Konten ab
-- (status = EXPIRED, oder Konto-baseUrl weicht von der Einstellung ab) und die
-- Benutzer verbinden neu.
--
-- Der DDL-Teil stammt aus `prisma migrate diff`, damit der Stand ohne Abweichung
-- zum Schema passt; die Zeilenschutz-Regeln sind von Hand ergaenzt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl):
-- "NextcloudFilesConfig" — tenant_isolation_policy nur ueber den Mandanten
-- (Verwaltungsdaten der Organisation, wie "DomainsConfig").
-- "NextcloudFilesAccount" — tenant_isolation_policy ueber Mandant UND Benutzer
-- (Form aus 20260929140000_reminder): ohne gesetzten Benutzer gilt nur der
-- Mandant, mit Benutzer zusaetzlich "userId". Genau das haelt einen Benutzer
-- davon ab, je das Konto eines anderen zu lesen. KEINE system_read_policy:
-- es gibt keinen Hintergrunddienst, der ueber alle Mandanten liest.
--
-- Faellt ein Benutzer weg, faellt sein Konto mit (ON DELETE CASCADE). Das
-- App-Passwort bleibt dann in der Nextcloud bestehen (Geraeteliste dort) — das
-- ist dokumentiert und fuer Etappe 1 bewusst nicht aufgeraeumt.
--
-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
--
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
-- Anwendungspruefungen im Dienst den Schutz allein.
-- CreateEnum
CREATE TYPE "NextcloudFilesAccountStatus" AS ENUM ('ACTIVE', 'EXPIRED');
-- CreateEnum
CREATE TYPE "NextcloudFilesConnectMethod" AS ENUM ('PASSWORD', 'LOGIN_FLOW');
-- CreateTable
CREATE TABLE "NextcloudFilesConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NextcloudFilesConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NextcloudFilesAccount" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"ncUserId" TEXT NOT NULL,
"ncDisplayName" TEXT,
"encryptedAppPassword" TEXT NOT NULL,
"status" "NextcloudFilesAccountStatus" NOT NULL DEFAULT 'ACTIVE',
"connectedVia" "NextcloudFilesConnectMethod" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NextcloudFilesAccount_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "NextcloudFilesConfig_tenantId_key" ON "NextcloudFilesConfig"("tenantId");
-- CreateIndex
CREATE INDEX "NextcloudFilesConfig_tenantId_idx" ON "NextcloudFilesConfig"("tenantId");
-- CreateIndex
CREATE INDEX "NextcloudFilesAccount_tenantId_idx" ON "NextcloudFilesAccount"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "NextcloudFilesAccount_tenantId_userId_key" ON "NextcloudFilesAccount"("tenantId", "userId");
-- AddForeignKey
ALTER TABLE "NextcloudFilesAccount" ADD CONSTRAINT "NextcloudFilesAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz: nur Mandant (Organisations-Einstellung)
ALTER TABLE "NextcloudFilesConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "NextcloudFilesConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "NextcloudFilesConfig"
USING ("tenantId" = current_tenant_id());
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260929140000_reminder)
ALTER TABLE "NextcloudFilesAccount" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "NextcloudFilesAccount" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "NextcloudFilesAccount"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
+46
View File
@@ -58,6 +58,7 @@ model User {
moduleGrants ModuleGrant[]
customModules CustomModule[]
reminders Reminder[]
nextcloudFilesAccounts NextcloudFilesAccount[]
nextcloudAlertSubscriptions NextcloudAlertSubscription[]
@@index([tenantId])
@@ -942,6 +943,51 @@ model DomainsOrder {
@@index([tenantId])
}
// quick-261008-mzu: Modul "Nextcloud-Dateien". Eine Nextcloud je Organisation
// (NextcloudFilesConfig, Singleton), ein eigenes Konto je Benutzer
// (NextcloudFilesAccount). Gespeichert wird nur das App-Passwort, AES-verschluesselt
// (CryptoService); das echte Passwort wird nie abgelegt. `baseUrl` im Konto ist die
// Adresse, fuer die das App-Passwort ausgestellt wurde: es geht nie an einen anderen
// Host, und ein Adresswechsel laesst alle Konten ablaufen. Zeilenschutz wie Reminder
// (Mandant UND Benutzer), keine Systemleserregel.
enum NextcloudFilesAccountStatus {
ACTIVE
EXPIRED
}
enum NextcloudFilesConnectMethod {
PASSWORD
LOGIN_FLOW
}
model NextcloudFilesConfig {
id String @id @default(uuid())
tenantId String @unique
baseUrl String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
model NextcloudFilesAccount {
id String @id @default(uuid())
tenantId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
baseUrl String
ncUserId String
ncDisplayName String?
encryptedAppPassword String
status NextcloudFilesAccountStatus @default(ACTIVE)
connectedVia NextcloudFilesConnectMethod
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, userId])
@@index([tenantId])
}
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
+2
View File
@@ -29,6 +29,7 @@ import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module';
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
import { NextcloudFilesModule } from './nextcloud-files/nextcloud-files.module';
import { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
@@ -62,6 +63,7 @@ import { RemindersModule } from './reminders/reminders.module';
ProxmoxModule,
NextcloudStatusModule,
DomainsModule,
NextcloudFilesModule,
KantineDatevModule,
HandelswareDatevModule,
CustomModulesModule,
@@ -8,6 +8,7 @@ import { DomainsController } from '../domains/domains.controller';
import { ModuleGrantsController } from '../groups/module-grants.controller';
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
import { KantineDatevController } from '../kantine-datev/kantine-datev.controller';
import { NextcloudFilesController } from '../nextcloud-files/nextcloud-files.controller';
import { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller';
import { ProxmoxController } from '../proxmox/proxmox.controller';
import { TendersController } from '../tenders/tenders.controller';
@@ -116,6 +117,23 @@ describe('Umgestellte Handler (Verwalten)', () => {
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it.each([
'getSettings',
'saveSettings',
'testSettings',
])('NextcloudFilesController.%s verlangt Verwalten für nextcloud-files (quick-261008-mzu)', (name) => {
expectManage(NextcloudFilesController, name, 'nextcloud-files');
});
// Spätere Aufgaben von quick-261008-mzu ergänzen diese Liste um ihre Benutzen-Handler.
it.each([
'getStatus',
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(NextcloudFilesController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');
@@ -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') };
}