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:
+84
@@ -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"
|
||||||
|
}
|
||||||
+50
@@ -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"
|
||||||
+64
@@ -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())
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user