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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:41:46 +02:00
parent 83b842b72c
commit 960696745f
35 changed files with 3282 additions and 2 deletions
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Gemeinsame Helfer der End-zu-Ende-Skripte von quick-261008-mzu (wird mit `source` geladen,
# hat keine Nebenwirkungen beim Laden). Alles gegen den lokalen Stack und die Test-Nextcloud
# aus nc-test-setup.sh. Nur Testwerte; liest keine .env-Dateien.
#
# Aufgabe 2 ergaenzt hier die Helfer "anna verbinden" (e2e_connect_anna) auf Basis dieser Datei.
API=${API:-http://localhost:3001}
WEB=${WEB:-http://localhost:3000/api-proxy}
NC_BASE=${NC_BASE:-http://172.17.0.1:18080}
E2E_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
E2E_TMP=${E2E_TMP:-$(mktemp -d)}
# Stoppt mit Meldung, wenn eine Erwartung nicht stimmt.
e2e_fail() { echo "E2E FAIL: $*" >&2; exit 1; }
# e2e_login <jar> [benutzer] [passwort] — meldet an, Cookies landen im <jar>.
e2e_login() {
local jar=$1 user=${2:-admin} pass=${3:-admin123} code
code=$(curl -s -o "$E2E_TMP/login.out" -w '%{http_code}' -c "$jar" -H 'Content-Type: application/json' \
-d "{\"username\":\"$user\",\"password\":\"$pass\"}" "$API/auth/login")
[ "$code" = "200" ] || [ "$code" = "201" ] || e2e_fail "login $user -> $code"
}
# e2e_status <jar> <methode> <url> [json-body] [ausgabedatei] — gibt den HTTP-Status aus,
# der Antwortkoerper landet in <ausgabedatei> (Vorgabe $E2E_TMP/body.out).
e2e_status() {
local jar=$1 method=$2 url=$3 body=${4:-} out=${5:-$E2E_TMP/body.out}
if [ -n "$body" ]; then
curl -s -o "$out" -w '%{http_code}' -b "$jar" -X "$method" -H 'Content-Type: application/json' -d "$body" "$url"
else
curl -s -o "$out" -w '%{http_code}' -b "$jar" -X "$method" "$url"
fi
}
# e2e_expect <erwartet> <ist> <beschreibung>
e2e_expect() { [ "$1" = "$2" ] || e2e_fail "$3: erwartet $1, war $2"; }
# e2e_contains <datei> <text> <beschreibung>
e2e_contains() { grep -q -- "$2" "$1" || { echo "--- Antwort:" >&2; cat "$1" >&2; echo >&2; e2e_fail "$3: '$2' fehlt"; }; }
# e2e_activate <jar> [slug] — aktiviert das Modul, falls noch nicht aktiv.
e2e_activate() {
local jar=$1 slug=${2:-nextcloud-files} id active
e2e_status "$jar" GET "$API/modules/catalog" "" "$E2E_TMP/catalog.out" >/dev/null
id=$(python3 -I -c 'import json,sys
slug=sys.argv[1]
for m in json.load(open(sys.argv[2])):
if m["slug"]==slug: print(m["id"]); break' "$slug" "$E2E_TMP/catalog.out")
[ -n "$id" ] || e2e_fail "Modul $slug nicht im Katalog"
active=$(python3 -I -c 'import json,sys
slug=sys.argv[1]
print([str(m.get("isActiveForTenant")) for m in json.load(open(sys.argv[2])) if m["slug"]==slug][0])' "$slug" "$E2E_TMP/catalog.out")
if [ "$active" != "True" ]; then
local code
code=$(e2e_status "$jar" POST "$API/modules/$id/activate")
case "$code" in 200|201) ;; *) e2e_fail "Aktivierung $slug -> $code" ;; esac
fi
}
# e2e_set_address <jar> [adresse] — setzt die Nextcloud-Adresse (mit Bestaetigung bei Wechsel).
e2e_set_address() {
local jar=$1 base=${2:-$NC_BASE} code
code=$(e2e_status "$jar" PUT "$API/modules/nextcloud-files/settings" "{\"baseUrl\":\"$base\",\"confirmReconnect\":true}")
e2e_expect 200 "$code" "Adresse setzen"
}
# e2e_second_user <name> <jar-ausgabe> — legt (als admin) einen Benutzer mit Rolle USER an,
# setzt sein Passwort auf "<name>-Pass-12345" (Pflicht-Passwortwechsel) und meldet ihn an.
e2e_second_user() {
local name=$1 jar=$2 admin_jar="$E2E_TMP/admin-second.jar" code first="Start-Pass-12345" final="${1}-Pass-12345"
e2e_login "$admin_jar"
code=$(e2e_status "$admin_jar" POST "$API/users" \
"{\"username\":\"$name\",\"email\":\"$name@example.com\",\"password\":\"$first\",\"displayName\":\"$name\",\"role\":\"USER\"}")
case "$code" in 200|201|409) ;; *) e2e_fail "Benutzer $name anlegen -> $code" ;; esac
if [ "$code" = "409" ]; then
e2e_login "$jar" "$name" "$final"
return 0
fi
e2e_login "$jar" "$name" "$first"
code=$(e2e_status "$jar" POST "$API/auth/change-password" "{\"currentPassword\":\"$first\",\"newPassword\":\"$final\"}")
e2e_expect 200 "$code" "Passwortwechsel $name"
e2e_login "$jar" "$name" "$final"
}
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# End-zu-Ende-Pruefung Aufgabe 1 (quick-261008-mzu): Modul aktivieren, Nextcloud-Adresse
# speichern und pruefen. Voraussetzung: lokaler Stack laeuft, nc-test-setup.sh ist gelaufen.
set -euo pipefail
# shellcheck source=e2e-lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/e2e-lib.sh"
JAR="$E2E_TMP/admin.jar"
S="$API/modules/nextcloud-files"
e2e_login "$JAR"
e2e_activate "$JAR"
# Speichern mit Pruefung
code=$(e2e_status "$JAR" PUT "$S/settings" "{\"baseUrl\":\"$NC_BASE\",\"confirmReconnect\":true}")
e2e_expect 200 "$code" "PUT settings"
e2e_contains "$E2E_TMP/body.out" '"ok":true' "PUT settings: check.ok"
# Verbindungspruefung der Test-Nextcloud: Version 34.x
code=$(e2e_status "$JAR" POST "$S/settings/test" "{\"baseUrl\":\"$NC_BASE\"}")
e2e_expect 200 "$code" "POST settings/test"
e2e_contains "$E2E_TMP/body.out" '"ok":true' "settings/test ok"
e2e_contains "$E2E_TMP/body.out" '"version":"34\.' "settings/test Version 34"
# Nicht erreichbare Adresse: 200 mit ok:false
code=$(e2e_status "$JAR" POST "$S/settings/test" '{"baseUrl":"http://172.17.0.1:1"}')
e2e_expect 200 "$code" "settings/test unerreichbar"
e2e_contains "$E2E_TMP/body.out" '"ok":false' "settings/test unerreichbar ok:false"
# Ungueltige Adresse: 400 invalidUrl
code=$(e2e_status "$JAR" PUT "$S/settings" '{"baseUrl":"ftp://x"}')
e2e_expect 400 "$code" "PUT ftp"
e2e_contains "$E2E_TMP/body.out" 'invalidUrl' "PUT ftp invalidUrl"
# Stand und Einstellungen
code=$(e2e_status "$JAR" GET "$S/status")
e2e_expect 200 "$code" "GET status"
e2e_contains "$E2E_TMP/body.out" '"configured":true' "status configured"
e2e_contains "$E2E_TMP/body.out" '"host":"172.17.0.1:18080"' "status host"
code=$(e2e_status "$JAR" GET "$S/settings")
e2e_expect 200 "$code" "GET settings"
e2e_contains "$E2E_TMP/body.out" '"baseUrl":"http://172.17.0.1:18080"' "settings baseUrl"
# Benutzen-Rechte: ein Benutzer ohne Verwalten bekommt 403 auf die Einstellungen, aber 200 auf status
UJAR="$E2E_TMP/user.jar"
e2e_second_user e2euser "$UJAR"
code=$(e2e_status "$UJAR" GET "$S/settings")
case "$code" in 403|404) ;; *) e2e_fail "Benutzer ohne Freigabe: GET settings erwartet 403/404, war $code" ;; esac
echo "e2e settings ok"
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Lokale Test-Nextcloud fuer quick-261008-mzu (idempotent).
# Container: tessera-nc-test (nextcloud:stable, SQLite), Host-Port 18080
# Benutzer: admin / Admin-Pass-12345
# anna / User1-Pass-12345 (ohne Zwei-Faktor, "Anna Müller")
# zoe / User2-Pass-12345 (Zwei-Faktor TOTP erzwungen, "Zwei Faktor")
# TOTP-Geheimnis von zoe im Container: /var/www/zoe-totp-secret
# Code: python3 -I totp.py "$(docker exec tessera-nc-test cat /var/www/zoe-totp-secret)"
# Nur Testwerte, nie fuer echte Systeme verwenden. Liest keine .env-Dateien.
set -euo pipefail
NAME=tessera-nc-test
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../../../.." && pwd)"
occ() { docker exec -u www-data "$NAME" php occ "$@"; }
if ! docker inspect "$NAME" >/dev/null 2>&1; then
docker run -d --name "$NAME" -p 18080:80 \
-e SQLITE_DATABASE=nextcloud \
-e NEXTCLOUD_ADMIN_USER=admin \
-e NEXTCLOUD_ADMIN_PASSWORD='Admin-Pass-12345' \
-e NEXTCLOUD_TRUSTED_DOMAINS='localhost 127.0.0.1 172.17.0.1 tessera-nc-test' \
nextcloud:stable >/dev/null
elif [ "$(docker inspect -f '{{.State.Running}}' "$NAME")" != "true" ]; then
docker start "$NAME" >/dev/null
fi
for _ in $(seq 1 36); do
if curl -s http://localhost:18080/status.php | grep -q '"installed":true'; then break; fi
sleep 5
done
curl -s http://localhost:18080/status.php | grep -q '"installed":true' || { echo "nextcloud not installed in time" >&2; exit 1; }
if ! occ user:info anna >/dev/null 2>&1; then
docker exec -u www-data -e OC_PASS='User1-Pass-12345' "$NAME" \
php occ user:add --password-from-env --display-name="Anna Müller" anna
fi
if ! occ user:info zoe >/dev/null 2>&1; then
docker exec -u www-data -e OC_PASS='User2-Pass-12345' "$NAME" \
php occ user:add --password-from-env --display-name="Zwei Faktor" zoe
fi
if ! occ group:list 2>/dev/null | grep -q 'twofa'; then
occ group:add twofa >/dev/null
fi
occ group:adduser twofa zoe >/dev/null 2>&1 || true
occ twofactorauth:enforce --on --group=twofa >/dev/null
if ! occ twofactorauth:state zoe 2>/dev/null | grep -q totp; then
docker cp "$HERE/totp.php" "$NAME:/tmp/totp.php"
SECRET="$(docker exec -u www-data "$NAME" php /tmp/totp.php zoe secret | tail -1)"
docker exec -u www-data "$NAME" php /tmp/totp.php zoe "$(python3 -I "$HERE/totp.py" "$SECRET")" >/dev/null
docker exec "$NAME" sh -c "printf '%s' '$SECRET' > /var/www/zoe-totp-secret"
fi
# Brute-Force-Sperre: gesamtes Docker-Adressband ausnehmen, damit wiederholte Testlaeufe
# die IP nie sperren. Das 429-Verhalten decken die Einheitstests ab.
occ config:app:set bruteForce whitelist_0 --value=172.16.0.0/12 >/dev/null
# Erreichbarkeit aus dem api-Container (Host-Gateway)
cd "$ROOT"
docker compose exec -T api node -e "fetch('http://172.17.0.1:18080/status.php').then(r=>process.exit(r.status===200?0:1)).catch(()=>process.exit(1))"
echo "nc test ready"
@@ -0,0 +1,14 @@
<?php
// Testhilfe (quick-261008-mzu): TOTP fuer ein Nextcloud-Konto ohne Browser einrichten.
// php totp.php <benutzer> secret -> gibt ein neues Geheimnis aus
// php totp.php <benutzer> <code> -> aktiviert TOTP mit dem Code
// Laeuft im Test-Container (tessera-nc-test) als www-data. Nur Testwerte.
require_once '/var/www/html/lib/base.php';
\OC_App::loadApp('twofactor_totp');
$totp = \OC::$server->get(\OCA\TwoFactorTOTP\Service\ITotp::class);
$user = \OC::$server->get(\OCP\IUserManager::class)->get($argv[1]);
if ($argv[2] === 'secret') {
echo $totp->createSecret($user), "\n";
} else {
var_dump($totp->enable($user, $argv[2]));
}
@@ -0,0 +1,15 @@
"""Testhilfe (quick-261008-mzu): aktueller TOTP-Code zu einem Base32-Geheimnis.
Aufruf: python3 -I totp.py <geheimnis>
"""
import base64
import hashlib
import hmac
import struct
import sys
import time
k = base64.b32decode(sys.argv[1].upper() + '=' * (-len(sys.argv[1]) % 8))
h = hmac.new(k, struct.pack('>Q', int(time.time()) // 30), hashlib.sha1).digest()
o = h[-1] & 15
print('%06d' % ((struct.unpack('>I', h[o:o + 4])[0] & 0x7FFFFFFF) % 1000000))