From 207d37b8d61f4ae177ab24f4790cb0a07a2eee14 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 10:40:39 +0200 Subject: [PATCH] feat(quick-261009-dkv): Teilen mit Personen und Gruppen - Durchstich, Schnittstelle - Freigaben-Schicht (nextcloud-shares.ts): eigener OCS-Aufruf, der Fehlertexte liest, Parser fuer Freigaben, Empfaenger und Freigaberegeln, Berechtigungsabbildung - Dienst mit Vorpruefung aus den Faehigkeiten, Dublettenpruefung und Fehlermatrix (nie 401/403 nach aussen), Begrenzung auf 15 neue Freigaben in 10 Minuten - Routen shares/policy, shares/by-path, sharees, POST/PUT/DELETE shares - oc:share-types als shareTypes an jedem Eintrag - Live-Test e2e-shares.sh (Abschnitt people) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../e2e/e2e-shares.sh | 231 +++++++ .../module-manage-handlers.spec.ts | 6 + .../dto/nextcloud-files-shares.dto.ts | 60 ++ .../nextcloud-files-shares.service.spec.ts | 519 +++++++++++++++ .../nextcloud-files-shares.service.ts | 377 +++++++++++ .../nextcloud-files.controller.spec.ts | 102 +++ .../nextcloud-files.controller.ts | 66 +- .../nextcloud-files/nextcloud-files.module.ts | 2 + .../nextcloud-files/nextcloud-files.types.ts | 64 +- .../nextcloud-login-guard.spec.ts | 41 ++ .../nextcloud-files/nextcloud-login-guard.ts | 31 + .../nextcloud-propfind.spec.ts | 12 + .../src/nextcloud-files/nextcloud-propfind.ts | 17 + .../nextcloud-files/nextcloud-shares.spec.ts | 597 ++++++++++++++++++ .../src/nextcloud-files/nextcloud-shares.ts | 464 ++++++++++++++ 15 files changed, 2584 insertions(+), 5 deletions(-) create mode 100644 .planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh create mode 100644 apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-shares.spec.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-shares.ts 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 new file mode 100644 index 0000000..5d1a111 --- /dev/null +++ b/.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh @@ -0,0 +1,231 @@ +#!/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. +# 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. +# +# Die Test-Nextcloud bekommt den Benutzer ben (Passwort User3-Pass-12345, ohne Zwei-Faktor) und die +# Gruppe tessera-team (mit ben). Die Ratenbegrenzung der Nextcloud wird NUR fuer diesen Lauf +# ausgeschaltet (sonst koennte wiederholtes Testen deren 429 ausloesen, das die Aufrufsperre fuer +# den ganzen Ursprung anhaelt). Die Begrenzung von Tessera (15 in 10 Minuten) beweisen die +# Einheitstests. Alles, was das Skript anfasst, wird im trap zurueckgesetzt. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HERE/../../261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh" + +SECTION=${1:-all} +case "$SECTION" in people | links | received | version | all) ;; *) e2e_fail "unbekannter Abschnitt: $SECTION" ;; esac + +FILES="$API/modules/nextcloud-files" +NC_DAV="http://localhost:18080/remote.php/dav" +NC_OCS="http://localhost:18080/ocs/v2.php/apps/files_sharing/api/v1" +ANNA="anna:User1-Pass-12345" +BEN="ben:User3-Pass-12345" +EPOCH=$(date +%s) +FIX="Tessera-Teilen-$EPOCH" +START_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# --- 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 + 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 + NC_OCC config:app:delete core "$key" >/dev/null 2>&1 + done + NC_OCC user:setting --delete anna files_sharing default_accept >/dev/null 2>&1 + rm -rf "$E2E_TMP" + exit $rc +} +trap cleanup EXIT + +# --- Hilfen ------------------------------------------------------------------------------------- +ADMIN="$E2E_TMP/admin.jar" +SEEN="$E2E_TMP/seen.txt" +: >"$SEEN" + +# call [json] — gibt den Status aus und merkt ihn in +# $SEEN fuer die Pruefung "nie 401/403" (Datei, weil $(...) in einer Unterschale laeuft). Antwortkoerper: $E2E_TMP/body.out +call() { + local code + code=$(e2e_status "$ADMIN" "$1" "$FILES/$2" "${3:-}") + echo "$code" >>"$SEEN" + echo "$code" +} + +# qget [ ...] — GET mit sauber codierter Query. +qget() { + local endpoint=$1 args=() code + shift + while [ $# -gt 0 ]; do + args+=(--data-urlencode "$1=$2") + shift 2 + done + code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -G "${args[@]}" "$FILES/$endpoint") + echo "$code" >>"$SEEN" + echo "$code" +} + +# jq_py — wertet einen Python-Ausdruck auf der JSON-Datei $E2E_TMP/body.out aus (d = Daten). +jq_py() { + python3 -I -c 'import json,sys +d=json.load(open(sys.argv[1])) +print(eval(sys.argv[2]))' "$E2E_TMP/body.out" "$1" +} + +# Direkt bei der Nextcloud: eingehende Freigaben von ben (JSON in $E2E_TMP/ben.out) +ben_shares() { + curl -s -u "$BEN" -H 'OCS-APIRequest: true' -H 'Accept: application/json' \ + "$NC_OCS/shares?shared_with_me=true" -o "$E2E_TMP/ben.out" +} +# ben_count — Anzahl der eingehenden Freigaben von ben mit diesem Ziel +ben_count() { + ben_shares + 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("file_target","").lstrip("/")==sys.argv[2]]))' "$E2E_TMP/ben.out" "$1" +} +ben_perm() { + ben_shares + python3 -I -c 'import json,sys +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" +} + +# --- Aufbau -------------------------------------------------------------------------------------- +e2e_wait_health +e2e_login "$ADMIN" +e2e_activate "$ADMIN" +e2e_set_address "$ADMIN" +code=$(e2e_connect_anna "$ADMIN") +e2e_expect 200 "$code" "anna verbinden" + +code=$(call GET shares/policy) +[ "$code" = "200" ] || e2e_fail "GET shares/policy -> $code. API-Container neu bauen: docker compose up -d --build api" + +# ben und die Gruppe (idempotent) +if ! NC_OCC user:info ben >/dev/null 2>&1; then + docker exec -u www-data -e OC_PASS='User3-Pass-12345' tessera-nc-test \ + php occ user:add --password-from-env --display-name="Ben Beispiel" ben >/dev/null +fi +GROUPS_NOW=$(NC_OCC group:list 2>/dev/null || true) +case "$GROUPS_NOW" in *tessera-team*) ;; *) NC_OCC group:add tessera-team >/dev/null ;; esac +NC_OCC group:adduser tessera-team ben >/dev/null 2>&1 || true +NC_OCC config:system:set ratelimit.protection.enabled --value=false --type=boolean >/dev/null + +# Ordner mit Datei und Unterordner in annas Konto +code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X MKCOL "$NC_DAV/files/anna/$FIX") +e2e_expect 201 "$code" "Fixture-Ordner" +code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X PUT --data-binary 'Bericht' "$NC_DAV/files/anna/$FIX/Bericht.txt") +e2e_expect 201 "$code" "Fixture Bericht.txt" +code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X MKCOL "$NC_DAV/files/anna/$FIX/Briefkasten") +e2e_expect 201 "$code" "Fixture Briefkasten" + +# --- Abschnitt people ---------------------------------------------------------------------------- +section_people() { + local code share_id group_id + + code=$(call GET shares/policy) + e2e_expect 200 "$code" "Richtlinie lesen" + e2e_expect True "$(jq_py 'd["enabled"] is True and d["links"]["passwordRequired"] is False')" "Richtlinie: enabled, Passwort nicht erzwungen" + + code=$(qget sharees term ben itemType folder) + e2e_expect 200 "$code" "Empfaengersuche ben" + e2e_expect True "$(jq_py 'any(s["kind"]=="user" and s["id"]=="ben" for s in d["sharees"])')" "Suche ben findet die Person" + code=$(qget sharees term tessera itemType folder) + e2e_expect 200 "$code" "Empfaengersuche tessera" + e2e_expect True "$(jq_py 'any(s["kind"]=="group" and s["id"]=="tessera-team" for s in d["sharees"])')" "Suche tessera findet die Gruppe" + code=$(qget sharees term '' itemType folder) + e2e_expect 200 "$code" "leere Suche" + e2e_expect '[]' "$(jq_py 'd["sharees"]')" "leere Suche liefert nichts" + + # Personenfreigabe: Ordner an ben, Bearbeiten + code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"user\",\"shareWith\":\"ben\",\"access\":\"edit\"}") + e2e_expect 201 "$code" "Personenfreigabe anlegen" + e2e_expect True "$(jq_py 'd["kind"]=="user" and d["access"]=="edit" and d["permissions"]==15 and d["shareWith"]=="ben"')" "Freigabe: Person, Bearbeiten, 15" + share_id=$(jq_py 'd["id"]') + e2e_expect 1 "$(ben_count "$FIX")" "ben sieht die Freigabe" + e2e_expect 15 "$(ben_perm "$FIX")" "ben: Berechtigung 15" + + # Dieselbe Freigabe nochmal: abgelehnt, kein zweiter Eintrag + code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"user\",\"shareWith\":\"ben\",\"access\":\"edit\"}") + e2e_expect 409 "$code" "doppelte Freigabe" + e2e_contains "$E2E_TMP/body.out" '"code":"shareAlreadyExists"' "doppelt: shareAlreadyExists" + e2e_expect 1 "$(ben_count "$FIX")" "ben sieht weiterhin genau eine Freigabe" + + # Hochladen ist fuer Personen nicht moeglich, und die Berechtigung kommt nur als Auswahl + code=$(call POST shares "{\"path\":\"/$FIX/Briefkasten\",\"kind\":\"user\",\"shareWith\":\"ben\",\"access\":\"upload\"}") + e2e_expect 400 "$code" "Hochladen fuer eine Person" + e2e_contains "$E2E_TMP/body.out" '"code":"shareAccessInvalid"' "upload: shareAccessInvalid" + code=$(call POST shares "{\"path\":\"/$FIX/Briefkasten\",\"kind\":\"user\",\"shareWith\":\"ben\",\"access\":\"abc\"}") + e2e_expect 400 "$code" "unbekannte Auswahl" + + # Auf Ansehen aendern + code=$(call PUT "shares/$share_id" '{"access":"view"}') + e2e_expect 200 "$code" "auf Ansehen aendern" + e2e_expect True "$(jq_py 'd["access"]=="view" and d["permissions"]==1')" "Freigabe: Ansehen, 1" + e2e_expect 1 "$(ben_perm "$FIX")" "ben: Berechtigung 1" + + # Gruppenfreigabe einer Datei + code=$(call POST shares "{\"path\":\"/$FIX/Bericht.txt\",\"kind\":\"group\",\"shareWith\":\"tessera-team\",\"access\":\"view\"}") + e2e_expect 201 "$code" "Gruppenfreigabe anlegen" + e2e_expect True "$(jq_py 'd["kind"]=="group" and d["itemType"]=="file" and d["permissions"]==1')" "Gruppenfreigabe: Gruppe, Datei, 1" + group_id=$(jq_py 'd["id"]') + + # Freigaben des Ordners: genau die Personenfreigabe + code=$(qget shares/by-path path "/$FIX") + e2e_expect 200 "$code" "Freigaben des Ordners" + e2e_expect True "$(jq_py 'len(d["shares"])==1 and d["shares"][0]["id"]=="'"$share_id"'"')" "by-path: genau die Personenfreigabe" + + # Kennzeichen in der Dateiliste + code=$(qget files path /) + e2e_expect 200 "$code" "Wurzel auflisten" + e2e_expect True "$(jq_py 'any(e["name"]=="'"$FIX"'" and 0 in e["shareTypes"] for e in d["entries"])')" "Wurzel: Ordner traegt shareTypes mit 0" + code=$(qget files path "/$FIX") + e2e_expect 200 "$code" "Ordner auflisten" + e2e_expect True "$(jq_py 'any(e["name"]=="Bericht.txt" and 1 in e["shareTypes"] for e in d["entries"])')" "Ordner: Bericht.txt traegt shareTypes mit 1" + e2e_expect True "$(jq_py 'any(e["name"]=="Briefkasten" and e["shareTypes"]==[] for e in d["entries"])')" "Ordner: Briefkasten ist nicht geteilt" + + # Entfernen + code=$(call DELETE "shares/$share_id") + e2e_expect 200 "$code" "Personenfreigabe entfernen" + e2e_expect 0 "$(ben_count "$FIX")" "ben sieht die Freigabe nicht mehr" + code=$(qget shares/by-path path "/$FIX") + e2e_expect 200 "$code" "Freigaben des Ordners nach dem Entfernen" + e2e_expect True "$(jq_py 'd["shares"]==[]')" "by-path: leer" + code=$(call DELETE "shares/$group_id") + e2e_expect 200 "$code" "Gruppenfreigabe entfernen" + code=$(call DELETE "shares/$share_id") + e2e_expect 404 "$code" "bereits entfernte Freigabe" + e2e_contains "$E2E_TMP/body.out" '"code":"shareNotFound"' "entfernt: shareNotFound" + code=$(call DELETE shares/abc) + e2e_expect 404 "$code" "ungueltige Kennung" + e2e_contains "$E2E_TMP/body.out" '"code":"shareNotFound"' "abc: shareNotFound" + + 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_version() { echo "e2e shares version: folgt in Aufgabe 3"; } + +case "$SECTION" in + people) section_people ;; + links) section_links ;; + received) section_received ;; + version) section_version ;; + all) + section_people + section_links + section_received + section_version + ;; +esac + +# Nie 401 oder 403 an den Browser (Fehlervertrag D-D) +if grep -qE '^(401|403)$' "$SEEN"; then e2e_fail "ein Aufruf endete mit 401 oder 403"; fi +: "$START_ISO" 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 553b45c..e8dc635 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -164,6 +164,12 @@ describe('Umgestellte Handler (Verwalten)', () => { 'completeUpload', 'uploadState', 'abortUpload', + 'getSharePolicy', + 'listSharesForPath', + 'searchSharees', + 'createShare', + 'updateShare', + 'deleteShare', ])('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 new file mode 100644 index 0000000..230ee2b --- /dev/null +++ b/apps/api/src/nextcloud-files/dto/nextcloud-files-shares.dto.ts @@ -0,0 +1,60 @@ +import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; + +/** + * Eingaben der Freigaberouten (quick-261009-dkv, D-11). Der Browser schickt nur + * die Aufzaehlungen `kind` und `access`, nie eine Freigabeart-Zahl oder eine + * Berechtigungsmaske; die API bildet sie selbst ab. Die globale ValidationPipe + * laeuft mit `whitelist: true` — unbekannte Felder (z. B. `permissions`) werden + * entfernt. Pfade laufen im Dienst durch `parseUserPath`. + */ + +/** Kein Steuerzeichen (auch kein Zeilenumbruch) in Namen, die an Nextcloud gehen. */ +// 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_ACCESS = ['view', 'edit', 'upload'] as const; +export const ITEM_TYPES = ['file', 'folder'] as const; + +export class ShareByPathQueryDto { + @IsString() + @IsNotEmpty() + @MaxLength(4096) + path!: string; +} + +export class ShareeQueryDto { + /** Leer ist erlaubt (der Dienst ruft dann Nextcloud nicht auf). */ + @IsString() + @MaxLength(100) + @Matches(NO_CONTROL) + term!: string; + + @IsIn(ITEM_TYPES) + itemType!: (typeof ITEM_TYPES)[number]; +} + +export class CreateShareDto { + @IsString() + @IsNotEmpty() + @MaxLength(4096) + path!: string; + + @IsIn(SHARE_KINDS_PEOPLE) + kind!: (typeof SHARE_KINDS_PEOPLE)[number]; + + @IsString() + @IsNotEmpty() + @MaxLength(255) + @Matches(NO_CONTROL) + shareWith!: string; + + @IsIn(SHARE_ACCESS) + access!: (typeof SHARE_ACCESS)[number]; +} + +export class UpdateShareDto { + @IsOptional() + @IsIn(SHARE_ACCESS) + access?: (typeof SHARE_ACCESS)[number]; +} 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 new file mode 100644 index 0000000..415229e --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts @@ -0,0 +1,519 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import { type NcSession } from './nextcloud-files.types'; +import { NextcloudFilesSharesService } from './nextcloud-files-shares.service'; +import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http'; +import { NextcloudLoginGuard } from './nextcloud-login-guard'; + +const SESSION: NcSession = { + baseUrl: 'https://cloud.example/nc', + ncUserId: 'anna', + authorization: 'Basic YW5uYTphcHAtcHctMTIz', + credentialKey: 'k1', +}; +const OCS = 'https://cloud.example/nc/ocs/v2.php'; +const SHARES = `${OCS}/apps/files_sharing/api/v1/shares`; +const BY_PATH = `${SHARES}?path=%2FProjekte&reshares=true`; +const CAPS_URL = `${OCS}/cloud/capabilities`; +const DAV = 'https://cloud.example/nc/remote.php/dav/files/anna'; + +const NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"'; +function statXml(name: string, folder: boolean, letters: string): string { + return ` + /nc/remote.php/dav/files/anna/${name} + ${folder ? '' : ''} + 7${letters} + ${folder ? '' : '5text/plain'} + HTTP/1.1 200 OK`; +} + +const ocs = (data: unknown, status = 200, message = 'OK') => + JSON.stringify({ ocs: { meta: { statuscode: status, message }, data } }); + +const CAPS = (over: Record = {}) => + ocs({ + capabilities: { + password_policy: { minLength: 10 }, + files_sharing: { + api_enabled: true, + group_sharing: true, + public: { enabled: true, upload: true }, + sharee: { minSearchStringLength: 0 }, + ...over, + }, + }, + }); + +function shareJson(over: Record = {}) { + return { + id: '17', + share_type: 0, + uid_owner: 'anna', + displayname_owner: 'Anna Müller', + permissions: 15, + can_edit: true, + can_delete: true, + stime: 1791532378, + path: '/Projekte', + item_type: 'folder', + item_permissions: 31, + mimetype: 'httpd/unix-directory', + file_target: '/Projekte', + share_with: 'ben', + share_with_displayname: 'Ben Beispiel', + ...over, + }; +} + +type Reply = { status: number; text?: string; headers?: Record }; + +function setup(replies: Reply[]) { + const calls: NcTransportRequest[] = []; + const queue = [...replies]; + const transport: NextcloudTransport = async (req) => { + calls.push(req); + const reply = queue.shift(); + if (!reply) throw new Error(`unerwarteter Aufruf ${req.method} ${req.url}`); + return { + statusCode: reply.status, + headers: reply.headers ?? {}, + body: Readable.from(reply.text !== undefined ? [Buffer.from(reply.text)] : []), + }; + }; + const account = { + getSession: vi.fn(async (..._a: unknown[]) => SESSION), + markExpired: vi.fn(async (..._a: unknown[]) => {}), + }; + const guard = new NextcloudLoginGuard(); + const clock = { t: 1_000_000 }; + guard.now = () => clock.t; + const service = new NextcloudFilesSharesService( + account as never, + new NextcloudCallGate(), + transport, + guard, + ); + return { service, account, calls, queue, clock, guard }; +} + +const codeOf = (e: unknown) => (e as { response: { code: string } }).response.code; +const bodyOf = (e: unknown) => (e as { response: Record }).response; +const statusOf = (e: unknown) => (e as { getStatus(): number }).getStatus(); + +async function failure(promise: Promise): Promise { + try { + await promise; + } catch (e) { + return e; + } + throw new Error('es wurde kein Fehler geworfen'); +} + +/** Antworten fuer ein erfolgreiches create(): stat, Faehigkeiten, Liste des Pfads, POST. */ +function createReplies( + over: { stat?: Reply; caps?: Reply; list?: Reply; post?: Reply } = {}, +): Reply[] { + return [ + over.stat ?? { status: 207, text: statXml('Projekte', true, 'RGDNVCK') }, + over.caps ?? { status: 200, text: CAPS() }, + over.list ?? { status: 200, text: ocs([]) }, + over.post ?? { status: 200, text: ocs(shareJson()) }, + ]; +} + +const INPUT = { path: '/Projekte', kind: 'user', shareWith: 'ben', access: 'edit' } as const; + +describe('NextcloudFilesSharesService — create', () => { + it('sendet PROPFIND, Faehigkeiten, Liste des Pfads, POST in dieser Reihenfolge und liefert die Freigabe', async () => { + const { service, account, calls } = setup(createReplies()); + const share = await service.create('t1', 'u1', INPUT); + expect(account.getSession).toHaveBeenCalledWith('t1', 'u1'); + expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([ + `PROPFIND ${DAV}/Projekte`, + `GET ${CAPS_URL}`, + `GET ${BY_PATH}`, + `POST ${SHARES}`, + ]); + expect(calls[0].headers.depth).toBe('0'); + expect(calls[3].headers['content-type']).toBe('application/json'); + expect(calls[3].body).toBe( + '{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}', + ); + expect(share).toMatchObject({ id: '17', kind: 'user', shareWith: 'ben', access: 'edit' }); + }); + + it('Gruppe: shareType 1', async () => { + const { service, calls } = setup( + createReplies({ + post: { status: 200, text: ocs(shareJson({ share_type: 1, share_with: 'team' })) }, + }), + ); + const share = await service.create('t1', 'u1', { ...INPUT, kind: 'group', shareWith: 'team' }); + expect(calls[3].body).toBe( + '{"path":"/Projekte","shareType":1,"shareWith":"team","permissions":15}', + ); + expect(share.kind).toBe('group'); + }); + + it('Datei: Bearbeiten sendet 3, Ansehen sendet 1', async () => { + for (const [access, permissions] of [ + ['edit', 3], + ['view', 1], + ] as const) { + const { service, calls } = setup([ + { status: 207, text: statXml('Bericht.txt', false, 'RGDNVW') }, + { status: 200, text: CAPS() }, + { status: 200, text: ocs([]) }, + { + status: 200, + text: ocs(shareJson({ path: '/Bericht.txt', item_type: 'file', permissions })), + }, + ]); + await service.create('t1', 'u1', { ...INPUT, path: '/Bericht.txt', access }); + expect(calls[2].url).toBe(`${SHARES}?path=%2FBericht.txt&reshares=true`); + expect(calls[3].body).toBe( + `{"path":"/Bericht.txt","shareType":0,"shareWith":"ben","permissions":${permissions}}`, + ); + } + }); + + it('Hochladen fuer eine Person, oder Bearbeiten bei Buchstaben RG: 400 shareAccessInvalid, kein POST', async () => { + const upload = setup(createReplies()); + const e1 = await failure(upload.service.create('t1', 'u1', { ...INPUT, access: 'upload' })); + expect(codeOf(e1)).toBe('shareAccessInvalid'); + expect(statusOf(e1)).toBe(400); + expect(upload.calls.some((c) => c.method === 'POST')).toBe(false); + + const readonly = setup( + createReplies({ stat: { status: 207, text: statXml('Projekte', true, 'RG') } }), + ); + const e2 = await failure(readonly.service.create('t1', 'u1', INPUT)); + expect(codeOf(e2)).toBe('shareAccessInvalid'); + expect(readonly.calls.some((c) => c.method === 'POST')).toBe(false); + }); + + it('der Empfaenger hat schon eine Freigabe: 409 shareAlreadyExists, kein POST', async () => { + const { service, calls } = setup( + createReplies({ list: { status: 200, text: ocs([shareJson()]) } }), + ); + const e = await failure(service.create('t1', 'u1', INPUT)); + expect(codeOf(e)).toBe('shareAlreadyExists'); + expect(statusOf(e)).toBe(409); + expect(calls.some((c) => c.method === 'POST')).toBe(false); + }); + + it('gleiche Kennung als Gruppe zaehlt nicht als vorhandene Personenfreigabe', async () => { + const { service } = setup( + createReplies({ + list: { status: 200, text: ocs([shareJson({ share_type: 1, share_with: 'ben' })]) }, + }), + ); + await expect(service.create('t1', 'u1', INPUT)).resolves.toMatchObject({ id: '17' }); + }); + + it('Gruppen ausgeschaltet: 409 sharingDisabled mit der Gruppenmeldung, kein POST', async () => { + const { service, calls } = setup( + createReplies({ caps: { status: 200, text: CAPS({ group_sharing: false }) } }), + ); + const e = await failure( + service.create('t1', 'u1', { ...INPUT, kind: 'group', shareWith: 'team' }), + ); + expect(codeOf(e)).toBe('sharingDisabled'); + expect(statusOf(e)).toBe(409); + expect(bodyOf(e).message).toBe('Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.'); + expect(calls.some((c) => c.method === 'POST')).toBe(false); + }); + + it('Freigabe-Schnittstelle aus: 409 sharingDisabled', async () => { + const { service, calls } = setup( + createReplies({ caps: { status: 200, text: CAPS({ api_enabled: false }) } }), + ); + const e = await failure(service.create('t1', 'u1', INPUT)); + expect(codeOf(e)).toBe('sharingDisabled'); + expect(bodyOf(e).message).toBe('Teilen ist in Ihrer Nextcloud ausgeschaltet.'); + expect(calls.some((c) => c.method === 'POST')).toBe(false); + }); + + it('leerer Pfad oder Wurzel: 400 invalidPath ohne jeden Aufruf', async () => { + for (const path of ['', '/']) { + const { service, account, calls } = setup([]); + const e = await failure(service.create('t1', 'u1', { ...INPUT, path })); + expect(codeOf(e)).toBe('invalidPath'); + expect(calls).toHaveLength(0); + expect(account.getSession).not.toHaveBeenCalled(); + } + }); + + it('Eintrag nicht vorhanden: 404 notFound, danach kein Freigabe-Aufruf', async () => { + const { service, calls } = setup([{ status: 404 }]); + const e = await failure(service.create('t1', 'u1', INPUT)); + expect(codeOf(e)).toBe('notFound'); + expect(calls).toHaveLength(1); + }); + + it.each([ + [404, undefined, 422, 'shareRecipientInvalid'], + [403, 'You cannot share a folder that contains other shares', 422, 'shareRejected'], + [400, undefined, 422, 'shareRejected'], + [500, undefined, 502, 'nextcloudError'], + [503, undefined, 503, 'nextcloudMaintenance'], + ])('POST antwortet %i -> %i %s', async (nc, message, http, code) => { + const { service } = setup( + createReplies({ + post: { + status: nc, + text: JSON.stringify({ + ocs: { meta: { statuscode: nc, message: message ?? 'x' }, data: [] }, + }), + }, + }), + ); + const e = await failure(service.create('t1', 'u1', INPUT)); + expect(codeOf(e)).toBe(code); + expect(statusOf(e)).toBe(http); + expect([401, 403]).not.toContain(statusOf(e)); + if (code === 'shareRejected' && message) expect(bodyOf(e).ncMessage).toBe(message); + }); + + it('POST antwortet 401: 409 connectionExpired und die Verbindung ist als abgelaufen markiert', async () => { + const { service, account } = setup(createReplies({ post: { status: 401 } })); + const e = await failure(service.create('t1', 'u1', INPUT)); + expect(codeOf(e)).toBe('connectionExpired'); + expect(statusOf(e)).toBe(409); + expect(account.markExpired).toHaveBeenCalledTimes(1); + expect(account.markExpired).toHaveBeenCalledWith('t1', 'u1'); + }); + + it('POST antwortet 429: 503 nextcloudLocked', async () => { + const { service } = setup( + createReplies({ post: { status: 429, headers: { 'retry-after': '60' } } }), + ); + const e = await failure(service.create('t1', 'u1', INPUT)); + expect(codeOf(e)).toBe('nextcloudLocked'); + expect(statusOf(e)).toBe(503); + }); +}); + +describe('NextcloudFilesSharesService — Begrenzung neuer Freigaben', () => { + it('15 in 10 Minuten gehen durch, die 16. ist 429 tooManyShares ohne Aufruf, nach 10 Minuten geht es wieder', async () => { + const replies: Reply[] = []; + for (let i = 0; i < 15; i++) replies.push(...createReplies()); + // der abgewiesene 16. Versuch verbraucht nur die Vorpruefungen, nie den POST + replies.push(...createReplies().slice(0, 3)); + replies.push(...createReplies()); + const { service, calls, clock } = setup(replies); + for (let i = 0; i < 15; i++) { + await service.create('t1', 'u1', { ...INPUT, shareWith: `ben${i}` }); + } + const before = calls.length; + const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' })); + expect(codeOf(e)).toBe('tooManyShares'); + expect(statusOf(e)).toBe(429); + expect(bodyOf(e).retryAfterSeconds).toBe(600); + // Vorpruefungen laufen, aber der POST wird nicht gesendet. + expect(calls.slice(before).some((c) => c.method === 'POST')).toBe(false); + + clock.t += 10 * 60 * 1000; + await expect( + service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' }), + ).resolves.toBeTruthy(); + }); + + it('eine durch Vorpruefung abgelehnte Eingabe zaehlt nicht mit', async () => { + const replies: Reply[] = []; + for (let i = 0; i < 3; i++) { + replies.push( + ...createReplies({ + list: { status: 200, text: ocs([shareJson({ share_with: 'x' })]) }, + }).slice(0, 3), + ); + } + for (let i = 0; i < 15; i++) replies.push(...createReplies()); + const { service } = setup(replies); + for (let i = 0; i < 3; i++) { + const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'x' })); + expect(codeOf(e)).toBe('shareAlreadyExists'); + } + for (let i = 0; i < 15; i++) await service.create('t1', 'u1', { ...INPUT, shareWith: `b${i}` }); + }); +}); + +describe('NextcloudFilesSharesService — update', () => { + const GET17 = `GET ${SHARES}/17`; + + it('Ansehen: GET der Freigabe, dann PUT nur mit der neuen Berechtigung', async () => { + const { service, calls } = setup([ + { status: 200, text: ocs([shareJson()]) }, + { status: 200, text: ocs(shareJson({ permissions: 1 })) }, + ]); + const share = await service.update('t1', 'u1', '17', { access: 'view' }); + expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([GET17, `PUT ${SHARES}/17`]); + expect(calls[1].body).toBe('{"permissions":1}'); + expect(share.access).toBe('view'); + }); + + it('Freigabe nicht aenderbar (can_edit false): 422 shareRejected ohne PUT', async () => { + const { service, calls } = setup([ + { status: 200, text: ocs([shareJson({ can_edit: false })]) }, + ]); + const e = await failure(service.update('t1', 'u1', '17', { access: 'view' })); + expect(codeOf(e)).toBe('shareRejected'); + expect(statusOf(e)).toBe(422); + expect(calls).toHaveLength(1); + }); + + it('PUT 404 -> 404 shareNotFound', async () => { + const { service } = setup([ + { status: 200, text: ocs([shareJson()]) }, + { status: 404, text: ocs([], 404, 'Wrong share ID, share does not exist') }, + ]); + const e = await failure(service.update('t1', 'u1', '17', { access: 'view' })); + expect(codeOf(e)).toBe('shareNotFound'); + expect(statusOf(e)).toBe(404); + }); + + it('PUT 400 und 403 -> 422 shareRejected mit Meldung der Nextcloud', async () => { + for (const nc of [400, 403]) { + const { service } = setup([ + { status: 200, text: ocs([shareJson()]) }, + { status: nc, text: ocs([], nc, 'Failed to update share.') }, + ]); + const e = await failure(service.update('t1', 'u1', '17', { access: 'view' })); + expect(codeOf(e)).toBe('shareRejected'); + expect(statusOf(e)).toBe(422); + expect(bodyOf(e).ncMessage).toBe('Failed to update share.'); + } + }); + + it("Kennung 'abc' und eine 21-stellige Kennung: 404 shareNotFound ohne Aufruf", async () => { + for (const id of ['abc', '1'.repeat(21), '../x', '']) { + const { service, calls } = setup([]); + const e = await failure(service.update('t1', 'u1', id, { access: 'view' })); + expect(codeOf(e)).toBe('shareNotFound'); + expect(calls).toHaveLength(0); + } + }); + + it('ohne Aenderung kommt die aktuelle Freigabe zurueck, es gibt kein PUT', async () => { + const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]); + const share = await service.update('t1', 'u1', '17', {}); + expect(share.id).toBe('17'); + expect(calls).toHaveLength(1); + }); + + it('gleiche Berechtigung wie bisher: kein PUT', async () => { + const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]); + await service.update('t1', 'u1', '17', { access: 'edit' }); + expect(calls).toHaveLength(1); + }); + + it('Bearbeiten bei einem Eintrag ohne Schreibrecht und Hochladen sind shareAccessInvalid', async () => { + const ro = setup([ + { status: 200, text: ocs([shareJson({ permissions: 1, item_permissions: 17 })]) }, + ]); + expect(codeOf(await failure(ro.service.update('t1', 'u1', '17', { access: 'edit' })))).toBe( + 'shareAccessInvalid', + ); + const up = setup([{ status: 200, text: ocs([shareJson()]) }]); + expect(codeOf(await failure(up.service.update('t1', 'u1', '17', { access: 'upload' })))).toBe( + 'shareAccessInvalid', + ); + }); + + it('GET der Freigabe 404 -> shareNotFound; 401 markiert die Verbindung', async () => { + const gone = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]); + expect(codeOf(await failure(gone.service.update('t1', 'u1', '17', { access: 'view' })))).toBe( + 'shareNotFound', + ); + const dead = setup([{ status: 401 }]); + const e = await failure(dead.service.update('t1', 'u1', '17', { access: 'view' })); + expect(codeOf(e)).toBe('connectionExpired'); + expect(dead.account.markExpired).toHaveBeenCalledTimes(1); + }); +}); + +describe('NextcloudFilesSharesService — remove, lesen, Richtlinie', () => { + it('remove: DELETE auf die Kennung', async () => { + const { service, calls } = setup([{ status: 200, text: ocs([]) }]); + await expect(service.remove('t1', 'u1', '17')).resolves.toEqual({ deleted: true }); + expect(calls[0].method).toBe('DELETE'); + expect(calls[0].url).toBe(`${SHARES}/17`); + expect(calls[0].body).toBeNull(); + }); + + it('remove: 404 -> shareNotFound, 403 -> 422 shareRejected, falsche Kennung ohne Aufruf', async () => { + const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]); + expect(codeOf(await failure(nf.service.remove('t1', 'u1', '17')))).toBe('shareNotFound'); + const forbidden = setup([{ status: 403, text: ocs([], 403, 'Could not delete share') }]); + const e = await failure(forbidden.service.remove('t1', 'u1', '17')); + expect(codeOf(e)).toBe('shareRejected'); + expect(statusOf(e)).toBe(422); + expect(bodyOf(e).ncMessage).toBe('Could not delete share'); + const bad = setup([]); + expect(codeOf(await failure(bad.service.remove('t1', 'u1', 'abc')))).toBe('shareNotFound'); + expect(bad.calls).toHaveLength(0); + }); + + it('Freigaben eines Pfads: Pfad normalisiert, Wurzel ist invalidPath', async () => { + const { service, calls } = setup([ + { status: 200, text: ocs([shareJson(), { share_type: 4, id: '9' }]) }, + ]); + const out = await service.sharesForPath('t1', 'u1', '/Projekte/'); + expect(calls[0].url).toBe(BY_PATH); + expect(out.path).toBe('/Projekte'); + expect(out.shares.map((s) => s.id)).toEqual(['17']); + expect(out.hidden).toBe(1); + const root = setup([]); + expect(codeOf(await failure(root.service.sharesForPath('t1', 'u1', '/')))).toBe('invalidPath'); + const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong path') }]); + expect(codeOf(await failure(nf.service.sharesForPath('t1', 'u1', '/x')))).toBe('notFound'); + }); + + it('Empfaengersuche: genau die erwartete Adresse; leerer Suchbegriff ruft nichts auf', async () => { + const { service, calls } = setup([ + { + status: 200, + text: ocs({ + users: [ + { + label: 'Ben Beispiel', + value: { shareType: 0, shareWith: 'ben' }, + shareWithDisplayNameUnique: 'ben', + }, + ], + groups: [], + }), + }, + ]); + const out = await service.sharees('t1', 'u1', ' ben ', 'folder'); + expect(calls[0].url).toBe( + `${OCS}/apps/files_sharing/api/v1/sharees?search=ben&itemType=folder&perPage=20&shareType%5B0%5D=0&shareType%5B1%5D=1`, + ); + expect(out.sharees).toEqual([ + { kind: 'user', id: 'ben', label: 'Ben Beispiel', detail: 'ben' }, + ]); + + const empty = setup([]); + await expect(empty.service.sharees('t1', 'u1', ' ', 'file')).resolves.toEqual({ sharees: [] }); + expect(empty.calls).toHaveLength(0); + }); + + it('Richtlinie: zwei Aufrufe -> zwei Anfragen an die Faehigkeiten (kein Zwischenspeicher)', async () => { + const { service, calls } = setup([ + { status: 200, text: CAPS() }, + { status: 200, text: CAPS({ group_sharing: false }) }, + ]); + const first = await service.policy('t1', 'u1'); + const second = await service.policy('t1', 'u1'); + expect(calls.map((c) => c.url)).toEqual([CAPS_URL, CAPS_URL]); + expect(first.groupsEnabled).toBe(true); + expect(second.groupsEnabled).toBe(false); + }); + + it('nie 401 oder 403 nach aussen, auch nicht bei 403 aus der Suche', async () => { + const { service } = setup([{ status: 403, text: ocs([], 403, 'nope') }]); + const e = await failure(service.sharees('t1', 'u1', 'be', 'file')); + expect([401, 403]).not.toContain(statusOf(e)); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts new file mode 100644 index 0000000..a3feb55 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts @@ -0,0 +1,377 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import * as dav from './nextcloud-dav'; +import { type NcSession, ncErrorDefault } from './nextcloud-files.types'; +import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; +import { NEXTCLOUD_TRANSPORT, type NextcloudTransport, parseUserPath } from './nextcloud-http'; +import { NextcloudLoginGuard } from './nextcloud-login-guard'; +import type { NcEntry } from './nextcloud-propfind'; +import { + CAPABILITIES_SEGMENTS, + isShareId, + type NcItemType, + type NcShareKind, + type NcShareView, + type NcSharee, + type NcSharePolicy, + OCS_CAPABILITIES_MAX_BYTES, + type OcsShareOptions, + ocsShareRequest, + parseShareList, + parseSharePolicy, + parseSharees, + permissionsFor, + SHAREE_SEGMENTS, + SHARES_BASE_SEGMENTS, +} from './nextcloud-shares'; +import { mapNcFailure } from './nextcloud-upstream'; + +/** + * Freigaben im Konto des angemeldeten Benutzers (quick-261009-dkv, D-11 bis D-16). + * + * Rechte: jede Methode beginnt mit `getSession(tenantId, userId)` — Mandant und + * Benutzer kommen aus dem Token, jeder Aufruf geht mit dem eigenen App-Passwort des + * Aufrufers an Nextcloud. Nextcloud bleibt die entscheidende Stelle (Eigentum, + * Teilen-Recht, Richtlinien); die API prueft vorher nur, was sie aus den + * Faehigkeiten und dem Eintrag selbst wissen kann, damit nie eine unlesbare + * Nextcloud-Meldung entsteht (Nextcloud antwortet beim Aendern auf fast alles mit + * "Failed to update share."). Dieser Dienst greift nicht auf die Datenbank zu. + * + * Fehlervertrag wie in Etappe 1: nie 401/403 an den Browser; Nextcloud-Meldungen + * (uebersetzt, nie auswerten!) hoechstens als `ncMessage` in einer zweiten Zeile. + * Nie loggen: Koerper, Passwoerter, Kennungen, Link-Adressen. + */ + +/** Eingabe fuer eine neue Freigabe an Person oder Gruppe (Links folgen in Aufgabe 2). */ +export interface CreateShareInput { + path: string; + kind: 'user' | 'group'; + shareWith: string; + access: 'view' | 'edit' | 'upload'; +} + +export interface UpdateShareInput { + access?: 'view' | 'edit' | 'upload'; +} + +/** Welche Felder gesendet wurden: bestimmt, wie ein Fehler gedeutet wird (D-15). */ +interface SentFields { + kind?: NcShareKind; + passwordNonEmpty?: boolean; + expireDateSent?: boolean; + expireDateNonEmpty?: boolean; +} + +type Operation = 'create' | 'update' | 'remove' | 'accept' | 'read-path' | 'read-id' | 'other'; + +const GROUPS_OFF = 'Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.'; + +function pathOf(segments: readonly string[]): string { + return `/${segments.join('/')}`; +} + +@Injectable() +export class NextcloudFilesSharesService { + constructor( + private readonly account: NextcloudFilesAccountService, + private readonly gate: NextcloudCallGate, + @Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport, + private readonly guard: NextcloudLoginGuard, + ) {} + + private session(tenantId: string, userId: string): Promise { + return this.account.getSession(tenantId, userId); + } + + private async transportFail( + tenantId: string, + userId: string, + result: { ok: boolean; kind?: string; status?: number; retryAfterSeconds?: number }, + ): Promise { + throw await mapNcFailure(result, { + onExpired: () => this.account.markExpired(tenantId, userId), + }); + } + + /** + * D-15: ein nicht erfolgreicher Aufruf wird zu genau einem deutschen Fehler. Transportfehler, + * abgelaufene Zugaenge, 429 und jeder Status ab 500 gehen unveraendert durch `mapNcFailure` + * (Etappe-1-Vertrag). Danach zaehlt nur (Aufruf, Status, gesendete Felder) — nie der Text. + */ + private async mapShareFailure( + tenantId: string, + userId: string, + operation: Operation, + result: { + ok: boolean; + kind?: string; + status?: number; + message?: string | null; + retryAfterSeconds?: number; + }, + sent: SentFields, + ): Promise { + const status = result.status; + if (!result.ok || status === undefined || status >= 500 || status === 401 || status === 429) { + return this.transportFail(tenantId, userId, result); + } + if (status < 400) return this.transportFail(tenantId, userId, { ok: true, status: 502 }); + const extra = result.message ? { ncMessage: result.message } : undefined; + const rejected = () => ncErrorDefault('shareRejected', extra); + + switch (operation) { + case 'create': + if (status === 400 && sent.passwordNonEmpty) { + throw ncErrorDefault('sharePasswordRejected', extra); + } + if (status === 404 && sent.expireDateNonEmpty) { + throw ncErrorDefault('shareExpiryInvalid', extra); + } + if (status === 404) { + throw sent.kind === 'link' + ? ncErrorDefault('notFound') + : ncErrorDefault('shareRecipientInvalid', extra); + } + throw rejected(); + case 'update': + if (status === 400 && sent.passwordNonEmpty) { + throw ncErrorDefault('sharePasswordRejected', extra); + } + if ((status === 400 || status === 404) && sent.expireDateSent) { + throw ncErrorDefault('shareExpiryInvalid', extra); + } + if (status === 404) throw ncErrorDefault('shareNotFound'); + throw rejected(); + case 'remove': + case 'accept': + case 'read-id': + if (status === 404) throw ncErrorDefault('shareNotFound'); + throw rejected(); + case 'read-path': + if (status === 404) throw ncErrorDefault('notFound'); + throw rejected(); + default: + throw rejected(); + } + } + + /** Ruft Nextcloud auf und liefert die Daten einer erfolgreichen (2xx) Antwort, sonst wirft es. */ + private async run( + tenantId: string, + userId: string, + session: NcSession, + opts: OcsShareOptions, + operation: Operation, + sent: SentFields = {}, + ): Promise { + const result = await ocsShareRequest(this.transport, this.gate, session, opts); + if (!result.ok || result.status < 200 || result.status >= 300) { + return this.mapShareFailure(tenantId, userId, operation, result, sent); + } + return result.data; + } + + /** D-13: Richtlinie bei jeder Nutzung frisch lesen (je Benutzer verschieden, nie zwischengespeichert). */ + private async loadPolicy( + tenantId: string, + userId: string, + session: NcSession, + ): Promise { + const data = await this.run( + tenantId, + userId, + session, + { method: 'GET', segments: CAPABILITIES_SEGMENTS, maxBytes: OCS_CAPABILITIES_MAX_BYTES }, + 'other', + ); + return parseSharePolicy(data); + } + + private async listByPath(tenantId: string, userId: string, session: NcSession, path: string) { + const data = await this.run( + tenantId, + userId, + session, + { + method: 'GET', + segments: SHARES_BASE_SEGMENTS, + query: { path, reshares: 'true' }, + }, + 'read-path', + ); + return parseShareList(data, session.ncUserId); + } + + private async loadShare( + tenantId: string, + userId: string, + session: NcSession, + id: string, + ): Promise { + const data = await this.run( + tenantId, + userId, + session, + { method: 'GET', segments: [...SHARES_BASE_SEGMENTS, id] }, + 'read-id', + ); + const share = parseShareList(data, session.ncUserId).shares[0]; + // Eine Freigabe anderer Art (E-Mail, Server ...) gibt es fuer Tessera nicht. + if (!share) throw ncErrorDefault('shareNotFound'); + return share; + } + + // --- Lesen ---------------------------------------------------------------------------------- + + async policy(tenantId: string, userId: string): Promise { + const session = await this.session(tenantId, userId); + return this.loadPolicy(tenantId, userId, session); + } + + async sharesForPath(tenantId: string, userId: string, rawPath: string) { + const segments = parseUserPath(rawPath); + if (segments.length === 0) throw ncErrorDefault('invalidPath'); + const session = await this.session(tenantId, userId); + const path = pathOf(segments); + const list = await this.listByPath(tenantId, userId, session, path); + return { path, ...list }; + } + + async sharees( + tenantId: string, + userId: string, + term: string, + itemType: NcItemType, + ): Promise<{ sharees: NcSharee[] }> { + const search = term.trim(); + if (search.length < 1) return { sharees: [] }; + const session = await this.session(tenantId, userId); + const data = await this.run( + tenantId, + userId, + session, + { + method: 'GET', + segments: SHAREE_SEGMENTS, + query: { + search, + itemType, + perPage: '20', + 'shareType[0]': '0', + 'shareType[1]': '1', + }, + }, + 'other', + ); + return { sharees: parseSharees(data) }; + } + + // --- Schreiben ------------------------------------------------------------------------------ + + /** Darf dieser Eintrag laut Nextcloud-Buchstaben geaendert oder befuellt werden (W, C oder K)? */ + private entryWritable(entry: NcEntry): boolean { + return /[WCK]/.test(entry.permissions); + } + + async create(tenantId: string, userId: string, input: CreateShareInput): Promise { + const segments = parseUserPath(input.path); + if (segments.length === 0) throw ncErrorDefault('invalidPath'); + const session = await this.session(tenantId, userId); + + // Art und Schreibbarkeit kommen aus der eigenen Abfrage, nie aus dem Browser (T-dkv-01). + const stat = await dav.stat(this.transport, this.gate, session, segments); + if (!stat.ok) return this.transportFail(tenantId, userId, stat); + if (stat.entry === null) { + if (stat.status === 404) throw ncErrorDefault('notFound'); + return this.transportFail(tenantId, userId, { ok: true, status: stat.status }); + } + const entry = stat.entry; + + const policy = await this.loadPolicy(tenantId, userId, session); + if (!policy.enabled) throw ncErrorDefault('sharingDisabled'); + if (input.kind === 'group' && !policy.groupsEnabled) { + throw ncErrorDefault('sharingDisabled', undefined, GROUPS_OFF); + } + if (!entry.permissions.includes('R')) throw ncErrorDefault('shareRejected'); + if (input.access === 'upload' || (input.access === 'edit' && !this.entryWritable(entry))) { + throw ncErrorDefault('shareAccessInvalid'); + } + const permissions = permissionsFor(input.access, entry.type); + if (permissions === null) throw ncErrorDefault('shareAccessInvalid'); + + 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'); + } + + // Erst nach jeder Vorpruefung zaehlen: abgelehnte Eingaben verbrauchen kein Kontingent. + this.guard.checkShareCreate(userId); + const data = await this.run( + tenantId, + userId, + session, + { + method: 'POST', + segments: SHARES_BASE_SEGMENTS, + json: { + path, + shareType: input.kind === 'user' ? 0 : 1, + shareWith: input.shareWith, + permissions, + }, + }, + 'create', + { kind: input.kind }, + ); + const created = parseShareList(data, session.ncUserId).shares[0]; + if (!created) throw ncErrorDefault('nextcloudError'); + return created; + } + + async update( + tenantId: string, + userId: string, + id: string, + input: UpdateShareInput, + ): Promise { + if (!isShareId(id)) throw ncErrorDefault('shareNotFound'); + 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; + + if (current.kind === 'link') throw ncErrorDefault('shareAccessInvalid'); + if (input.access === 'upload' || (input.access === 'edit' && !current.itemWritable)) { + throw ncErrorDefault('shareAccessInvalid'); + } + const permissions = permissionsFor(input.access, current.itemType); + if (permissions === null) throw ncErrorDefault('shareAccessInvalid'); + if (current.access === input.access) return current; + + const data = await this.run( + tenantId, + userId, + session, + { method: 'PUT', segments: [...SHARES_BASE_SEGMENTS, id], json: { permissions } }, + 'update', + { kind: current.kind }, + ); + const updated = parseShareList(data, session.ncUserId).shares[0]; + if (!updated) throw ncErrorDefault('nextcloudError'); + return updated; + } + + async remove(tenantId: string, userId: string, id: string): Promise<{ deleted: true }> { + if (!isShareId(id)) throw ncErrorDefault('shareNotFound'); + const session = await this.session(tenantId, userId); + await this.run( + tenantId, + userId, + session, + { method: 'DELETE', segments: [...SHARES_BASE_SEGMENTS, id] }, + 'remove', + ); + return { deleted: 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 96bff79..b172b16 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts @@ -16,6 +16,15 @@ const ADMIN_HANDLERS = ['saveSettings']; /** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */ const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle']; const TRANSFER_PARAM = ['putChunk', 'completeUpload', 'uploadState', 'abortUpload']; +/** Teilen (quick-261009-dkv): alle auf Benutzen-Ebene. */ +const SHARE_HANDLERS = [ + 'getSharePolicy', + 'listSharesForPath', + 'searchSharees', + 'createShare', + 'updateShare', + 'deleteShare', +]; const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM]; /** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */ @@ -109,6 +118,34 @@ describe('NextcloudFilesController — Metadaten', () => { expect(route('abortUpload')).toEqual([3, 'uploads/:uploadId']); expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']); expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']); + // Teilen (quick-261009-dkv) + expect(route('getSharePolicy')).toEqual([0, 'shares/policy']); + expect(route('listSharesForPath')).toEqual([0, 'shares/by-path']); + expect(route('searchSharees')).toEqual([0, 'sharees']); + expect(route('createShare')).toEqual([1, 'shares']); + expect(route('updateShare')).toEqual([2, 'shares/:id']); + expect(route('deleteShare')).toEqual([3, 'shares/:id']); + }); + + it('die Teilen-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => { + for (const name of SHARE_HANDLERS) { + expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined(); + expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined(); + } + }); + + it('die beiden :id-Handler des Teilens stehen nach allen statischen Handlern', () => { + const names = routeHandlers(); + for (const name of ['getSharePolicy', 'listSharesForPath', 'searchSharees', 'createShare']) { + expect(names.indexOf(name), name).toBeLessThan(names.indexOf('pollFlow')); + } + const staticLast = Math.max( + ...names + .filter((n) => !String(Reflect.getMetadata('path', proto[n])).includes(':')) + .map((n) => names.indexOf(n)), + ); + expect(names.indexOf('updateShare')).toBeGreaterThan(staticLast); + expect(names.indexOf('deleteShare')).toBeGreaterThan(staticLast); }); it('keiner der Anmelde-Handler traegt Verwalten oder einen Rollen-Decorator', () => { @@ -246,6 +283,17 @@ describe('NextcloudFilesController — Delegation', () => { }; } + function makeShares() { + return { + policy: vi.fn(async (..._a: unknown[]) => ({ enabled: true })), + sharesForPath: vi.fn(async (..._a: unknown[]) => ({ shares: [] })), + sharees: vi.fn(async (..._a: unknown[]) => ({ sharees: [] })), + create: vi.fn(async (..._a: unknown[]) => ({ id: '1' })), + update: vi.fn(async (..._a: unknown[]) => ({ id: '1' })), + remove: vi.fn(async (..._a: unknown[]) => ({ deleted: true })), + }; + } + function makeFiles() { return { list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })), @@ -264,6 +312,7 @@ describe('NextcloudFilesController — Delegation', () => { files as any, makeTransfer() as any, makeServerInfo() as any, + makeShares() as any, ); const r = userReq('t1', 'u1'); const res = { end: vi.fn() } as any; @@ -289,6 +338,7 @@ describe('NextcloudFilesController — Delegation', () => { makeFiles() as any, transfer as any, makeServerInfo() as any, + makeShares() as any, ); const r = userReq('t1', 'u1'); (r as any).headers = { range: 'bytes=0-99' }; @@ -363,6 +413,7 @@ describe('NextcloudFilesController — Delegation', () => { makeFiles() as any, transfer as any, makeServerInfo() as any, + makeShares() as any, ); const r = userReq('t1', undefined); const res = {} as any; @@ -378,6 +429,51 @@ describe('NextcloudFilesController — Delegation', () => { expect(transfer.uploadState).not.toHaveBeenCalled(); }); + it('Teilen-Handler: Mandant und Benutzer aus dem Token, Eingaben aus Query und Body, Kennung als Zeichenkette', async () => { + const shares = makeShares(); + const controller = new NextcloudFilesController( + makeSettings() as any, + makeAccount() as any, + makeFiles() as any, + makeTransfer() as any, + makeServerInfo() as any, + shares as any, + ); + const r = userReq('t1', 'u1'); + await controller.getSharePolicy(r); + await controller.listSharesForPath(r, { path: '/Projekte' } as any); + await controller.searchSharees(r, { term: 'ben', itemType: 'folder' } as any); + const dto = { path: '/Projekte', kind: 'user', shareWith: 'ben', access: 'edit' } as any; + await controller.createShare(r, dto); + await controller.updateShare(r, '17', { access: 'view' } as any); + await controller.deleteShare(r, '17'); + 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'); + }); + + it('Teilen-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => { + const shares = makeShares(); + const controller = new NextcloudFilesController( + makeSettings() as any, + makeAccount() as any, + makeFiles() as any, + makeTransfer() as any, + makeServerInfo() as any, + shares as any, + ); + const r = userReq('t1', undefined); + 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); + const noTenant = userReq(undefined, 'u1'); + await expect(controller.getSharePolicy(noTenant)).rejects.toBeInstanceOf(ForbiddenException); + for (const fn of Object.values(shares)) expect(fn).not.toHaveBeenCalled(); + }); + it('Datei-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => { const files = makeFiles(); const controller = new NextcloudFilesController( @@ -386,6 +482,7 @@ describe('NextcloudFilesController — Delegation', () => { files as any, makeTransfer() as any, makeServerInfo() as any, + makeShares() as any, ); const r = userReq('t1', undefined); await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException); @@ -405,6 +502,7 @@ describe('NextcloudFilesController — Delegation', () => { makeFiles() as any, makeTransfer() as any, makeServerInfo() as any, + makeShares() as any, ); await controller.getStatus(userReq('t1', 'u1')); const admin = { tenantId: 't1', user: { id: 'a1', role: 'ADMIN' } } as any; @@ -439,6 +537,7 @@ describe('NextcloudFilesController — Delegation', () => { makeFiles() as any, makeTransfer() as any, makeServerInfo() as any, + makeShares() as any, ); const manager = { tenantId: 't1', user: { id: 'm1', role: 'USER' } } as any; await controller.testSettings(manager, { baseUrl: 'https://CLOUD.example/' } as any); @@ -463,6 +562,7 @@ describe('NextcloudFilesController — Delegation', () => { makeFiles() as any, makeTransfer() as any, makeServerInfo() as any, + makeShares() as any, ); await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf( ForbiddenException, @@ -481,6 +581,7 @@ describe('NextcloudFilesController — Delegation', () => { makeFiles() as any, makeTransfer() as any, serverInfo as any, + makeShares() as any, ); const res = { end: vi.fn() } as any; await controller.getServer(userReq('t1', 'u1')); @@ -500,6 +601,7 @@ describe('NextcloudFilesController — Delegation', () => { makeFiles() as any, makeTransfer() as any, makeServerInfo() as any, + makeShares() as any, ); await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf( ForbiddenException, diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts index 542eaa9..68715ac 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts @@ -26,6 +26,12 @@ import { PathQueryDto, PreviewQueryDto, } from './dto/nextcloud-files-ops.dto'; +import { + CreateShareDto, + ShareByPathQueryDto, + ShareeQueryDto, + UpdateShareDto, +} from './dto/nextcloud-files-shares.dto'; import { SaveNextcloudFilesSettingsDto, TestNextcloudFilesSettingsDto, @@ -39,6 +45,7 @@ import { } from './dto/nextcloud-files-transfer.dto'; import { NextcloudFilesService } from './nextcloud-files.service'; import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; +import { NextcloudFilesSharesService } from './nextcloud-files-shares.service'; import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; import { NextcloudFilesTransferService, @@ -64,10 +71,12 @@ import { NextcloudServerInfoService } from './nextcloud-server-info'; * GET server/logo, POST connect/password, POST connect/flow, * DELETE connect, GET files, DELETE files, POST folders, POST move, * GET preview, GET download, POST 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. + * 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. * 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`). @@ -104,6 +113,7 @@ export class NextcloudFilesController { private readonly files: NextcloudFilesService, private readonly transfer: NextcloudFilesTransferService, private readonly serverInfo: NextcloudServerInfoService, + private readonly shares: NextcloudFilesSharesService, ) {} private requireTenantId(req: AuthenticatedRequest): string { @@ -290,6 +300,39 @@ export class NextcloudFilesController { ); } + // --- Teilen (statisch; Benutzen; Freigaben gibt es nur im eigenen Konto) ---------------- + + /** Freigaberegeln der Nextcloud des Aufrufers (frisch gelesen, nie zwischengespeichert). */ + @Get('shares/policy') + async getSharePolicy(@Req() req: AuthenticatedRequest) { + return this.shares.policy(this.requireTenantId(req), this.requireUserId(req)); + } + + @Get('shares/by-path') + async listSharesForPath(@Req() req: AuthenticatedRequest, @Query() query: ShareByPathQueryDto) { + return this.shares.sharesForPath( + this.requireTenantId(req), + this.requireUserId(req), + query.path, + ); + } + + /** Empfaengersuche (Personen und Gruppen) ueber die Nextcloud. */ + @Get('sharees') + async searchSharees(@Req() req: AuthenticatedRequest, @Query() query: ShareeQueryDto) { + return this.shares.sharees( + this.requireTenantId(req), + this.requireUserId(req), + query.term, + query.itemType, + ); + } + + @Post('shares') + async createShare(@Req() req: AuthenticatedRequest, @Body() dto: CreateShareDto) { + return this.shares.create(this.requireTenantId(req), this.requireUserId(req), dto); + } + // --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) --------------- @Get('connect/flow/:flowId') @@ -353,4 +396,19 @@ export class NextcloudFilesController { async abortUpload(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) { return this.transfer.abortUpload(this.requireTenantId(req), this.requireUserId(req), uploadId); } + + /** Die Kennung prueft der Dienst (nur Ziffern); die Berechtigung kommt als Auswahl, nie als Maske. */ + @Put('shares/:id') + async updateShare( + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Body() dto: UpdateShareDto, + ) { + return this.shares.update(this.requireTenantId(req), this.requireUserId(req), id, dto); + } + + @Delete('shares/:id') + async deleteShare(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + return this.shares.remove(this.requireTenantId(req), this.requireUserId(req), id); + } } diff --git a/apps/api/src/nextcloud-files/nextcloud-files.module.ts b/apps/api/src/nextcloud-files/nextcloud-files.module.ts index 8470cf2..b22a314 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.module.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.module.ts @@ -6,6 +6,7 @@ import { NextcloudFilesController } from './nextcloud-files.controller'; import { seedNextcloudFilesModule } from './nextcloud-files.seed'; import { NextcloudFilesService } from './nextcloud-files.service'; import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; +import { NextcloudFilesSharesService } from './nextcloud-files-shares.service'; import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service'; import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http'; @@ -27,6 +28,7 @@ import { NextcloudServerInfoService } from './nextcloud-server-info'; NextcloudFilesAccountService, NextcloudFilesService, NextcloudFilesTransferService, + NextcloudFilesSharesService, NextcloudServerInfoService, NextcloudLoginGuard, LoginFlowStore, diff --git a/apps/api/src/nextcloud-files/nextcloud-files.types.ts b/apps/api/src/nextcloud-files/nextcloud-files.types.ts index 3024e77..188662c 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.types.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.types.ts @@ -41,7 +41,19 @@ export type NcErrorCode = | 'flowExpired' | 'tooManyFlows' | 'invalidUrl' - | 'confirmReconnect'; + | 'confirmReconnect' + | 'sharingDisabled' + | 'linkSharingDisabled' + | 'shareAccessInvalid' + | 'shareRecipientInvalid' + | 'shareAlreadyExists' + | 'sharePasswordRequired' + | 'sharePasswordRejected' + | 'shareExpiryRequired' + | 'shareExpiryInvalid' + | 'shareRejected' + | 'shareNotFound' + | 'tooManyShares'; interface ErrorDefault { status: number; @@ -179,6 +191,56 @@ export const NC_ERROR_DEFAULTS: Record = { message: 'Bei einem Wechsel der Adresse müssen sich alle verbundenen Benutzer neu anmelden. Bitte bestätigen Sie den Wechsel.', }, + sharingDisabled: { + status: 409, + message: 'Teilen ist in Ihrer Nextcloud ausgeschaltet.', + }, + linkSharingDisabled: { + status: 409, + message: 'Öffentliche Links sind in Ihrer Nextcloud ausgeschaltet.', + }, + shareAccessInvalid: { + status: 400, + message: 'Diese Berechtigung ist für diesen Eintrag nicht möglich.', + }, + shareRecipientInvalid: { + status: 422, + message: 'Diese Person oder Gruppe kennt Ihre Nextcloud nicht.', + }, + shareAlreadyExists: { + status: 409, + message: 'Der Eintrag ist schon so geteilt. Ändern Sie die vorhandene Freigabe.', + }, + sharePasswordRequired: { + status: 400, + message: 'Ihre Nextcloud verlangt für Links ein Passwort.', + }, + sharePasswordRejected: { + status: 400, + message: + 'Nextcloud lehnt dieses Passwort ab. Bitte wählen Sie ein längeres oder weniger gebräuchliches Passwort.', + }, + shareExpiryRequired: { + status: 400, + message: 'Ihre Nextcloud verlangt für Links ein Ablaufdatum.', + }, + shareExpiryInvalid: { + status: 400, + message: + 'Dieses Ablaufdatum lässt Ihre Nextcloud nicht zu. Es darf nicht in der Vergangenheit und nicht nach dem erlaubten Höchstdatum liegen.', + }, + shareRejected: { + status: 422, + message: 'Nextcloud hat diese Freigabe abgelehnt.', + }, + shareNotFound: { + status: 404, + message: 'Diese Freigabe gibt es nicht mehr.', + }, + tooManyShares: { + status: 429, + message: 'Sie haben in kurzer Zeit viele Freigaben angelegt. Bitte warten Sie einige Minuten.', + }, }; /** Baut die HttpException mit dem Koerper `{ code, message, ...extra }`. */ diff --git a/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts b/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts index 610acfb..349ee51 100644 --- a/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts @@ -151,6 +151,47 @@ describe('NextcloudLoginGuard — Start der Browser-Anmeldung', () => { }); }); +describe('NextcloudLoginGuard — neue Freigaben (quick-261009-dkv, D-09)', () => { + it('15 in 10 Minuten gehen durch, die 16. ist 429 tooManyShares mit Wartezeit bis zum Fensterende', () => { + const { guard, clock } = makeGuard(); + for (let i = 0; i < 15; i++) { + guard.checkShareCreate('u1'); + clock.t += 1000; + } + // aelteste Freigabe liegt 15 s zurueck -> 600 - 15 = 585 s + const blocked = codeOf(() => guard.checkShareCreate('u1')); + expect(blocked.status).toBe(429); + expect(blocked.body.code).toBe('tooManyShares'); + expect(blocked.body.retryAfterSeconds).toBe(585); + }); + + it('Benutzer B ist von Benutzer A unabhaengig', () => { + const { guard } = makeGuard(); + for (let i = 0; i < 15; i++) guard.checkShareCreate('a'); + expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429); + expect(codeOf(() => guard.checkShareCreate('b')).status).toBeUndefined(); + }); + + it('nach 10 Minuten darf der Benutzer wieder; eine abgewiesene Freigabe zaehlt nicht mit', () => { + const { guard, clock } = makeGuard(); + for (let i = 0; i < 15; i++) guard.checkShareCreate('a'); + expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429); + expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429); + clock.t += 10 * MIN; + for (let i = 0; i < 15; i++) { + expect(codeOf(() => guard.checkShareCreate('a')).status).toBeUndefined(); + } + expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429); + }); + + it('beruehrt weder Fehlerzaehler noch Flow-Starts', () => { + const { guard } = makeGuard(); + for (let i = 0; i < 15; i++) guard.checkShareCreate('u1'); + expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined(); + expect(codeOf(() => guard.checkFlowStart('u1')).status).toBeUndefined(); + }); +}); + describe('LoginFlowStore', () => { function makeStore() { const store = new LoginFlowStore(); diff --git a/apps/api/src/nextcloud-files/nextcloud-login-guard.ts b/apps/api/src/nextcloud-files/nextcloud-login-guard.ts index a371303..614fa4f 100644 --- a/apps/api/src/nextcloud-files/nextcloud-login-guard.ts +++ b/apps/api/src/nextcloud-files/nextcloud-login-guard.ts @@ -37,6 +37,14 @@ export const SERVER_FAILURE_LIMIT = 8; export const SERVER_FAILURE_WINDOW_MS = 30 * 60 * 1000; export const FLOW_START_LIMIT = 10; export const FLOW_START_WINDOW_MS = 10 * 60 * 1000; +/** + * Neue Freigaben je Benutzer (quick-261009-dkv, D-09): Nextcloud erlaubt 20 in 10 + * Minuten (`UserRateLimit`); deren 429 haette keinen `Retry-After` und legte die + * Aufrufsperre ueber den ganzen Ursprung (alle Benutzer, 15 Minuten). Tessera + * bremst deshalb schon bei 15. + */ +export const SHARE_CREATE_LIMIT = 15; +export const SHARE_CREATE_WINDOW_MS = 10 * 60 * 1000; /** Ein gezaehlter Versuch; `inFlight` = Nextcloud hat noch nicht geantwortet. */ interface Attempt { @@ -66,6 +74,12 @@ function removeAttempt(list: Attempt[] | undefined, attempt: Attempt): void { if (i >= 0) list.splice(i, 1); } +function tooManyShares(retryAfterMs: number) { + return ncErrorDefault('tooManyShares', { + retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)), + }); +} + function tooMany(retryAfterMs: number) { return ncErrorDefault('tooManyAttempts', { retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)), @@ -80,6 +94,7 @@ export class NextcloudLoginGuard { private readonly userFailures = new Map(); private readonly serverFailures = new Map(); private readonly flowStarts = new Map(); + private readonly shareCreates = new Map(); /** * Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429 @@ -167,6 +182,22 @@ export class NextcloudLoginGuard { starts.push(now); this.flowStarts.set(userId, starts); } + + /** + * Zaehlt eine neue Freigabe (15 je Benutzer in 10 Minuten); die 16. wird mit 429 + * `tooManyShares` abgewiesen, OHNE Nextcloud anzusprechen. Der Aufrufer ruft das erst + * unmittelbar vor dem POST auf, nach jeder Vorpruefung — abgelehnte Eingaben zaehlen nicht. + */ + checkShareCreate(userId: string): void { + const now = this.now(); + const times = pruneTimes(this.shareCreates.get(userId) ?? [], now, SHARE_CREATE_WINDOW_MS); + if (times.length >= SHARE_CREATE_LIMIT) { + this.shareCreates.set(userId, times); + throw tooManyShares(times[0] + SHARE_CREATE_WINDOW_MS - now); + } + times.push(now); + this.shareCreates.set(userId, times); + } } // --- Browser-Anmeldung (Login Flow v2) ----------------------------------------- diff --git a/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts b/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts index 1113605..40c6c97 100644 --- a/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts @@ -41,6 +41,7 @@ const XML = ` RGDNVCK 5000 0 + false HTTP/1.1 200 OK @@ -63,6 +64,7 @@ const XML = ` RGDNVW 42 1 + 303abc true HTTP/1.1 200 OK @@ -113,6 +115,7 @@ describe('parsePropfind', () => { permissions: 'RGDNVCK', hasPreview: false, favorite: false, + shareTypes: [], }, { name: '12345', @@ -126,6 +129,7 @@ describe('parsePropfind', () => { permissions: 'RGDNVW', hasPreview: true, favorite: true, + shareTypes: [0, 3], }, { name: '50%25.txt', @@ -139,10 +143,18 @@ describe('parsePropfind', () => { permissions: 'RGDNVW', hasPreview: false, favorite: false, + shareTypes: [], }, ]); }); + it('Freigabearten (oc:share-types): doppelte und Nichtzahlen entfallen, leeres Element ist leer (quick-261009-dkv)', () => { + const entries = parsePropfind(XML, CTX).entries; + expect(entries.find((e) => e.name === '12345')?.shareTypes).toEqual([0, 3]); + expect(entries.find((e) => e.name === 'Ärger & Co')?.shareTypes).toEqual([]); + expect(entries.find((e) => e.name === '50%25.txt')?.shareTypes).toEqual([]); + }); + it('der Name bleibt eine Zeichenkette (12345), nie eine Zahl', () => { const entry = parsePropfind(XML, CTX).entries.find((e) => e.fileId === '00042'); expect(typeof entry?.name).toBe('string'); diff --git a/apps/api/src/nextcloud-files/nextcloud-propfind.ts b/apps/api/src/nextcloud-files/nextcloud-propfind.ts index 644668f..88347d1 100644 --- a/apps/api/src/nextcloud-files/nextcloud-propfind.ts +++ b/apps/api/src/nextcloud-files/nextcloud-propfind.ts @@ -44,6 +44,11 @@ export interface NcEntry { permissions: string; hasPreview: boolean; favorite: boolean; + /** + * Freigabearten, in denen der Eintrag geteilt ist (`oc:share-types`: 0 Person, 1 Gruppe, + * 3 Link ...), aufsteigend und ohne Doppelte; leer = nicht (selbst) geteilt (quick-261009-dkv). + */ + shareTypes: number[]; } export interface NcQuota { @@ -173,6 +178,17 @@ function isoOf(value: unknown): string | null { return Number.isFinite(time) ? new Date(time).toISOString() : null; } +/** `0...` -> ganze Zahlen 0..99, aufsteigend, ohne Doppelte. */ +function shareTypesOf(value: unknown): number[] { + if (!isDict(value)) return []; + const found = new Set(); + for (const item of asArray(value['share-type'] as unknown)) { + const n = numberOf(item); + if (n !== null && Number.isInteger(n) && n >= 0 && n <= 99) found.add(n); + } + return [...found].sort((a, b) => a - b); +} + function buildEntry(rel: readonly string[], props: Dict): NcEntry { const folder = isFolder(props); const sizeRaw = folder @@ -191,6 +207,7 @@ function buildEntry(rel: readonly string[], props: Dict): NcEntry { permissions: text(props.permissions) ?? '', hasPreview: text(props['has-preview']) === 'true', favorite: text(props.favorite) === '1', + shareTypes: shareTypesOf(props['share-types']), }; } diff --git a/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts b/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts new file mode 100644 index 0000000..406c552 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts @@ -0,0 +1,597 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import type { NcSession } from './nextcloud-files.types'; +import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http'; +import { + accessOf, + OCS_OK_MAX_BYTES, + ocsShareRequest, + parseShare, + parseShareList, + parseSharePolicy, + parseSharees, + permissionsFor, + SHARES_BASE_SEGMENTS, +} from './nextcloud-shares'; + +const SESSION: NcSession = { + baseUrl: 'https://cloud.example/nc', + ncUserId: 'anna', + authorization: 'Basic YW5uYTphcHAtcHctMTIz', + credentialKey: 'k1', +}; +const SHARES = 'https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/shares'; + +type Reply = { status: number; text?: string; headers?: Record }; + +function setup(reply: Reply) { + const calls: NcTransportRequest[] = []; + const gate = new NextcloudCallGate(); + const transport: NextcloudTransport = async (req) => { + calls.push(req); + return { + statusCode: reply.status, + headers: reply.headers ?? {}, + body: Readable.from(reply.text !== undefined ? [Buffer.from(reply.text)] : []), + }; + }; + return { calls, gate, transport }; +} + +const ok = (data: unknown, message = 'OK') => + JSON.stringify({ ocs: { meta: { status: 'ok', statuscode: 200, message }, data } }); + +/** Lebende Antwort (Research, Nextcloud 34.0.4): Ordner /Projekte an zoe. */ +const USER_SHARE = { + id: '1', + share_type: 0, + uid_owner: 'anna', + displayname_owner: 'Anna Müller', + permissions: 31, + can_edit: true, + can_delete: true, + stime: 1791532378, + parent: null, + expiration: '2026-12-31 23:59:59', + token: null, + uid_file_owner: 'anna', + note: '', + label: '', + displayname_file_owner: 'Anna Müller', + path: '/Projekte', + item_type: 'folder', + item_permissions: 31, + 'is-mount-root': false, + 'mount-type': '', + mimetype: 'httpd/unix-directory', + has_preview: false, + storage_id: 'home::anna', + storage: 3, + item_source: 294, + file_source: 294, + file_parent: 93, + file_target: '/Projekte', + item_size: 6810, + item_mtime: 1791489905, + share_with: 'zoe', + share_with_displayname: 'Zwei Faktor', + share_with_displayname_unique: 'zoe', + mail_send: 1, + hide_download: 0, + attributes: null, +}; + +const LINK_SHARE = { + ...USER_SHARE, + id: '5', + share_type: 3, + path: '/Projekte/Angebot.pdf', + file_target: '/Angebot.pdf', + item_type: 'file', + mimetype: 'application/pdf', + item_permissions: 19, + permissions: 1, + expiration: null, + token: 'AbC123', + url: 'http://cloud.example/nc/index.php/s/AbC123', + password: 'redacted', + share_with: 'redacted', + share_with_displayname: '(Geteilter Link)', + label: 'Kunde', +}; + +const SHAREES = { + exact: { + users: [], + groups: [ + { + label: 'twofa', + value: { shareType: 1, shareWith: 'twofa' }, + }, + ], + remotes: [], + emails: [], + }, + users: [ + { + label: 'Zwei Faktor', + subline: '', + icon: 'icon-user', + value: { shareType: 0, shareWith: 'zoe' }, + shareWithDisplayNameUnique: 'zoe', + status: [], + }, + ], + groups: [{ label: 'twofa', value: { shareType: 1, shareWith: 'twofa' } }], + remotes: [{ label: 'x', value: { shareType: 6, shareWith: 'x@y' } }], + emails: [{ label: 'm', value: { shareType: 4, shareWith: 'm@y' } }], + lookup: [], +}; + +const CAPS = { + version: { major: 34 }, + capabilities: { + password_policy: { minLength: 10, enforceNonCommonPassword: true }, + files_sharing: { + api_enabled: true, + public: { + enabled: true, + password: { enforced: false, askForOptionalPassword: false }, + expire_date: { enabled: false }, + expire_date_internal: { enabled: false }, + upload: true, + upload_files_drop: true, + multiple_links: true, + }, + resharing: true, + group_sharing: true, + default_permissions: 31, + sharee: { minSearchStringLength: 0 }, + }, + }, +}; + +describe('ocsShareRequest — genaue Aufrufe', () => { + it('Freigaben eines Pfads: GET mit codiertem Pfad, Auth- und OCS-Kopfzeilen, ohne Cookie', async () => { + const { calls, gate, transport } = setup({ status: 200, text: ok([]) }); + await ocsShareRequest(transport, gate, SESSION, { + method: 'GET', + segments: SHARES_BASE_SEGMENTS, + query: { path: '/Projekte/Ärger 100%', reshares: 'true' }, + }); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('GET'); + expect(calls[0].url).toBe(`${SHARES}?path=%2FProjekte%2F%C3%84rger%20100%25&reshares=true`); + expect(calls[0].headers.authorization).toBe('Basic YW5uYTphcHAtcHctMTIz'); + expect(calls[0].headers['ocs-apirequest']).toBe('true'); + expect(calls[0].headers.accept).toBe('application/json'); + expect(calls[0].headers.cookie).toBeUndefined(); + expect(calls[0].body).toBeNull(); + }); + + it('Empfaengersuche: genau die erwartete Adresse mit durchnummerierten shareType-Schluesseln', async () => { + const { calls, gate, transport } = setup({ status: 200, text: ok({}) }); + await ocsShareRequest(transport, gate, SESSION, { + method: 'GET', + segments: ['apps', 'files_sharing', 'api', 'v1', 'sharees'], + query: { + search: 'ben', + itemType: 'folder', + perPage: '20', + 'shareType[0]': '0', + 'shareType[1]': '1', + }, + }); + expect(calls[0].url).toBe( + 'https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=ben&itemType=folder&perPage=20&shareType%5B0%5D=0&shareType%5B1%5D=1', + ); + }); + + it('Anlegen: POST mit JSON-Kopfzeile und dem Koerper als Zeichenkette', async () => { + const { calls, gate, transport } = setup({ status: 200, text: ok(USER_SHARE) }); + await ocsShareRequest(transport, gate, SESSION, { + method: 'POST', + segments: SHARES_BASE_SEGMENTS, + json: { path: '/Projekte', shareType: 0, shareWith: 'ben', permissions: 15 }, + }); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toBe(SHARES); + expect(calls[0].headers['content-type']).toBe('application/json'); + expect(calls[0].body).toBe( + '{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}', + ); + }); + + it('Aendern und Loeschen: Kennung als eigenes Segment, DELETE ohne Koerper', async () => { + const put = setup({ status: 200, text: ok(USER_SHARE) }); + await ocsShareRequest(put.transport, put.gate, SESSION, { + method: 'PUT', + segments: [...SHARES_BASE_SEGMENTS, '17'], + json: { permissions: 1 }, + }); + expect(put.calls[0].method).toBe('PUT'); + expect(put.calls[0].url).toBe(`${SHARES}/17`); + expect(put.calls[0].body).toBe('{"permissions":1}'); + + const del = setup({ status: 200, text: ok([]) }); + await ocsShareRequest(del.transport, del.gate, SESSION, { + method: 'DELETE', + segments: [...SHARES_BASE_SEGMENTS, '17'], + }); + expect(del.calls[0].method).toBe('DELETE'); + expect(del.calls[0].url).toBe(`${SHARES}/17`); + expect(del.calls[0].body).toBeNull(); + expect(del.calls[0].headers['content-type']).toBeUndefined(); + }); + + it('Faehigkeiten: GET cloud/capabilities', async () => { + const { calls, gate, transport } = setup({ status: 200, text: ok(CAPS) }); + const res = await ocsShareRequest(transport, gate, SESSION, { + method: 'GET', + segments: ['cloud', 'capabilities'], + }); + expect(calls[0].url).toBe('https://cloud.example/nc/ocs/v2.php/cloud/capabilities'); + expect(res.ok && res.data).toEqual(CAPS); + }); +}); + +describe('ocsShareRequest — Antworten', () => { + const NOT_FOUND = + '{"ocs":{"meta":{"status":"failure","statuscode":404,"message":"Wrong share ID, share does not exist"},"data":[]}}'; + + it('404 mit OCS-Text kommt als ok:true mit Status und Text', async () => { + const { gate, transport } = setup({ status: 404, text: NOT_FOUND }); + const res = await ocsShareRequest(transport, gate, SESSION, { + method: 'GET', + segments: [...SHARES_BASE_SEGMENTS, '9'], + }); + expect(res).toMatchObject({ + ok: true, + status: 404, + message: 'Wrong share ID, share does not exist', + }); + }); + + it('der Text wird von Steuerzeichen befreit und auf 300 Zeichen gekuerzt', async () => { + const dirty = `a\u0000b\u001b[31m${'x'.repeat(500)}`; + const { gate, transport } = setup({ status: 400, text: ok([], dirty) }); + const res = await ocsShareRequest(transport, gate, SESSION, { + method: 'GET', + segments: SHARES_BASE_SEGMENTS, + }); + expect(res.ok && res.message).not.toBeNull(); + const message = res.ok ? (res.message as string) : ''; + expect(message).toHaveLength(300); + expect(message.startsWith('a b [31m')).toBe(true); + // biome-ignore lint/suspicious/noControlCharactersInRegex: Pruefung auf Steuerzeichen + expect(/[\u0000-\u001f\u007f]/.test(message)).toBe(false); + }); + + it('kein JSON bei 2xx -> invalid-response; bei 4xx nur ohne Text', async () => { + const good = setup({ status: 200, text: '' }); + expect( + await ocsShareRequest(good.transport, good.gate, SESSION, { + method: 'GET', + segments: SHARES_BASE_SEGMENTS, + }), + ).toEqual({ ok: false, kind: 'invalid-response' }); + + const bad = setup({ status: 400, text: '' }); + expect( + await ocsShareRequest(bad.transport, bad.gate, SESSION, { + method: 'GET', + segments: SHARES_BASE_SEGMENTS, + }), + ).toEqual({ ok: true, status: 400, message: null, data: null }); + }); + + it('eine 2xx-Antwort ueber 8 MiB ist too-large', async () => { + const big = `${ok('x')}${' '.repeat(OCS_OK_MAX_BYTES)}`; + const { gate, transport } = setup({ status: 200, text: big }); + const res = await ocsShareRequest(transport, gate, SESSION, { + method: 'GET', + segments: SHARES_BASE_SEGMENTS, + }); + expect(res).toMatchObject({ ok: false, kind: 'too-large' }); + }); + + it('eine Fehlerantwort ueber 64 KiB bleibt ein Fehlerstatus ohne Text', async () => { + const big = `${NOT_FOUND}${' '.repeat(70 * 1024)}`; + const { gate, transport } = setup({ status: 404, text: big }); + const res = await ocsShareRequest(transport, gate, SESSION, { + method: 'GET', + segments: SHARES_BASE_SEGMENTS, + }); + expect(res).toEqual({ ok: true, status: 404, message: null, data: null }); + }); + + it('429 haelt den Ursprung an: der naechste Aufruf wird nicht gesendet', async () => { + const { calls, gate, transport } = setup({ status: 429, headers: { 'retry-after': '30' } }); + const first = await ocsShareRequest(transport, gate, SESSION, { + method: 'POST', + segments: SHARES_BASE_SEGMENTS, + json: { path: '/a' }, + }); + expect(first).toMatchObject({ ok: false, kind: 'http', status: 429 }); + const second = await ocsShareRequest(transport, gate, SESSION, { + method: 'GET', + segments: SHARES_BASE_SEGMENTS, + }); + expect(second).toMatchObject({ ok: false, kind: 'paused' }); + expect(calls).toHaveLength(1); + }); + + it('401 -> credential-dead', async () => { + const { gate, transport } = setup({ status: 401 }); + const res = await ocsShareRequest(transport, gate, SESSION, { + method: 'GET', + segments: SHARES_BASE_SEGMENTS, + }); + expect(res).toMatchObject({ ok: false, kind: 'credential-dead' }); + }); +}); + +describe('parseShare', () => { + it('die lebende Personenfreigabe wird zu genau der kleinen Ansicht', () => { + expect(parseShare(USER_SHARE, 'anna')).toEqual({ + id: '1', + kind: 'user', + path: '/Projekte', + name: 'Projekte', + itemType: 'folder', + mime: null, + itemWritable: true, + permissions: 31, + access: 'edit', + shareWith: 'zoe', + shareWithName: 'Zwei Faktor', + ownerId: 'anna', + ownerName: 'Anna Müller', + canEdit: true, + canDelete: true, + expiration: '2026-12-31', + label: '', + url: null, + hasPassword: false, + target: '/Projekte', + sharedAt: '2026-10-09T07:52:58.000Z', + }); + }); + + it('kopiert keine unbekannten Felder', () => { + const json = JSON.stringify(parseShare(USER_SHARE, 'anna')); + for (const word of ['storage_id', 'attributes', 'mail_send', 'item_source', 'token']) { + expect(json).not.toContain(word); + } + }); + + it('Link des Eigentuemers: Adresse bleibt, Passwort nur als hasPassword, nie "redacted"', () => { + const own = parseShare(LINK_SHARE, 'anna'); + expect(own).toMatchObject({ + kind: 'link', + hasPassword: true, + url: 'http://cloud.example/nc/index.php/s/AbC123', + shareWith: null, + shareWithName: null, + itemType: 'file', + mime: 'application/pdf', + label: 'Kunde', + name: 'Angebot.pdf', + }); + expect(JSON.stringify(own)).not.toContain('redacted'); + expect(JSON.stringify(own)).not.toContain('"token"'); + }); + + it('dieselbe Freigabe fuer einen anderen Betrachter hat keine Adresse', () => { + expect(parseShare(LINK_SHARE, 'zoe')?.url).toBeNull(); + }); + + it('eine Adresse mit anderem Schema wird verworfen', () => { + expect(parseShare({ ...LINK_SHARE, url: 'javascript:alert(1)' }, 'anna')?.url).toBeNull(); + expect(parseShare({ ...LINK_SHARE, url: 'ftp://x.example/s/1' }, 'anna')?.url).toBeNull(); + }); + + it('andere Arten und fehlerhafte Kennungen ergeben null', () => { + expect(parseShare({ ...USER_SHARE, share_type: 4 }, 'anna')).toBeNull(); + expect(parseShare({ ...USER_SHARE, share_type: 6 }, 'anna')).toBeNull(); + expect(parseShare({ ...USER_SHARE, id: 'abc' }, 'anna')).toBeNull(); + expect(parseShare('x', 'anna')).toBeNull(); + }); + + it('eingehende Freigabe: Name aus dem Ziel im eigenen Baum, Eigentuemer fremd', () => { + const incoming = parseShare( + { + ...USER_SHARE, + uid_owner: 'ben', + displayname_owner: 'Ben Beispiel', + path: '/Ben-Ordner/Quelle', + file_target: '/Ben-Ordner', + permissions: 1, + item_permissions: 1, + can_edit: false, + }, + 'anna', + ); + expect(incoming).toMatchObject({ + name: 'Ben-Ordner', + target: '/Ben-Ordner', + ownerId: 'ben', + ownerName: 'Ben Beispiel', + access: 'view', + itemWritable: false, + canEdit: false, + }); + }); + + it('Anzeigetexte: Steuerzeichen raus', () => { + const share = parseShare( + { ...USER_SHARE, share_with_displayname: 'Z\u0007oe\n Faktor' }, + 'anna', + ); + expect(share?.shareWithName).toBe('Z oe Faktor'); + }); +}); + +describe('accessOf / permissionsFor', () => { + it.each([ + [1, 'view'], + [17, 'view'], + [4, 'upload'], + [3, 'edit'], + [15, 'edit'], + [31, 'edit'], + [0, 'custom'], + [16, 'custom'], + ])('accessOf(%i) = %s', (permissions, access) => { + expect(accessOf(permissions)).toBe(access); + }); + + it('permissionsFor nach Eintragsart', () => { + expect(permissionsFor('edit', 'folder')).toBe(15); + expect(permissionsFor('edit', 'file')).toBe(3); + expect(permissionsFor('view', 'folder')).toBe(1); + expect(permissionsFor('view', 'file')).toBe(1); + expect(permissionsFor('upload', 'folder')).toBe(4); + expect(permissionsFor('upload', 'file')).toBeNull(); + }); +}); + +describe('parseShareList', () => { + it('nimmt ein Feld (GET) und ein Objekt (POST/PUT), zaehlt andere Arten als versteckt', () => { + const list = parseShareList( + [USER_SHARE, { ...USER_SHARE, id: '2', share_type: 4 }, LINK_SHARE], + 'anna', + ); + expect(list.shares.map((s) => s.id)).toEqual(['1', '5']); + expect(list.hidden).toBe(1); + expect(list.truncated).toBe(false); + expect(parseShareList(USER_SHARE, 'anna').shares).toHaveLength(1); + expect(parseShareList(null, 'anna')).toEqual({ shares: [], hidden: 0, truncated: false }); + }); + + it('behaelt hoechstens 2000 und meldet truncated', () => { + const many = Array.from({ length: 2003 }, (_, i) => ({ ...USER_SHARE, id: String(i + 1) })); + const list = parseShareList(many, 'anna'); + expect(list.shares).toHaveLength(2000); + expect(list.truncated).toBe(true); + }); +}); + +describe('parseSharees', () => { + it('fuehrt genaue und weitere Treffer zusammen, ohne Doppelte, nur Personen und Gruppen', () => { + expect(parseSharees(SHAREES)).toEqual([ + { kind: 'user', id: 'zoe', label: 'Zwei Faktor', detail: 'zoe' }, + { kind: 'group', id: 'twofa', label: 'twofa', detail: null }, + ]); + }); + + it('hoechstens 25 Treffer; Unsinn ergibt eine leere Liste', () => { + const users = Array.from({ length: 40 }, (_, i) => ({ + label: `U${i}`, + value: { shareType: 0, shareWith: `u${i}` }, + })); + expect(parseSharees({ users })).toHaveLength(25); + expect(parseSharees(null)).toEqual([]); + expect(parseSharees({ users: 'x' })).toEqual([]); + }); +}); + +describe('parseSharePolicy', () => { + it('die lebenden Faehigkeiten', () => { + expect(parseSharePolicy(CAPS)).toEqual({ + enabled: true, + groupsEnabled: true, + links: { + enabled: true, + passwordRequired: false, + passwordSuggested: false, + expiryDefaultDays: null, + expiryEnforced: false, + uploadAllowed: true, + multipleLinks: true, + }, + internalExpiry: { defaultDays: null, enforced: false }, + minSearchLength: 0, + passwordMinLength: 10, + }); + }); + + it('Links aus (public = { enabled: false }) schaltet jede Linkregel ab', () => { + const caps = { + capabilities: { files_sharing: { api_enabled: true, public: { enabled: false } } }, + }; + const policy = parseSharePolicy(caps); + expect(policy.enabled).toBe(true); + expect(policy.links).toEqual({ + enabled: false, + passwordRequired: false, + passwordSuggested: false, + expiryDefaultDays: null, + expiryEnforced: false, + uploadAllowed: false, + multipleLinks: false, + }); + }); + + it('Ablauf erzwungen mit Tagen als Zeichenkette; ungueltige Tage ergeben null', () => { + const withExpiry = (expire_date: unknown) => ({ + capabilities: { + files_sharing: { api_enabled: true, public: { enabled: true, expire_date } }, + }, + }); + const enforced = parseSharePolicy(withExpiry({ enabled: true, days: '7', enforced: true })); + expect(enforced.links.expiryDefaultDays).toBe(7); + expect(enforced.links.expiryEnforced).toBe(true); + expect( + parseSharePolicy(withExpiry({ enabled: true, days: 'abc', enforced: true })).links + .expiryDefaultDays, + ).toBeNull(); + expect( + parseSharePolicy(withExpiry({ enabled: true, days: '0' })).links.expiryDefaultDays, + ).toBeNull(); + // ohne enabled gibt es keine Regel, auch wenn Tage dastehen + const disabled = parseSharePolicy(withExpiry({ enabled: false, days: '7', enforced: true })); + expect(disabled.links.expiryDefaultDays).toBeNull(); + expect(disabled.links.expiryEnforced).toBe(false); + }); + + it('Passwort erzwungen / empfohlen', () => { + const withPw = (password: unknown) => ({ + capabilities: { + files_sharing: { api_enabled: true, public: { enabled: true, password } }, + }, + }); + expect(parseSharePolicy(withPw({ enforced: true })).links.passwordRequired).toBe(true); + const suggested = parseSharePolicy(withPw({ enforced: false, askForOptionalPassword: true })); + expect(suggested.links.passwordRequired).toBe(false); + expect(suggested.links.passwordSuggested).toBe(true); + }); + + it('keine files_sharing-Faehigkeit oder api_enabled false -> ausgeschaltet', () => { + expect(parseSharePolicy({ capabilities: {} }).enabled).toBe(false); + expect(parseSharePolicy(null).enabled).toBe(false); + const off = parseSharePolicy({ + capabilities: { files_sharing: { api_enabled: false, public: { enabled: true } } }, + }); + expect(off.enabled).toBe(false); + expect(off.links.enabled).toBe(false); + }); + + it('keine Gruppenfreigabe, Mindestlaenge der Suche', () => { + const policy = parseSharePolicy({ + capabilities: { + files_sharing: { + api_enabled: true, + group_sharing: false, + public: { enabled: true, multiple_links: false }, + sharee: { minSearchStringLength: '3' }, + }, + }, + }); + expect(policy.groupsEnabled).toBe(false); + expect(policy.links.multipleLinks).toBe(false); + expect(policy.minSearchLength).toBe(3); + expect(policy.passwordMinLength).toBeNull(); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-shares.ts b/apps/api/src/nextcloud-files/nextcloud-shares.ts new file mode 100644 index 0000000..aa97fd4 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-shares.ts @@ -0,0 +1,464 @@ +import type { NextcloudCallGate } from './nextcloud-call-gate'; +import type { NcSession } from './nextcloud-files.types'; +import { + discardBody, + type NcFailure, + type NextcloudTransport, + ncRequest, + readCappedText, +} from './nextcloud-http'; + +/** + * Freigaben-Schicht des Moduls "Nextcloud-Dateien" (quick-261009-dkv, D-11/D-12): + * die OCS-Schnittstelle fuer Freigaben, Empfaengersuche und die Freigaberegeln + * (`cloud/capabilities`) auf Basis von `ncRequest`. Es gelten unveraendert die + * Regeln der Etappe 1: fester Pfadanfang `/ocs/v2.php/`, Segmente einzeln codiert, + * keine Weiterleitungen, keine Cookies, und es wird NIE eine Adresse aus einer + * Nextcloud-Antwort aufgerufen (weder `url` einer Freigabe noch `api.generate` + * aus den Faehigkeiten). + * + * Warum nicht `ocsRequest` aus `nextcloud-auth-client.ts`: der Anmeldecode + * verlaesst sich darauf, dass ein 403 als `app-password-given` gilt und der Text + * einer Fehlerantwort verworfen wird. Bei Freigaben steht genau dort die + * Begruendung (`ocs.meta.message`), und ein 403 heisst "nicht erlaubt". Darum ein + * eigener Aufruf, der den Koerper bei JEDEM Status liest (Erfolg: bis 8 MiB, + * Fehler: bis 64 KiB, Faehigkeiten: bis 1 MiB). + * + * Nie loggen oder weitergeben: Passwoerter, Kennungen (`token`) und Link-Adressen. + * Die Parser bauen kleine eigene Ansichten; unbekannte Felder (Speicherkennungen, + * `attributes`, `mail_send` ...) werden nie kopiert. + */ + +export type NcShareKind = 'user' | 'group' | 'link'; +export type NcShareAccess = 'view' | 'edit' | 'upload' | 'custom'; +export type NcItemType = 'file' | 'folder'; + +/** Eine Freigabe, wie der Browser sie sieht. */ +export interface NcShareView { + id: string; + kind: NcShareKind; + /** Pfad im Bereich des Eigentuemers (bei eigenen Freigaben im eigenen Konto). */ + path: string; + name: string; + itemType: NcItemType; + mime: string | null; + /** Der Eintrag selbst erlaubt Aendern/Anlegen (Berechtigungsbits 2 oder 4). */ + itemWritable: boolean; + permissions: number; + access: NcShareAccess; + /** Kennung von Person/Gruppe; bei Links null. */ + shareWith: string | null; + shareWithName: string | null; + ownerId: string | null; + ownerName: string | null; + canEdit: boolean; + canDelete: boolean; + /** `YYYY-MM-DD` oder null. */ + expiration: string | null; + label: string; + /** Nur bei eigenen Links, nur http/https. */ + url: string | null; + hasPassword: boolean; + /** Pfad im eigenen Baum des Empfaengers (bei eigenen Freigaben der eigene Pfad). */ + target: string; + sharedAt: string | null; + /** Noch nicht angenommen (nur eingehende). */ + pending?: boolean; +} + +export interface NcSharee { + kind: 'user' | 'group'; + id: string; + label: string; + detail: string | null; +} + +export interface NcSharePolicy { + /** Freigabe-Schnittstelle der Nextcloud an. */ + enabled: boolean; + groupsEnabled: boolean; + links: { + enabled: boolean; + passwordRequired: boolean; + passwordSuggested: boolean; + /** Tage des Standard-Ablaufs (nur wenn die Nextcloud einen vorgibt). */ + expiryDefaultDays: number | null; + expiryEnforced: boolean; + uploadAllowed: boolean; + multipleLinks: boolean; + }; + /** Regeln fuer Personen- und Gruppenfreigaben (nur zur Anzeige). */ + internalExpiry: { defaultDays: number | null; enforced: boolean }; + minSearchLength: number; + passwordMinLength: number | null; +} + +// --- Grenzen ------------------------------------------------------------------------------- + +export const SHARES_BASE_SEGMENTS = ['apps', 'files_sharing', 'api', 'v1', 'shares'] as const; +export const SHAREE_SEGMENTS = ['apps', 'files_sharing', 'api', 'v1', 'sharees'] as const; +export const CAPABILITIES_SEGMENTS = ['cloud', 'capabilities'] as const; + +export const OCS_OK_MAX_BYTES = 8 * 1024 * 1024; +export const OCS_ERROR_MAX_BYTES = 64 * 1024; +export const OCS_CAPABILITIES_MAX_BYTES = 1024 * 1024; +export const MAX_SHARES = 2000; +export const MAX_SHAREES = 25; +const SHARE_TIMEOUT_MS = 15_000; +const MESSAGE_MAX = 300; +const DISPLAY_MAX = 255; +const URL_MAX = 2048; + +const SHARE_ID_RE = /^\d{1,20}$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +// --- Hilfen ---------------------------------------------------------------------------------- + +type Dict = Record; + +function isDict(value: unknown): value is Dict { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Steuerzeichen entfernen, Leerraum zusammenfassen, kuerzen — fuer alles, was angezeigt wird. */ +export function cleanText(value: unknown, max: number): string { + if (typeof value !== 'string') return ''; + return ( + value + // biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max) + ); +} + +function intOf(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value); + if (typeof value === 'string' && /^-?\d{1,15}$/.test(value.trim())) return Number(value.trim()); + return null; +} + +export function isShareId(value: unknown): value is string { + return typeof value === 'string' && SHARE_ID_RE.test(value); +} + +// --- Aufruf ----------------------------------------------------------------------------------- + +export interface OcsShareOptions { + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + segments: readonly string[]; + query?: Record; + /** JSON-Koerper (POST/PUT); Passwoerter stehen nie in einer Adresse. */ + json?: Record; + /** Obergrenze fuer eine erfolgreiche Antwort (Standard 8 MiB). */ + maxBytes?: number; +} + +export type OcsShareResult = + | { ok: true; status: number; message: string | null; data: unknown } + | NcFailure; + +/** + * OCS-Aufruf mit dem Zugang der Sitzung. Liefert fuer JEDEN HTTP-Status + * `{ ok: true, status, message, data }` (auch 4xx/5xx); Transportfehler, 401 + * (`credential-dead`) und 429 (Sperre) kommen wie bei `ncRequest` als Fehlerergebnis. + */ +export async function ocsShareRequest( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + opts: OcsShareOptions, +): Promise { + const res = await ncRequest(transport, gate, { + baseUrl: session.baseUrl, + prefix: '/ocs/v2.php/', + segments: opts.segments, + query: opts.query, + method: opts.method, + ocs: true, + authorization: session.authorization, + credentialKey: session.credentialKey, + ...(opts.json !== undefined + ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(opts.json) } + : {}), + headersTimeoutMs: SHARE_TIMEOUT_MS, + bodyTimeoutMs: SHARE_TIMEOUT_MS, + }); + if (!res.ok) return res; + + const success = res.status >= 200 && res.status < 300; + const text = await readCappedText( + res.body, + success ? (opts.maxBytes ?? OCS_OK_MAX_BYTES) : OCS_ERROR_MAX_BYTES, + ); + if (!text.ok) { + discardBody(res.body); + // Eine zu grosse Fehlerantwort bleibt ein Fehlerstatus, nur ohne Text. + if (!success && text.kind === 'too-large') { + return { ok: true, status: res.status, message: null, data: null }; + } + return { ok: false, kind: text.kind, detail: text.detail }; + } + + let ocs: Dict | null = null; + try { + const parsed: unknown = JSON.parse(text.text); + if (isDict(parsed) && isDict(parsed.ocs)) ocs = parsed.ocs; + } catch { + // kein JSON + } + if (ocs === null) { + if (success) return { ok: false, kind: 'invalid-response' }; + return { ok: true, status: res.status, message: null, data: null }; + } + const meta = isDict(ocs.meta) ? ocs.meta : {}; + const message = cleanText(meta.message, MESSAGE_MAX); + return { ok: true, status: res.status, message: message === '' ? null : message, data: ocs.data }; +} + +// --- Berechtigungen ----------------------------------------------------------------------------- + +const READ = 1; +const UPDATE = 2; +const CREATE = 4; +const DELETE = 8; + +/** + * Berechtigungsmaske fuer die einfache Auswahl (D-11): Ansehen 1; Bearbeiten Ordner 15 / + * Datei 3; Nur hochladen 4 (nur Ordner, sonst null). Das Teilen-Bit (16) wird nie gesendet. + */ +export function permissionsFor( + access: 'view' | 'edit' | 'upload', + itemType: NcItemType, +): number | null { + if (access === 'view') return READ; + if (access === 'edit') + return itemType === 'folder' ? READ | UPDATE | CREATE | DELETE : READ | UPDATE; + return itemType === 'folder' ? CREATE : null; +} + +/** Umkehrung: aus der Maske (Bit 16 wird ignoriert) die einfache Auswahl oder `custom`. */ +export function accessOf(permissions: number): NcShareAccess { + const mask = permissions & 15; + if (mask === READ) return 'view'; + if (mask === CREATE) return 'upload'; + if ((mask & READ) !== 0 && (mask & (UPDATE | CREATE | DELETE)) !== 0) return 'edit'; + return 'custom'; +} + +// --- Parser ------------------------------------------------------------------------------------- + +function lastSegment(path: string): string { + const parts = path.split('/').filter((p) => p !== ''); + return parts.length > 0 ? parts[parts.length - 1] : ''; +} + +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; +} + +function publicUrl(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > URL_MAX) return null; + try { + const u = new URL(value); + return u.protocol === 'http:' || u.protocol === 'https:' ? value : null; + } catch { + return null; + } +} + +/** `null` fuer nicht unterstuetzte Arten (E-Mail, Server, Talk ...) und fehlerhafte Eintraege. */ +export function parseShare(raw: unknown, selfId: string): NcShareView | null { + if (!isDict(raw)) return null; + const type = intOf(raw.share_type); + const kind: NcShareKind | null = + type === 0 ? 'user' : type === 1 ? 'group' : type === 3 ? 'link' : null; + if (kind === null) return null; + const id = typeof raw.id === 'number' ? String(raw.id) : raw.id; + if (!isShareId(id)) return null; + + const ownerId = cleanText(raw.uid_owner, DISPLAY_MAX) || null; + const received = ownerId !== null && ownerId !== selfId; + const target = cleanText(raw.file_target, 4096); + const path = cleanText(raw.path, 4096) || target; + const itemType: NcItemType = + raw.item_type === 'folder' || raw.mimetype === 'httpd/unix-directory' ? 'folder' : 'file'; + const name = lastSegment(received ? target || path : path || target); + const permissions = intOf(raw.permissions) ?? 0; + const itemPermissions = intOf(raw.item_permissions) ?? permissions; + const expirationRaw = typeof raw.expiration === 'string' ? raw.expiration.slice(0, 10) : ''; + const stime = intOf(raw.stime); + const sharedAt = + stime !== null && stime > 0 && Number.isFinite(new Date(stime * 1000).getTime()) + ? new Date(stime * 1000).toISOString() + : null; + const mimeRaw = cleanText(raw.mimetype, DISPLAY_MAX); + + return { + id, + kind, + path, + name, + itemType, + mime: itemType === 'file' && mimeRaw !== '' ? mimeRaw : null, + itemWritable: (itemPermissions & (UPDATE | CREATE)) !== 0, + permissions, + access: accessOf(permissions), + shareWith: kind === 'link' ? null : cleanText(raw.share_with, DISPLAY_MAX) || null, + shareWithName: + kind === 'link' ? null : cleanText(raw.share_with_displayname, DISPLAY_MAX) || null, + ownerId, + ownerName: cleanText(raw.displayname_owner, DISPLAY_MAX) || null, + canEdit: raw.can_edit === true, + canDelete: raw.can_delete === true, + expiration: isRealDate(expirationRaw) ? expirationRaw : null, + label: cleanText(raw.label, DISPLAY_MAX), + url: kind === 'link' && ownerId === selfId ? publicUrl(raw.url) : null, + // Nextcloud sendet beim Passwort nie den Wert, nur den Text "redacted" — hier zaehlt nur, DASS eins gesetzt ist. + hasPassword: kind === 'link' && typeof raw.password === 'string' && raw.password !== '', + target, + sharedAt, + }; +} + +export interface ParsedShares { + shares: NcShareView[]; + /** Eintraege anderer Arten (E-Mail, Server ...), nur als Zahl. */ + hidden: number; + truncated: boolean; +} + +/** `data` ist bei Listen ein Feld, bei POST/PUT ein einzelnes Objekt. */ +export function parseShareList(data: unknown, selfId: string): ParsedShares { + const items = Array.isArray(data) ? data : isDict(data) ? [data] : []; + const shares: NcShareView[] = []; + let hidden = 0; + let truncated = false; + for (const item of items) { + const share = parseShare(item, selfId); + if (share === null) { + hidden += 1; + } else if (shares.length >= MAX_SHARES) { + truncated = true; + } else { + shares.push(share); + } + } + return { shares, hidden, truncated }; +} + +function shareeList(value: unknown, wanted: 0 | 1): NcSharee[] { + if (!Array.isArray(value)) return []; + const out: NcSharee[] = []; + for (const item of value) { + if (!isDict(item) || !isDict(item.value)) continue; + if (intOf(item.value.shareType) !== wanted) continue; + const id = cleanText(item.value.shareWith, DISPLAY_MAX); + if (id === '') continue; + const label = cleanText(item.label, DISPLAY_MAX) || id; + const unique = cleanText(item.shareWithDisplayNameUnique, DISPLAY_MAX); + out.push({ + kind: wanted === 0 ? 'user' : 'group', + id, + label, + detail: wanted === 0 ? unique || id : null, + }); + } + return out; +} + +/** Treffer der Empfaengersuche: nur Personen und Gruppen, genaue und weitere Treffer zusammen, ohne Doppelte. */ +export function parseSharees(data: unknown): NcSharee[] { + if (!isDict(data)) return []; + const exact = isDict(data.exact) ? data.exact : {}; + const all = [ + ...shareeList(exact.users, 0), + ...shareeList(data.users, 0), + ...shareeList(exact.groups, 1), + ...shareeList(data.groups, 1), + ]; + const seen = new Set(); + const out: NcSharee[] = []; + for (const s of all) { + const key = `${s.kind}:${s.id}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(s); + if (out.length >= MAX_SHAREES) break; + } + return out; +} + +function days(value: unknown): number | null { + const n = intOf(value); + return n !== null && n >= 1 ? Math.min(n, 3650) : null; +} + +/** + * Freigaberegeln aus `cloud/capabilities` (Antwort-`data` oder dessen `capabilities`). + * Fehlende Schluessel heissen "keine Regel", kein Fehler: ohne `expire_date.enabled` gibt + * es keine Tage; sind Links aus, steht unter `public` nur `enabled: false`. + */ +export function parseSharePolicy(data: unknown): NcSharePolicy { + const caps = isDict(data) && isDict(data.capabilities) ? data.capabilities : data; + const files = isDict(caps) && isDict(caps.files_sharing) ? caps.files_sharing : null; + const pwPolicy = isDict(caps) && isDict(caps.password_policy) ? caps.password_policy : null; + const minLen = pwPolicy ? intOf(pwPolicy.minLength) : null; + const passwordMinLength = minLen !== null && minLen >= 1 && minLen <= 256 ? minLen : null; + + const off: NcSharePolicy = { + enabled: false, + groupsEnabled: false, + links: { + enabled: false, + passwordRequired: false, + passwordSuggested: false, + expiryDefaultDays: null, + expiryEnforced: false, + uploadAllowed: false, + multipleLinks: false, + }, + internalExpiry: { defaultDays: null, enforced: false }, + minSearchLength: 0, + passwordMinLength, + }; + if (files === null || files.api_enabled === false) return off; + + const sharee = isDict(files.sharee) ? files.sharee : {}; + const min = intOf(sharee.minSearchStringLength); + const minSearchLength = min !== null ? Math.min(Math.max(min, 0), 32) : 0; + + const pub = isDict(files.public) ? files.public : {}; + const linksEnabled = pub.enabled === true; + const expiry = (value: unknown): { defaultDays: number | null; enforced: boolean } => { + const e = isDict(value) ? value : {}; + if (e.enabled !== true) return { defaultDays: null, enforced: false }; + return { defaultDays: days(e.days), enforced: e.enforced === true }; + }; + const password = isDict(pub.password) ? pub.password : {}; + const passwordRequired = linksEnabled && password.enforced === true; + const link = expiry(pub.expire_date); + const internal = expiry(pub.expire_date_internal); + + return { + enabled: true, + groupsEnabled: files.group_sharing !== false, + links: linksEnabled + ? { + enabled: true, + passwordRequired, + passwordSuggested: !passwordRequired && password.askForOptionalPassword === true, + expiryDefaultDays: link.defaultDays, + expiryEnforced: link.enforced, + uploadAllowed: pub.upload === true, + multipleLinks: pub.multiple_links !== false, + } + : off.links, + internalExpiry: internal, + minSearchLength, + passwordMinLength, + }; +}