feat(nextcloud-files): Modul Dateien mit Nextcloud-Adresse, Verbindungsprüfung und gesicherter Verbindungsschicht

- Migration 20261008180000: NextcloudFilesConfig (Organisation) und NextcloudFilesAccount
  (Mandant UND Benutzer per Zeilenschutz, nur verschlüsseltes App-Passwort)
- Transportschicht ncRequest: feste Pfadanfänge, Segmentcodierung, keine Weiterleitungen,
  keine Cookies, Zeitgrenzen; Aufrufsperre pausiert den Ursprung nach jedem 429 und
  sperrt Zugangsschlüssel nach dem ersten 401
- Einstellungen: Adresse speichern/prüfen, Adresswechsel läuft alle Konten ab (mit Bestätigung)
- Controller mit Verwalten nur auf Einstellungen, Seed, Modul, Registrierung in Web
  (Ladefunktion, Symbol folder, Navigation, Layout) und Reiter Einstellungen
- RLS-Inventar fortgeschrieben, Testaufbau (tessera-nc-test) und E2E-Skripte

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:41:46 +02:00
parent 83b842b72c
commit 960696745f
35 changed files with 3282 additions and 2 deletions
@@ -0,0 +1,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 <jar> [benutzer] [passwort] — meldet an, Cookies landen im <jar>.
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 <jar> <methode> <url> [json-body] [ausgabedatei] — gibt den HTTP-Status aus,
# der Antwortkoerper landet in <ausgabedatei> (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 <erwartet> <ist> <beschreibung>
e2e_expect() { [ "$1" = "$2" ] || e2e_fail "$3: erwartet $1, war $2"; }
# e2e_contains <datei> <text> <beschreibung>
e2e_contains() { grep -q -- "$2" "$1" || { echo "--- Antwort:" >&2; cat "$1" >&2; echo >&2; e2e_fail "$3: '$2' fehlt"; }; }
# e2e_activate <jar> [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 <jar> [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 <name> <jar-ausgabe> — legt (als admin) einen Benutzer mit Rolle USER an,
# setzt sein Passwort auf "<name>-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"
}
@@ -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"
@@ -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"
@@ -0,0 +1,14 @@
<?php
// Testhilfe (quick-261008-mzu): TOTP fuer ein Nextcloud-Konto ohne Browser einrichten.
// php totp.php <benutzer> secret -> gibt ein neues Geheimnis aus
// php totp.php <benutzer> <code> -> 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]));
}
@@ -0,0 +1,15 @@
"""Testhilfe (quick-261008-mzu): aktueller TOTP-Code zu einem Base32-Geheimnis.
Aufruf: python3 -I totp.py <geheimnis>
"""
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))
@@ -0,0 +1,105 @@
-- 261008-mzu — Modul "Nextcloud-Dateien" (Etappe 1).
--
-- Zweck: zwei Tabellen und zwei Aufzaehlungen.
-- * "NextcloudFilesConfig": Einstellungen der Organisation (Singleton), genau
-- eine Nextcloud-Adresse (`baseUrl`, Normalform von normalizeCloudUrl).
-- * "NextcloudFilesAccount": das persoenliche Konto eines Benutzers in dieser
-- Nextcloud. Gespeichert wird NUR das App-Passwort, AES-256-GCM-verschluesselt
-- ueber den CryptoService (`encryptedAppPassword`); das echte Passwort des
-- Benutzers wird nie abgelegt. `ncUserId` ist die Kennung in der Nextcloud
-- (nicht der eingegebene Anmeldename), `connectedVia` merkt sich den Weg
-- (Passwort oder Login Flow v2 fuer Zwei-Faktor-Konten).
--
-- Warum das Konto die Adresse mitfuehrt ("baseUrl"): ein App-Passwort gilt nur
-- fuer die Nextcloud, die es ausgestellt hat. Es wird nie an einen anderen Host
-- geschickt; wechselt die Adresse der Organisation, laufen alle Konten ab
-- (status = EXPIRED, oder Konto-baseUrl weicht von der Einstellung ab) und die
-- Benutzer verbinden neu.
--
-- Der DDL-Teil stammt aus `prisma migrate diff`, damit der Stand ohne Abweichung
-- zum Schema passt; die Zeilenschutz-Regeln sind von Hand ergaenzt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl):
-- "NextcloudFilesConfig" — tenant_isolation_policy nur ueber den Mandanten
-- (Verwaltungsdaten der Organisation, wie "DomainsConfig").
-- "NextcloudFilesAccount" — tenant_isolation_policy ueber Mandant UND Benutzer
-- (Form aus 20260929140000_reminder): ohne gesetzten Benutzer gilt nur der
-- Mandant, mit Benutzer zusaetzlich "userId". Genau das haelt einen Benutzer
-- davon ab, je das Konto eines anderen zu lesen. KEINE system_read_policy:
-- es gibt keinen Hintergrunddienst, der ueber alle Mandanten liest.
--
-- Faellt ein Benutzer weg, faellt sein Konto mit (ON DELETE CASCADE). Das
-- App-Passwort bleibt dann in der Nextcloud bestehen (Geraeteliste dort) — das
-- ist dokumentiert und fuer Etappe 1 bewusst nicht aufgeraeumt.
--
-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
--
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
-- Anwendungspruefungen im Dienst den Schutz allein.
-- CreateEnum
CREATE TYPE "NextcloudFilesAccountStatus" AS ENUM ('ACTIVE', 'EXPIRED');
-- CreateEnum
CREATE TYPE "NextcloudFilesConnectMethod" AS ENUM ('PASSWORD', 'LOGIN_FLOW');
-- CreateTable
CREATE TABLE "NextcloudFilesConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NextcloudFilesConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NextcloudFilesAccount" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"ncUserId" TEXT NOT NULL,
"ncDisplayName" TEXT,
"encryptedAppPassword" TEXT NOT NULL,
"status" "NextcloudFilesAccountStatus" NOT NULL DEFAULT 'ACTIVE',
"connectedVia" "NextcloudFilesConnectMethod" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NextcloudFilesAccount_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "NextcloudFilesConfig_tenantId_key" ON "NextcloudFilesConfig"("tenantId");
-- CreateIndex
CREATE INDEX "NextcloudFilesConfig_tenantId_idx" ON "NextcloudFilesConfig"("tenantId");
-- CreateIndex
CREATE INDEX "NextcloudFilesAccount_tenantId_idx" ON "NextcloudFilesAccount"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "NextcloudFilesAccount_tenantId_userId_key" ON "NextcloudFilesAccount"("tenantId", "userId");
-- AddForeignKey
ALTER TABLE "NextcloudFilesAccount" ADD CONSTRAINT "NextcloudFilesAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz: nur Mandant (Organisations-Einstellung)
ALTER TABLE "NextcloudFilesConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "NextcloudFilesConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "NextcloudFilesConfig"
USING ("tenantId" = current_tenant_id());
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260929140000_reminder)
ALTER TABLE "NextcloudFilesAccount" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "NextcloudFilesAccount" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "NextcloudFilesAccount"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
+46
View File
@@ -58,6 +58,7 @@ model User {
moduleGrants ModuleGrant[] moduleGrants ModuleGrant[]
customModules CustomModule[] customModules CustomModule[]
reminders Reminder[] reminders Reminder[]
nextcloudFilesAccounts NextcloudFilesAccount[]
nextcloudAlertSubscriptions NextcloudAlertSubscription[] nextcloudAlertSubscriptions NextcloudAlertSubscription[]
@@index([tenantId]) @@index([tenantId])
@@ -942,6 +943,51 @@ model DomainsOrder {
@@index([tenantId]) @@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- // Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle // Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId, // Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
+2
View File
@@ -29,6 +29,7 @@ import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module'; import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module';
import { KantineDatevModule } from './kantine-datev/kantine-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 { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
import { ProxmoxModule } from './proxmox/proxmox.module'; import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module'; import { CustomModulesModule } from './custom-modules/custom-modules.module';
@@ -62,6 +63,7 @@ import { RemindersModule } from './reminders/reminders.module';
ProxmoxModule, ProxmoxModule,
NextcloudStatusModule, NextcloudStatusModule,
DomainsModule, DomainsModule,
NextcloudFilesModule,
KantineDatevModule, KantineDatevModule,
HandelswareDatevModule, HandelswareDatevModule,
CustomModulesModule, CustomModulesModule,
@@ -8,6 +8,7 @@ import { DomainsController } from '../domains/domains.controller';
import { ModuleGrantsController } from '../groups/module-grants.controller'; import { ModuleGrantsController } from '../groups/module-grants.controller';
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller'; import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
import { KantineDatevController } from '../kantine-datev/kantine-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 { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller';
import { ProxmoxController } from '../proxmox/proxmox.controller'; import { ProxmoxController } from '../proxmox/proxmox.controller';
import { TendersController } from '../tenders/tenders.controller'; import { TendersController } from '../tenders/tenders.controller';
@@ -116,6 +117,23 @@ describe('Umgestellte Handler (Verwalten)', () => {
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); 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', () => { it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev'); expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev'); expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');
@@ -0,0 +1,25 @@
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Nextcloud-Adresse speichern (quick-261008-mzu). `confirmReconnect` bestaetigt
* ausdruecklich, dass beim Wechsel der Adresse alle verbundenen Benutzer
* abgemeldet werden (ihre App-Passwoerter gelten nur fuer die alte Nextcloud).
*/
export class SaveNextcloudFilesSettingsDto {
@IsString()
@IsNotEmpty()
@MaxLength(2048)
baseUrl!: string;
@IsOptional()
@IsBoolean()
confirmReconnect?: boolean;
}
/** Verbindungspruefung einer (noch nicht gespeicherten) Adresse. */
export class TestNextcloudFilesSettingsDto {
@IsString()
@IsNotEmpty()
@MaxLength(2048)
baseUrl!: string;
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import {
DEAD_KEY_TTL_MS,
DEFAULT_PAUSE_SECONDS,
MAX_DEAD_KEYS,
MAX_PAUSE_SECONDS,
NextcloudCallGate,
} from './nextcloud-call-gate';
function makeGate() {
const gate = new NextcloudCallGate();
const clock = { t: 1_000_000 };
gate.now = () => clock.t;
return { gate, clock };
}
describe('NextcloudCallGate — Sperre je Ursprung', () => {
it('nach pause(): 900 Sekunden, nur fuer diesen Ursprung', () => {
const { gate } = makeGate();
expect(gate.isPaused('https://cloud.example')).toEqual({ paused: false, retryAfterSeconds: 0 });
gate.pause('https://cloud.example');
expect(gate.isPaused('https://cloud.example')).toEqual({
paused: true,
retryAfterSeconds: DEFAULT_PAUSE_SECONDS,
});
expect(DEFAULT_PAUSE_SECONDS).toBe(900);
expect(gate.isPaused('https://other.example').paused).toBe(false);
});
it('Retry-After 120 -> 120 Sekunden; 99999 -> auf 3600 gedeckelt; Unsinn -> 900', () => {
const { gate } = makeGate();
gate.pause('https://a.example', '120');
expect(gate.isPaused('https://a.example').retryAfterSeconds).toBe(120);
gate.pause('https://b.example', '99999');
expect(gate.isPaused('https://b.example').retryAfterSeconds).toBe(3600);
expect(MAX_PAUSE_SECONDS).toBe(3600);
gate.pause('https://c.example', 'Wed, 21 Oct 2026 07:28:00 GMT');
expect(gate.isPaused('https://c.example').retryAfterSeconds).toBe(900);
gate.pause('https://d.example', '0');
expect(gate.isPaused('https://d.example').retryAfterSeconds).toBe(900);
});
it('nach Ablauf geht es wieder, die Restzeit sinkt mit der Uhr', () => {
const { gate, clock } = makeGate();
gate.pause('https://cloud.example', '120');
clock.t += 60_000;
expect(gate.isPaused('https://cloud.example')).toEqual({ paused: true, retryAfterSeconds: 60 });
clock.t += 60_000;
expect(gate.isPaused('https://cloud.example').paused).toBe(false);
});
it('eine kuerzere zweite Sperre verkuerzt eine laengere nicht', () => {
const { gate } = makeGate();
gate.pause('https://cloud.example', '600');
gate.pause('https://cloud.example', '60');
expect(gate.isPaused('https://cloud.example').retryAfterSeconds).toBe(600);
});
});
describe('NextcloudCallGate — Sperre je Zugangsschluessel', () => {
it('markDead(): Schluessel tot, andere Schluessel nicht', () => {
const { gate } = makeGate();
expect(gate.isDead('k1')).toBe(false);
gate.markDead('k1');
expect(gate.isDead('k1')).toBe(true);
expect(gate.isDead('k2')).toBe(false);
});
it('markDead() bricht das Signal des Schluessels ab, nicht das eines anderen', () => {
const { gate } = makeGate();
const s1 = gate.signalFor('k1');
const s2 = gate.signalFor('k2');
expect(s1.aborted).toBe(false);
gate.markDead('k1');
expect(s1.aborted).toBe(true);
expect(s2.aborted).toBe(false);
expect(gate.signalFor('k1').aborted).toBe(true);
});
it('tote Schluessel laufen nach 24 Stunden ab', () => {
const { gate, clock } = makeGate();
gate.markDead('k1');
clock.t += DEAD_KEY_TTL_MS - 1;
expect(gate.isDead('k1')).toBe(true);
clock.t += 2;
expect(gate.isDead('k1')).toBe(false);
});
it('haelt nie mehr als 10000 tote Schluessel, die aeltesten fliegen zuerst raus', () => {
const { gate } = makeGate();
for (let i = 0; i < MAX_DEAD_KEYS + 5; i++) gate.markDead(`key-${i}`);
expect(gate.isDead('key-0')).toBe(false);
expect(gate.isDead('key-4')).toBe(false);
expect(gate.isDead('key-5')).toBe(true);
expect(gate.isDead(`key-${MAX_DEAD_KEYS + 4}`)).toBe(true);
// intern gezaehlt: genau MAX_DEAD_KEYS Schluessel sind noch tot
let dead = 0;
for (let i = 0; i < MAX_DEAD_KEYS + 5; i++) if (gate.isDead(`key-${i}`)) dead++;
expect(dead).toBe(MAX_DEAD_KEYS);
});
});
@@ -0,0 +1,122 @@
import { Injectable } from '@nestjs/common';
/** Sperrzeit, wenn Nextcloud 429 ohne `Retry-After` liefert (Standard der Brute-Force-Sperre). */
export const DEFAULT_PAUSE_SECONDS = 15 * 60;
/** Obergrenze fuer eine Sperrzeit, auch wenn Nextcloud mehr verlangt. */
export const MAX_PAUSE_SECONDS = 60 * 60;
/** So lange bleibt ein als widerrufen erkannter Zugangsschluessel gemerkt. */
export const DEAD_KEY_TTL_MS = 24 * 60 * 60 * 1000;
/** Hoechstzahl gemerkter Zugangsschluessel (aelteste fliegen zuerst raus). */
export const MAX_DEAD_KEYS = 10_000;
/**
* Aufrufsperre (quick-261008-mzu, D-O) — ein prozessweites Objekt, das jeder
* Nextcloud-Aufruf in `ncRequest` befragt, damit kein Aufrufer sie umgehen kann.
*
* (a) Sperre je Nextcloud-Ursprung: Die Brute-Force-Sperre der Nextcloud gilt
* je IP-Adresse, und alle Tessera-Benutzer kommen von EINER Server-Adresse.
* Jede weitere Anfrage waehrend der Sperre verlaengert sie oder ist
* verschwendet. Deshalb wird nach JEDEM 429 (egal bei welchem Aufruf) der
* ganze Ursprung angehalten; das Tor wiederholt nichts, es haelt den
* Verkehr an.
*
* (b) Sperre je Zugangsschluessel: Ein widerrufenes App-Passwort darf Nextcloud
* nicht bombardieren — dort zaehlt jedes 401 als Fehlanmeldung. Nach dem
* ersten 401 zu einem gespeicherten App-Passwort gilt der Schluessel als
* tot: spaetere Aufrufe gehen gar nicht erst raus, und laufende Aufrufe
* desselben Schluessels werden abgebrochen.
*
* Der Zustand liegt im Arbeitsspeicher; ein Neustart der API hebt ihn auf (die
* Nextcloud-Sperre selbst bleibt dort bestehen und wird beim naechsten 429
* wieder erkannt).
*/
@Injectable()
export class NextcloudCallGate {
/** Zeitquelle in Millisekunden; Tests ersetzen sie. */
now: () => number = () => Date.now();
private readonly pausedUntil = new Map<string, number>();
private readonly dead = new Map<string, number>();
private readonly controllers = new Map<string, AbortController>();
// --- (a) Sperre je Ursprung --------------------------------------------------
isPaused(origin: string): { paused: boolean; retryAfterSeconds: number } {
const until = this.pausedUntil.get(origin);
if (until === undefined) return { paused: false, retryAfterSeconds: 0 };
const remainingMs = until - this.now();
if (remainingMs <= 0) {
this.pausedUntil.delete(origin);
return { paused: false, retryAfterSeconds: 0 };
}
return { paused: true, retryAfterSeconds: Math.ceil(remainingMs / 1000) };
}
/** Haelt den Ursprung an: `Retry-After` (Sekunden) oder 15 Minuten, hoechstens 60. */
pause(origin: string, retryAfterHeader?: string | null): number {
let seconds = DEFAULT_PAUSE_SECONDS;
if (retryAfterHeader !== undefined && retryAfterHeader !== null) {
const trimmed = String(retryAfterHeader).trim();
if (/^\d{1,9}$/.test(trimmed)) {
const parsed = Number.parseInt(trimmed, 10);
if (parsed > 0) seconds = parsed;
}
}
seconds = Math.min(seconds, MAX_PAUSE_SECONDS);
const until = this.now() + seconds * 1000;
const existing = this.pausedUntil.get(origin);
this.pausedUntil.set(origin, existing !== undefined && existing > until ? existing : until);
return seconds;
}
// --- (b) Sperre je Zugangsschluessel ----------------------------------------
isDead(key: string): boolean {
const expires = this.dead.get(key);
if (expires === undefined) return false;
if (expires <= this.now()) {
this.dead.delete(key);
return false;
}
return true;
}
/** Merkt den Schluessel als tot und bricht alle laufenden Aufrufe damit ab. */
markDead(key: string): void {
this.dead.delete(key); // neu einsortieren: Map behaelt die Einfuegereihenfolge
this.dead.set(key, this.now() + DEAD_KEY_TTL_MS);
while (this.dead.size > MAX_DEAD_KEYS) {
const oldest = this.dead.keys().next().value;
if (oldest === undefined) break;
this.dead.delete(oldest);
}
const controller = this.controllers.get(key);
if (controller) {
controller.abort(new Error('credential-dead'));
this.controllers.delete(key);
}
}
/**
* Signal, das abbricht, sobald der Schluessel stirbt. Fuer einen schon toten
* Schluessel kommt ein bereits abgebrochenes Signal zurueck.
*/
signalFor(key: string): AbortSignal {
if (this.isDead(key)) {
const done = new AbortController();
done.abort(new Error('credential-dead'));
return done.signal;
}
let controller = this.controllers.get(key);
if (!controller) {
controller = new AbortController();
this.controllers.set(key, controller);
while (this.controllers.size > MAX_DEAD_KEYS) {
const oldest = this.controllers.keys().next().value;
if (oldest === undefined) break;
this.controllers.delete(oldest);
}
}
return controller.signal;
}
}
@@ -0,0 +1,255 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
}));
import type { NextcloudCheckResult } from '../nextcloud-status/nextcloud-status-fetch';
import { NextcloudCallGate } from './nextcloud-call-gate';
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
interface Row {
baseUrl: string;
}
function reachable(over: Partial<NextcloudCheckResult> = {}): NextcloudCheckResult {
return {
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '34.0.4',
edition: '',
productName: 'Nextcloud',
errorKind: null,
errorDetail: null,
...over,
};
}
function unreachable(
errorKind: NextcloudCheckResult['errorKind'],
errorDetail: string | null = null,
): NextcloudCheckResult {
return {
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind,
errorDetail,
};
}
function makeService(opts: {
row?: Row | null;
activeAccounts?: number;
check?: NextcloudCheckResult;
}) {
const state = { row: opts.row ?? null };
const upsert = vi.fn(async ({ create, update }: any) => {
state.row = state.row ? { ...state.row, ...update } : { ...create };
return state.row;
});
const updateMany = vi.fn(async (..._a: unknown[]) => ({ count: 0 }));
const count = vi.fn(async (..._a: unknown[]) => opts.activeAccounts ?? 0);
const tenants: string[] = [];
const db = {
__bound: (tenantId: string) => {
tenants.push(tenantId);
return {
nextcloudFilesConfig: { findUnique: vi.fn(async () => state.row), upsert },
nextcloudFilesAccount: { count, updateMany },
};
},
};
const fetcher = vi.fn(async (..._a: unknown[]) => opts.check ?? reachable());
const gate = new NextcloudCallGate();
const service = new NextcloudFilesSettingsService(db as any, gate, fetcher as any);
return { service, state, upsert, updateMany, count, fetcher, gate, tenants };
}
const codeOf = async (p: Promise<unknown>) => {
try {
await p;
} catch (e) {
const body = (e as any).getResponse?.();
return { status: (e as any).getStatus?.(), body };
}
return null;
};
describe('NextcloudFilesSettingsService — Lesen', () => {
it('GET settings ohne Zeile: keine Adresse, keine Konten', async () => {
const { service, count } = makeService({});
expect(await service.getSettings('t1')).toEqual({ baseUrl: null, connectedAccounts: 0 });
expect(count).not.toHaveBeenCalled();
});
it('GET settings mit Zeile zaehlt die aktiven Konten der Organisation', async () => {
const { service, count, tenants } = makeService({
row: { baseUrl: 'https://cloud.example' },
activeAccounts: 3,
});
expect(await service.getSettings('t1')).toEqual({
baseUrl: 'https://cloud.example',
connectedAccounts: 3,
});
expect(count).toHaveBeenCalledWith({ where: { tenantId: 't1', status: 'ACTIVE' } });
expect(tenants.every((t) => t === 't1')).toBe(true);
});
it('getStatus: nicht eingerichtet und eingerichtet', async () => {
expect(await makeService({}).service.getStatus('t1')).toEqual({
configured: false,
serverUrl: null,
host: null,
account: null,
});
const set = makeService({ row: { baseUrl: 'https://cloud.example:8443/nc' } });
expect(await set.service.getStatus('t1')).toEqual({
configured: true,
serverUrl: 'https://cloud.example:8443/nc',
host: 'cloud.example:8443',
account: null,
});
});
});
describe('NextcloudFilesSettingsService — Speichern', () => {
it('normalisiert die Adresse (Gross-/Kleinschreibung des Hosts, /index.php, Leerraum)', async () => {
const { service, upsert } = makeService({});
const view = await service.saveSettings('t1', {
baseUrl: ' https://Cloud.Example/nc/index.php ',
});
expect(upsert).toHaveBeenCalledWith({
where: { tenantId: 't1' },
create: { tenantId: 't1', baseUrl: 'https://cloud.example/nc' },
update: { baseUrl: 'https://cloud.example/nc' },
});
expect(view.baseUrl).toBe('https://cloud.example/nc');
});
it('ftp:// ist 400 invalidUrl', async () => {
const { service, upsert } = makeService({});
const err = await codeOf(service.saveSettings('t1', { baseUrl: 'ftp://x' }));
expect(err?.status).toBe(400);
expect(err?.body.code).toBe('invalidUrl');
expect(upsert).not.toHaveBeenCalled();
});
it('dieselbe Adresse noch einmal: kein Schreiben, keine Ablaufmarkierung', async () => {
const { service, upsert, updateMany } = makeService({
row: { baseUrl: 'https://cloud.example' },
});
const view = await service.saveSettings('t1', { baseUrl: 'https://cloud.example/' });
expect(view.baseUrl).toBe('https://cloud.example');
expect(upsert).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
});
it('neue Adresse mit 2 verbundenen Konten ohne Bestaetigung: 409 confirmReconnect, nichts geschrieben', async () => {
const { service, upsert, updateMany } = makeService({
row: { baseUrl: 'https://alt.example' },
activeAccounts: 2,
});
const err = await codeOf(service.saveSettings('t1', { baseUrl: 'https://neu.example' }));
expect(err?.status).toBe(409);
expect(err?.body.code).toBe('confirmReconnect');
expect(err?.body.connectedAccounts).toBe(2);
expect(upsert).not.toHaveBeenCalled();
expect(updateMany).not.toHaveBeenCalled();
});
it('mit confirmReconnect: Adresse gespeichert, alle Konten der Organisation abgelaufen, Zuhoerer gerufen', async () => {
const { service, upsert, updateMany, fetcher } = makeService({
row: { baseUrl: 'https://alt.example' },
activeAccounts: 2,
});
const listener = vi.fn();
const other = vi.fn();
service.onAddressChange(listener);
service.onAddressChange(other);
const view = await service.saveSettings('t1', {
baseUrl: 'https://neu.example',
confirmReconnect: true,
});
expect(upsert).toHaveBeenCalledTimes(1);
expect(updateMany).toHaveBeenCalledWith({
where: { tenantId: 't1' },
data: { status: 'EXPIRED' },
});
expect(listener).toHaveBeenCalledWith('t1');
expect(other).toHaveBeenCalledWith('t1');
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls[0][0]).toBe('https://neu.example');
expect(view.check?.ok).toBe(true);
expect(view.connectedAccounts).toBe(0);
});
it('erste Einrichtung braucht keine Bestaetigung und zaehlt keine Konten', async () => {
const { service, count } = makeService({ activeAccounts: 5 });
await service.saveSettings('t1', { baseUrl: 'https://cloud.example' });
expect(count).not.toHaveBeenCalled();
});
});
describe('NextcloudFilesSettingsService — Verbindungspruefung', () => {
it('erreichbar: ok mit Version', async () => {
const { service, fetcher } = makeService({ check: reachable() });
const res = await service.testAddress('https://cloud.example');
expect(res).toMatchObject({
ok: true,
kind: 'ok',
version: '34.0.4',
productName: 'Nextcloud',
});
expect(res.message).toContain('34.0.4');
expect(fetcher.mock.calls[0][0]).toBe('https://cloud.example');
});
it('HTTP 400 ist der Hinweis auf die vertrauenswuerdigen Domains', async () => {
const { service } = makeService({ check: unreachable('http-status', 'HTTP 400') });
const res = await service.testAddress('https://cloud.example');
expect(res.ok).toBe(false);
expect(res.kind).toBe('trusted-domains');
expect(res.message).toContain('vertrauenswürdigen Domains');
});
it('Zeitueberschreitung hat einen deutschen Text', async () => {
const { service } = makeService({ check: unreachable('timeout') });
const res = await service.testAddress('https://cloud.example');
expect(res).toMatchObject({ ok: false, kind: 'timeout' });
expect(res.message).toContain('nicht rechtzeitig');
});
it('Wartungsmodus ist kein Erfolg', async () => {
const { service } = makeService({ check: reachable({ maintenance: true }) });
expect(await service.testAddress('https://cloud.example')).toMatchObject({
ok: false,
kind: 'maintenance',
});
});
it('eine gesperrte Adresse (Aufrufsperre) fragt Nextcloud gar nicht erst', async () => {
const { service, fetcher, gate } = makeService({});
gate.pause('https://cloud.example');
const res = await service.testAddress('https://cloud.example');
expect(res).toMatchObject({ ok: false, kind: 'paused' });
expect(fetcher).not.toHaveBeenCalled();
});
it('ein 429 aus der Pruefung sperrt den Ursprung fuer alle weiteren Aufrufe', async () => {
const { service, gate } = makeService({ check: unreachable('http-status', 'HTTP 429') });
const res = await service.testAddress('https://cloud.example');
expect(res.kind).toBe('locked');
expect(gate.isPaused('https://cloud.example').paused).toBe(true);
});
it('eine ungueltige Adresse ist 400 invalidUrl', async () => {
const { service } = makeService({});
const err = await codeOf(service.testAddress('nonsense'));
expect(err?.body.code).toBe('invalidUrl');
});
});
@@ -0,0 +1,262 @@
import { Inject, Injectable } from '@nestjs/common';
import { fetch as undiciFetch } from 'undici';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import {
type FetchStatusOptions,
fetchNextcloudStatus,
type NextcloudCheckResult,
normalizeCloudUrl,
} from '../nextcloud-status/nextcloud-status-fetch';
import { NextcloudCallGate } from './nextcloud-call-gate';
import {
type NextcloudFilesCheckView,
type NextcloudFilesSettingsView,
type NextcloudFilesStatusView,
ncErrorDefault,
} from './nextcloud-files.types';
/** Nest-Token fuer den Abruf von `status.php` (Tests setzen eine Attrappe ein). */
export const NEXTCLOUD_STATUS_FETCHER = 'NEXTCLOUD_STATUS_FETCHER';
export type NextcloudStatusFetcher = (
baseUrl: string,
opts?: FetchStatusOptions,
) => Promise<NextcloudCheckResult>;
export const defaultStatusFetcher: NextcloudStatusFetcher = fetchNextcloudStatus;
export type AddressChangeListener = (tenantId: string) => void;
interface ConfigRow {
baseUrl: string;
}
const TRUSTED_DOMAINS_TEXT =
'Nextcloud lehnt diese Adresse ab. Bitte nehmen Sie den Rechnernamen in die vertrauenswürdigen Domains (trusted_domains) der Nextcloud auf.';
function hostOf(baseUrl: string): string | null {
try {
return new URL(baseUrl).host;
} catch {
return null;
}
}
function originOf(baseUrl: string): string | null {
try {
return new URL(baseUrl).origin;
} catch {
return null;
}
}
/**
* Einstellungen des Moduls "Nextcloud-Dateien" (quick-261008-mzu): die EINE
* Nextcloud-Adresse der Organisation. Gesamter Zugriff auf `nextcloudFilesConfig`
* liegt ausschliesslich hier; vom Konto-Modell nur die mandantengebundenen
* Verwaltungsvorgaenge (zaehlen, nach Adresswechsel alle ablaufen lassen).
*
* Wechselt die Adresse, werden alle Konten der Organisation als abgelaufen
* markiert (ihre App-Passwoerter gelten nur fuer die alte Nextcloud) und die
* eingetragenen Zuhoerer benachrichtigt (offene Browser-Anmeldungen, zwischen-
* gespeicherte Serverkennung).
*/
@Injectable()
export class NextcloudFilesSettingsService {
private readonly listeners: AddressChangeListener[] = [];
constructor(
private readonly prisma: PrismaService,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_STATUS_FETCHER) private readonly statusFetcher: NextcloudStatusFetcher,
) {}
/** Meldet einen Zuhoerer an, der nach einem Adresswechsel mit der Organisation aufgerufen wird. */
onAddressChange(listener: AddressChangeListener): void {
this.listeners.push(listener);
}
// --- Lesen -----------------------------------------------------------------
private async loadConfig(tenantId: string): Promise<ConfigRow | null> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.nextcloudFilesConfig.findUnique({ where: { tenantId } });
return (row as ConfigRow | null) ?? null;
}
private async countActiveAccounts(tenantId: string): Promise<number> {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.nextcloudFilesAccount.count({ where: { tenantId, status: 'ACTIVE' } });
}
/** Gespeicherte Adresse der Organisation oder `null`. */
async getBaseUrl(tenantId: string): Promise<string | null> {
return (await this.loadConfig(tenantId))?.baseUrl ?? null;
}
/** Kurzer Stand fuer jeden Benutzer mit Modulzugriff. Das Konto fuellt Aufgabe 2 ein. */
async getStatus(tenantId: string): Promise<NextcloudFilesStatusView> {
const baseUrl = await this.getBaseUrl(tenantId);
return {
configured: baseUrl !== null,
serverUrl: baseUrl,
host: baseUrl ? hostOf(baseUrl) : null,
account: null,
};
}
async getSettings(tenantId: string): Promise<NextcloudFilesSettingsView> {
const baseUrl = await this.getBaseUrl(tenantId);
const connectedAccounts = baseUrl === null ? 0 : await this.countActiveAccounts(tenantId);
return { baseUrl, connectedAccounts };
}
// --- Speichern -------------------------------------------------------------
async saveSettings(
tenantId: string,
dto: { baseUrl: string; confirmReconnect?: boolean },
): Promise<NextcloudFilesSettingsView> {
const baseUrl = normalizeCloudUrl(dto.baseUrl);
if (baseUrl === null) throw ncErrorDefault('invalidUrl');
const current = await this.loadConfig(tenantId);
if (current?.baseUrl === baseUrl) return this.getSettings(tenantId);
if (current) {
const connectedAccounts = await this.countActiveAccounts(tenantId);
if (connectedAccounts > 0 && dto.confirmReconnect !== true) {
throw ncErrorDefault('confirmReconnect', { connectedAccounts });
}
}
const writer = forTenant(this.prisma, tenantId);
await writer.nextcloudFilesConfig.upsert({
where: { tenantId },
create: { tenantId, baseUrl },
update: { baseUrl },
});
const expirer = forTenant(this.prisma, tenantId);
await expirer.nextcloudFilesAccount.updateMany({
where: { tenantId },
data: { status: 'EXPIRED' },
});
for (const listener of this.listeners) listener(tenantId);
const check = await this.testAddress(baseUrl);
return { baseUrl, connectedAccounts: 0, check };
}
// --- Verbindungspruefung -----------------------------------------------------
/**
* Fragt `status.php` der Adresse ab (ohne Zugangsdaten). Antwortet immer mit
* `{ ok, kind, message, version, productName }`; Netz- und HTTP-Probleme sind
* keine Serverfehler. Steht der Ursprung wegen eines 429 auf der Aufrufsperre,
* geht gar keine Anfrage raus.
*/
async testAddress(rawBaseUrl: string): Promise<NextcloudFilesCheckView> {
const baseUrl = normalizeCloudUrl(rawBaseUrl);
if (baseUrl === null) throw ncErrorDefault('invalidUrl');
const origin = originOf(baseUrl);
if (origin) {
const pause = this.gate.isPaused(origin);
if (pause.paused) {
const minutes = Math.max(1, Math.ceil(pause.retryAfterSeconds / 60));
return {
ok: false,
kind: 'paused',
message: `Nextcloud sperrt Anmeldungen vom Tessera-Server zurzeit. Bitte warten Sie etwa ${minutes} Minuten.`,
version: null,
productName: null,
};
}
}
// Merkt, ob die Adresse nur ueber eine Weiterleitung antwortet: im Betrieb folgt Tessera
// keiner Weiterleitung, die Adresse muss dann direkt eingetragen werden.
let redirected = false;
const recordingFetch = (async (...args: Parameters<typeof undiciFetch>) => {
const response = await undiciFetch(...args);
if (response.status >= 300 && response.status < 400) redirected = true;
return response;
}) as typeof undiciFetch;
const result = await this.statusFetcher(baseUrl, { fetchImpl: recordingFetch });
return this.describeCheck(result, redirected, origin);
}
private describeCheck(
result: NextcloudCheckResult,
redirected: boolean,
origin: string | null,
): NextcloudFilesCheckView {
const fail = (kind: string, message: string): NextcloudFilesCheckView => ({
ok: false,
kind,
message,
version: null,
productName: null,
});
if (result.reachable) {
if (result.maintenance) {
return fail('maintenance', 'Nextcloud befindet sich im Wartungsmodus.');
}
const name = result.productName ?? 'Nextcloud';
const version = result.versionString;
const base = version
? `Verbindung erfolgreich: ${name} ${version}.`
: 'Verbindung erfolgreich.';
const hint = redirected
? ' Die Adresse leitet allerdings um. Tessera folgt keiner Weiterleitung, tragen Sie bitte die endgültige Adresse ein (zum Beispiel mit https).'
: '';
return {
ok: true,
kind: redirected ? 'redirected' : 'ok',
message: base + hint,
version,
productName: result.productName,
};
}
switch (result.errorKind) {
case 'timeout':
return fail(
'timeout',
'Nextcloud hat nicht rechtzeitig geantwortet. Bitte prüfen Sie die Adresse und versuchen Sie es erneut.',
);
case 'network':
return fail('network', 'Unter dieser Adresse ist keine Nextcloud erreichbar.');
case 'tls':
return fail(
'tls',
'Das Zertifikat der Nextcloud konnte nicht geprüft werden. Bitte prüfen Sie die Adresse oder hinterlegen Sie die Zertifizierungsstelle auf dem Tessera-Server.',
);
case 'http-status': {
if (result.errorDetail === 'HTTP 400') return fail('trusted-domains', TRUSTED_DOMAINS_TEXT);
if (result.errorDetail === 'HTTP 429') {
if (origin) this.gate.pause(origin);
return fail(
'locked',
'Nextcloud sperrt Anmeldungen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in einigen Minuten erneut.',
);
}
return fail(
'http-status',
`Nextcloud hat unter dieser Adresse mit einem Fehler geantwortet (${result.errorDetail ?? 'unbekannt'}).`,
);
}
case 'redirect':
return fail(
'redirect',
'Die Adresse leitet mehrfach um oder führt nicht zu einer Nextcloud. Bitte prüfen Sie sie.',
);
case 'too-large':
case 'not-nextcloud':
return fail('not-nextcloud', 'Unter dieser Adresse antwortet keine Nextcloud.');
default:
return fail('network', 'Unter dieser Adresse ist keine Nextcloud erreichbar.');
}
}
}
@@ -0,0 +1,120 @@
import 'reflect-metadata';
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { NextcloudFilesController } from './nextcloud-files.controller';
const proto = NextcloudFilesController.prototype as any;
const req = (tenantId?: string) => ({ tenantId }) as any;
/** Verwalten (Administratoren und Freigabestufe Verwalten). Spaetere Aufgaben ergaenzen nichts hier. */
const MANAGE_HANDLERS = ['getSettings', 'saveSettings', 'testSettings'];
/** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */
function routeHandlers(): string[] {
return Object.getOwnPropertyNames(NextcloudFilesController.prototype).filter(
(n) =>
n !== 'constructor' &&
typeof proto[n] === 'function' &&
Reflect.getMetadata('path', proto[n]) !== undefined,
);
}
/**
* Wiederverwendbar (spaetere Aufgaben fuegen nur Handler hinzu): jeder Handler,
* dessen Pfad ein `:` enthaelt, steht NACH allen statischen Handlern.
*/
function expectParamRoutesLast(controller: { prototype: object }): void {
const p = controller.prototype as any;
const names = Object.getOwnPropertyNames(controller.prototype).filter(
(n) =>
n !== 'constructor' &&
typeof p[n] === 'function' &&
Reflect.getMetadata('path', p[n]) !== undefined,
);
const isParam = (n: string) => String(Reflect.getMetadata('path', p[n])).includes(':');
const firstParam = names.findIndex(isParam);
if (firstParam === -1) return;
names.slice(firstParam).forEach((n) => {
expect(isParam(n), `${n} steht nach einer Parameterroute, ist aber statisch`).toBe(true);
});
}
describe('NextcloudFilesController — Metadaten', () => {
it('haengt an modules/nextcloud-files und traegt @UseModule(nextcloud-files)', () => {
expect(Reflect.getMetadata('path', NextcloudFilesController)).toBe('modules/nextcloud-files');
expect(Reflect.getMetadata(MODULE_SLUG_KEY, NextcloudFilesController)).toBe('nextcloud-files');
});
it('Einstellungen und Pruefung verlangen Verwalten, ohne Rollen-Decorator', () => {
for (const name of MANAGE_HANDLERS) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBe(true);
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
}
});
it('alle anderen Handler stehen auf Benutzen-Ebene', () => {
const others = routeHandlers().filter((n) => !MANAGE_HANDLERS.includes(n));
expect(others).toContain('getStatus');
for (const name of others) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
}
});
it('Pfade und Methoden', () => {
const route = (name: string) => [
Reflect.getMetadata('method', proto[name]),
Reflect.getMetadata('path', proto[name]),
];
// RequestMethod: GET 0, POST 1, PUT 2, DELETE 3
expect(route('getStatus')).toEqual([0, 'status']);
expect(route('getSettings')).toEqual([0, 'settings']);
expect(route('saveSettings')).toEqual([2, 'settings']);
expect(route('testSettings')).toEqual([1, 'settings/test']);
});
it('POST settings/test antwortet 200, nicht 201', () => {
expect(Reflect.getMetadata('__httpCode__', proto.testSettings)).toBe(200);
});
});
describe('NextcloudFilesController — Routen-Reihenfolge (statisch vor Parameter)', () => {
it('deklariert jeden Handler mit :-Pfad nach allen statischen Handlern', () => {
expectParamRoutesLast(NextcloudFilesController);
});
});
describe('NextcloudFilesController — Delegation', () => {
function makeSettings() {
return {
getStatus: vi.fn(async (..._a: unknown[]) => ({ configured: true })),
getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })),
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
testAddress: vi.fn(async (..._a: unknown[]) => ({ ok: true })),
};
}
it('reicht den Mandanten aus dem Token weiter, nie aus dem Body', async () => {
const settings = makeSettings();
const controller = new NextcloudFilesController(settings as any);
await controller.getStatus(req('t1'));
await controller.getSettings(req('t1'));
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
await controller.testSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
expect(settings.getStatus).toHaveBeenCalledWith('t1');
expect(settings.getSettings).toHaveBeenCalledWith('t1');
expect(settings.saveSettings).toHaveBeenCalledWith('t1', { baseUrl: 'https://x.example' });
expect(settings.testAddress).toHaveBeenCalledWith('https://x.example');
});
it('ohne Mandantenkontext: ForbiddenException', async () => {
const controller = new NextcloudFilesController(makeSettings() as any);
await expect(controller.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.getSettings(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
await expect(
controller.testSettings(req(undefined), { baseUrl: 'https://x.example' } as any),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
@@ -0,0 +1,87 @@
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
Post,
Put,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import {
SaveNextcloudFilesSettingsDto,
TestNextcloudFilesSettingsDto,
} from './dto/nextcloud-files-settings.dto';
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
/**
* `@UseModule('nextcloud-files')` auf Klassenebene — Aktivierung UND Freigabe.
* `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query;
* die Benutzerkennung (spaetere Aufgaben) ausschliesslich aus dem Token.
*
* Rechte je Route (quick-261008-mzu, D-N):
* Verwalten (`@ModuleManage('nextcloud-files')`, Administratoren und Benutzer
* mit der Freigabestufe Verwalten): GET settings, PUT settings,
* POST settings/test.
* Benutzen (nur Klassen-`@UseModule`): alles andere — GET status, GET server,
* GET server/logo, POST connect/password, POST connect/flow,
* DELETE connect, GET files, DELETE files, POST folders, POST move,
* GET preview, GET download, GET download/zip, POST uploads, PUT
* uploads/file; danach die Parameterrouten am ENDE: GET/DELETE
* connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
* uploads/:uploadId. Jeder Benutzer arbeitet nur im eigenen Konto.
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
* RolesGuard wuerde Verwalter sonst aussperren.
*
* REIHENFOLGE: alle statischen Routen stehen VOR jeder Route mit `:param`, sonst
* faengt die Parameterroute sie ab (404-Shadowing, Unit-Tests ohne die
* Reihenfolge-Pruefung fangen das nicht). Jede Aufgabe haengt ihre statischen
* Routen vor den Parameterblock und ihre Parameterrouten ans ENDE;
* `nextcloud-files.controller.spec.ts` prueft die Deklarationsreihenfolge.
*
* FEHLER: bei einem Nextcloud-seitigen Problem antwortet die API NIE mit 401
* oder 403 (die Weboberflaeche liest beides als Tessera-Sitzung bzw.
* Tessera-Recht), sondern mit `{ code, message }` und einem anderen Status
* (siehe `nextcloud-files.types.ts`).
*/
@Controller('modules/nextcloud-files')
@UseModule('nextcloud-files')
export class NextcloudFilesController {
constructor(private readonly settings: NextcloudFilesSettingsService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get('status')
async getStatus(@Req() req: AuthenticatedRequest) {
return this.settings.getStatus(this.requireTenantId(req));
}
@Get('settings')
@ModuleManage('nextcloud-files')
async getSettings(@Req() req: AuthenticatedRequest) {
return this.settings.getSettings(this.requireTenantId(req));
}
@Put('settings')
@ModuleManage('nextcloud-files')
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: SaveNextcloudFilesSettingsDto) {
return this.settings.saveSettings(this.requireTenantId(req), dto);
}
@Post('settings/test')
@HttpCode(200)
@ModuleManage('nextcloud-files')
async testSettings(@Req() req: AuthenticatedRequest, @Body() dto: TestNextcloudFilesSettingsDto) {
this.requireTenantId(req);
return this.settings.testAddress(dto.baseUrl);
}
}
@@ -0,0 +1,45 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { NextcloudCallGate } from './nextcloud-call-gate';
import { NextcloudFilesController } from './nextcloud-files.controller';
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
import {
defaultStatusFetcher,
NEXTCLOUD_STATUS_FETCHER,
NextcloudFilesSettingsService,
} from './nextcloud-files-settings.service';
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
/**
* Modul "Dateien" (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in
* Tessera. Traegt sich beim Start in die Modulverwaltung ein; aktiviert wird per
* Marktplatz. `CryptoService` kommt aus dem globalen `CryptoModule`,
* `PrismaService` ist global. Die Aufrufsperre ist ein einziges Objekt fuer den
* ganzen Prozess (alle Benutzer teilen die Server-Adresse gegenueber Nextcloud).
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [NextcloudFilesController],
providers: [
NextcloudFilesSettingsService,
NextcloudCallGate,
{ provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport },
{ provide: NEXTCLOUD_STATUS_FETCHER, useValue: defaultStatusFetcher },
],
exports: [NextcloudCallGate, NextcloudFilesSettingsService, NEXTCLOUD_TRANSPORT],
})
export class NextcloudFilesModule implements OnModuleInit {
private readonly logger = new Logger(NextcloudFilesModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedNextcloudFilesModule(this.moduleRegistryService);
this.logger.log('Nextcloud files module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed nextcloud files module', error);
}
}
}
@@ -0,0 +1,23 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service';
/**
* Traegt das Modul "Dateien" (Nextcloud-Dateien) in die Modulverwaltung ein
* (quick-261008-mzu). Kategorie `infrastructure` neben Nextcloud-Status —
* Administratoren koennen es umhaengen. Der Slug ist zugleich der Wert in
* `@UseModule`.
*/
export async function seedNextcloudFilesModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'nextcloud-files',
name: 'Dateien',
version: '1.0.0',
category: 'infrastructure',
description: {
de: 'Dateien Ihrer Nextcloud ansehen, hochladen, herunterladen und ordnen',
en: 'View, upload, download and organise the files in your Nextcloud',
},
isSystem: true,
});
}
@@ -0,0 +1,251 @@
import { HttpException } from '@nestjs/common';
/**
* Gemeinsame Typen des Moduls "Nextcloud-Dateien" (quick-261008-mzu).
*
* Fehlervertrag (D-D): die API antwortet bei einem Nextcloud-seitigen Fehler
* NIE mit 401 oder 403 — die Weboberflaeche liest beides als Problem mit der
* Tessera-Sitzung bzw. den Tessera-Rechten und wuerde den Benutzer abmelden
* oder aussperren. Jeder Fehlerkoerper ist `{ code, message }` mit deutschem
* Text; die Oberflaeche ordnet den Code einem Text zu und faellt auf `message`
* zurueck.
*/
export type NcErrorCode =
| 'notConfigured'
| 'notConnected'
| 'connectionExpired'
| 'accountBroken'
| 'credentialsOrTwoFactor'
| 'useBrowserLogin'
| 'tooManyAttempts'
| 'nextcloudLocked'
| 'nextcloudMaintenance'
| 'nextcloudRedirect'
| 'nextcloudUnavailable'
| 'nextcloudError'
| 'notFound'
| 'nameTaken'
| 'changedMeanwhile'
| 'pathConflict'
| 'moveIntoItself'
| 'locked'
| 'notAllowed'
| 'invalidName'
| 'invalidPath'
| 'quotaExceeded'
| 'lengthRequired'
| 'chunkTooLarge'
| 'fileTooLarge'
| 'flowExpired'
| 'tooManyFlows'
| 'invalidUrl'
| 'confirmReconnect';
interface ErrorDefault {
status: number;
message: string;
}
/** Vorgabe-Statuscode und deutscher Text je Fehlercode (D-D). */
export const NC_ERROR_DEFAULTS: Record<NcErrorCode, ErrorDefault> = {
notConfigured: {
status: 409,
message: 'Für Ihre Organisation ist noch keine Nextcloud-Adresse hinterlegt.',
},
notConnected: {
status: 409,
message: 'Sie sind noch nicht mit Ihrer Nextcloud verbunden.',
},
connectionExpired: {
status: 409,
message: 'Die Verbindung zu Ihrer Nextcloud ist abgelaufen. Bitte verbinden Sie sich neu.',
},
accountBroken: {
status: 500,
message:
'Die gespeicherte Verbindung ließ sich nicht entschlüsseln. Bitte trennen Sie die Verbindung und verbinden Sie sich neu.',
},
credentialsOrTwoFactor: {
status: 422,
message:
'Die Anmeldung ist nicht gelungen. Entweder stimmen Benutzername und Passwort nicht, oder Ihr Konto nutzt Zwei-Faktor-Anmeldung. In dem Fall melden Sie sich bitte im Browser an.',
},
useBrowserLogin: {
status: 422,
message:
'Nextcloud erlaubt für dieses Konto keine Anmeldung mit Passwort. Bitte melden Sie sich im Browser an.',
},
tooManyAttempts: {
status: 429,
message:
'Zu viele fehlgeschlagene Anmeldungen. Bitte warten Sie einige Minuten, bevor Sie es erneut versuchen.',
},
nextcloudLocked: {
status: 503,
message:
'Nextcloud sperrt Anmeldungen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in einigen Minuten erneut.',
},
nextcloudMaintenance: {
status: 503,
message: 'Nextcloud befindet sich im Wartungsmodus. Bitte versuchen Sie es später erneut.',
},
nextcloudRedirect: {
status: 502,
message:
'Nextcloud leitet um. Bitte korrigieren Sie die Adresse in den Einstellungen (zum Beispiel auf https).',
},
nextcloudUnavailable: {
status: 504,
message: 'Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.',
},
nextcloudError: {
status: 502,
message: 'Nextcloud hat eine unerwartete Antwort geliefert.',
},
notFound: {
status: 404,
message: 'Der Eintrag wurde nicht gefunden.',
},
nameTaken: {
status: 409,
message: 'Ein Eintrag mit diesem Namen existiert bereits.',
},
changedMeanwhile: {
status: 409,
message: 'Der Eintrag wurde zwischenzeitlich geändert.',
},
pathConflict: {
status: 409,
message: 'Der Zielordner existiert nicht oder der Vorgang ist an dieser Stelle nicht möglich.',
},
moveIntoItself: {
status: 400,
message: 'Ein Ordner kann nicht in sich selbst verschoben werden.',
},
locked: {
status: 409,
message: 'Der Eintrag ist gerade gesperrt, weil ihn jemand bearbeitet.',
},
notAllowed: {
status: 422,
message: 'Nextcloud erlaubt diese Aktion für diesen Eintrag nicht.',
},
invalidName: {
status: 400,
message: 'Dieser Name ist nicht erlaubt.',
},
invalidPath: {
status: 400,
message: 'Dieser Pfad ist nicht gültig.',
},
quotaExceeded: {
status: 507,
message: 'Der Speicherplatz in Nextcloud ist erschöpft.',
},
lengthRequired: {
status: 411,
message: 'Die Größe der Übertragung fehlt.',
},
chunkTooLarge: {
status: 413,
message: 'Das Teilstück ist zu groß.',
},
fileTooLarge: {
status: 413,
message: 'Die Datei ist zu groß für die Übertragung.',
},
flowExpired: {
status: 410,
message: 'Die Anmeldung im Browser ist abgelaufen. Bitte starten Sie sie erneut.',
},
tooManyFlows: {
status: 503,
message:
'Zurzeit laufen zu viele Anmeldungen im Browser. Bitte versuchen Sie es gleich erneut.',
},
invalidUrl: {
status: 400,
message: 'Das ist keine gültige Adresse. Bitte geben Sie sie mit http:// oder https:// an.',
},
confirmReconnect: {
status: 409,
message:
'Bei einem Wechsel der Adresse müssen sich alle verbundenen Benutzer neu anmelden. Bitte bestätigen Sie den Wechsel.',
},
};
/** Baut die HttpException mit dem Koerper `{ code, message, ...extra }`. */
export function ncError(
code: NcErrorCode,
httpStatus: number,
message: string,
extra?: Record<string, unknown>,
): HttpException {
return new HttpException({ code, message, ...(extra ?? {}) }, httpStatus);
}
/** Wie `ncError`, aber mit Statuscode und Text aus `NC_ERROR_DEFAULTS`. */
export function ncErrorDefault(
code: NcErrorCode,
extra?: Record<string, unknown>,
message?: string,
): HttpException {
const def = NC_ERROR_DEFAULTS[code];
return ncError(code, def.status, message ?? def.message, extra);
}
/** Art eines fehlgeschlagenen Nextcloud-Aufrufs (Transportschicht). */
export type NcFailureKind =
| 'redirect'
| 'timeout'
| 'network'
| 'tls'
| 'too-large'
| 'invalid-response'
| 'http'
| 'paused'
| 'credential-dead'
| 'aborted';
/** Zugangsdaten eines Benutzers fuer einen Aufruf; das App-Passwort steht nur hier. */
export interface NcSession {
baseUrl: string;
ncUserId: string;
/** `Basic base64(ncUserId:appPassword)` — nie loggen, nie zurueckgeben. */
authorization: string;
/** Erste 16 Hex-Zeichen von sha256 ueber den verschluesselten Wert. */
credentialKey: string;
}
// --- Ansichten fuer die Weboberflaeche ---------------------------------------
export interface NextcloudFilesAccountView {
connected: boolean;
/** true, wenn das Konto abgelaufen ist (Adresswechsel, widerrufen, 401). */
expired: boolean;
ncUserId: string | null;
displayName: string | null;
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null;
}
export interface NextcloudFilesStatusView {
configured: boolean;
serverUrl: string | null;
host: string | null;
account: NextcloudFilesAccountView | null;
}
export interface NextcloudFilesCheckView {
ok: boolean;
kind: string;
message: string;
version: string | null;
productName: string | null;
}
export interface NextcloudFilesSettingsView {
baseUrl: string | null;
connectedAccounts: number;
check?: NextcloudFilesCheckView;
}
@@ -0,0 +1,397 @@
import { Readable } from 'node:stream';
import { describe, expect, it, vi } from 'vitest';
import { NextcloudCallGate } from './nextcloud-call-gate';
import {
basicAuth,
buildNcUrl,
encodeSegments,
type NcTransportRequest,
type NcTransportResponse,
type NextcloudTransport,
ncRequest,
parseUserPath,
readCappedText,
validateNewName,
validateSegment,
} from './nextcloud-http';
const BASE = 'https://cloud.example/nc';
function bodyOf(text = ''): Readable {
return Readable.from(text === '' ? [] : [Buffer.from(text)]);
}
function reply(
statusCode: number,
text = '',
headers: Record<string, string> = {},
): NcTransportResponse {
return { statusCode, headers, body: bodyOf(text) };
}
function fakeTransport(handler: (req: NcTransportRequest) => Promise<NcTransportResponse>) {
const calls: NcTransportRequest[] = [];
const transport: NextcloudTransport = async (req) => {
calls.push(req);
return handler(req);
};
return { transport, calls };
}
function errCode(code: string): Error & { code: string } {
return Object.assign(new Error('boom'), { code });
}
function codeOf(fn: () => unknown): string | undefined {
try {
fn();
} catch (e) {
return (e as { getResponse?: () => { code?: string } }).getResponse?.()?.code;
}
return undefined;
}
describe('buildNcUrl / encodeSegments', () => {
it('codiert Umlaute, Leerzeichen, & und % segmentweise (Literale)', () => {
expect(buildNcUrl(BASE, '/remote.php/dav/files/', ['anna', 'Ärger & Ölpreis 100%.txt'])).toBe(
'https://cloud.example/nc/remote.php/dav/files/anna/%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt',
);
expect(encodeSegments(['a b#c?d.txt'])).toBe('a%20b%23c%3Fd.txt');
expect(encodeSegments(['50%25.txt'])).toBe('50%2525.txt');
});
it('haengt eine Query codiert an', () => {
expect(buildNcUrl(BASE, '/index.php/core/preview', [], { fileId: '123', x: '256' })).toBe(
'https://cloud.example/nc/index.php/core/preview?fileId=123&x=256',
);
});
it('ein Pfadanfang ausserhalb der Liste wirft vor jedem Aufruf', () => {
expect(() => buildNcUrl(BASE, '/admin/secret' as never, [])).toThrow();
expect(() => buildNcUrl(BASE, '/status.php', ['x'])).toThrow();
});
it('eine unnormalisierte Basis wirft', () => {
expect(() => buildNcUrl('ftp://cloud.example', '/status.php')).toThrow();
expect(() => buildNcUrl('https://user:pw@cloud.example', '/status.php')).toThrow();
});
});
describe('Pfade', () => {
it('parseUserPath: Wurzel und normale Pfade', () => {
expect(parseUserPath('')).toEqual([]);
expect(parseUserPath('/')).toEqual([]);
expect(parseUserPath(undefined)).toEqual([]);
expect(parseUserPath('/Projekte/2026/')).toEqual(['Projekte', '2026']);
expect(parseUserPath('Projekte/Ärger & Ölpreis.txt')).toEqual([
'Projekte',
'Ärger & Ölpreis.txt',
]);
});
it.each([
['/a//b', 'leeres Segment'],
['/a/../b', '..'],
['/./a', '.'],
['a\\b', 'Rueckwaertsstrich'],
['a\u0000b', 'NUL'],
['a\u0007b', 'Steuerzeichen'],
[`/${'ä'.repeat(128)}`, '256 Byte'],
[`/${Array.from({ length: 101 }, () => 'a').join('/')}`, '101 Segmente'],
[`/${'a'.repeat(4100)}`, 'zu lang'],
])('parseUserPath lehnt %s ab (%s)', (raw) => {
expect(codeOf(() => parseUserPath(raw))).toBe('invalidPath');
});
it('ein Segment mit genau 255 Byte ist erlaubt, validateSegment("..") wirft', () => {
expect(() => validateSegment('a'.repeat(255))).not.toThrow();
expect(codeOf(() => validateSegment('..'))).toBe('invalidPath');
});
it('validateNewName: .part, Leerraum und Segmentverstoesse sind invalidName', () => {
expect(validateNewName('Bericht.pdf')).toBe('Bericht.pdf');
expect(codeOf(() => validateNewName('x.part'))).toBe('invalidName');
expect(codeOf(() => validateNewName(' '))).toBe('invalidName');
expect(codeOf(() => validateNewName('a/b'))).toBe('invalidName');
});
});
describe('ncRequest — Transport', () => {
it('sendet User-Agent, nie ein Cookie, und den Authorization-Literal fuer anna/geheim', async () => {
const { transport, calls } = fakeTransport(async () => reply(200, '{}'));
const gate = new NextcloudCallGate();
expect(basicAuth('anna', 'geheim')).toBe('Basic YW5uYTpnZWhlaW0=');
const res = await ncRequest(transport, gate, {
baseUrl: BASE,
prefix: '/ocs/v2.php/',
segments: ['cloud', 'user'],
method: 'GET',
authorization: basicAuth('anna', 'geheim'),
ocs: true,
});
expect(res.ok).toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('https://cloud.example/nc/ocs/v2.php/cloud/user');
expect(calls[0].method).toBe('GET');
expect(calls[0].headers).toEqual({
'user-agent': 'Tessera (Nextcloud-Dateien)',
'ocs-apirequest': 'true',
accept: 'application/json',
authorization: 'Basic YW5uYTpnZWhlaW0=',
});
expect(Object.keys(calls[0].headers)).not.toContain('cookie');
});
it('verweigert freie cookie/host/authorization-Kopfzeilen', async () => {
const { transport } = fakeTransport(async () => reply(200));
for (const name of ['Cookie', 'host', 'Authorization']) {
await expect(
ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
headers: { [name]: 'x' },
}),
).rejects.toThrow();
}
});
it('3xx ist Fehlerart redirect, der Transport wird genau einmal aufgerufen', async () => {
const { transport, calls } = fakeTransport(async () =>
reply(302, '', { location: 'https://evil.example/' }),
);
const res = await ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
});
expect(res).toEqual({ ok: false, kind: 'redirect', status: 302 });
expect(calls).toHaveLength(1);
});
it('haengender Transport mit 20 ms Zeitgrenze ist timeout', async () => {
const { transport } = fakeTransport(() => new Promise(() => {}));
const res = await ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
headersTimeoutMs: 20,
});
expect(res).toEqual({ ok: false, kind: 'timeout' });
});
it('ENOTFOUND ist network, CERT_HAS_EXPIRED ist tls, undici-Zeitgrenze ist timeout', async () => {
const run = async (code: string) => {
const { transport } = fakeTransport(async () => {
throw errCode(code);
});
return ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
});
};
expect(await run('ENOTFOUND')).toEqual({ ok: false, kind: 'network', detail: 'ENOTFOUND' });
expect(await run('CERT_HAS_EXPIRED')).toEqual({
ok: false,
kind: 'tls',
detail: 'CERT_HAS_EXPIRED',
});
expect(await run('UND_ERR_HEADERS_TIMEOUT')).toEqual({
ok: false,
kind: 'timeout',
detail: 'UND_ERR_HEADERS_TIMEOUT',
});
});
it('ein Abbruch durch den Aufrufer ist aborted', async () => {
const ctrl = new AbortController();
const { transport } = fakeTransport(() => new Promise(() => {}));
const p = ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/status.php',
method: 'GET',
signal: ctrl.signal,
});
ctrl.abort();
expect(await p).toEqual({ ok: false, kind: 'aborted' });
});
it('JSON eines Fehlers enthaelt weder "Basic " noch das Passwort', async () => {
const { transport } = fakeTransport(async () => {
throw errCode('ECONNREFUSED');
});
const res = await ncRequest(transport, new NextcloudCallGate(), {
baseUrl: BASE,
prefix: '/ocs/v2.php/',
segments: ['core', 'getapppassword'],
method: 'GET',
authorization: basicAuth('anna', 'geheim'),
});
const json = JSON.stringify(res);
expect(json).not.toContain('Basic ');
expect(json).not.toContain('geheim');
expect(json).not.toContain('YW5uYTpnZWhlaW0=');
});
});
describe('ncRequest — Aufrufsperre', () => {
function gateWithClock() {
const gate = new NextcloudCallGate();
const clock = { t: 5_000_000 };
gate.now = () => clock.t;
return { gate, clock };
}
it('ein 429 haelt den ganzen Ursprung an, ohne Transportaufruf; ein anderer Ursprung geht raus', async () => {
const { gate } = gateWithClock();
const { transport, calls } = fakeTransport(async () => reply(429, '{}'));
const first = await ncRequest(transport, gate, {
baseUrl: 'https://cloud.example',
prefix: '/ocs/v2.php/',
segments: ['core', 'getapppassword'],
method: 'GET',
});
expect(first).toEqual({ ok: false, kind: 'http', status: 429, retryAfterSeconds: 900 });
expect(calls).toHaveLength(1);
const second = await ncRequest(transport, gate, {
baseUrl: 'https://cloud.example',
prefix: '/remote.php/dav/files/',
segments: ['anna'],
method: 'PROPFIND',
});
expect(second).toEqual({ ok: false, kind: 'paused', retryAfterSeconds: 900 });
expect(calls).toHaveLength(1);
const other = fakeTransport(async () => reply(200, '{}'));
const third = await ncRequest(other.transport, gate, {
baseUrl: 'https://other.example',
prefix: '/status.php',
method: 'GET',
});
expect(third.ok).toBe(true);
expect(other.calls).toHaveLength(1);
});
it('Retry-After 120 -> 120 s, 99999 -> 3600 s, danach geht es wieder', async () => {
const { gate, clock } = gateWithClock();
const answers = [reply(429, '', { 'retry-after': '120' }), reply(200, '{}')];
const { transport, calls } = fakeTransport(async () => answers.shift() as NcTransportResponse);
const opts = {
baseUrl: 'https://cloud.example',
prefix: '/status.php',
method: 'GET',
} as const;
expect(await ncRequest(transport, gate, opts)).toMatchObject({ retryAfterSeconds: 120 });
expect(await ncRequest(transport, gate, opts)).toMatchObject({ kind: 'paused' });
clock.t += 121_000;
expect((await ncRequest(transport, gate, opts)).ok).toBe(true);
expect(calls).toHaveLength(2);
const big = fakeTransport(async () => reply(429, '', { 'retry-after': '99999' }));
const { gate: gate2 } = gateWithClock();
expect(await ncRequest(big.transport, gate2, opts)).toMatchObject({ retryAfterSeconds: 3600 });
});
it('ein 401 mit credentialKey: der Schluessel stirbt, laufende Aufrufe brechen ab, spaetere gehen nicht raus', async () => {
const { gate } = gateWithClock();
let release: (() => void) | undefined;
let abortedSeen = false;
const transport: NextcloudTransport = (req) => {
if (req.url.endsWith('/slow')) {
return new Promise<NcTransportResponse>((_resolve, reject) => {
req.signal.addEventListener('abort', () => {
abortedSeen = true;
reject(new Error('aborted'));
});
release = () => reject(new Error('never'));
});
}
if (req.url.endsWith('/revoked')) return Promise.resolve(reply(401, '{}'));
return Promise.resolve(reply(200, '{}'));
};
const base = { baseUrl: 'https://cloud.example', method: 'GET' } as const;
const slow = ncRequest(transport, gate, {
...base,
prefix: '/remote.php/dav/files/',
segments: ['anna', 'slow'],
credentialKey: 'k1',
});
const killed = await ncRequest(transport, gate, {
...base,
prefix: '/remote.php/dav/files/',
segments: ['anna', 'revoked'],
credentialKey: 'k1',
});
expect(killed).toEqual({ ok: false, kind: 'credential-dead', status: 401 });
expect(await slow).toEqual({ ok: false, kind: 'credential-dead' });
expect(abortedSeen).toBe(true);
expect(release).toBeTypeOf('function');
const calls = vi.fn(transport);
const later = await ncRequest(calls, gate, {
...base,
prefix: '/remote.php/dav/files/',
segments: ['anna', 'ok'],
credentialKey: 'k1',
});
expect(later).toEqual({ ok: false, kind: 'credential-dead' });
expect(calls).not.toHaveBeenCalled();
const other = await ncRequest(calls, gate, {
...base,
prefix: '/remote.php/dav/files/',
segments: ['anna', 'ok'],
credentialKey: 'k2',
});
expect(other.ok).toBe(true);
expect(calls).toHaveBeenCalledTimes(1);
});
it('ein 401 ohne credentialKey (Passwortanmeldung) markiert nichts', async () => {
const { gate } = gateWithClock();
const { transport, calls } = fakeTransport(async () => reply(401, '{}'));
const opts = {
baseUrl: 'https://cloud.example',
prefix: '/ocs/v2.php/',
segments: ['core', 'getapppassword'],
method: 'GET',
} as const;
const first = await ncRequest(transport, gate, opts);
expect(first.ok && first.status).toBe(401);
await ncRequest(transport, gate, opts);
expect(calls).toHaveLength(2);
expect(gate.isDead('k1')).toBe(false);
});
});
describe('readCappedText', () => {
it('liest bis zur Grenze', async () => {
expect(
await readCappedText(Readable.from([Buffer.from('abc'), Buffer.from('def')]), 6),
).toEqual({
ok: true,
text: 'abcdef',
});
});
it('ueber der Grenze ist too-large', async () => {
const res = await readCappedText(Readable.from([Buffer.from('abcd'), Buffer.from('efg')]), 6);
expect(res).toEqual({ ok: false, kind: 'too-large' });
});
it('ein Lesefehler wird zur Fehlerart', async () => {
const stream = new Readable({
read() {
this.destroy(errCode('ECONNRESET'));
},
});
expect(await readCappedText(stream, 100)).toEqual({
ok: false,
kind: 'network',
detail: 'ECONNRESET',
});
});
});
@@ -0,0 +1,429 @@
import type { Readable } from 'node:stream';
import { request } from 'undici';
import { normalizeCloudUrl } from '../nextcloud-status/nextcloud-status-fetch';
import type { NextcloudCallGate } from './nextcloud-call-gate';
import { type NcFailureKind, ncErrorDefault } from './nextcloud-files.types';
/**
* Einzige Transportschicht des Moduls "Nextcloud-Dateien" (quick-261008-mzu).
* Jeder Aufruf an eine Nextcloud geht durch `ncRequest` — dort sitzen die
* Schutzregeln, damit sie kein Aufrufer umgehen kann.
*
* Warum interne Adressen erlaubt sind (L-01): die Nextcloud steht oft im Haus,
* und die Adresse setzt allein ein Verwalter. Der gemeinsame Schutz
* `isPublicHttpUrl` (nur oeffentliche Adressen) wird deshalb hier mit Absicht
* NICHT verwendet. Stattdessen wird die Reichweite so eingegrenzt:
* - Es gibt genau EINE Basisadresse je Organisation (Normalform von
* `normalizeCloudUrl`). Jede URL ist `Basis + fester Pfadanfang +
* einzeln codierte Segmente`; der Pfadanfang kommt aus einer festen Liste.
* - Benutzereingaben sind nur Pfadsegmente. Sie laufen durch
* `validateSegment` und werden Stueck fuer Stueck mit `encodeURIComponent`
* codiert (nie ein ganzer Pfad auf einmal).
* - Es werden KEINE Weiterleitungen befolgt (jedes 3xx ist ein Fehler), und
* nie wird eine URL aus einer Nextcloud-Antwort aufgerufen.
* - Es werden nie Cookies gesendet; `set-cookie` wird nie weitergereicht.
* - Zeit- und Groessengrenzen auf jedem Aufruf. Zertifikate werden immer
* geprueft (es gibt keinen Schalter, das abzustellen; eine interne CA
* gehoert in `NODE_EXTRA_CA_CERTS` des api-Containers).
* - Ein Adresswechsel laesst alle gespeicherten App-Passwoerter ablaufen,
* das Konto fuehrt die Adresse mit, fuer die das Passwort galt.
*
* Warum undici `request` und nicht `fetch`: Node-Streams als Anfragekoerper
* ohne Umweg, kein Weiterleitungsfolgen von sich aus, die Antwort kommt als
* lesbarer Strom, der mit `stream.pipeline` weitergereicht werden kann.
*
* Nie loggen: Kopfzeilen, Zugangsdaten, App-Passwoerter. Fehlerergebnisse
* tragen nur Art, Statuscode und eine kurze Kennung (z. B. `ENOTFOUND`).
*/
export const NC_USER_AGENT = 'Tessera (Nextcloud-Dateien)';
/** Nest-Token fuer den einsetzbaren Transport (Tests setzen eine Attrappe ein). */
export const NEXTCLOUD_TRANSPORT = 'NEXTCLOUD_TRANSPORT';
/** Erlaubte feste Pfadanfaenge (D-C). Alles andere wirft vor jedem Aufruf. */
export const ALLOWED_PREFIXES = [
'/status.php',
'/ocs/v2.php/',
'/index.php/login/v2',
'/index.php/login/v2/poll',
'/index.php/core/preview',
'/remote.php/dav/files/',
'/remote.php/dav/uploads/',
'/index.php/apps/theming/image/logo',
'/core/img/logo/logo.svg',
] as const;
export type NcPrefix = (typeof ALLOWED_PREFIXES)[number];
export const DEFAULT_HEADERS_TIMEOUT_MS = 15_000;
export const DEFAULT_BODY_TIMEOUT_MS = 15_000;
// --- Transport -------------------------------------------------------------------
export type NcHeaders = Record<string, string | string[] | undefined>;
export interface NcTransportRequest {
url: string;
method: string;
headers: Record<string, string>;
body?: Readable | string | Buffer | null;
headersTimeoutMs: number;
bodyTimeoutMs: number;
signal: AbortSignal;
}
export interface NcTransportResponse {
statusCode: number;
headers: NcHeaders;
body: Readable;
}
export type NextcloudTransport = (req: NcTransportRequest) => Promise<NcTransportResponse>;
/** Standardtransport auf Basis von undici `request` (keine Cookies, keine Weiterleitungen). */
export const undiciTransport: NextcloudTransport = async (req) => {
const res = await request(req.url, {
// undici kennt PROPFIND/MKCOL/MOVE zur Laufzeit; die Typen nennen nur die Standardmethoden.
method: req.method as never,
headers: req.headers,
body: (req.body ?? undefined) as never,
headersTimeout: req.headersTimeoutMs,
bodyTimeout: req.bodyTimeoutMs,
signal: req.signal,
});
return {
statusCode: res.statusCode,
headers: res.headers as NcHeaders,
body: res.body as unknown as Readable,
};
};
// --- Pfade und URLs -----------------------------------------------------------------
const MAX_SEGMENT_BYTES = 255;
const MAX_SEGMENTS = 100;
const MAX_PATH_CHARS = 4096;
/**
* Ein einzelnes Pfadsegment pruefen (D-H): nicht leer, nicht `.`/`..`, kein
* `/`, `\`, NUL oder Steuerzeichen, hoechstens 255 UTF-8-Byte.
*/
export function validateSegment(segment: string): string {
if (
typeof segment !== 'string' ||
segment === '' ||
segment === '.' ||
segment === '..' ||
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Prueffall
/[\\/\u0000-\u001f\u007f]/.test(segment) ||
Buffer.byteLength(segment, 'utf8') > MAX_SEGMENT_BYTES
) {
throw ncErrorDefault('invalidPath');
}
return segment;
}
/** Jedes Segment einzeln mit `encodeURIComponent` codieren und mit `/` verbinden. */
export function encodeSegments(segments: readonly string[]): string {
return segments.map((s) => encodeURIComponent(validateSegment(s))).join('/');
}
/**
* Pfad aus Query/Body in Segmente zerlegen. `''` und `/` sind die Wurzel; ein
* fuehrender und ein abschliessender `/` werden entfernt; jedes Segment laeuft
* durch `validateSegment`; mehr als 100 Segmente oder 4096 Zeichen sind
* ungueltig. Namen bleiben unveraendert (keine Unicode-Normalisierung).
*/
export function parseUserPath(raw: string | undefined | null): string[] {
if (raw === undefined || raw === null) return [];
if (typeof raw !== 'string' || raw.length > MAX_PATH_CHARS) throw ncErrorDefault('invalidPath');
let path = raw;
if (path.startsWith('/')) path = path.slice(1);
if (path.endsWith('/')) path = path.slice(0, -1);
if (path === '') return [];
const segments = path.split('/');
if (segments.length > MAX_SEGMENTS) throw ncErrorDefault('invalidPath');
return segments.map(validateSegment);
}
/** Neue Namen (Ordner anlegen, Umbenennen): Segmentregeln plus `.part` und Leerraum. */
export function validateNewName(name: string): string {
try {
validateSegment(name);
} catch {
throw ncErrorDefault('invalidName');
}
if (name.trim() === '' || name.toLowerCase().endsWith('.part')) {
throw ncErrorDefault('invalidName');
}
return name;
}
/**
* Baut die URL `Basis + Pfadanfang + codierte Segmente (+ Query)`. Die Basis
* muss die Normalform von `normalizeCloudUrl` haben; der Pfadanfang muss in
* `ALLOWED_PREFIXES` stehen. Verstoesse sind Programmierfehler und werfen.
*/
export function buildNcUrl(
baseUrl: string,
prefix: NcPrefix,
segments: readonly string[] = [],
query?: Record<string, string>,
): string {
if (!(ALLOWED_PREFIXES as readonly string[]).includes(prefix)) {
throw new Error(`Nextcloud-Pfadanfang nicht erlaubt: ${prefix}`);
}
const base = normalizeCloudUrl(baseUrl);
if (base === null) throw new Error('Nextcloud-Basisadresse ist ungültig');
if (segments.length > 0 && !prefix.endsWith('/')) {
throw new Error(`Nextcloud-Pfadanfang ${prefix} nimmt keine Segmente`);
}
let url = `${base}${prefix}${encodeSegments(segments)}`;
if (query) {
const pairs = Object.entries(query).map(
([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
);
if (pairs.length > 0) url += `?${pairs.join('&')}`;
}
return url;
}
/** `Basic base64(benutzer:geheimnis)` — das Ergebnis nie loggen oder zurueckgeben. */
export function basicAuth(user: string, secret: string): string {
return `Basic ${Buffer.from(`${user}:${secret}`, 'utf8').toString('base64')}`;
}
// --- Aufruf -----------------------------------------------------------------------------
export interface NcRequestOptions {
baseUrl: string;
prefix: NcPrefix;
segments?: readonly string[];
query?: Record<string, string>;
method: string;
headers?: Record<string, string>;
body?: Readable | string | Buffer | null;
/** Fertiger `Authorization`-Wert (siehe `basicAuth`). */
authorization?: string;
/** Gesetzt bei Aufrufen mit gespeichertem App-Passwort (siehe Aufrufsperre). */
credentialKey?: string;
/** OCS-Aufruf: setzt `OCS-APIRequest` und `Accept: application/json`. */
ocs?: boolean;
headersTimeoutMs?: number;
bodyTimeoutMs?: number;
signal?: AbortSignal;
}
export interface NcFailure {
ok: false;
kind: NcFailureKind;
status?: number;
retryAfterSeconds?: number;
/** Kurze eigene Kennung wie `ENOTFOUND` — nie ein Antworttext. */
detail?: string;
}
export type NcResult = { ok: true; status: number; headers: NcHeaders; body: Readable } | NcFailure;
const TLS_CODES = new Set([
'CERT_HAS_EXPIRED',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'ERR_TLS_CERT_ALTNAME_INVALID',
'ERR_TLS_CERT_ALTNAME_INVALID_ALTERNATE',
'CERT_NOT_YET_VALID',
'CERT_UNTRUSTED',
'CERT_REVOKED',
'CERT_SIGNATURE_FAILURE',
'HOSTNAME_MISMATCH',
]);
const TIMEOUT_CODES = new Set([
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_BODY_TIMEOUT',
'UND_ERR_CONNECT_TIMEOUT',
'ETIMEDOUT',
]);
function errorCode(err: unknown): string | null {
const e = err as { code?: unknown; cause?: { code?: unknown } } | null;
const code = e?.cause?.code ?? e?.code;
return typeof code === 'string' && /^[A-Z0-9_]{2,80}$/.test(code) ? code : null;
}
/** Ordnet einen Transportfehler einer Fehlerart zu (ohne den Fehlertext zu uebernehmen). */
export function classifyTransportError(err: unknown): NcFailure {
const code = errorCode(err);
if (code && TLS_CODES.has(code)) return { ok: false, kind: 'tls', detail: code };
if (code && TIMEOUT_CODES.has(code)) return { ok: false, kind: 'timeout', detail: code };
return { ok: false, kind: 'network', ...(code ? { detail: code } : {}) };
}
function headerOne(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) return value[0];
return value;
}
/** Antwortkoerper verwerfen, ohne einen Fehler nach aussen zu geben. */
export function discardBody(body: Readable | null | undefined): void {
if (!body) return;
try {
const dumpable = body as unknown as { dump?: () => Promise<void> };
if (typeof dumpable.dump === 'function') {
dumpable.dump().catch(() => {});
} else {
body.destroy();
}
} catch {
// schon verbraucht
}
}
const FORBIDDEN_CUSTOM_HEADERS = new Set(['cookie', 'host', 'authorization']);
/**
* Fuehrt einen Aufruf aus. Wirft nie bei Netz- oder HTTP-Problemen; das
* Ergebnis ist `{ ok: true, status, headers, body }` oder `{ ok: false, kind, ... }`.
* HTTP-Fehlerstatus (404, 412, ...) kommen als `ok: true` zurueck, damit der
* Aufrufer sie deuten kann. Ausnahmen, die hier schon entschieden werden:
* - 429: haelt den ganzen Ursprung an (Aufrufsperre) -> `{ ok: false, kind: 'http', status: 429 }`
* - 401 mit `credentialKey`: Schluessel stirbt -> `kind: 'credential-dead'`
* - 3xx: `kind: 'redirect'` (es wird nie gefolgt)
* Vor dem Transport prueft sie die Aufrufsperre (`paused`, `credential-dead`).
*/
export async function ncRequest(
transport: NextcloudTransport,
gate: NextcloudCallGate,
opts: NcRequestOptions,
): Promise<NcResult> {
const url = buildNcUrl(opts.baseUrl, opts.prefix, opts.segments ?? [], opts.query);
const origin = new URL(url).origin;
if (opts.credentialKey && gate.isDead(opts.credentialKey)) {
return { ok: false, kind: 'credential-dead' };
}
const pause = gate.isPaused(origin);
if (pause.paused) {
return { ok: false, kind: 'paused', retryAfterSeconds: pause.retryAfterSeconds };
}
const headers: Record<string, string> = { 'user-agent': NC_USER_AGENT };
if (opts.ocs) {
headers['ocs-apirequest'] = 'true';
headers.accept = 'application/json';
}
for (const [name, value] of Object.entries(opts.headers ?? {})) {
const lower = name.toLowerCase();
if (FORBIDDEN_CUSTOM_HEADERS.has(lower)) {
throw new Error(`Kopfzeile ${lower} darf nicht frei gesetzt werden`);
}
headers[lower] = value;
}
if (opts.authorization) headers.authorization = opts.authorization;
const own = new AbortController();
const signals: AbortSignal[] = [own.signal];
if (opts.signal) signals.push(opts.signal);
if (opts.credentialKey) signals.push(gate.signalFor(opts.credentialKey));
const signal = AbortSignal.any(signals);
const headersTimeoutMs = opts.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS;
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
own.abort(new Error('timeout'));
}, headersTimeoutMs);
// Auch ein Transport, der das Signal nicht beachtet, soll nicht haengen bleiben.
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_, reject) => {
onAbort = () => reject(signal.reason ?? new Error('aborted'));
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
});
aborted.catch(() => {});
const pending = Promise.resolve().then(() =>
transport({
url,
method: opts.method,
headers,
body: opts.body ?? null,
headersTimeoutMs,
bodyTimeoutMs: opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS,
signal,
}),
);
pending.catch(() => {});
let res: NcTransportResponse;
try {
res = await Promise.race([pending, aborted]);
} catch (err) {
// Eine spaet eintreffende Antwort des abgebrochenen Aufrufs wegwerfen.
pending.then((late) => discardBody(late.body)).catch(() => {});
if (opts.credentialKey && gate.isDead(opts.credentialKey)) {
return { ok: false, kind: 'credential-dead' };
}
if (timedOut) return { ok: false, kind: 'timeout' };
if (opts.signal?.aborted) return { ok: false, kind: 'aborted' };
return classifyTransportError(err);
} finally {
clearTimeout(timer);
if (onAbort) signal.removeEventListener('abort', onAbort);
}
const status = res.statusCode;
if (status === 429) {
const seconds = gate.pause(origin, headerOne(res.headers['retry-after']));
discardBody(res.body);
return { ok: false, kind: 'http', status, retryAfterSeconds: seconds };
}
if (status === 401 && opts.credentialKey) {
gate.markDead(opts.credentialKey);
discardBody(res.body);
return { ok: false, kind: 'credential-dead', status };
}
if (status >= 300 && status < 400) {
discardBody(res.body);
return { ok: false, kind: 'redirect', status };
}
return { ok: true, status, headers: res.headers, body: res.body };
}
export type CappedText =
| { ok: true; text: string }
| { ok: false; kind: 'too-large' | 'timeout' | 'network' | 'tls'; detail?: string };
/** Liest einen Antwortkoerper als Text, hoechstens `maxBytes` (sonst `too-large`). */
export async function readCappedText(
body: AsyncIterable<Uint8Array | string> & { destroy?: (err?: Error) => unknown },
maxBytes: number,
): Promise<CappedText> {
const chunks: Buffer[] = [];
let total = 0;
try {
for await (const chunk of body) {
const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk);
total += buf.byteLength;
if (total > maxBytes) {
try {
body.destroy?.();
} catch {
// schon beendet
}
return { ok: false, kind: 'too-large' };
}
chunks.push(buf);
}
} catch (err) {
const failure = classifyTransportError(err);
return {
ok: false,
kind: failure.kind as 'timeout' | 'network' | 'tls',
...(failure.detail ? { detail: failure.detail } : {}),
};
}
return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
}
@@ -8,6 +8,7 @@ import DomaincheckLayout from './domaincheck/layout';
import DomainsLayout from './domains/layout'; import DomainsLayout from './domains/layout';
import HandelswareDatevLayout from './handelsware-datev/layout'; import HandelswareDatevLayout from './handelsware-datev/layout';
import KantineDatevLayout from './kantine-datev/layout'; import KantineDatevLayout from './kantine-datev/layout';
import NextcloudFilesLayout from './nextcloud-files/layout';
import NextcloudStatusLayout from './nextcloud-status/layout'; import NextcloudStatusLayout from './nextcloud-status/layout';
import TenderRadarLayout from './tender-radar/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], ['handelsware-datev', HandelswareDatevLayout],
['nextcloud-status', NextcloudStatusLayout], ['nextcloud-status', NextcloudStatusLayout],
['domains', DomainsLayout], ['domains', DomainsLayout],
['nextcloud-files', NextcloudFilesLayout],
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => { ] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
const element = Layout({ children: placeholderChild }); const element = Layout({ children: placeholderChild });
@@ -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=<IP-Adresse des Tessera-Servers>';
type Message = { kind: 'ok' | 'error'; text: string };
function MessageLine({ message }: { message: Message | null }) {
if (!message) return null;
return (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
);
}
/**
* 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<NextcloudFilesSettings | null>(null);
const [address, setAddress] = useState('');
const [loadFailed, setLoadFailed] = useState(false);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmCount, setConfirmCount] = useState<number | null>(null);
const [message, setMessage] = useState<Message | null>(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 (
<div className="space-y-6">
<SettingsSection
id="nextcloud-files-address"
title={t('address.title')}
description={t('address.description')}
footer={
confirmCount === null ? (
<>
<MessageLine message={message} />
<button
type="button"
className={SECONDARY_BUTTON}
disabled={busy || empty}
onClick={() => void runTest()}
>
{testing ? t('address.testing') : t('address.test')}
</button>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={busy || empty || loadFailed}
onClick={onSaveClick}
>
{saving ? t('saving') : t('save')}
</button>
</>
) : (
<>
<p role="alert" className="mr-auto text-sm text-foreground">
{t('reconnect.message', { count: confirmCount })}
</p>
<button
type="button"
className={SECONDARY_BUTTON}
disabled={saving}
onClick={() => setConfirmCount(null)}
>
{t('reconnect.cancel')}
</button>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving}
onClick={() => void save(true)}
>
{t('reconnect.confirm')}
</button>
</>
)
}
>
{loadFailed ? (
<p role="alert" className="text-sm text-destructive">
{tErrors('request')}
</p>
) : (
<div className="space-y-2">
<label
htmlFor="nextcloud-files-address-input"
className="block text-sm font-medium text-foreground"
>
{t('address.label')}
</label>
<input
id="nextcloud-files-address-input"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
className={INPUT_CLASS}
value={address}
placeholder={t('address.placeholder')}
onChange={(event) => {
setAddress(event.target.value);
setMessage(null);
setConfirmCount(null);
}}
/>
{settings?.baseUrl && (
<p className="text-xs text-muted-foreground">
{t('address.connectedUsers', { count: settings.connectedAccounts })}
</p>
)}
</div>
)}
</SettingsSection>
<SettingsSection
id="nextcloud-files-hints"
title={t('hints.title')}
description={t('hints.description')}
>
<div className="space-y-3 text-sm text-foreground">
<p>{t('hints.whitelist')}</p>
<pre className="overflow-x-auto rounded-md bg-well px-3 py-2 text-xs">
<code>{WHITELIST_COMMAND}</code>
</pre>
<p>{t('hints.https')}</p>
<p>{t('hints.trustedDomains')}</p>
<p>{t('hints.twoFactor')}</p>
</div>
</SettingsSection>
</div>
);
}
@@ -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 <ModuleAccessGate moduleSlug="nextcloud-files">{children}</ModuleAccessGate>;
}
@@ -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(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
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<typeof import('@/lib/nextcloud-files-api')>();
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> = {}): NextcloudFilesStatus {
return {
configured: true,
serverUrl: 'https://cloud.example',
host: 'cloud.example',
account: null,
...over,
};
}
function settings(over: Partial<NextcloudFilesSettings> = {}): 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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
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(<NextcloudFilesPage />);
expect(await screen.findByTestId('nextcloud-files-main')).toBeTruthy();
expect(screen.queryByRole('navigation')).toBeNull();
});
});
describe('SettingsTab', () => {
async function openSettings() {
render(<NextcloudFilesPage />);
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();
});
});
@@ -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<NextcloudFilesStatus | null>(null);
const [statusError, setStatusError] = useState(false);
const [tab, setTab] = useState<TabId>('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 (
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="nextcloud-files" title={t('title')} description={t('description')} />
{statusError && (
<p role="alert" className="text-sm text-destructive">
{t('errors.loadStatus')}
</p>
)}
{canManage && <TabBar tabs={tabs} active={activeTab} onChange={setTab} />}
{activeTab === 'files' && status && !status.configured && (
<SettingsSection title={t('notConfigured.title')}>
<p className="text-sm text-muted-foreground">
{canManage ? t('notConfigured.managerHint') : t('notConfigured.userHint')}
</p>
{canManage && (
<button
type="button"
onClick={() => setTab('settings')}
className="mt-3 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
>
{t('notConfigured.goToSettings')}
</button>
)}
</SettingsSection>
)}
{activeTab === 'files' && status?.configured && (
<section data-testid="nextcloud-files-main" className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('main.server', { host: status.host ?? '' })}
</p>
</section>
)}
{activeTab === 'settings' && canManage && (
<SettingsTab onChanged={() => void reloadStatus()} />
)}
</div>
);
}
@@ -66,6 +66,9 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" /> <path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
</> </>
), ),
folder: (
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
),
tile: ( tile: (
<> <>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /> <path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
+2 -1
View File
@@ -7,7 +7,7 @@
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`). * (`--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<string, ModuleIconId> = { const ICONS: Record<string, ModuleIconId> = {
'tender-radar': 'radar', 'tender-radar': 'radar',
@@ -19,6 +19,7 @@ const ICONS: Record<string, ModuleIconId> = {
'kantine-datev': 'utensils', 'kantine-datev': 'utensils',
'handelsware-datev': 'shopping-bag', 'handelsware-datev': 'shopping-bag',
domains: 'earth', domains: 'earth',
'nextcloud-files': 'folder',
}; };
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */ /** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
+6
View File
@@ -83,6 +83,12 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
{ ssr: false }, { ssr: false },
), ),
}, },
'nextcloud-files': {
component: dynamic(
() => import('@/app/(portal)/modules/nextcloud-files/page'),
{ ssr: false },
),
},
}; };
/** /**
+120
View File
@@ -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<string, unknown> = {},
) {
super(message);
this.name = 'NextcloudFilesRequestError';
}
}
async function failure(res: Response): Promise<NextcloudFilesRequestError> {
let message = `Request failed (${res.status})`;
let code: string | null = null;
let extra: Record<string, unknown> = {};
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<string, unknown>;
extra = rest;
}
} catch {
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
}
return new NextcloudFilesRequestError(res.status, code, message, extra);
}
async function request<T>(
path: string,
init: { method?: string; json?: unknown } = {},
): Promise<T> {
const method = init.method ?? 'GET';
const headers: Record<string, string> = {};
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<NextcloudFilesStatus> {
return request<NextcloudFilesStatus>('/status');
}
export function getNextcloudFilesSettings(): Promise<NextcloudFilesSettings> {
return request<NextcloudFilesSettings>('/settings');
}
export function saveNextcloudFilesSettings(input: {
baseUrl: string;
confirmReconnect?: boolean;
}): Promise<NextcloudFilesSettings> {
return request<NextcloudFilesSettings>('/settings', { method: 'PUT', json: input });
}
export function testNextcloudFilesSettings(baseUrl: string): Promise<NextcloudFilesCheck> {
return request<NextcloudFilesCheck>('/settings/test', { method: 'POST', json: { baseUrl } });
}
+1
View File
@@ -33,6 +33,7 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
'kantine-datev': 'kantineDatev.title', 'kantine-datev': 'kantineDatev.title',
'handelsware-datev': 'handelswareDatev.title', 'handelsware-datev': 'handelswareDatev.title',
domains: 'domains.title', domains: 'domains.title',
'nextcloud-files': 'nextcloudFiles.title',
}; };
/** /**
+48
View File
@@ -2320,5 +2320,53 @@
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.", "request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"loadStatus": "Der Zustand der AutoDNS-Anbindung konnte nicht geladen werden." "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."
}
}
} }
} }
+48
View File
@@ -2320,5 +2320,53 @@
"request": "The request failed. Please try again.", "request": "The request failed. Please try again.",
"loadStatus": "The state of the AutoDNS connection could not be loaded." "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."
}
}
} }
} }
@@ -234,4 +234,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Zusammenfassung', 'Zusammenfassung',
'passender', 'passender',
'dass', 'dass',
// quick-261008-mzu: Modul Dateien (Nextcloud) — korrektes Deutsch mit „ss“
'zuverlässig',
]; ];
File diff suppressed because one or more lines are too long