diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh new file mode 100755 index 0000000..4e7782b --- /dev/null +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Gemeinsame Helfer der End-zu-Ende-Skripte von quick-261008-mzu (wird mit `source` geladen, +# hat keine Nebenwirkungen beim Laden). Alles gegen den lokalen Stack und die Test-Nextcloud +# aus nc-test-setup.sh. Nur Testwerte; liest keine .env-Dateien. +# +# Aufgabe 2 ergaenzt hier die Helfer "anna verbinden" (e2e_connect_anna) auf Basis dieser Datei. + +API=${API:-http://localhost:3001} +WEB=${WEB:-http://localhost:3000/api-proxy} +NC_BASE=${NC_BASE:-http://172.17.0.1:18080} +E2E_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +E2E_TMP=${E2E_TMP:-$(mktemp -d)} + +# Stoppt mit Meldung, wenn eine Erwartung nicht stimmt. +e2e_fail() { echo "E2E FAIL: $*" >&2; exit 1; } + +# e2e_login [benutzer] [passwort] — meldet an, Cookies landen im . +e2e_login() { + local jar=$1 user=${2:-admin} pass=${3:-admin123} code + code=$(curl -s -o "$E2E_TMP/login.out" -w '%{http_code}' -c "$jar" -H 'Content-Type: application/json' \ + -d "{\"username\":\"$user\",\"password\":\"$pass\"}" "$API/auth/login") + [ "$code" = "200" ] || [ "$code" = "201" ] || e2e_fail "login $user -> $code" +} + +# e2e_status [json-body] [ausgabedatei] — gibt den HTTP-Status aus, +# der Antwortkoerper landet in (Vorgabe $E2E_TMP/body.out). +e2e_status() { + local jar=$1 method=$2 url=$3 body=${4:-} out=${5:-$E2E_TMP/body.out} + if [ -n "$body" ]; then + curl -s -o "$out" -w '%{http_code}' -b "$jar" -X "$method" -H 'Content-Type: application/json' -d "$body" "$url" + else + curl -s -o "$out" -w '%{http_code}' -b "$jar" -X "$method" "$url" + fi +} + +# e2e_expect +e2e_expect() { [ "$1" = "$2" ] || e2e_fail "$3: erwartet $1, war $2"; } + +# e2e_contains +e2e_contains() { grep -q -- "$2" "$1" || { echo "--- Antwort:" >&2; cat "$1" >&2; echo >&2; e2e_fail "$3: '$2' fehlt"; }; } + +# e2e_activate [slug] — aktiviert das Modul, falls noch nicht aktiv. +e2e_activate() { + local jar=$1 slug=${2:-nextcloud-files} id active + e2e_status "$jar" GET "$API/modules/catalog" "" "$E2E_TMP/catalog.out" >/dev/null + id=$(python3 -I -c 'import json,sys +slug=sys.argv[1] +for m in json.load(open(sys.argv[2])): + if m["slug"]==slug: print(m["id"]); break' "$slug" "$E2E_TMP/catalog.out") + [ -n "$id" ] || e2e_fail "Modul $slug nicht im Katalog" + active=$(python3 -I -c 'import json,sys +slug=sys.argv[1] +print([str(m.get("isActiveForTenant")) for m in json.load(open(sys.argv[2])) if m["slug"]==slug][0])' "$slug" "$E2E_TMP/catalog.out") + if [ "$active" != "True" ]; then + local code + code=$(e2e_status "$jar" POST "$API/modules/$id/activate") + case "$code" in 200|201) ;; *) e2e_fail "Aktivierung $slug -> $code" ;; esac + fi +} + +# e2e_set_address [adresse] — setzt die Nextcloud-Adresse (mit Bestaetigung bei Wechsel). +e2e_set_address() { + local jar=$1 base=${2:-$NC_BASE} code + code=$(e2e_status "$jar" PUT "$API/modules/nextcloud-files/settings" "{\"baseUrl\":\"$base\",\"confirmReconnect\":true}") + e2e_expect 200 "$code" "Adresse setzen" +} + +# e2e_second_user — legt (als admin) einen Benutzer mit Rolle USER an, +# setzt sein Passwort auf "-Pass-12345" (Pflicht-Passwortwechsel) und meldet ihn an. +e2e_second_user() { + local name=$1 jar=$2 admin_jar="$E2E_TMP/admin-second.jar" code first="Start-Pass-12345" final="${1}-Pass-12345" + e2e_login "$admin_jar" + code=$(e2e_status "$admin_jar" POST "$API/users" \ + "{\"username\":\"$name\",\"email\":\"$name@example.com\",\"password\":\"$first\",\"displayName\":\"$name\",\"role\":\"USER\"}") + case "$code" in 200|201|409) ;; *) e2e_fail "Benutzer $name anlegen -> $code" ;; esac + if [ "$code" = "409" ]; then + e2e_login "$jar" "$name" "$final" + return 0 + fi + e2e_login "$jar" "$name" "$first" + code=$(e2e_status "$jar" POST "$API/auth/change-password" "{\"currentPassword\":\"$first\",\"newPassword\":\"$final\"}") + e2e_expect 200 "$code" "Passwortwechsel $name" + e2e_login "$jar" "$name" "$final" +} diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-settings.sh b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-settings.sh new file mode 100755 index 0000000..491d1b3 --- /dev/null +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-settings.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# End-zu-Ende-Pruefung Aufgabe 1 (quick-261008-mzu): Modul aktivieren, Nextcloud-Adresse +# speichern und pruefen. Voraussetzung: lokaler Stack laeuft, nc-test-setup.sh ist gelaufen. +set -euo pipefail +# shellcheck source=e2e-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/e2e-lib.sh" + +JAR="$E2E_TMP/admin.jar" +S="$API/modules/nextcloud-files" + +e2e_login "$JAR" +e2e_activate "$JAR" + +# Speichern mit Pruefung +code=$(e2e_status "$JAR" PUT "$S/settings" "{\"baseUrl\":\"$NC_BASE\",\"confirmReconnect\":true}") +e2e_expect 200 "$code" "PUT settings" +e2e_contains "$E2E_TMP/body.out" '"ok":true' "PUT settings: check.ok" + +# Verbindungspruefung der Test-Nextcloud: Version 34.x +code=$(e2e_status "$JAR" POST "$S/settings/test" "{\"baseUrl\":\"$NC_BASE\"}") +e2e_expect 200 "$code" "POST settings/test" +e2e_contains "$E2E_TMP/body.out" '"ok":true' "settings/test ok" +e2e_contains "$E2E_TMP/body.out" '"version":"34\.' "settings/test Version 34" + +# Nicht erreichbare Adresse: 200 mit ok:false +code=$(e2e_status "$JAR" POST "$S/settings/test" '{"baseUrl":"http://172.17.0.1:1"}') +e2e_expect 200 "$code" "settings/test unerreichbar" +e2e_contains "$E2E_TMP/body.out" '"ok":false' "settings/test unerreichbar ok:false" + +# Ungueltige Adresse: 400 invalidUrl +code=$(e2e_status "$JAR" PUT "$S/settings" '{"baseUrl":"ftp://x"}') +e2e_expect 400 "$code" "PUT ftp" +e2e_contains "$E2E_TMP/body.out" 'invalidUrl' "PUT ftp invalidUrl" + +# Stand und Einstellungen +code=$(e2e_status "$JAR" GET "$S/status") +e2e_expect 200 "$code" "GET status" +e2e_contains "$E2E_TMP/body.out" '"configured":true' "status configured" +e2e_contains "$E2E_TMP/body.out" '"host":"172.17.0.1:18080"' "status host" +code=$(e2e_status "$JAR" GET "$S/settings") +e2e_expect 200 "$code" "GET settings" +e2e_contains "$E2E_TMP/body.out" '"baseUrl":"http://172.17.0.1:18080"' "settings baseUrl" + +# Benutzen-Rechte: ein Benutzer ohne Verwalten bekommt 403 auf die Einstellungen, aber 200 auf status +UJAR="$E2E_TMP/user.jar" +e2e_second_user e2euser "$UJAR" +code=$(e2e_status "$UJAR" GET "$S/settings") +case "$code" in 403|404) ;; *) e2e_fail "Benutzer ohne Freigabe: GET settings erwartet 403/404, war $code" ;; esac + +echo "e2e settings ok" diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh new file mode 100755 index 0000000..d815e4e --- /dev/null +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Lokale Test-Nextcloud fuer quick-261008-mzu (idempotent). +# Container: tessera-nc-test (nextcloud:stable, SQLite), Host-Port 18080 +# Benutzer: admin / Admin-Pass-12345 +# anna / User1-Pass-12345 (ohne Zwei-Faktor, "Anna Müller") +# zoe / User2-Pass-12345 (Zwei-Faktor TOTP erzwungen, "Zwei Faktor") +# TOTP-Geheimnis von zoe im Container: /var/www/zoe-totp-secret +# Code: python3 -I totp.py "$(docker exec tessera-nc-test cat /var/www/zoe-totp-secret)" +# Nur Testwerte, nie fuer echte Systeme verwenden. Liest keine .env-Dateien. +set -euo pipefail + +NAME=tessera-nc-test +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../../.." && pwd)" +occ() { docker exec -u www-data "$NAME" php occ "$@"; } + +if ! docker inspect "$NAME" >/dev/null 2>&1; then + docker run -d --name "$NAME" -p 18080:80 \ + -e SQLITE_DATABASE=nextcloud \ + -e NEXTCLOUD_ADMIN_USER=admin \ + -e NEXTCLOUD_ADMIN_PASSWORD='Admin-Pass-12345' \ + -e NEXTCLOUD_TRUSTED_DOMAINS='localhost 127.0.0.1 172.17.0.1 tessera-nc-test' \ + nextcloud:stable >/dev/null +elif [ "$(docker inspect -f '{{.State.Running}}' "$NAME")" != "true" ]; then + docker start "$NAME" >/dev/null +fi + +for _ in $(seq 1 36); do + if curl -s http://localhost:18080/status.php | grep -q '"installed":true'; then break; fi + sleep 5 +done +curl -s http://localhost:18080/status.php | grep -q '"installed":true' || { echo "nextcloud not installed in time" >&2; exit 1; } + +if ! occ user:info anna >/dev/null 2>&1; then + docker exec -u www-data -e OC_PASS='User1-Pass-12345' "$NAME" \ + php occ user:add --password-from-env --display-name="Anna Müller" anna +fi +if ! occ user:info zoe >/dev/null 2>&1; then + docker exec -u www-data -e OC_PASS='User2-Pass-12345' "$NAME" \ + php occ user:add --password-from-env --display-name="Zwei Faktor" zoe +fi + +if ! occ group:list 2>/dev/null | grep -q 'twofa'; then + occ group:add twofa >/dev/null +fi +occ group:adduser twofa zoe >/dev/null 2>&1 || true +occ twofactorauth:enforce --on --group=twofa >/dev/null + +if ! occ twofactorauth:state zoe 2>/dev/null | grep -q totp; then + docker cp "$HERE/totp.php" "$NAME:/tmp/totp.php" + SECRET="$(docker exec -u www-data "$NAME" php /tmp/totp.php zoe secret | tail -1)" + docker exec -u www-data "$NAME" php /tmp/totp.php zoe "$(python3 -I "$HERE/totp.py" "$SECRET")" >/dev/null + docker exec "$NAME" sh -c "printf '%s' '$SECRET' > /var/www/zoe-totp-secret" +fi + +# Brute-Force-Sperre: gesamtes Docker-Adressband ausnehmen, damit wiederholte Testlaeufe +# die IP nie sperren. Das 429-Verhalten decken die Einheitstests ab. +occ config:app:set bruteForce whitelist_0 --value=172.16.0.0/12 >/dev/null + +# Erreichbarkeit aus dem api-Container (Host-Gateway) +cd "$ROOT" +docker compose exec -T api node -e "fetch('http://172.17.0.1:18080/status.php').then(r=>process.exit(r.status===200?0:1)).catch(()=>process.exit(1))" + +echo "nc test ready" diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.php b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.php new file mode 100644 index 0000000..81f0ee7 --- /dev/null +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.php @@ -0,0 +1,14 @@ + secret -> gibt ein neues Geheimnis aus +// php totp.php -> aktiviert TOTP mit dem Code +// Laeuft im Test-Container (tessera-nc-test) als www-data. Nur Testwerte. +require_once '/var/www/html/lib/base.php'; +\OC_App::loadApp('twofactor_totp'); +$totp = \OC::$server->get(\OCA\TwoFactorTOTP\Service\ITotp::class); +$user = \OC::$server->get(\OCP\IUserManager::class)->get($argv[1]); +if ($argv[2] === 'secret') { + echo $totp->createSecret($user), "\n"; +} else { + var_dump($totp->enable($user, $argv[2])); +} diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.py b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.py new file mode 100644 index 0000000..677893b --- /dev/null +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.py @@ -0,0 +1,15 @@ +"""Testhilfe (quick-261008-mzu): aktueller TOTP-Code zu einem Base32-Geheimnis. + +Aufruf: python3 -I totp.py +""" +import base64 +import hashlib +import hmac +import struct +import sys +import time + +k = base64.b32decode(sys.argv[1].upper() + '=' * (-len(sys.argv[1]) % 8)) +h = hmac.new(k, struct.pack('>Q', int(time.time()) // 30), hashlib.sha1).digest() +o = h[-1] & 15 +print('%06d' % ((struct.unpack('>I', h[o:o + 4])[0] & 0x7FFFFFFF) % 1000000)) diff --git a/apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql b/apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql new file mode 100644 index 0000000..3b2082a --- /dev/null +++ b/apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql @@ -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()) + ); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c1846ba..37fe3cd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index fc5558b..9dc31a2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/module-registry/module-manage-handlers.spec.ts b/apps/api/src/module-registry/module-manage-handlers.spec.ts index 77ef5dd..2722886 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -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'); diff --git a/apps/api/src/nextcloud-files/dto/nextcloud-files-settings.dto.ts b/apps/api/src/nextcloud-files/dto/nextcloud-files-settings.dto.ts new file mode 100644 index 0000000..d32ea53 --- /dev/null +++ b/apps/api/src/nextcloud-files/dto/nextcloud-files-settings.dto.ts @@ -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; +} diff --git a/apps/api/src/nextcloud-files/nextcloud-call-gate.spec.ts b/apps/api/src/nextcloud-files/nextcloud-call-gate.spec.ts new file mode 100644 index 0000000..4bd7705 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-call-gate.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-call-gate.ts b/apps/api/src/nextcloud-files/nextcloud-call-gate.ts new file mode 100644 index 0000000..bbedccc --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-call-gate.ts @@ -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(); + private readonly dead = new Map(); + private readonly controllers = new Map(); + + // --- (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; + } +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files-settings.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files-settings.service.spec.ts new file mode 100644 index 0000000..946081b --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files-settings.service.spec.ts @@ -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 { + 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) => { + 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'); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts b/apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts new file mode 100644 index 0000000..1e34be7 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts @@ -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; +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 { + 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 { + 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 { + 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 { + const baseUrl = await this.getBaseUrl(tenantId); + return { + configured: baseUrl !== null, + serverUrl: baseUrl, + host: baseUrl ? hostOf(baseUrl) : null, + account: null, + }; + } + + async getSettings(tenantId: string): Promise { + 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 { + 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 { + 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) => { + 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.'); + } + } +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts new file mode 100644 index 0000000..123f67b --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts new file mode 100644 index 0000000..d7d4183 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts @@ -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); + } +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files.module.ts b/apps/api/src/nextcloud-files/nextcloud-files.module.ts new file mode 100644 index 0000000..2158792 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files.module.ts @@ -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 { + 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); + } + } +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files.seed.ts b/apps/api/src/nextcloud-files/nextcloud-files.seed.ts new file mode 100644 index 0000000..f3df4e1 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files.seed.ts @@ -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 { + 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, + }); +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files.types.ts b/apps/api/src/nextcloud-files/nextcloud-files.types.ts new file mode 100644 index 0000000..4f69d53 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files.types.ts @@ -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 = { + 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, +): 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, + 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; +} diff --git a/apps/api/src/nextcloud-files/nextcloud-http.spec.ts b/apps/api/src/nextcloud-files/nextcloud-http.spec.ts new file mode 100644 index 0000000..a2740dd --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-http.spec.ts @@ -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 = {}, +): NcTransportResponse { + return { statusCode, headers, body: bodyOf(text) }; +} + +function fakeTransport(handler: (req: NcTransportRequest) => Promise) { + 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((_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', + }); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-http.ts b/apps/api/src/nextcloud-files/nextcloud-http.ts new file mode 100644 index 0000000..cfcc91d --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-http.ts @@ -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; + +export interface NcTransportRequest { + url: string; + method: string; + headers: Record; + body?: Readable | string | Buffer | null; + headersTimeoutMs: number; + bodyTimeoutMs: number; + signal: AbortSignal; +} + +export interface NcTransportResponse { + statusCode: number; + headers: NcHeaders; + body: Readable; +} + +export type NextcloudTransport = (req: NcTransportRequest) => Promise; + +/** Standardtransport auf Basis von undici `request` (keine Cookies, keine Weiterleitungen). */ +export const undiciTransport: NextcloudTransport = async (req) => { + const res = await request(req.url, { + // undici kennt PROPFIND/MKCOL/MOVE zur Laufzeit; die Typen nennen nur die Standardmethoden. + method: req.method as never, + headers: req.headers, + body: (req.body ?? undefined) as never, + headersTimeout: req.headersTimeoutMs, + bodyTimeout: req.bodyTimeoutMs, + signal: req.signal, + }); + return { + statusCode: res.statusCode, + headers: res.headers as NcHeaders, + body: res.body as unknown as Readable, + }; +}; + +// --- Pfade und URLs ----------------------------------------------------------------- + +const MAX_SEGMENT_BYTES = 255; +const MAX_SEGMENTS = 100; +const MAX_PATH_CHARS = 4096; + +/** + * Ein einzelnes 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 { + 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; + method: string; + headers?: Record; + body?: Readable | string | Buffer | null; + /** Fertiger `Authorization`-Wert (siehe `basicAuth`). */ + authorization?: string; + /** Gesetzt bei Aufrufen mit gespeichertem App-Passwort (siehe Aufrufsperre). */ + credentialKey?: string; + /** OCS-Aufruf: setzt `OCS-APIRequest` und `Accept: application/json`. */ + ocs?: boolean; + headersTimeoutMs?: number; + bodyTimeoutMs?: number; + signal?: AbortSignal; +} + +export interface NcFailure { + ok: false; + kind: NcFailureKind; + status?: number; + retryAfterSeconds?: number; + /** Kurze eigene Kennung wie `ENOTFOUND` — nie ein Antworttext. */ + detail?: string; +} + +export type NcResult = { ok: true; status: number; headers: NcHeaders; body: Readable } | NcFailure; + +const TLS_CODES = new Set([ + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'ERR_TLS_CERT_ALTNAME_INVALID_ALTERNATE', + 'CERT_NOT_YET_VALID', + 'CERT_UNTRUSTED', + 'CERT_REVOKED', + 'CERT_SIGNATURE_FAILURE', + 'HOSTNAME_MISMATCH', +]); +const TIMEOUT_CODES = new Set([ + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_BODY_TIMEOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'ETIMEDOUT', +]); + +function errorCode(err: unknown): string | null { + const e = err as { code?: unknown; cause?: { code?: unknown } } | null; + const code = e?.cause?.code ?? e?.code; + return typeof code === 'string' && /^[A-Z0-9_]{2,80}$/.test(code) ? code : null; +} + +/** Ordnet einen Transportfehler einer Fehlerart zu (ohne den Fehlertext zu uebernehmen). */ +export function classifyTransportError(err: unknown): NcFailure { + const code = errorCode(err); + if (code && TLS_CODES.has(code)) return { ok: false, kind: 'tls', detail: code }; + if (code && TIMEOUT_CODES.has(code)) return { ok: false, kind: 'timeout', detail: code }; + return { ok: false, kind: 'network', ...(code ? { detail: code } : {}) }; +} + +function headerOne(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) return value[0]; + return value; +} + +/** Antwortkoerper verwerfen, ohne einen Fehler nach aussen zu geben. */ +export function discardBody(body: Readable | null | undefined): void { + if (!body) return; + try { + const dumpable = body as unknown as { dump?: () => Promise }; + if (typeof dumpable.dump === 'function') { + dumpable.dump().catch(() => {}); + } else { + body.destroy(); + } + } catch { + // schon verbraucht + } +} + +const FORBIDDEN_CUSTOM_HEADERS = new Set(['cookie', 'host', 'authorization']); + +/** + * Fuehrt einen Aufruf aus. Wirft nie bei Netz- oder HTTP-Problemen; das + * Ergebnis ist `{ ok: true, status, headers, body }` oder `{ ok: false, kind, ... }`. + * HTTP-Fehlerstatus (404, 412, ...) kommen als `ok: true` zurueck, damit der + * Aufrufer sie deuten kann. Ausnahmen, die hier schon entschieden werden: + * - 429: haelt den ganzen Ursprung an (Aufrufsperre) -> `{ ok: false, kind: 'http', status: 429 }` + * - 401 mit `credentialKey`: Schluessel stirbt -> `kind: 'credential-dead'` + * - 3xx: `kind: 'redirect'` (es wird nie gefolgt) + * Vor dem Transport prueft sie die Aufrufsperre (`paused`, `credential-dead`). + */ +export async function ncRequest( + transport: NextcloudTransport, + gate: NextcloudCallGate, + opts: NcRequestOptions, +): Promise { + const url = buildNcUrl(opts.baseUrl, opts.prefix, opts.segments ?? [], opts.query); + 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 = { 'user-agent': NC_USER_AGENT }; + if (opts.ocs) { + headers['ocs-apirequest'] = 'true'; + headers.accept = 'application/json'; + } + for (const [name, value] of Object.entries(opts.headers ?? {})) { + const lower = name.toLowerCase(); + if (FORBIDDEN_CUSTOM_HEADERS.has(lower)) { + throw new Error(`Kopfzeile ${lower} darf nicht frei gesetzt werden`); + } + headers[lower] = value; + } + if (opts.authorization) headers.authorization = opts.authorization; + + const own = new AbortController(); + const signals: AbortSignal[] = [own.signal]; + if (opts.signal) signals.push(opts.signal); + if (opts.credentialKey) signals.push(gate.signalFor(opts.credentialKey)); + const signal = AbortSignal.any(signals); + + const headersTimeoutMs = opts.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + own.abort(new Error('timeout')); + }, headersTimeoutMs); + + // Auch ein Transport, der das Signal nicht beachtet, soll nicht haengen bleiben. + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(signal.reason ?? new Error('aborted')); + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); + aborted.catch(() => {}); + + const pending = Promise.resolve().then(() => + transport({ + url, + method: opts.method, + headers, + body: opts.body ?? null, + headersTimeoutMs, + bodyTimeoutMs: opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS, + signal, + }), + ); + pending.catch(() => {}); + + let res: NcTransportResponse; + try { + res = await Promise.race([pending, aborted]); + } catch (err) { + // Eine spaet eintreffende Antwort des abgebrochenen Aufrufs wegwerfen. + pending.then((late) => discardBody(late.body)).catch(() => {}); + if (opts.credentialKey && gate.isDead(opts.credentialKey)) { + return { ok: false, kind: 'credential-dead' }; + } + if (timedOut) return { ok: false, kind: 'timeout' }; + if (opts.signal?.aborted) return { ok: false, kind: 'aborted' }; + return classifyTransportError(err); + } finally { + clearTimeout(timer); + if (onAbort) signal.removeEventListener('abort', onAbort); + } + + const status = res.statusCode; + if (status === 429) { + const seconds = gate.pause(origin, headerOne(res.headers['retry-after'])); + discardBody(res.body); + return { ok: false, kind: 'http', status, retryAfterSeconds: seconds }; + } + if (status === 401 && opts.credentialKey) { + gate.markDead(opts.credentialKey); + discardBody(res.body); + return { ok: false, kind: 'credential-dead', status }; + } + if (status >= 300 && status < 400) { + discardBody(res.body); + return { ok: false, kind: 'redirect', status }; + } + return { ok: true, status, headers: res.headers, body: res.body }; +} + +export type CappedText = + | { ok: true; text: string } + | { ok: false; kind: 'too-large' | 'timeout' | 'network' | 'tls'; detail?: string }; + +/** Liest einen Antwortkoerper als Text, hoechstens `maxBytes` (sonst `too-large`). */ +export async function readCappedText( + body: AsyncIterable & { destroy?: (err?: Error) => unknown }, + maxBytes: number, +): Promise { + const chunks: Buffer[] = []; + let total = 0; + try { + for await (const chunk of body) { + const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk); + total += buf.byteLength; + if (total > maxBytes) { + try { + body.destroy?.(); + } catch { + // schon beendet + } + return { ok: false, kind: 'too-large' }; + } + chunks.push(buf); + } + } catch (err) { + const failure = classifyTransportError(err); + return { + ok: false, + kind: failure.kind as 'timeout' | 'network' | 'tls', + ...(failure.detail ? { detail: failure.detail } : {}), + }; + } + return { ok: true, text: Buffer.concat(chunks).toString('utf8') }; +} diff --git a/apps/web/src/app/(portal)/modules/module-layouts.test.tsx b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx index 47d31f7..9ca7aae 100644 --- a/apps/web/src/app/(portal)/modules/module-layouts.test.tsx +++ b/apps/web/src/app/(portal)/modules/module-layouts.test.tsx @@ -8,6 +8,7 @@ import DomaincheckLayout from './domaincheck/layout'; import DomainsLayout from './domains/layout'; import HandelswareDatevLayout from './handelsware-datev/layout'; import KantineDatevLayout from './kantine-datev/layout'; +import NextcloudFilesLayout from './nextcloud-files/layout'; import NextcloudStatusLayout from './nextcloud-status/layout'; import TenderRadarLayout from './tender-radar/layout'; @@ -46,6 +47,7 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)', ['handelsware-datev', HandelswareDatevLayout], ['nextcloud-status', NextcloudStatusLayout], ['domains', DomainsLayout], + ['nextcloud-files', NextcloudFilesLayout], ] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => { const element = Layout({ children: placeholderChild }); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/SettingsTab.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/SettingsTab.tsx new file mode 100644 index 0000000..f5a41d8 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/SettingsTab.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; +import { SettingsSection } from '@/components/control-center/settings-section'; +import { + getNextcloudFilesSettings, + type NextcloudFilesCheck, + NextcloudFilesRequestError, + type NextcloudFilesSettings, + saveNextcloudFilesSettings, + testNextcloudFilesSettings, +} from '@/lib/nextcloud-files-api'; + +const INPUT_CLASS = + 'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'; +const PRIMARY_BUTTON = + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50'; +const SECONDARY_BUTTON = + 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + +/** Befehl fuer die Ausnahmeliste der Brute-Force-Erkennung (sprachneutral, kein Uebersetzungstext). */ +const WHITELIST_COMMAND = + 'occ config:app:set bruteForce whitelist_0 --value='; + +type Message = { kind: 'ok' | 'error'; text: string }; + +function MessageLine({ message }: { message: Message | null }) { + if (!message) return null; + return ( +

+ {message.text} +

+ ); +} + +/** + * Einstellungen des Moduls Dateien (quick-261008-mzu): die EINE Nextcloud-Adresse + * der Organisation, die Verbindungspruefung und Hinweise fuer die + * Nextcloud-Administration. Nur fuer Verwalter sichtbar (die Seite blendet den + * Reiter sonst aus; bindend ist die API). + */ +export function SettingsTab({ onChanged }: { onChanged: () => void }) { + const t = useTranslations('nextcloudFiles.settings'); + const tErrors = useTranslations('nextcloudFiles.errors'); + + const [settings, setSettings] = useState(null); + const [address, setAddress] = useState(''); + const [loadFailed, setLoadFailed] = useState(false); + const [testing, setTesting] = useState(false); + const [saving, setSaving] = useState(false); + const [confirmCount, setConfirmCount] = useState(null); + const [message, setMessage] = useState(null); + + const errorText = (error: unknown) => + error instanceof NextcloudFilesRequestError ? error.message : tErrors('request'); + + useEffect(() => { + let cancelled = false; + getNextcloudFilesSettings() + .then((loaded) => { + if (cancelled) return; + setSettings(loaded); + setAddress(loaded.baseUrl ?? ''); + }) + .catch(() => { + if (!cancelled) setLoadFailed(true); + }); + return () => { + cancelled = true; + }; + }, []); + + const showCheck = (check: NextcloudFilesCheck) => + setMessage({ kind: check.ok ? 'ok' : 'error', text: check.message }); + + const runTest = async () => { + setTesting(true); + setMessage(null); + try { + showCheck(await testNextcloudFilesSettings(address.trim())); + } catch (error) { + setMessage({ kind: 'error', text: errorText(error) }); + } finally { + setTesting(false); + } + }; + + const save = async (confirmReconnect: boolean) => { + setSaving(true); + setMessage(null); + try { + const saved = await saveNextcloudFilesSettings({ + baseUrl: address.trim(), + ...(confirmReconnect ? { confirmReconnect: true } : {}), + }); + setSettings(saved); + setAddress(saved.baseUrl ?? address.trim()); + setConfirmCount(null); + if (saved.check) showCheck(saved.check); + else setMessage({ kind: 'ok', text: t('saved') }); + onChanged(); + } catch (error) { + if (error instanceof NextcloudFilesRequestError && error.code === 'confirmReconnect') { + const count = Number(error.extra.connectedAccounts); + setConfirmCount(Number.isFinite(count) ? count : (settings?.connectedAccounts ?? 0)); + } else { + setMessage({ kind: 'error', text: errorText(error) }); + } + } finally { + setSaving(false); + } + }; + + const onSaveClick = () => { + const changed = address.trim() !== (settings?.baseUrl ?? ''); + const connected = settings?.connectedAccounts ?? 0; + if (changed && settings?.baseUrl && connected > 0) { + // Vor dem Wechsel ausdruecklich bestaetigen lassen: alle verbundenen Benutzer muessen sich neu anmelden. + setMessage(null); + setConfirmCount(connected); + return; + } + void save(false); + }; + + const busy = testing || saving; + const empty = address.trim() === ''; + + return ( +
+ + + + + + ) : ( + <> +

+ {t('reconnect.message', { count: confirmCount })} +

+ + + + ) + } + > + {loadFailed ? ( +

+ {tErrors('request')} +

+ ) : ( +
+ + { + setAddress(event.target.value); + setMessage(null); + setConfirmCount(null); + }} + /> + {settings?.baseUrl && ( +

+ {t('address.connectedUsers', { count: settings.connectedAccounts })} +

+ )} +
+ )} +
+ + +
+

