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:
@@ -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') };
|
||||
}
|
||||
Reference in New Issue
Block a user