From fe429dbe0139634aec36ea7a042baff2ae160d0b Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 11:19:31 +0200 Subject: [PATCH] feat(quick-261009-dkv): Oeffentliche Links nach den Regeln der Nextcloud, Uebersichten und Annehmen, Schnittstelle - Links anlegen und aendern: Passwort, Ablaufdatum und Bezeichnung nur im JSON-Koerper, Vorpruefung aus den frisch gelesenen Faehigkeiten (Passwort/Ablauf verlangt, Hochladen, mehrere Links), echte Datumspruefung, nur geaenderte Felder beim Aendern - Routen shares/mine, shares/received (angenommen plus offen, 404/405 bei offen = leer) und POST shares/:id/accept am Ende der Klasse - Fehlerzweige fuer Passwort und Ablauf mit Tests; nie Passwort in Antwort oder Fehler - Live-Test e2e-shares.sh: Abschnitte links (Pflicht-Passwort und Pflicht-Ablauf per occ) und received (eingehend, offen, annehmen, verlassen) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../e2e/e2e-shares.sh | 246 ++++++++- .../module-manage-handlers.spec.ts | 3 + .../dto/nextcloud-files-shares.dto.ts | 58 ++- .../nextcloud-files-shares.service.spec.ts | 473 ++++++++++++++++++ .../nextcloud-files-shares.service.ts | 215 +++++++- .../nextcloud-files.controller.spec.ts | 31 +- .../nextcloud-files.controller.ts | 30 +- .../nextcloud-files/nextcloud-shares.spec.ts | 19 + .../src/nextcloud-files/nextcloud-shares.ts | 2 +- 9 files changed, 1034 insertions(+), 43 deletions(-) diff --git a/.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh b/.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh index 5d1a111..317f833 100644 --- a/.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh +++ b/.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # quick-261009-dkv: Teilen-Schnittstelle gegen die Test-Nextcloud (tessera-nc-test). # Aufruf: e2e-shares.sh [people|links|received|version|all] (Vorgabe: all) -# Aufgabe 1 liefert Aufbau, Aufraeumen und den Abschnitt "people"; links und received -# folgen in Aufgabe 2, version in Aufgabe 3. +# Aufgabe 1 liefert Aufbau, Aufraeumen und den Abschnitt "people"; Aufgabe 2 die Abschnitte +# "links" (Passwort- und Ablaufregeln der Nextcloud per occ umgeschaltet) und "received" +# (eingehende und offene Freigaben); version folgt in Aufgabe 3. # Setzt einen laufenden Stack (api neu gebaut: docker compose up -d --build api) und # nc-test-setup.sh voraus. Nur Testwerte; liest keine .env-Dateien. # @@ -25,13 +26,20 @@ ANNA="anna:User1-Pass-12345" BEN="ben:User3-Pass-12345" EPOCH=$(date +%s) FIX="Tessera-Teilen-$EPOCH" +BENFIX="Ben-Ordner-$EPOCH" +BENBOX="Ben-Briefkasten-$EPOCH" START_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ) +REPO="$(cd "$HERE/../../../.." && pwd)" # --- Aufraeumen: alles zuruecksetzen, was das Skript angefasst hat ---------------------------- cleanup() { local rc=$? set +e curl -s -o /dev/null -u "$ANNA" -X DELETE "$NC_DAV/files/anna/$FIX" 2>/dev/null + curl -s -o /dev/null -u "$BEN" -X DELETE "$NC_DAV/files/ben/$BENFIX" 2>/dev/null + curl -s -o /dev/null -u "$BEN" -X DELETE "$NC_DAV/files/ben/$BENBOX" 2>/dev/null + # Papierkorb leeren, damit abgelehnte oder verlassene Ordner nicht liegen bleiben + NC_OCC trashbin:cleanup ben >/dev/null 2>&1 NC_OCC config:system:delete ratelimit.protection.enabled >/dev/null 2>&1 for key in shareapi_enforce_links_password shareapi_default_expire_date \ shareapi_enforce_expire_date shareapi_expire_after_n_days; do @@ -96,6 +104,50 @@ d=json.load(open(sys.argv[1]))["ocs"]["data"] print([s["permissions"] for s in d if s.get("file_target","").lstrip("/")==sys.argv[2]][0])' "$E2E_TMP/ben.out" "$1" } +# policy_wait — wartet bis zu 30 s, bis die Richtlinie den Ausdruck +# erfuellt. Die Nextcloud haelt ihre Faehigkeiten nach einer occ-Aenderung kurz noch in einem +# Zwischenspeicher (gemessen: Sekunden), Tessera selbst speichert nichts. +policy_wait() { + local i code ok=0 + for i in $(seq 1 40); do + code=$(call GET shares/policy) + if [ "$code" = "200" ] && [ "$(jq_py "$1")" = "True" ]; then + ok=$((ok + 1)) + # dreimal hintereinander, falls mehrere Nextcloud-Prozesse verschieden schnell nachziehen + [ "$ok" -ge 3 ] && return 0 + else + ok=0 + sleep 1 + fi + done + e2e_expect True "$(jq_py "$1")" "$2" +} + +# Direkt bei der Nextcloud: Anzahl der Link-Freigaben (Art 3) eines Pfads in annas Konto +anna_link_count() { + curl -s -u "$ANNA" -H 'OCS-APIRequest: true' -H 'Accept: application/json' -G \ + --data-urlencode "path=$1" "$NC_OCS/shares" -o "$E2E_TMP/anna.out" + python3 -I -c 'import json,sys +d=json.load(open(sys.argv[1]))["ocs"]["data"] +print(len([s for s in d if s.get("share_type")==3]))' "$E2E_TMP/anna.out" +} + +# nc_put — dieselbe Aenderung DIREKT bei der Nextcloud (nur zum Messen des +# Status, den Tessera sonst hinter einem eigenen Fehlercode verbirgt); gibt den Status aus. +nc_put() { + curl -s -o "$E2E_TMP/nc-put.out" -w '%{http_code}' -u "$ANNA" -X PUT \ + -H 'OCS-APIRequest: true' -H 'Accept: application/json' \ + --data-urlencode "$2=$3" "$NC_OCS/shares/$1" +} + +# Direkt bei der Nextcloud: ben teilt einen Ordner mit anna (nur Ansehen); gibt den Status aus. +ben_share_with_anna() { + curl -s -o /dev/null -w '%{http_code}' -u "$BEN" -X MKCOL "$NC_DAV/files/ben/$1" >/dev/null + curl -s -o "$E2E_TMP/ben-share.out" -w '%{http_code}' -u "$BEN" -X POST \ + -H 'OCS-APIRequest: true' -H 'Accept: application/json' \ + --data-urlencode "path=/$1" -d shareType=0 -d shareWith=anna -d permissions=1 "$NC_OCS/shares" +} + # --- Aufbau -------------------------------------------------------------------------------------- e2e_wait_health e2e_login "$ADMIN" @@ -209,8 +261,194 @@ section_people() { echo "e2e shares people ok" } -section_links() { echo "e2e shares links: folgt in Aufgabe 2"; } -section_received() { echo "e2e shares received: folgt in Aufgabe 2"; } +section_links() { + local code id1 id2 id3 id4 before pw plus3 plus30 measured ids urls="" + + # Ausgangslage: keine Pflicht, kein Ablauf (Zustand des Skripts, nicht der Vorlauf) + NC_OCC config:app:delete core shareapi_enforce_links_password >/dev/null 2>&1 || true + policy_wait 'd["links"]["enabled"] is True and d["links"]["passwordRequired"] is False and d["links"]["expiryEnforced"] is False and d["links"]["expiryDefaultDays"] is None' "Ausgangslage: Links an, keine Pflicht" + + # Link ohne Passwort und ohne Ablauf (freiwillig) + code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"\"}") + e2e_expect 201 "$code" "Link anlegen" + e2e_expect True "$(jq_py 'd["kind"]=="link" and d["hasPassword"] is False and d["expiration"] is None and d["access"]=="view" and d["permissions"] & 15 == 1')" "Link: Ansehen, ohne Passwort, ohne Ablauf" + e2e_expect True "$(jq_py 'd["url"].startswith("'"$NC_BASE"'/")')" "Link-Adresse beginnt mit der Nextcloud-Adresse" + id1=$(jq_py 'd["id"]') + urls="$urls $(jq_py 'd["url"]')" + + # Nur hochladen bei einem Ordner + code=$(call POST shares "{\"path\":\"/$FIX/Briefkasten\",\"kind\":\"link\",\"access\":\"upload\",\"expireDate\":\"\"}") + e2e_expect 201 "$code" "Briefkasten-Link anlegen" + e2e_expect True "$(jq_py 'd["permissions"] & 15 == 4 and d["access"]=="upload"')" "Briefkasten-Link: Berechtigung 4 (die Nextcloud setzt bei Links das Teilen-Bit dazu)" + id2=$(jq_py 'd["id"]') + urls="$urls $(jq_py 'd["url"]')" + + # Nur hochladen bei einer Datei: abgelehnt, nichts bei der Nextcloud + before=$(anna_link_count "/$FIX/Bericht.txt") + code=$(call POST shares "{\"path\":\"/$FIX/Bericht.txt\",\"kind\":\"link\",\"access\":\"upload\",\"expireDate\":\"\"}") + e2e_expect 400 "$code" "Nur hochladen bei einer Datei" + e2e_contains "$E2E_TMP/body.out" '"code":"shareAccessInvalid"' "Datei-Upload: shareAccessInvalid" + e2e_expect "$before" "$(anna_link_count "/$FIX/Bericht.txt")" "Nextcloud hat keinen neuen Link fuer die Datei" + + # --- Passwort wird von der Nextcloud verlangt --- + NC_OCC config:app:set core shareapi_enforce_links_password --value=true --type=boolean >/dev/null + policy_wait 'd["links"]["passwordRequired"] is True' "Richtlinie: Passwort verlangt" + + before=$(anna_link_count "/$FIX") + code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"\"}") + e2e_expect 400 "$code" "Link ohne Passwort bei Pflicht" + e2e_contains "$E2E_TMP/body.out" '"code":"sharePasswordRequired"' "ohne Passwort: sharePasswordRequired" + e2e_expect "$before" "$(anna_link_count "/$FIX")" "Nextcloud hat keinen neuen Link (Passwort fehlte)" + + pw="Tessera-E2E-$(python3 -I -c 'import secrets,string;print("".join(secrets.choice(string.ascii_letters+string.digits) for _ in range(16)))')" + code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"password\":\"$pw\",\"expireDate\":\"\",\"label\":\"Kunde\"}") + e2e_expect 201 "$code" "Link mit starkem Passwort" + e2e_expect True "$(jq_py 'd["hasPassword"] is True and d["label"]=="Kunde"')" "Link: hasPassword, Bezeichnung" + if grep -qF -- "$pw" "$E2E_TMP/body.out"; then e2e_fail "das Passwort steht in der Antwort"; fi + id3=$(jq_py 'd["id"]') + urls="$urls $(jq_py 'd["url"]')" + + code=$(call PUT "shares/$id3" '{"password":"abc"}') + e2e_expect 400 "$code" "schwaches Passwort beim Aendern" + e2e_contains "$E2E_TMP/body.out" '"code":"sharePasswordRejected"' "schwach: sharePasswordRejected" + e2e_expect True "$(jq_py 'bool(d.get("ncMessage"))')" "schwach: Meldung der Nextcloud vorhanden" + measured=$(nc_put "$id3" password abc) + echo "gemessen: Nextcloud antwortet auf PUT mit schwachem Passwort mit Status $measured" + + code=$(call PUT "shares/$id3" '{"password":""}') + e2e_expect 400 "$code" "Passwort entfernen bei Pflicht" + e2e_contains "$E2E_TMP/body.out" '"code":"sharePasswordRequired"' "entfernen: sharePasswordRequired" + + NC_OCC config:app:delete core shareapi_enforce_links_password >/dev/null + policy_wait 'd["links"]["passwordRequired"] is False' "Richtlinie: Passwort wieder freiwillig" + + # --- Ablaufdatum wird von der Nextcloud verlangt (7 Tage) --- + NC_OCC config:app:set core shareapi_default_expire_date --value=true --type=boolean >/dev/null + NC_OCC config:app:set core shareapi_enforce_expire_date --value=true --type=boolean >/dev/null + NC_OCC config:app:set core shareapi_expire_after_n_days --value=7 >/dev/null + policy_wait 'd["links"]["expiryDefaultDays"]==7 and d["links"]["expiryEnforced"] is True' "Richtlinie: Ablauf 7 Tage verlangt" + + before=$(anna_link_count "/$FIX") + code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"\"}") + e2e_expect 400 "$code" "Link ohne Ablauf bei Pflicht" + e2e_contains "$E2E_TMP/body.out" '"code":"shareExpiryRequired"' "ohne Ablauf: shareExpiryRequired" + e2e_expect "$before" "$(anna_link_count "/$FIX")" "Nextcloud hat keinen neuen Link (Ablauf fehlte)" + + plus3=$(date -u -d '+3 days' +%F) + plus30=$(date -u -d '+30 days' +%F) + code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"$plus3\"}") + e2e_expect 201 "$code" "Link mit Ablauf in 3 Tagen" + e2e_expect "$plus3" "$(jq_py 'd["expiration"]')" "Link: Ablauf wie gesendet" + id4=$(jq_py 'd["id"]') + urls="$urls $(jq_py 'd["url"]')" + + code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"$plus30\"}") + echo "gemessen: Tessera antwortet auf ein neues Datum jenseits des Hoechstwerts mit Status $code" + e2e_expect 400 "$code" "Link mit Ablauf jenseits des Hoechstwerts" + e2e_contains "$E2E_TMP/body.out" '"code":"shareExpiryInvalid"' "zu spaet: shareExpiryInvalid" + + code=$(call PUT "shares/$id4" "{\"expireDate\":\"$plus30\"}") + e2e_expect 400 "$code" "Ablauf jenseits des Hoechstwerts beim Aendern" + e2e_contains "$E2E_TMP/body.out" '"code":"shareExpiryInvalid"' "Aendern, zu spaet: shareExpiryInvalid" + measured=$(nc_put "$id4" expireDate "$plus30") + echo "gemessen: Nextcloud antwortet auf PUT mit Ablauf jenseits des Hoechstwerts mit Status $measured" + + code=$(call PUT "shares/$id4" '{"expireDate":""}') + e2e_expect 400 "$code" "Ablauf entfernen bei Pflicht" + e2e_contains "$E2E_TMP/body.out" '"code":"shareExpiryRequired"' "Ablauf entfernen: shareExpiryRequired" + + before=$(anna_link_count "/$FIX") + code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"link\",\"access\":\"view\",\"expireDate\":\"31.12.2026x\"}") + e2e_expect 400 "$code" "Datum in falschem Format" + e2e_expect "$before" "$(anna_link_count "/$FIX")" "Nextcloud hat keinen neuen Link (Datum ungueltig)" + + for key in shareapi_default_expire_date shareapi_enforce_expire_date shareapi_expire_after_n_days; do + NC_OCC config:app:delete core "$key" >/dev/null + done + policy_wait 'd["links"]["expiryDefaultDays"] is None and d["links"]["expiryEnforced"] is False' "Richtlinie: Ablauf wieder frei" + + # Uebersicht und Aufraeumen + code=$(call GET shares/mine) + e2e_expect 200 "$code" "Eigene Freigaben" + ids="$id1 $id2 $id3 $id4" + for id in $ids; do + e2e_expect True "$(jq_py 'any(s["id"]=="'"$id"'" and s["kind"]=="link" for s in d["shares"])')" "mine enthaelt Link $id" + done + for id in $ids; do + code=$(call DELETE "shares/$id") + e2e_expect 200 "$code" "Link $id loeschen" + done + e2e_expect 0 "$(anna_link_count "/$FIX")" "alle Links der Fixture sind weg" + + # Weder Passwort noch Link-Adresse im Protokoll der API + local log + log=$(cd "$REPO" && docker compose logs api --since "$START_ISO" 2>&1 || true) + case "$log" in *"$pw"*) e2e_fail "das Passwort steht im Protokoll der API" ;; esac + for u in $urls; do + case "$log" in *"$u"*) e2e_fail "eine Link-Adresse steht im Protokoll der API" ;; esac + done + + echo "e2e shares links ok" +} + +section_received() { + local code id sfx_letters pid + + # Eingehend und angenommen (Vorgabe der Nextcloud: sofort angenommen) + NC_OCC user:setting --delete anna files_sharing default_accept >/dev/null 2>&1 || true + code=$(ben_share_with_anna "$BENFIX") + e2e_expect 200 "$code" "ben teilt einen Ordner mit anna" + + code=$(call GET shares/received) + e2e_expect 200 "$code" "Mit mir geteilt" + e2e_expect True "$(jq_py 'any(s["target"]=="/'"$BENFIX"'" and s["ownerName"]=="Ben Beispiel" and s["canEdit"] is False and s["kind"]=="user" for s in d["shares"])')" "received: Ordner von Ben Beispiel, nicht aenderbar" + id=$(jq_py '[s["id"] for s in d["shares"] if s["target"]=="/'"$BENFIX"'"][0]') + + code=$(qget files path /) + e2e_expect 200 "$code" "Wurzel auflisten" + sfx_letters=$(jq_py '[e["permissions"] for e in d["entries"] if e["name"]=="'"$BENFIX"'"][0]') + echo "gemessen: Berechtigungsbuchstaben des eingehenden Ordners: $sfx_letters" + case "$sfx_letters" in *S*) ;; *) e2e_fail "eingehender Eintrag ohne Buchstaben S ($sfx_letters): Kennzeichen auf nc:mount-type umstellen" ;; esac + + code=$(call GET shares/mine) + e2e_expect 200 "$code" "Eigene Freigaben" + e2e_expect True "$(jq_py 'not any(s["target"]=="/'"$BENFIX"'" or s["path"]=="/'"$BENFIX"'" for s in d["shares"])')" "mine enthaelt die eingehende Freigabe nicht" + + # Verlassen (DELETE durch die Empfaengerin) + code=$(call DELETE "shares/$id") + e2e_expect 200 "$code" "Freigabe verlassen" + code=$(call GET shares/received) + e2e_expect True "$(jq_py 'not any(s["target"]=="/'"$BENFIX"'" for s in d["shares"]+d["pending"])')" "received: die verlassene Freigabe ist weg" + curl -s -u "$BEN" -H 'OCS-APIRequest: true' -H 'Accept: application/json' -G \ + --data-urlencode "path=/$BENFIX" "$NC_OCS/shares" -o "$E2E_TMP/ben-after.out" + echo "gemessen: bens eigene Liste nach dem Verlassen: $(python3 -I -c 'import json,sys +d=json.load(open(sys.argv[1]))["ocs"]["data"] +print(len(d), "Freigaben", [(s.get("share_type"), s.get("share_with"), s.get("permissions")) for s in d])' "$E2E_TMP/ben-after.out")" + + # Offene Freigabe: annas Vorgabe auf "nicht automatisch annehmen" + NC_OCC user:setting anna files_sharing default_accept no >/dev/null + code=$(ben_share_with_anna "$BENBOX") + e2e_expect 200 "$code" "ben teilt den Briefkasten mit anna" + code=$(call GET shares/received) + e2e_expect 200 "$code" "Mit mir geteilt (offen)" + e2e_expect True "$(jq_py 'any(s["name"].startswith("'"$BENBOX"'") and s.get("pending") is True for s in d["pending"]) and not any(s["name"].startswith("'"$BENBOX"'") for s in d["shares"])')" "received: offene Freigabe nur unter pending" + pid=$(jq_py '[s["id"] for s in d["pending"] if s["name"].startswith("'"$BENBOX"'")][0]') + + code=$(call POST "shares/$pid/accept") + e2e_expect 200 "$code" "offene Freigabe annehmen" + e2e_expect True "$(jq_py 'd["accepted"] is True')" "accept: accepted" + code=$(call GET shares/received) + e2e_expect True "$(jq_py 'any(s["name"].startswith("'"$BENBOX"'") for s in d["shares"]) and not d["pending"]')" "received: angenommen, nichts mehr offen" + + code=$(call DELETE "shares/$pid") + e2e_expect 200 "$code" "angenommene Freigabe verlassen" + code=$(call POST "shares/abc/accept") + e2e_expect 404 "$code" "annehmen mit ungueltiger Kennung" + e2e_contains "$E2E_TMP/body.out" '"code":"shareNotFound"' "accept abc: shareNotFound" + NC_OCC user:setting --delete anna files_sharing default_accept >/dev/null 2>&1 || true + + echo "e2e shares received ok" +} section_version() { echo "e2e shares version: folgt in Aufgabe 3"; } case "$SECTION" in diff --git a/apps/api/src/module-registry/module-manage-handlers.spec.ts b/apps/api/src/module-registry/module-manage-handlers.spec.ts index e8dc635..9235f1f 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -168,8 +168,11 @@ describe('Umgestellte Handler (Verwalten)', () => { 'listSharesForPath', 'searchSharees', 'createShare', + 'listMyShares', + 'listReceivedShares', 'updateShare', 'deleteShare', + 'acceptShare', ])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => { const fn = handler(NextcloudFilesController, name); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined(); diff --git a/apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts b/apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts index 230ee2b..2a33ec6 100644 --- a/apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts +++ b/apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts @@ -1,4 +1,12 @@ -import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { + IsIn, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + ValidateIf, +} from 'class-validator'; /** * Eingaben der Freigaberouten (quick-261009-dkv, D-11). Der Browser schickt nur @@ -12,8 +20,11 @@ import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'clas // biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff const NO_CONTROL = /^[^\u0000-\u001f\u007f]*$/; -export const SHARE_KINDS_PEOPLE = ['user', 'group'] as const; +export const SHARE_KINDS = ['user', 'group', 'link'] as const; export const SHARE_ACCESS = ['view', 'edit', 'upload'] as const; +/** Ablaufdatum: leer (ohne Ablauf) oder `YYYY-MM-DD`; ob es ein echtes Datum ist, prueft der Dienst. */ +const EXPIRE_DATE = /^(\d{4}-\d{2}-\d{2})?$/; + export const ITEM_TYPES = ['file', 'folder'] as const; export class ShareByPathQueryDto { @@ -40,21 +51,58 @@ export class CreateShareDto { @MaxLength(4096) path!: string; - @IsIn(SHARE_KINDS_PEOPLE) - kind!: (typeof SHARE_KINDS_PEOPLE)[number]; + @IsIn(SHARE_KINDS) + kind!: (typeof SHARE_KINDS)[number]; + /** Nur bei Personen und Gruppen; bei Links ohne Bedeutung. */ + @ValidateIf((o: CreateShareDto) => o.kind !== 'link') @IsString() @IsNotEmpty() @MaxLength(255) @Matches(NO_CONTROL) - shareWith!: string; + shareWith?: string; @IsIn(SHARE_ACCESS) access!: (typeof SHARE_ACCESS)[number]; + + /** Nur Links. Nie in einer Adresse, nie in einer Antwort oder einem Log. */ + @IsOptional() + @IsString() + @MaxLength(256) + password?: string; + + @IsOptional() + @IsString() + @Matches(EXPIRE_DATE) + expireDate?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + @Matches(NO_CONTROL) + label?: string; } export class UpdateShareDto { @IsOptional() @IsIn(SHARE_ACCESS) access?: (typeof SHARE_ACCESS)[number]; + + /** Leer = Passwort entfernen (der Dienst prueft, ob die Nextcloud ein Passwort verlangt). */ + @IsOptional() + @IsString() + @MaxLength(256) + password?: string; + + /** Leer = Ablaufdatum entfernen. */ + @IsOptional() + @IsString() + @Matches(EXPIRE_DATE) + expireDate?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + @Matches(NO_CONTROL) + label?: string; } diff --git a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts index 9adf125..35e153b 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts @@ -518,3 +518,476 @@ describe('NextcloudFilesSharesService — remove, lesen, Richtlinie', () => { expect([401, 403]).not.toContain(statusOf(e)); }); }); + +// --- Links (quick-261009-dkv, Aufgabe 2) ----------------------------------------------------------- + +const PW = 'Geheim-Pass-2026!'; + +function linkJson(over: Record = {}) { + return shareJson({ + id: '31', + share_type: 3, + share_with: null, + share_with_displayname: null, + permissions: 1, + token: 'AbC123', + url: 'https://cloud.example/nc/index.php/s/AbC123', + password: 'redacted', + label: 'Kunde', + ...over, + }); +} + +/** Faehigkeiten mit Link-Regeln: `public` wird mit den angegebenen Werten ueberlagert. */ +const LINK_CAPS = (pub: Record = {}, over: Record = {}) => + CAPS({ public: { enabled: true, upload: true, ...pub }, ...over }); + +const LINK_INPUT = { + path: '/Projekte', + kind: 'link', + access: 'view', + password: PW, + expireDate: '', + label: 'Kunde', +} as const; + +function linkReplies( + over: { stat?: Reply; caps?: Reply; list?: Reply; post?: Reply } = {}, + withList = false, +): Reply[] { + const replies: Reply[] = [ + over.stat ?? { status: 207, text: statXml('Projekte', true, 'RGDNVCK') }, + over.caps ?? { status: 200, text: LINK_CAPS() }, + ]; + if (withList) replies.push(over.list ?? { status: 200, text: ocs([]) }); + replies.push(over.post ?? { status: 200, text: ocs(linkJson()) }); + return replies; +} + +describe('NextcloudFilesSharesService — Link anlegen', () => { + it('sendet genau den erwarteten JSON-Koerper und gibt weder Passwort noch Kennung heraus', async () => { + const { service, calls } = setup(linkReplies()); + const share = await service.create('t1', 'u1', LINK_INPUT); + expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([ + `PROPFIND ${DAV}/Projekte`, + `GET ${CAPS_URL}`, + `POST ${SHARES}`, + ]); + expect(calls[2].body).toBe( + '{"path":"/Projekte","shareType":3,"permissions":1,"password":"Geheim-Pass-2026!","expireDate":"","label":"Kunde"}', + ); + expect(share).toMatchObject({ + id: '31', + kind: 'link', + hasPassword: true, + url: 'https://cloud.example/nc/index.php/s/AbC123', + label: 'Kunde', + }); + const text = JSON.stringify(share); + expect(text).not.toContain(PW); + expect(text).not.toContain('redacted'); + expect(text).not.toContain('"token"'); + }); + + it('ohne Passwort, Ablauf und Bezeichnung werden diese Felder nicht gesendet', async () => { + const { service, calls } = setup(linkReplies()); + await service.create('t1', 'u1', { path: '/Projekte', kind: 'link', access: 'view' }); + expect(calls[2].body).toBe('{"path":"/Projekte","shareType":3,"permissions":1}'); + }); + + it('Nur hochladen bei einem Ordner sendet 4; bei einer Datei und ohne erlaubtes Hochladen: shareAccessInvalid ohne POST', async () => { + const ok = setup(linkReplies()); + await ok.service.create('t1', 'u1', { ...LINK_INPUT, access: 'upload' }); + expect(ok.calls[2].body).toContain('"permissions":4'); + + const file = setup( + linkReplies({ stat: { status: 207, text: statXml('Bericht.txt', false, 'RGDNVW') } }), + ); + const e1 = await failure( + file.service.create('t1', 'u1', { ...LINK_INPUT, path: '/Bericht.txt', access: 'upload' }), + ); + expect(codeOf(e1)).toBe('shareAccessInvalid'); + expect(statusOf(e1)).toBe(400); + expect(file.calls.some((c) => c.method === 'POST')).toBe(false); + + for (const access of ['edit', 'upload'] as const) { + const off = setup(linkReplies({ caps: { status: 200, text: LINK_CAPS({ upload: false }) } })); + const e2 = await failure(off.service.create('t1', 'u1', { ...LINK_INPUT, access })); + expect(codeOf(e2)).toBe('shareAccessInvalid'); + expect(off.calls.some((c) => c.method === 'POST')).toBe(false); + } + }); + + it('Ordner ohne Schreibrecht (RG): Nur hochladen und Bearbeiten sind shareAccessInvalid', async () => { + for (const access of ['edit', 'upload'] as const) { + const { service, calls } = setup( + linkReplies({ stat: { status: 207, text: statXml('Projekte', true, 'RG') } }), + ); + const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, access })); + expect(codeOf(e)).toBe('shareAccessInvalid'); + expect(calls.some((c) => c.method === 'POST')).toBe(false); + } + }); + + it('Links ausgeschaltet: 409 linkSharingDisabled', async () => { + const { service, calls } = setup( + linkReplies({ caps: { status: 200, text: CAPS({ public: { enabled: false } }) } }), + ); + const e = await failure(service.create('t1', 'u1', LINK_INPUT)); + expect(codeOf(e)).toBe('linkSharingDisabled'); + expect(statusOf(e)).toBe(409); + expect(calls.some((c) => c.method === 'POST')).toBe(false); + }); + + it('nur ein Link erlaubt und schon einer da: 409 shareAlreadyExists; mehrere erlaubt: keine Listenabfrage', async () => { + const one = setup( + linkReplies( + { + caps: { status: 200, text: LINK_CAPS({ multiple_links: false }) }, + list: { status: 200, text: ocs([linkJson()]) }, + }, + true, + ), + ); + const e = await failure(one.service.create('t1', 'u1', LINK_INPUT)); + expect(codeOf(e)).toBe('shareAlreadyExists'); + expect(one.calls.some((c) => c.method === 'POST')).toBe(false); + + const many = setup(linkReplies()); + await many.service.create('t1', 'u1', LINK_INPUT); + expect(many.calls.some((c) => c.url === BY_PATH)).toBe(false); + }); + + it('Passwort verlangt und keins gesendet: 400 sharePasswordRequired nach nur zwei Aufrufen', async () => { + for (const password of [undefined, '']) { + const { service, calls } = setup( + linkReplies({ + caps: { status: 200, text: LINK_CAPS({ password: { enforced: true } }) }, + }), + ); + const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, password })); + expect(codeOf(e)).toBe('sharePasswordRequired'); + expect(statusOf(e)).toBe(400); + expect(calls.map((c) => c.method)).toEqual(['PROPFIND', 'GET']); + } + }); + + it('Ablauf verlangt und fehlend oder leer: 400 shareExpiryRequired ohne POST', async () => { + for (const expireDate of [undefined, '']) { + const { service, calls } = setup( + linkReplies({ + caps: { + status: 200, + text: LINK_CAPS({ expire_date: { enabled: true, days: '7', enforced: true } }), + }, + }), + ); + const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, expireDate })); + expect(codeOf(e)).toBe('shareExpiryRequired'); + expect(calls.some((c) => c.method === 'POST')).toBe(false); + } + }); + + it('Ablauf verlangt und ein Datum gesendet: das Datum steht im Koerper', async () => { + const { service, calls } = setup( + linkReplies({ + caps: { + status: 200, + text: LINK_CAPS({ expire_date: { enabled: true, days: '7', enforced: true } }), + }, + post: { status: 200, text: ocs(linkJson({ expiration: '2026-12-01 00:00:00' })) }, + }), + ); + const share = await service.create('t1', 'u1', { ...LINK_INPUT, expireDate: '2026-12-01' }); + expect(calls[2].body).toContain('"expireDate":"2026-12-01"'); + expect(share.expiration).toBe('2026-12-01'); + }); + + it("kein echtes Datum ('2026-02-30'): 400 shareExpiryInvalid ohne POST", async () => { + const { service, calls } = setup(linkReplies()); + const e = await failure( + service.create('t1', 'u1', { ...LINK_INPUT, expireDate: '2026-02-30' }), + ); + expect(codeOf(e)).toBe('shareExpiryInvalid'); + expect(statusOf(e)).toBe(400); + expect(calls.some((c) => c.method === 'POST')).toBe(false); + }); + + it('Passwort, Ablauf und Bezeichnung werden bei Personen und Gruppen nie weitergegeben', async () => { + const { service, calls } = setup(createReplies()); + await service.create('t1', 'u1', { + ...INPUT, + password: PW, + expireDate: '2026-12-01', + label: 'x', + }); + expect(calls[3].body).toBe( + '{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}', + ); + }); + + it('Person oder Gruppe ohne Empfaenger: 422 shareRecipientInvalid ohne Aufruf', async () => { + const { service, calls } = setup([]); + const e = await failure( + service.create('t1', 'u1', { path: '/Projekte', kind: 'user', access: 'view' }), + ); + expect(codeOf(e)).toBe('shareRecipientInvalid'); + expect(calls).toHaveLength(0); + }); + + it.each([ + [400, 'Password is too short', { password: PW, expireDate: '' }, 'sharePasswordRejected', 400], + [ + 404, + 'Expiration date is in the past', + { password: PW, expireDate: '2026-12-01' }, + 'shareExpiryInvalid', + 400, + ], + [ + 403, + 'Passwords are enforced for link and mail shares', + { password: PW, expireDate: '' }, + 'shareRejected', + 422, + ], + [404, 'Could not create share', { password: PW, expireDate: '' }, 'notFound', 404], + ] as const)('POST antwortet %i (%s) -> %s', async (nc, message, extra, code, http) => { + const { service } = setup(linkReplies({ post: { status: nc, text: ocs([], nc, message) } })); + const e = await failure(service.create('t1', 'u1', { ...LINK_INPUT, ...extra })); + expect(codeOf(e)).toBe(code); + expect(statusOf(e)).toBe(http); + expect([401, 403]).not.toContain(statusOf(e)); + if (code !== 'notFound') expect(bodyOf(e).ncMessage).toBe(message); + expect(JSON.stringify(bodyOf(e))).not.toContain(PW); + }); +}); + +describe('NextcloudFilesSharesService — Link aendern', () => { + const PUT31 = `PUT ${SHARES}/31`; + const linkWith = (over: Record = {}) => ({ + status: 200, + text: ocs([linkJson(over)]), + }); + const caps = (pub: Record = {}): Reply => ({ + status: 200, + text: LINK_CAPS(pub), + }); + + it('sendet nur die geaenderten Felder', async () => { + const cases: Array<[Record, string]> = [ + [{ password: 'Neu-Pass-2026!x' }, '{"password":"Neu-Pass-2026!x"}'], + [{ expireDate: '' }, '{"expireDate":""}'], + [{ access: 'upload' }, '{"permissions":4}'], + [{ label: 'Angebot' }, '{"label":"Angebot"}'], + ]; + for (const [input, body] of cases) { + const { service, calls } = setup([ + linkWith({ expiration: '2026-12-01 00:00:00' }), + caps(), + { status: 200, text: ocs(linkJson()) }, + ]); + await service.update('t1', 'u1', '31', input); + expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([ + `GET ${SHARES}/31`, + `GET ${CAPS_URL}`, + PUT31, + ]); + expect(calls[2].body).toBe(body); + } + }); + + it('unveraenderte Werte ergeben kein PUT', async () => { + const { service, calls } = setup([ + linkWith({ expiration: '2026-12-01 00:00:00', label: 'Kunde', password: null }), + caps(), + ]); + await service.update('t1', 'u1', '31', { + access: 'view', + expireDate: '2026-12-01', + label: 'Kunde', + password: '', + }); + expect(calls.some((c) => c.method === 'PUT')).toBe(false); + }); + + it("Passwort '' bei verlangtem Passwort: 400 sharePasswordRequired ohne PUT", async () => { + const { service, calls } = setup([linkWith(), caps({ password: { enforced: true } })]); + const e = await failure(service.update('t1', 'u1', '31', { password: '' })); + expect(codeOf(e)).toBe('sharePasswordRequired'); + expect(calls.some((c) => c.method === 'PUT')).toBe(false); + }); + + it("Ablauf '' bei verlangtem Ablauf: 400 shareExpiryRequired ohne PUT", async () => { + const { service, calls } = setup([ + linkWith({ expiration: '2026-12-01 00:00:00' }), + caps({ expire_date: { enabled: true, days: '7', enforced: true } }), + ]); + const e = await failure(service.update('t1', 'u1', '31', { expireDate: '' })); + expect(codeOf(e)).toBe('shareExpiryRequired'); + expect(calls.some((c) => c.method === 'PUT')).toBe(false); + }); + + it("Passwort '' ohne Pflicht entfernt das Passwort (nur wenn eins gesetzt ist)", async () => { + const { service, calls } = setup([ + linkWith(), + caps(), + { status: 200, text: ocs(linkJson({ password: null })) }, + ]); + const share = await service.update('t1', 'u1', '31', { password: '' }); + expect(calls[2].body).toBe('{"password":""}'); + expect(share.hasPassword).toBe(false); + }); + + it('Links ausgeschaltet: 409 linkSharingDisabled', async () => { + const { service } = setup([ + linkWith(), + { status: 200, text: CAPS({ public: { enabled: false } }) }, + ]); + expect(codeOf(await failure(service.update('t1', 'u1', '31', { label: 'x' })))).toBe( + 'linkSharingDisabled', + ); + }); + + it('Bearbeiten oder Hochladen ohne erlaubtes Hochladen: shareAccessInvalid', async () => { + const { service } = setup([linkWith(), caps({ upload: false })]); + expect(codeOf(await failure(service.update('t1', 'u1', '31', { access: 'edit' })))).toBe( + 'shareAccessInvalid', + ); + }); + + it('kein echtes Datum: 400 shareExpiryInvalid ohne PUT', async () => { + const { service, calls } = setup([linkWith(), caps()]); + const e = await failure(service.update('t1', 'u1', '31', { expireDate: '2026-13-01' })); + expect(codeOf(e)).toBe('shareExpiryInvalid'); + expect(calls.some((c) => c.method === 'PUT')).toBe(false); + }); + + it.each([ + [400, { password: 'schwach' }, 'sharePasswordRejected', 400], + [400, { expireDate: '2027-01-01' }, 'shareExpiryInvalid', 400], + [404, { expireDate: '2027-01-01' }, 'shareExpiryInvalid', 400], + [404, { label: 'x' }, 'shareNotFound', 404], + [400, { label: 'x' }, 'shareRejected', 422], + [403, { label: 'x' }, 'shareRejected', 422], + ] as const)('PUT antwortet %i mit %j -> %s', async (nc, input, code, http) => { + const { service } = setup([ + linkWith(), + caps(), + { status: nc, text: ocs([], nc, 'Failed to update share.') }, + ]); + const e = await failure(service.update('t1', 'u1', '31', input)); + expect(codeOf(e)).toBe(code); + expect(statusOf(e)).toBe(http); + if (code !== 'shareNotFound') expect(bodyOf(e).ncMessage).toBe('Failed to update share.'); + expect(JSON.stringify(bodyOf(e))).not.toContain('schwach'); + }); + + it('bei Personenfreigaben werden Link-Felder verworfen', async () => { + const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]); + await service.update('t1', 'u1', '17', { password: 'x', expireDate: '2026-12-01', label: 'y' }); + expect(calls).toHaveLength(1); + }); +}); + +describe('NextcloudFilesSharesService — Uebersichten und Annehmen', () => { + const received = (over: Record = {}) => + shareJson({ + id: '40', + uid_owner: 'ben', + displayname_owner: 'Ben Beispiel', + share_with: 'anna', + path: '/Ben-Ordner', + file_target: '/Ben-Ordner', + permissions: 1, + can_edit: false, + ...over, + }); + + it('mine: GET auf die Freigaben ohne Abfrage, Arten 0/1/3, andere nur als Zahl', async () => { + const { service, calls } = setup([ + { + status: 200, + text: ocs([shareJson(), linkJson(), { share_type: 4, id: '5' }]), + }, + ]); + const out = await service.mine('t1', 'u1'); + expect(calls).toHaveLength(1); + expect(`${calls[0].method} ${calls[0].url}`).toBe(`GET ${SHARES}`); + expect(out.shares.map((s) => s.kind)).toEqual(['user', 'link']); + expect(out.hidden).toBe(1); + expect(out.truncated).toBe(false); + }); + + it('received: angenommene plus offene Freigaben, nur Arten 0/1, offene mit pending', async () => { + const { service, calls } = setup([ + { + status: 200, + text: ocs([ + received(), + received({ id: '41', share_type: 1 }), + linkJson({ uid_owner: 'ben' }), + ]), + }, + { status: 200, text: ocs([received({ id: '42', file_target: '/Ben-Briefkasten' })]) }, + ]); + const out = await service.received('t1', 'u1'); + expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([ + `GET ${SHARES}?shared_with_me=true`, + `GET ${SHARES}/pending`, + ]); + expect(out.shares.map((s) => s.id)).toEqual(['40', '41']); + expect(out.pending.map((s) => [s.id, s.pending])).toEqual([['42', true]]); + expect(out.pending[0].name).toBe('Ben-Briefkasten'); + expect(out.shares.every((s) => s.pending === undefined)).toBe(true); + expect(out.hidden).toBe(1); + }); + + it('received: eine offene Freigabe steht nicht doppelt bei den angenommenen', async () => { + const { service } = setup([ + { status: 200, text: ocs([received()]) }, + { status: 200, text: ocs([received()]) }, + ]); + const out = await service.received('t1', 'u1'); + expect(out.shares).toHaveLength(0); + expect(out.pending.map((s) => s.id)).toEqual(['40']); + }); + + it.each([ + 404, 405, + ])('received: die offene Liste antwortet %i -> leer, kein Fehler', async (nc) => { + const { service } = setup([ + { status: 200, text: ocs([received()]) }, + { status: nc, text: ocs([], nc, 'nope') }, + ]); + const out = await service.received('t1', 'u1'); + expect(out.pending).toEqual([]); + expect(out.shares).toHaveLength(1); + }); + + it('received: 500 bei der offenen Liste ist ein Fehler, 401 markiert die Verbindung', async () => { + const boom = setup([ + { status: 200, text: ocs([]) }, + { status: 500, text: ocs([], 500, 'x') }, + ]); + expect(codeOf(await failure(boom.service.received('t1', 'u1')))).toBe('nextcloudError'); + const dead = setup([{ status: 401 }]); + expect(codeOf(await failure(dead.service.received('t1', 'u1')))).toBe('connectionExpired'); + expect(dead.account.markExpired).toHaveBeenCalledTimes(1); + }); + + it('accept: POST auf pending/', async () => { + const { service, calls } = setup([{ status: 200, text: ocs([]) }]); + await expect(service.accept('t1', 'u1', '17')).resolves.toEqual({ accepted: true }); + expect(`${calls[0].method} ${calls[0].url}`).toBe(`POST ${SHARES}/pending/17`); + expect(calls[0].body).toBeNull(); + }); + + it('accept: 404 -> shareNotFound, falsche Kennung ohne Aufruf', async () => { + const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]); + expect(codeOf(await failure(nf.service.accept('t1', 'u1', '17')))).toBe('shareNotFound'); + const bad = setup([]); + expect(codeOf(await failure(bad.service.accept('t1', 'u1', 'x')))).toBe('shareNotFound'); + expect(bad.calls).toHaveLength(0); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts index 4248926..3b14668 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts @@ -8,6 +8,7 @@ import { NextcloudLoginGuard } from './nextcloud-login-guard'; import type { NcEntry } from './nextcloud-propfind'; import { CAPABILITIES_SEGMENTS, + isRealDate, isShareId, type NcItemType, type NcShareKind, @@ -42,16 +43,27 @@ import { mapNcFailure } from './nextcloud-upstream'; * Nie loggen: Koerper, Passwoerter, Kennungen, Link-Adressen. */ -/** Eingabe fuer eine neue Freigabe an Person oder Gruppe (Links folgen in Aufgabe 2). */ +/** + * Eingabe fuer eine neue Freigabe. Bei Links gelten `password`, `expireDate` und `label` + * (`expireDate: ''` heisst "ohne Ablauf" und wird gesendet; fehlt das Feld, gilt die + * Voreinstellung der Nextcloud); bei Personen und Gruppen werden diese Felder verworfen. + */ export interface CreateShareInput { path: string; - kind: 'user' | 'group'; - shareWith: string; + kind: 'user' | 'group' | 'link'; + shareWith?: string; access: 'view' | 'edit' | 'upload'; + password?: string; + expireDate?: string; + label?: string; } +/** Leere Zeichenkette heisst bei Passwort/Ablauf "entfernen", bei der Bezeichnung "leeren". */ export interface UpdateShareInput { access?: 'view' | 'edit' | 'upload'; + password?: string; + expireDate?: string; + label?: string; } /** Welche Felder gesendet wurden: bestimmt, wie ein Fehler gedeutet wird (D-15). */ @@ -273,9 +285,32 @@ export class NextcloudFilesSharesService { return /[WCK]/.test(entry.permissions); } + /** Passwort- und Ablaufregeln fuer einen Link; Fehler bevor irgendetwas an Nextcloud geht (D-16). */ + private checkLinkRules( + policy: NcSharePolicy, + password: string | undefined, + expireDate: string | undefined, + mode: 'create' | 'update', + ): void { + // Beim Anlegen fehlt das Passwort, beim Aendern heisst '' "Passwort entfernen". + const passwordMissing = mode === 'create' ? !password : password === ''; + if (policy.links.passwordRequired && passwordMissing) { + throw ncErrorDefault('sharePasswordRequired'); + } + if (expireDate !== undefined && expireDate !== '' && !isRealDate(expireDate)) { + throw ncErrorDefault('shareExpiryInvalid'); + } + const expiryMissing = mode === 'create' ? !expireDate : expireDate === ''; + if (policy.links.expiryEnforced && expiryMissing) { + throw ncErrorDefault('shareExpiryRequired'); + } + } + async create(tenantId: string, userId: string, input: CreateShareInput): Promise { const segments = parseUserPath(input.path); if (segments.length === 0) throw ncErrorDefault('invalidPath'); + const isLink = input.kind === 'link'; + if (!isLink && !input.shareWith) throw ncErrorDefault('shareRecipientInvalid'); const session = await this.session(tenantId, userId); // Art und Schreibbarkeit kommen aus der eigenen Abfrage, nie aus dem Browser (T-dkv-01). @@ -292,17 +327,45 @@ export class NextcloudFilesSharesService { if (input.kind === 'group' && !policy.groupsEnabled) { throw ncErrorDefault('sharingDisabled', { scope: 'groups' }, GROUPS_OFF); } + if (isLink && !policy.links.enabled) throw ncErrorDefault('linkSharingDisabled'); if (!entry.permissions.includes('R')) throw ncErrorDefault('shareRejected'); - if (input.access === 'upload' || (input.access === 'edit' && !this.entryWritable(entry))) { + if ( + (input.access === 'upload' && !isLink) || + (input.access !== 'view' && !this.entryWritable(entry)) || + (isLink && input.access !== 'view' && !policy.links.uploadAllowed) + ) { throw ncErrorDefault('shareAccessInvalid'); } const permissions = permissionsFor(input.access, entry.type); if (permissions === null) throw ncErrorDefault('shareAccessInvalid'); + if (isLink) this.checkLinkRules(policy, input.password, input.expireDate, 'create'); const path = pathOf(segments); - const existing = await this.listByPath(tenantId, userId, session, path); - if (existing.shares.some((s) => s.kind === input.kind && s.shareWith === input.shareWith)) { - throw ncErrorDefault('shareAlreadyExists'); + // Doppelte Freigaben wuerden bei Nextcloud die Benachrichtigung erneut ausloesen (T-dkv-09); + // bei Links nur noetig, wenn die Nextcloud nur einen Link je Eintrag erlaubt. + if (!isLink || !policy.links.multipleLinks) { + const existing = await this.listByPath(tenantId, userId, session, path); + const duplicate = isLink + ? existing.shares.some((s) => s.kind === 'link') + : existing.shares.some((s) => s.kind === input.kind && s.shareWith === input.shareWith); + if (duplicate) throw ncErrorDefault('shareAlreadyExists'); + } + + const json: Record = isLink + ? { path, shareType: 3, permissions } + : { path, shareType: input.kind === 'user' ? 0 : 1, shareWith: input.shareWith, permissions }; + const sent: SentFields = { kind: input.kind }; + if (isLink) { + if (input.password) { + json.password = input.password; + sent.passwordNonEmpty = true; + } + if (input.expireDate !== undefined) { + json.expireDate = input.expireDate; + sent.expireDateSent = true; + sent.expireDateNonEmpty = input.expireDate !== ''; + } + if (input.label) json.label = input.label; } // Erst nach jeder Vorpruefung zaehlen: abgelehnte Eingaben verbrauchen kein Kontingent. @@ -311,18 +374,9 @@ export class NextcloudFilesSharesService { tenantId, userId, session, - { - method: 'POST', - segments: SHARES_BASE_SEGMENTS, - json: { - path, - shareType: input.kind === 'user' ? 0 : 1, - shareWith: input.shareWith, - permissions, - }, - }, + { method: 'POST', segments: SHARES_BASE_SEGMENTS, json }, 'create', - { kind: input.kind }, + sent, ); const created = parseShareList(data, session.ncUserId).shares[0]; if (!created) throw ncErrorDefault('nextcloudError'); @@ -339,23 +393,60 @@ export class NextcloudFilesSharesService { const session = await this.session(tenantId, userId); const current = await this.loadShare(tenantId, userId, session, id); if (!current.canEdit) throw ncErrorDefault('shareRejected'); - if (input.access === undefined) return current; + const isLink = current.kind === 'link'; + // Passwort, Ablauf und Bezeichnung gibt es nur bei Links (bei anderen Arten verworfen). + const wantsLinkFields = + isLink && + (input.password !== undefined || input.expireDate !== undefined || input.label !== undefined); + if (input.access === undefined && !wantsLinkFields) return current; - if (current.kind === 'link') throw ncErrorDefault('shareAccessInvalid'); - if (input.access === 'upload' || (input.access === 'edit' && !current.itemWritable)) { - throw ncErrorDefault('shareAccessInvalid'); + const policy = isLink ? await this.loadPolicy(tenantId, userId, session) : null; + if (policy !== null) { + if (!policy.enabled) throw ncErrorDefault('sharingDisabled'); + if (!policy.links.enabled) throw ncErrorDefault('linkSharingDisabled'); } - const permissions = permissionsFor(input.access, current.itemType); - if (permissions === null) throw ncErrorDefault('shareAccessInvalid'); - if (current.access === input.access) return current; + const json: Record = {}; + const sent: SentFields = { kind: current.kind }; + + if (input.access !== undefined) { + if ( + (input.access === 'upload' && !isLink) || + (input.access !== 'view' && !current.itemWritable) || + (policy !== null && input.access !== 'view' && !policy.links.uploadAllowed) + ) { + throw ncErrorDefault('shareAccessInvalid'); + } + const permissions = permissionsFor(input.access, current.itemType); + if (permissions === null) throw ncErrorDefault('shareAccessInvalid'); + if (current.access !== input.access) json.permissions = permissions; + } + + if (policy !== null && wantsLinkFields) { + this.checkLinkRules(policy, input.password, input.expireDate, 'update'); + // Das Passwort laesst sich nicht vergleichen: nicht leer wird gesendet, leer nur wenn eins gesetzt ist. + if (input.password) { + json.password = input.password; + sent.passwordNonEmpty = true; + } else if (input.password === '' && current.hasPassword) { + json.password = ''; + } + if (input.expireDate !== undefined && input.expireDate !== (current.expiration ?? '')) { + json.expireDate = input.expireDate; + sent.expireDateSent = true; + sent.expireDateNonEmpty = input.expireDate !== ''; + } + if (input.label !== undefined && input.label !== current.label) json.label = input.label; + } + + if (Object.keys(json).length === 0) return current; const data = await this.run( tenantId, userId, session, - { method: 'PUT', segments: [...SHARES_BASE_SEGMENTS, id], json: { permissions } }, + { method: 'PUT', segments: [...SHARES_BASE_SEGMENTS, id], json }, 'update', - { kind: current.kind }, + sent, ); const updated = parseShareList(data, session.ncUserId).shares[0]; if (!updated) throw ncErrorDefault('nextcloudError'); @@ -374,4 +465,74 @@ export class NextcloudFilesSharesService { ); return { deleted: true }; } + + // --- Uebersichten und Annehmen (D-17) -------------------------------------------------------- + + /** Alle eigenen Freigaben (Personen, Gruppen, Links); andere Arten nur als Zahl. */ + async mine(tenantId: string, userId: string) { + const session = await this.session(tenantId, userId); + const data = await this.run( + tenantId, + userId, + session, + { method: 'GET', segments: SHARES_BASE_SEGMENTS }, + 'other', + ); + return parseShareList(data, session.ncUserId); + } + + /** + * Mit mir geteilt: angenommene (`shared_with_me`) und noch offene Freigaben (`pending`). + * Nur Personen- und Gruppenfreigaben; die offene Liste fehlt auf aelteren Servern (404/405 + * -> leer, kein Fehler). Eine offene Freigabe steht nie zugleich bei den angenommenen. + */ + async received(tenantId: string, userId: string) { + const session = await this.session(tenantId, userId); + const accepted = await this.run( + tenantId, + userId, + session, + { method: 'GET', segments: SHARES_BASE_SEGMENTS, query: { shared_with_me: 'true' } }, + 'other', + ); + const pendingResult = await ocsShareRequest(this.transport, this.gate, session, { + method: 'GET', + segments: [...SHARES_BASE_SEGMENTS, 'pending'], + }); + let pendingData: unknown = []; + if (!pendingResult.ok || pendingResult.status < 200 || pendingResult.status >= 300) { + const missing = + pendingResult.ok && (pendingResult.status === 404 || pendingResult.status === 405); + if (!missing) return this.mapShareFailure(tenantId, userId, 'other', pendingResult, {}); + } else { + pendingData = pendingResult.data; + } + + const a = parseShareList(accepted, session.ncUserId); + const p = parseShareList(pendingData, session.ncUserId); + const people = (shares: NcShareView[]) => shares.filter((s) => s.kind !== 'link'); + const pending = people(p.shares).map((s) => ({ ...s, pending: true })); + const pendingIds = new Set(pending.map((s) => s.id)); + const shares = people(a.shares).filter((s) => !pendingIds.has(s.id)); + const links = a.shares.length - people(a.shares).length + (p.shares.length - pending.length); + return { + shares, + pending, + hidden: a.hidden + p.hidden + links, + truncated: a.truncated || p.truncated, + }; + } + + async accept(tenantId: string, userId: string, id: string): Promise<{ accepted: true }> { + if (!isShareId(id)) throw ncErrorDefault('shareNotFound'); + const session = await this.session(tenantId, userId); + await this.run( + tenantId, + userId, + session, + { method: 'POST', segments: [...SHARES_BASE_SEGMENTS, 'pending', id] }, + 'accept', + ); + return { accepted: true }; + } } diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts index b172b16..6e7987f 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts @@ -22,8 +22,11 @@ const SHARE_HANDLERS = [ 'listSharesForPath', 'searchSharees', 'createShare', + 'listMyShares', + 'listReceivedShares', 'updateShare', 'deleteShare', + 'acceptShare', ]; const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM]; @@ -125,6 +128,13 @@ describe('NextcloudFilesController — Metadaten', () => { expect(route('createShare')).toEqual([1, 'shares']); expect(route('updateShare')).toEqual([2, 'shares/:id']); expect(route('deleteShare')).toEqual([3, 'shares/:id']); + expect(route('listMyShares')).toEqual([0, 'shares/mine']); + expect(route('listReceivedShares')).toEqual([0, 'shares/received']); + expect(route('acceptShare')).toEqual([1, 'shares/:id/accept']); + }); + + it('acceptShare antwortet mit 200 statt 201', () => { + expect(Reflect.getMetadata('__httpCode__', proto.acceptShare)).toBe(200); }); it('die Teilen-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => { @@ -136,7 +146,14 @@ describe('NextcloudFilesController — Metadaten', () => { it('die beiden :id-Handler des Teilens stehen nach allen statischen Handlern', () => { const names = routeHandlers(); - for (const name of ['getSharePolicy', 'listSharesForPath', 'searchSharees', 'createShare']) { + for (const name of [ + 'getSharePolicy', + 'listSharesForPath', + 'searchSharees', + 'createShare', + 'listMyShares', + 'listReceivedShares', + ]) { expect(names.indexOf(name), name).toBeLessThan(names.indexOf('pollFlow')); } const staticLast = Math.max( @@ -146,6 +163,7 @@ describe('NextcloudFilesController — Metadaten', () => { ); expect(names.indexOf('updateShare')).toBeGreaterThan(staticLast); expect(names.indexOf('deleteShare')).toBeGreaterThan(staticLast); + expect(names.indexOf('acceptShare')).toBeGreaterThan(staticLast); }); it('keiner der Anmelde-Handler traegt Verwalten oder einen Rollen-Decorator', () => { @@ -291,6 +309,9 @@ describe('NextcloudFilesController — Delegation', () => { create: vi.fn(async (..._a: unknown[]) => ({ id: '1' })), update: vi.fn(async (..._a: unknown[]) => ({ id: '1' })), remove: vi.fn(async (..._a: unknown[]) => ({ deleted: true })), + mine: vi.fn(async (..._a: unknown[]) => ({ shares: [] })), + received: vi.fn(async (..._a: unknown[]) => ({ shares: [], pending: [] })), + accept: vi.fn(async (..._a: unknown[]) => ({ accepted: true })), }; } @@ -447,12 +468,18 @@ describe('NextcloudFilesController — Delegation', () => { await controller.createShare(r, dto); await controller.updateShare(r, '17', { access: 'view' } as any); await controller.deleteShare(r, '17'); + await controller.listMyShares(r); + await controller.listReceivedShares(r); + await controller.acceptShare(r, '18'); expect(shares.policy).toHaveBeenCalledWith('t1', 'u1'); expect(shares.sharesForPath).toHaveBeenCalledWith('t1', 'u1', '/Projekte'); expect(shares.sharees).toHaveBeenCalledWith('t1', 'u1', 'ben', 'folder'); expect(shares.create).toHaveBeenCalledWith('t1', 'u1', dto); expect(shares.update).toHaveBeenCalledWith('t1', 'u1', '17', { access: 'view' }); expect(shares.remove).toHaveBeenCalledWith('t1', 'u1', '17'); + expect(shares.mine).toHaveBeenCalledWith('t1', 'u1'); + expect(shares.received).toHaveBeenCalledWith('t1', 'u1'); + expect(shares.accept).toHaveBeenCalledWith('t1', 'u1', '18'); }); it('Teilen-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => { @@ -469,6 +496,8 @@ describe('NextcloudFilesController — Delegation', () => { await expect(controller.getSharePolicy(r)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.createShare(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.deleteShare(r, '1')).rejects.toBeInstanceOf(ForbiddenException); + await expect(controller.listMyShares(r)).rejects.toBeInstanceOf(ForbiddenException); + await expect(controller.acceptShare(r, '1')).rejects.toBeInstanceOf(ForbiddenException); const noTenant = userReq(undefined, 'u1'); await expect(controller.getSharePolicy(noTenant)).rejects.toBeInstanceOf(ForbiddenException); for (const fn of Object.values(shares)) expect(fn).not.toHaveBeenCalled(); diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts index 68715ac..2a335b2 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts @@ -72,11 +72,12 @@ import { NextcloudServerInfoService } from './nextcloud-server-info'; * DELETE connect, GET files, DELETE files, POST folders, POST move, * GET preview, GET download, POST download/zip, POST uploads, PUT * uploads/file, GET shares/policy, GET shares/by-path, GET sharees, - * POST shares (quick-261009-dkv: Teilen); 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, PUT/DELETE shares/:id. Jeder Benutzer arbeitet nur - * im eigenen Konto. + * GET shares/mine, GET shares/received, POST shares (quick-261009-dkv: + * Teilen); 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, PUT/DELETE shares/:id, POST shares/:id/accept. + * Jeder Benutzer arbeitet nur im eigenen Konto. * Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale * RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler * traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`). @@ -328,6 +329,18 @@ export class NextcloudFilesController { ); } + /** Eigene Freigaben (Personen, Gruppen, Links). */ + @Get('shares/mine') + async listMyShares(@Req() req: AuthenticatedRequest) { + return this.shares.mine(this.requireTenantId(req), this.requireUserId(req)); + } + + /** Mit mir geteilt, auch die noch nicht angenommenen. */ + @Get('shares/received') + async listReceivedShares(@Req() req: AuthenticatedRequest) { + return this.shares.received(this.requireTenantId(req), this.requireUserId(req)); + } + @Post('shares') async createShare(@Req() req: AuthenticatedRequest, @Body() dto: CreateShareDto) { return this.shares.create(this.requireTenantId(req), this.requireUserId(req), dto); @@ -407,8 +420,15 @@ export class NextcloudFilesController { return this.shares.update(this.requireTenantId(req), this.requireUserId(req), id, dto); } + /** Loeschen, Ablehnen und Verlassen sind fuer Nextcloud dasselbe (DELETE durch den Empfaenger). */ @Delete('shares/:id') async deleteShare(@Req() req: AuthenticatedRequest, @Param('id') id: string) { return this.shares.remove(this.requireTenantId(req), this.requireUserId(req), id); } + + @Post('shares/:id/accept') + @HttpCode(200) + async acceptShare(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + return this.shares.accept(this.requireTenantId(req), this.requireUserId(req), id); + } } diff --git a/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts b/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts index 406c552..aeb1eb0 100644 --- a/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts @@ -5,6 +5,7 @@ import type { NcSession } from './nextcloud-files.types'; import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http'; import { accessOf, + isRealDate, OCS_OK_MAX_BYTES, ocsShareRequest, parseShare, @@ -595,3 +596,21 @@ describe('parseSharePolicy', () => { expect(policy.passwordMinLength).toBeNull(); }); }); + +describe('isRealDate', () => { + it('nimmt nur echte Kalendertage im Format JJJJ-MM-TT an', () => { + expect(isRealDate('2026-10-09')).toBe(true); + expect(isRealDate('2028-02-29')).toBe(true); + for (const bad of [ + '2026-02-30', + '2027-02-29', + '2026-13-01', + '31.12.2026', + '2026-1-1', + '2026-12-31x', + '', + ]) { + expect(isRealDate(bad), bad).toBe(false); + } + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-shares.ts b/apps/api/src/nextcloud-files/nextcloud-shares.ts index aa97fd4..bce24ff 100644 --- a/apps/api/src/nextcloud-files/nextcloud-shares.ts +++ b/apps/api/src/nextcloud-files/nextcloud-shares.ts @@ -254,7 +254,7 @@ function lastSegment(path: string): string { return parts.length > 0 ? parts[parts.length - 1] : ''; } -function isRealDate(value: string): boolean { +export function isRealDate(value: string): boolean { if (!DATE_RE.test(value)) return false; const d = new Date(`${value}T00:00:00Z`); return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;