{t('hints.whitelist')}

+
+            {WHITELIST_COMMAND}
+          
+

{t('hints.https')}

+

{t('hints.trustedDomains')}

+

{t('hints.twoFactor')}

+
+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/layout.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/layout.tsx new file mode 100644 index 0000000..a17c2aa --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/layout.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react'; +import { ModuleAccessGate } from '@/components/modules/module-access-gate'; + +export default function NextcloudFilesLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx new file mode 100644 index 0000000..32dc33f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx @@ -0,0 +1,198 @@ +import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NextcloudFilesSettings, NextcloudFilesStatus } from '@/lib/nextcloud-files-api'; +import de from '@/messages/de.json'; +import NextcloudFilesPage from './page'; + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +const mockGetStatus = vi.fn(); +const mockGetSettings = vi.fn(); +const mockSaveSettings = vi.fn(); +const mockTestSettings = vi.fn(); + +vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getNextcloudFilesStatus: (...args: unknown[]) => mockGetStatus(...args), + getNextcloudFilesSettings: (...args: unknown[]) => mockGetSettings(...args), + saveNextcloudFilesSettings: (...args: unknown[]) => mockSaveSettings(...args), + testNextcloudFilesSettings: (...args: unknown[]) => mockTestSettings(...args), + }; +}); + +let mockCanManage: boolean | null = true; +vi.mock('@/lib/use-module-capability', () => ({ + useCanManageModule: () => mockCanManage, +})); + +function status(over: Partial = {}): NextcloudFilesStatus { + return { + configured: true, + serverUrl: 'https://cloud.example', + host: 'cloud.example', + account: null, + ...over, + }; +} + +function settings(over: Partial = {}): NextcloudFilesSettings { + return { baseUrl: 'https://cloud.example', connectedAccounts: 0, ...over }; +} + +beforeEach(() => { + mockCanManage = true; + mockGetStatus.mockReset().mockResolvedValue(status()); + mockGetSettings.mockReset().mockResolvedValue(settings()); + mockSaveSettings.mockReset(); + mockTestSettings.mockReset(); +}); + +afterEach(() => cleanup()); + +describe('NextcloudFilesPage', () => { + it('nicht eingerichtet, Verwalter: Hinweis mit Knopf zu den Einstellungen', async () => { + mockGetStatus.mockResolvedValue(status({ configured: false, serverUrl: null, host: null })); + render(); + expect(await screen.findByText('Die Nextcloud ist noch nicht eingerichtet.')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Zu den Einstellungen' })); + expect(await screen.findByLabelText('Adresse')).toBeTruthy(); + }); + + it('nicht eingerichtet, Benutzen: Hinweis auf einen Administrator, kein Reiter, kein Einstellungsknopf', async () => { + mockCanManage = false; + mockGetStatus.mockResolvedValue(status({ configured: false, serverUrl: null, host: null })); + render(); + expect(await screen.findByText(/Bitte wenden Sie sich an einen Administrator/)).toBeTruthy(); + expect(screen.queryByRole('navigation')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Zu den Einstellungen' })).toBeNull(); + expect(mockGetSettings).not.toHaveBeenCalled(); + }); + + it('Verwalter sehen die Reiter Dateien und Einstellungen', async () => { + render(); + expect(await screen.findByRole('button', { name: 'Dateien' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Einstellungen' })).toBeTruthy(); + expect(await screen.findByTestId('nextcloud-files-main')).toBeTruthy(); + }); + + it('eingerichtet, Benutzen: Hauptbereich ohne Reiterleiste', async () => { + mockCanManage = false; + render(); + expect(await screen.findByTestId('nextcloud-files-main')).toBeTruthy(); + expect(screen.queryByRole('navigation')).toBeNull(); + }); +}); + +describe('SettingsTab', () => { + async function openSettings() { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' })); + return screen.findByLabelText('Adresse'); + } + + it('"Verbindung prüfen" ruft die Pruefung genau einmal je Klick auf, ist waehrenddessen gesperrt und zeigt die Meldung', async () => { + let resolve!: (v: unknown) => void; + mockTestSettings.mockReturnValue(new Promise((r) => (resolve = r))); + const input = await openSettings(); + fireEvent.change(input, { target: { value: ' https://cloud.example ' } }); + const button = screen.getByRole('button', { name: 'Verbindung prüfen' }); + fireEvent.click(button); + expect(mockTestSettings).toHaveBeenCalledTimes(1); + expect(mockTestSettings).toHaveBeenCalledWith('https://cloud.example'); + const running = await screen.findByRole('button', { name: 'Prüfe …' }); + expect((running as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(running); + expect(mockTestSettings).toHaveBeenCalledTimes(1); + resolve({ + ok: true, + kind: 'ok', + message: 'Verbindung erfolgreich: Nextcloud 34.0.4.', + version: '34.0.4', + productName: 'Nextcloud', + }); + expect(await screen.findByText('Verbindung erfolgreich: Nextcloud 34.0.4.')).toBeTruthy(); + }); + + it('eine fehlgeschlagene Pruefung zeigt den Text der API als Fehler', async () => { + mockTestSettings.mockResolvedValue({ + ok: false, + kind: 'timeout', + message: 'Nextcloud hat nicht rechtzeitig geantwortet.', + version: null, + productName: null, + }); + await openSettings(); + fireEvent.click(screen.getByRole('button', { name: 'Verbindung prüfen' })); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('nicht rechtzeitig'); + }); + + it('Aenderung der Adresse mit verbundenen Benutzern: erst Bestaetigung mit Anzahl, nur die bestaetigte Speicherung sendet confirmReconnect', async () => { + mockGetSettings.mockResolvedValue(settings({ connectedAccounts: 2 })); + mockSaveSettings.mockResolvedValue(settings({ baseUrl: 'https://neu.example' })); + const input = await openSettings(); + await screen.findByText('2 Benutzer sind verbunden.'); + fireEvent.change(input, { target: { value: 'https://neu.example' } }); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + expect(mockSaveSettings).not.toHaveBeenCalled(); + expect( + await screen.findByText( + '2 Benutzer sind verbunden. Nach dem Wechsel müssen sich alle neu anmelden.', + ), + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Adresse ändern' })); + await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1)); + expect(mockSaveSettings).toHaveBeenCalledWith({ + baseUrl: 'https://neu.example', + confirmReconnect: true, + }); + }); + + it('Abbrechen in der Bestaetigung speichert nichts', async () => { + mockGetSettings.mockResolvedValue(settings({ connectedAccounts: 1 })); + const input = await openSettings(); + await screen.findByText('Ein Benutzer ist verbunden.'); + fireEvent.change(input, { target: { value: 'https://neu.example' } }); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + fireEvent.click(await screen.findByRole('button', { name: 'Abbrechen' })); + expect(mockSaveSettings).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Speichern' })).toBeTruthy(); + }); + + it('ohne verbundene Benutzer wird sofort ohne confirmReconnect gespeichert', async () => { + mockSaveSettings.mockResolvedValue( + settings({ + baseUrl: 'https://neu.example', + check: { + ok: true, + kind: 'ok', + message: 'Verbindung erfolgreich.', + version: '34.0.4', + productName: 'Nextcloud', + }, + }), + ); + const input = await openSettings(); + fireEvent.change(input, { target: { value: 'https://neu.example' } }); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1)); + expect(mockSaveSettings).toHaveBeenCalledWith({ baseUrl: 'https://neu.example' }); + expect(await screen.findByText('Verbindung erfolgreich.')).toBeTruthy(); + }); + + it('die Hinweiskarte nennt den Befehl fuer die Ausnahmeliste', async () => { + await openSettings(); + expect(screen.getByText(/occ config:app:set bruteForce whitelist_0/)).toBeTruthy(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx new file mode 100644 index 0000000..586b984 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useState } from 'react'; +import { TabBar } from '@/components/accounting/tab-bar'; +import { SettingsSection } from '@/components/control-center/settings-section'; +import { PageHeader } from '@/components/layout/page-header'; +import { getNextcloudFilesStatus, type NextcloudFilesStatus } from '@/lib/nextcloud-files-api'; +import { useCanManageModule } from '@/lib/use-module-capability'; +import { SettingsTab } from './components/SettingsTab'; + +type TabId = 'files' | 'settings'; + +/** + * Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera. + * Die Adresse der Nextcloud stellen Administratoren und Benutzer mit der + * Freigabestufe Verwalten im Reiter "Einstellungen" ein; alle anderen sehen nur + * den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Der + * Abschnitt `nextcloud-files-main` ist die Stelle, an der das Verbinden des + * eigenen Kontos und der Dateibrowser eingehaengt werden. + */ +export default function NextcloudFilesPage() { + const t = useTranslations('nextcloudFiles'); + const canManage = useCanManageModule('nextcloud-files') === true; + + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(false); + const [tab, setTab] = useState('files'); + + const reloadStatus = useCallback(async () => { + try { + setStatus(await getNextcloudFilesStatus()); + setStatusError(false); + } catch { + setStatusError(true); + } + }, []); + + useEffect(() => { + void reloadStatus(); + }, [reloadStatus]); + + const tabs: { id: TabId; label: string }[] = [{ id: 'files', label: t('tabs.files') }]; + if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') }); + const activeTab: TabId = tabs.some((x) => x.id === tab) ? tab : 'files'; + + return ( +
+ + {statusError && ( +

+ {t('errors.loadStatus')} +

+ )} + {canManage && } + {activeTab === 'files' && status && !status.configured && ( + +

+ {canManage ? t('notConfigured.managerHint') : t('notConfigured.userHint')} +

+ {canManage && ( + + )} +
+ )} + {activeTab === 'files' && status?.configured && ( +
+

+ {t('main.server', { host: status.host ?? '' })} +

+
+ )} + {activeTab === 'settings' && canManage && ( + void reloadStatus()} /> + )} +
+ ); +} diff --git a/apps/web/src/components/modules/module-tile.tsx b/apps/web/src/components/modules/module-tile.tsx index 8f7fdb1..bcf486e 100644 --- a/apps/web/src/components/modules/module-tile.tsx +++ b/apps/web/src/components/modules/module-tile.tsx @@ -66,6 +66,9 @@ const GLYPHS: Record = { ), + folder: ( + + ), tile: ( <> diff --git a/apps/web/src/lib/module-identity.ts b/apps/web/src/lib/module-identity.ts index 5216428..b1e74fa 100644 --- a/apps/web/src/lib/module-identity.ts +++ b/apps/web/src/lib/module-identity.ts @@ -7,7 +7,7 @@ * (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`). */ -export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'earth' | 'tile'; +export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'earth' | 'folder' | 'tile'; const ICONS: Record = { 'tender-radar': 'radar', @@ -19,6 +19,7 @@ const ICONS: Record = { 'kantine-datev': 'utensils', 'handelsware-datev': 'shopping-bag', domains: 'earth', + 'nextcloud-files': 'folder', }; /** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */ diff --git a/apps/web/src/lib/module-loader.ts b/apps/web/src/lib/module-loader.ts index 357d9bc..c493c51 100644 --- a/apps/web/src/lib/module-loader.ts +++ b/apps/web/src/lib/module-loader.ts @@ -83,6 +83,12 @@ export const MODULE_REGISTRY: Record = { { ssr: false }, ), }, + 'nextcloud-files': { + component: dynamic( + () => import('@/app/(portal)/modules/nextcloud-files/page'), + { ssr: false }, + ), + }, }; /** diff --git a/apps/web/src/lib/nextcloud-files-api.ts b/apps/web/src/lib/nextcloud-files-api.ts new file mode 100644 index 0000000..fdbdabe --- /dev/null +++ b/apps/web/src/lib/nextcloud-files-api.ts @@ -0,0 +1,120 @@ +/** + * Nextcloud-Dateien — API-Client (quick-261008-mzu). Konsumiert + * `/modules/nextcloud-files/*`. `credentials: 'include'` fuer Cookie-Auth, + * `NEXT_PUBLIC_API_URL` als Basis (Muster `nextcloud-status-api.ts`). Der + * Browser spricht nie mit der Nextcloud selbst; Zugangsdaten kommen hier nie + * zurueck. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +const BASE = '/modules/nextcloud-files'; + +export interface NextcloudFilesAccount { + connected: boolean; + expired: boolean; + ncUserId: string | null; + displayName: string | null; + connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null; +} + +export interface NextcloudFilesStatus { + configured: boolean; + serverUrl: string | null; + host: string | null; + account: NextcloudFilesAccount | null; +} + +export interface NextcloudFilesCheck { + ok: boolean; + kind: string; + message: string; + version: string | null; + productName: string | null; +} + +export interface NextcloudFilesSettings { + baseUrl: string | null; + connectedAccounts: number; + check?: NextcloudFilesCheck; +} + +/** Fehler mit HTTP-Status, maschinenlesbarer Kennung, deutscher Servermeldung und Zusatzfeldern. */ +export class NextcloudFilesRequestError extends Error { + constructor( + readonly status: number, + readonly code: string | null, + message: string, + readonly extra: Record = {}, + ) { + super(message); + this.name = 'NextcloudFilesRequestError'; + } +} + +async function failure(res: Response): Promise { + let message = `Request failed (${res.status})`; + let code: string | null = null; + let extra: Record = {}; + try { + const body = await res.json(); + const raw = body?.message; + if (Array.isArray(raw)) message = raw.join(' '); + else if (typeof raw === 'string') message = raw; + if (typeof body?.code === 'string') code = body.code; + if (body && typeof body === 'object') { + const { + code: _code, + message: _message, + statusCode: _statusCode, + error: _error, + ...rest + } = body as Record; + extra = rest; + } + } catch { + // Antwort ohne JSON-Koerper — Standardmeldung bleibt. + } + return new NextcloudFilesRequestError(res.status, code, message, extra); +} + +async function request( + path: string, + init: { method?: string; json?: unknown } = {}, +): Promise { + const method = init.method ?? 'GET'; + const headers: Record = {}; + let body: string | undefined; + if (init.json !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(init.json); + } + const res = await fetch(`${API_URL}${BASE}${path}`, { + method, + credentials: 'include', + headers, + body, + ...(method === 'GET' ? { cache: 'no-store' as const } : {}), + }); + if (!res.ok) throw await failure(res); + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export function getNextcloudFilesStatus(): Promise { + return request('/status'); +} + +export function getNextcloudFilesSettings(): Promise { + return request('/settings'); +} + +export function saveNextcloudFilesSettings(input: { + baseUrl: string; + confirmReconnect?: boolean; +}): Promise { + return request('/settings', { method: 'PUT', json: input }); +} + +export function testNextcloudFilesSettings(baseUrl: string): Promise { + return request('/settings/test', { method: 'POST', json: { baseUrl } }); +} diff --git a/apps/web/src/lib/stores/nav-store.ts b/apps/web/src/lib/stores/nav-store.ts index b2deb65..4909691 100644 --- a/apps/web/src/lib/stores/nav-store.ts +++ b/apps/web/src/lib/stores/nav-store.ts @@ -33,6 +33,7 @@ const MODULE_TITLE_KEYS: Record = { 'kantine-datev': 'kantineDatev.title', 'handelsware-datev': 'handelswareDatev.title', domains: 'domains.title', + 'nextcloud-files': 'nextcloudFiles.title', }; /** diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index b82e13f..a9d08ea 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -2320,5 +2320,53 @@ "request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.", "loadStatus": "Der Zustand der AutoDNS-Anbindung konnte nicht geladen werden." } + }, + "nextcloudFiles": { + "title": "Dateien", + "description": "Ihre Dateien in der Nextcloud ansehen, hochladen, herunterladen und ordnen.", + "tabs": { + "files": "Dateien", + "settings": "Einstellungen" + }, + "notConfigured": { + "title": "Die Nextcloud ist noch nicht eingerichtet.", + "managerHint": "Tragen Sie in den Einstellungen die Adresse Ihrer Nextcloud ein.", + "userHint": "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten.", + "goToSettings": "Zu den Einstellungen" + }, + "main": { + "server": "Nextcloud: {host}" + }, + "errors": { + "request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.", + "loadStatus": "Der Stand der Nextcloud-Anbindung konnte nicht geladen werden." + }, + "settings": { + "save": "Speichern", + "saving": "Speichere …", + "saved": "Gespeichert.", + "address": { + "title": "Nextcloud-Adresse", + "description": "Tragen Sie die Adresse so ein, wie Ihre Benutzer die Nextcloud im Browser aufrufen, zum Beispiel https://cloud.ihre-firma.de. Interne Adressen sind erlaubt.", + "label": "Adresse", + "placeholder": "https://cloud.ihre-firma.de", + "test": "Verbindung prüfen", + "testing": "Prüfe …", + "connectedUsers": "{count, plural, =0 {Zurzeit ist niemand verbunden.} one {Ein Benutzer ist verbunden.} other {# Benutzer sind verbunden.}}" + }, + "reconnect": { + "message": "{count, plural, one {Ein Benutzer ist verbunden. Nach dem Wechsel muss er sich neu anmelden.} other {# Benutzer sind verbunden. Nach dem Wechsel müssen sich alle neu anmelden.}}", + "confirm": "Adresse ändern", + "cancel": "Abbrechen" + }, + "hints": { + "title": "Hinweise für die Nextcloud-Administration", + "description": "Damit die Anmeldung für alle Benutzer zuverlässig funktioniert.", + "whitelist": "Alle Tessera-Benutzer erreichen die Nextcloud von derselben Adresse, der des Tessera-Servers. Mehrere fehlgeschlagene Anmeldungen führen dazu, dass die Nextcloud diese Adresse für alle sperrt. Tragen Sie die Adresse des Tessera-Servers deshalb in die Ausnahmeliste der Brute-Force-Erkennung ein:", + "https": "Verwenden Sie nach Möglichkeit https. Zertifikate werden geprüft; eine eigene Zertifizierungsstelle muss auf dem Tessera-Server hinterlegt sein.", + "trustedDomains": "Der Rechnername der Adresse muss in den vertrauenswürdigen Domains (trusted_domains) der Nextcloud stehen.", + "twoFactor": "Benutzer mit Zwei-Faktor-Anmeldung verbinden sich über den Browser, die Anmeldung mit Passwort genügt für sie nicht." + } + } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 6d4e917..780168e 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -2320,5 +2320,53 @@ "request": "The request failed. Please try again.", "loadStatus": "The state of the AutoDNS connection could not be loaded." } + }, + "nextcloudFiles": { + "title": "Files", + "description": "View, upload, download and organise the files in your Nextcloud.", + "tabs": { + "files": "Files", + "settings": "Settings" + }, + "notConfigured": { + "title": "Nextcloud has not been set up yet.", + "managerHint": "Enter the address of your Nextcloud in the settings.", + "userHint": "Please contact an administrator or someone with the Manage permission level.", + "goToSettings": "Go to settings" + }, + "main": { + "server": "Nextcloud: {host}" + }, + "errors": { + "request": "The request failed. Please try again.", + "loadStatus": "The state of the Nextcloud connection could not be loaded." + }, + "settings": { + "save": "Save", + "saving": "Saving …", + "saved": "Saved.", + "address": { + "title": "Nextcloud address", + "description": "Enter the address the way your users open Nextcloud in the browser, for example https://cloud.your-company.com. Internal addresses are allowed.", + "label": "Address", + "placeholder": "https://cloud.your-company.com", + "test": "Check connection", + "testing": "Checking …", + "connectedUsers": "{count, plural, =0 {Nobody is connected right now.} one {One user is connected.} other {# users are connected.}}" + }, + "reconnect": { + "message": "{count, plural, one {One user is connected. After the change they have to sign in again.} other {# users are connected. After the change everyone has to sign in again.}}", + "confirm": "Change address", + "cancel": "Cancel" + }, + "hints": { + "title": "Notes for the Nextcloud administration", + "description": "So that signing in works reliably for everyone.", + "whitelist": "All Tessera users reach Nextcloud from the same address, that of the Tessera server. Several failed sign-ins make Nextcloud block this address for everyone. Add the address of the Tessera server to the allow list of the brute-force protection:", + "https": "Use https where possible. Certificates are verified; a private certificate authority has to be installed on the Tessera server.", + "trustedDomains": "The host name of the address has to be listed in the trusted domains (trusted_domains) of Nextcloud.", + "twoFactor": "Users with two-factor authentication connect through the browser; signing in with a password is not enough for them." + } + } } } diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index fb1a9ca..d4cd0c3 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -234,4 +234,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ 'Zusammenfassung', 'passender', 'dass', + // quick-261008-mzu: Modul Dateien (Nextcloud) — korrektes Deutsch mit „ss“ + 'zuverlässig', ]; diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index b108233..6206f96 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -182,8 +182,9 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | kantine-datev | 0 | 2 | 0 | **quick-261002-fm5:** neu, zwei gebundene Rohtreffer in `kantine-datev.service.ts` (`getSettings` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. | | handelsware-datev | 0 | 8 | 0 | **quick-261002-fm5:** neu, acht gebundene Rohtreffer über `tenantPrisma` in `handelsware-datev.service.ts` (`handelswareDatevConfig` 2, `handelswareKonto` 6), nachgemessen mit der Gate-Schleife: 0/8/0. Dazu fünf Zugriffe über den Transaktionsparameter `tx` von `withTenantTransaction` (`handelswareDatevConfig` 1, `handelswareKonto` 4), die diese einfache Rohtrefferzählung strukturell nicht sieht (siehe Hinweis zu `groups` oben) — die Bestandsaufnahme unten führt sie. Kein ungebundener Zugriff, kein Systemkontext. | | domains | 0 | 30 | 0 | **quick-261008-dts (Aufgabe 3):** +15 gebunden, nachgemessen mit der Gate-Schleife: 0/30/0. NEU `domains-orders.service.ts` mit 15 gebundenen Rohtreffern auf `domainsOrder` über `tenantPrisma`, je Methode ein eigener Klient (`createOrder` 3: `findFirst`, `updateMany` für den Entwurf, `create`; `submitOrder` 5: `findFirst` vor dem Anspruch, `updateMany` als atomarer Anspruch DRAFT→SUBMITTING mit Zählerprüfung, `findFirst` nach verlorenem Anspruch, `updateMany` fürs Ergebnis, `findFirst` zum Zurückgeben; `refreshOpenOrders` 3: `findMany` für hängende Ansprüche, `updateMany` auf UNKNOWN, `findMany` für offene Aufträge; `writeIf` 1: `updateMany` an den erwarteten Zustand gebunden; `cancelOrder` 2: `findFirst`, `updateMany`; `listOrders` 1: `findMany`). Jeder `where` trägt `tenantId`. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. Vorher: **quick-261008-dts (Aufgabe 2):** +13 gebunden, nachgemessen mit der Gate-Schleife: 0/15/0. NEU `domains-directory.service.ts` mit 13 gebundenen Rohtreffern über `tenantPrisma`, je Methode ein eigener Klient (`domainsCustomer` 8: `findMany` in `loadCustomers`, `findFirst` in `requireCustomer` und `createCustomer`, `create`, `updateMany` in `createCustomer` und `updateCustomer` (Markierung „eigene Firma“ lösen), `updateMany` fürs Ändern in `updateCustomer`, `deleteMany` in `deleteCustomer`; `domainsContactAssignment` 5: `findMany` in `loadAssignments`, `count` in `deleteCustomer`, zweimal `upsert` in `createContact`/`assignContacts`, `deleteMany` in `assignContacts`). Jeder `where` trägt `tenantId`, bei Zuordnungen zusätzlich das aktive System. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. Vorher: **quick-261008-dts (Aufgabe 1):** neu, zwei gebundene Rohtreffer über `tenantPrisma` in `domains-settings.service.ts` (`domainsConfig`: `loadRow` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. Die weiteren Dienste des Moduls (Kunden und Kontakte, Bestellungen) tragen ihre Zeilen, sobald sie gebaut sind. | +| nextcloud-files | 0 | 4 | 0 | **quick-261008-mzu (Aufgabe 1):** neu, vier gebundene Rohtreffer über je einen eigenen Klienten pro Methode in `nextcloud-files-settings.service.ts`, nachgemessen mit der Gate-Schleife: 0/4/0. `nextcloudFilesConfig` 2 (`loadConfig` `findUnique`, `saveSettings` `upsert`), `nextcloudFilesAccount` 2 (`countActiveAccounts` `count`, `saveSettings` `updateMany` auf ABGELAUFEN nach einem Adresswechsel). Nur mandantengebundene Verwaltungsvorgänge der Organisation; das persönliche Konto je Benutzer (Lesen, Verbinden, Trennen) kommt mit Aufgabe 2 und wird dann mit Mandant UND Benutzer gebunden. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. | | nextcloud-status | 0 | 23 | 1 | **quick-261002-kxc (Aufgabe 3, Endstand):** 21→23 gebunden — `listRecentAlerts` in `nextcloud-alert.service.ts` liest die Abonnements des Aufrufers (`nextcloudAlertSubscription.findMany`, Klient mit Mandant UND Benutzer) und dazu die letzten Übergänge dieser Clouds (`nextcloudInstance.findMany`, nur skalare Felder, `where: { tenantId, id: { in } }`); System unverändert 1. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/23/1. Vorher: **quick-261002-kxc (Aufgabe 1):** 14→21 gebunden, System unverändert 1 — NEU `nextcloud-alert.service.ts` mit sieben gebundenen Rohtreffern (`subscribe`: `nextcloudInstance.findFirst`, `nextcloudAlertSubscription.upsert`; `unsubscribe`: `deleteMany`; `subscribedInstanceIds`: `findMany`; `evaluateAfterCheck`: `nextcloudInstance.updateMany`; `notifySubscribers`: `nextcloudAlertSubscription.findMany`, `user.findMany`), je Methode ein Klient; `nextcloud-status.service.ts` unverändert bei 14. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/21/1. Vorher: **quick-261002-k67 (Aufgabe 2, Endstand):** 4→14 gebunden, 0→1 System — Schreibwege und Planer: `updateInstance` (`findFirst`, `update`), `deleteInstance` (`findFirst`, `delete`), `uploadLogo` (`findFirst`, `update`), `getLogo` (`findFirst`), `removeLogo` (`findFirst`, `update`), `listInstanceIdsForTenant` (`findMany`) bringen zehn weitere gebundene Rohtreffer, je Methode ein Klient. Der eine System-Rohtreffer ist `loadAllInstancesForScheduler()` (`systemPrisma.nextcloudInstance.findMany`, nur `select: { id, tenantId }`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Nachgemessen mit der Gate-Schleife: 0/14/1. Vorher: **quick-261002-k67 (Aufgabe 1):** neu, vier gebundene Rohtreffer in `nextcloud-status.service.ts` (`listForTenant` `findMany`, `createInstance` `create`, `checkInstance` `findFirst` und `update`). | -| **Summe** | **65** | **325** | **8** | **quick-261008-dts (Aufgabe 3):** Gebunden +15 = `domains` (`domains-orders.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/325/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 2):** Gebunden +13 = `domains` (`domains-directory.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/310/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 1):** Gebunden +2 = `domains` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 65/297/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261003-387:** Ungebunden +4 und Gebunden +22 = `module-categories` (neu, siehe dortige Zeile), System unverändert: 65/295/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor, die Differenz zur vorigen Messung ist genau +4/+22. Vorher: **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| **Summe** | **65** | **329** | **8** | **quick-261008-mzu (Aufgabe 1):** Gebunden +4 = `nextcloud-files` (`nextcloud-files-settings.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/329/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 3):** Gebunden +15 = `domains` (`domains-orders.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/325/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 2):** Gebunden +13 = `domains` (`domains-directory.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/310/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 1):** Gebunden +2 = `domains` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 65/297/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261003-387:** Ungebunden +4 und Gebunden +22 = `module-categories` (neu, siehe dortige Zeile), System unverändert: 65/295/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor, die Differenz zur vorigen Messung ist genau +4/+22. Vorher: **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare) @@ -430,6 +431,11 @@ quick-261008-dts (Aufgabe 3): +1 `muss-mandantengebunden` (`domains-orders.servi 104 Paare, davon 63 `muss-mandantengebunden`, 23 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. +quick-261008-mzu (Aufgabe 1): +2 `muss-mandantengebunden` (`nextcloud-files-settings.service.ts`/`nextcloudFilesConfig` +und `/nextcloudFilesAccount`, beide `gebunden`): +106 Paare, davon 65 `muss-mandantengebunden`, 23 `keine-mandantengebundene-tabelle`, 16 `beides`, +2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. + quick-261002-kxc (Aufgabe 3): Paarzahl unverändert (96); `listRecentAlerts` nutzt dieselben Paare `nextcloud-alert.service.ts`/`nextcloudAlertSubscription` und `/nextcloudInstance`. @@ -899,6 +905,8 @@ werden. | apps/api/src/domains/domains-directory.service.ts | domainsContactAssignment | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 2):** neu — Zuordnung AutoDNS-Kontakt → Kunde. Das System (Demo/Live) ist Teil des Schlüssels (`@@unique([tenantId, environment, autodnsContactId])`), weil Kontakt-Nummern beider Systeme zwangsläufig kollidieren; jede Abfrage filtert auf das AKTIVE System. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000). Bewusst KEINE `system_read_policy`. Fünf Rohtreffer über `tenantPrisma` (`findMany`, `count`, zweimal `upsert`, `deleteMany`); der Fremdschlüssel auf den Kunden steht auf `Restrict`, ein Kunde mit Zuordnungen wird nicht gelöscht. Kein `include`/relationales `select`. | | apps/api/src/domains/domains-orders.service.ts | domainsOrder | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 3):** neu — Registrierungsaufträge des Moduls Domains (Entwurf, Anspruch, Ergebnis, Prüfspur mit Benutzer und Zeitpunkt der Bestätigung). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Organisationsdaten. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Stand wird beim Öffnen des Reiters abgeglichen. Fünfzehn Rohtreffer über `tenantPrisma`, je Methode ein eigener Klient; jeder `where` trägt `tenantId`, fremde Nummern enden als 404. Der Anspruch ist ein `updateMany` mit `status: 'DRAFT'` und dem aktiven System in der Bedingung; erst bei Zähler 1 geht `POST /domain` hinaus (Geld, höchstens einmal). Die Eindeutigkeit offener Aufträge je Domain und System sichert die Spalte `openKey` (`@@unique([tenantId, environment, openKey])`). Kein `include`/relationales `select`. | | apps/api/src/domains/domains-settings.service.ts | domainsConfig | muss-mandantengebunden | gebunden | **quick-261008-dts:** neu — Einstellungen des Moduls Domains (AutoDNS), eine Zeile je Organisation (Singleton): aktives System (Demo/Live), getrennte Zugänge je System mit AES-verschlüsseltem Passwort (CryptoService, nie an den Client zurückgegeben). Spalte für Standard-Nameserver mit quick-261008-h3t entfernt (Migration 20261008160000). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Einstellungen der Organisation, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Auftragsstatus wird beim Öffnen der Seite abgefragt. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`loadRow` `findUnique`, `saveSettings` `upsert`); `where` trägt `tenantId`. | +| apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts | nextcloudFilesAccount | muss-mandantengebunden | gebunden | **quick-261008-mzu (Aufgabe 1):** neu — das persönliche Nextcloud-Konto je Benutzer (verschlüsseltes App-Passwort). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` über Mandant UND Benutzer (Form aus Migration 20260929140000, `current_user_id() IS NULL OR "userId" = current_user_id()`, Migration 20261008180000). Bewusst KEINE `system_read_policy`: kein Hintergrunddienst liest über alle Mandanten. In DIESER Datei nur die beiden Verwaltungsvorgänge der Organisation über `const tenantPrisma`/`const expirer = forTenant(this.prisma, tenantId)` ohne Benutzer: `count` der aktiven Konten (für die Warnung vor einem Adresswechsel) und `updateMany` auf ABGELAUFEN für alle Konten der Organisation nach dem Adresswechsel; beide `where` tragen `tenantId`. Lesen und Schreiben des eigenen Kontos (mit Benutzer-Id aus dem Token) liegen in einer eigenen Datei und werden mit Aufgabe 2 eingetragen. Kein `include`/relationales `select`. | +| apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts | nextcloudFilesConfig | muss-mandantengebunden | gebunden | **quick-261008-mzu (Aufgabe 1):** neu — die EINE Nextcloud-Adresse der Organisation (Singleton, `tenantId` eindeutig). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008180000) — Einstellung der Organisation, keine persönlichen Daten. Bewusst KEINE `system_read_policy`. Zwei Rohtreffer über je einen eigenen Klienten (`loadConfig` `findUnique`, `saveSettings` `upsert`); `where` trägt `tenantId`. Kein `include`/relationales `select`. | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | nextcloudAlertSubscription | muss-mandantengebunden | gebunden | **quick-261002-kxc:** neu — die persönliche Glocke „Benachrichtigen“ je Benutzer und Cloud. `tenantId`- und `userId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension (Migration 20261002170000, Form aus `Reminder`), bewusst OHNE `system_read_policy` — die Tabelle wird nie im Systemkontext gelesen. Jeder Zugriff läuft an den Mandanten gebunden: `subscribe`/`unsubscribe`/`subscribedInstanceIds` über `forTenant(this.prisma, tenantId, userId)` (Benutzer aus dem Token, nie aus dem Body), der Versand (`notifySubscribers`) über `forTenant(this.prisma, tenantId)` mit `where: { tenantId, instanceId }`; `listRecentAlerts` (Meldung in Tessera) liest nur die Abonnements des Aufrufers (`where: { tenantId, userId }`, Benutzer aus dem Token). | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | nextcloudInstance | muss-mandantengebunden | gebunden | **quick-261002-kxc:** `subscribe` prüft per `findFirst` mit `where: { id, tenantId }`, dass die Cloud dem Mandanten gehört (sonst 404); `evaluateAfterCheck` beansprucht den Übergang per `updateMany` mit `where: { id, tenantId, alertState }` VOR dem Mailversand (nur `count === 1` meldet, mehrere API-Instanzen und Neustarts melden nie doppelt). Beides an den Mandanten gebunden, kein Systemkontext. | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | user | muss-mandantengebunden | gebunden | **quick-261002-kxc:** `notifySubscribers` liest die Empfänger der Abonnements (`user.findMany` mit `where: { tenantId, id: { in } }`, nur skalare Felder E-Mail, Rolle, `isActive`), gebunden an den Mandanten der Cloud. Konto-Aktivität und Modulzugriff werden beim Senden erneut geprüft (nicht erst beim Einschalten der Glocke). |