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) <noreply@anthropic.com>
This commit is contained in:
+231
@@ -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 <methode> <pfad-nach-/modules/nextcloud-files/> [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 <endpoint> <schluessel> <wert> [<schluessel> <wert> ...] — 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 <ausdruck> — 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 <ordnername> — 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"
|
||||||
@@ -164,6 +164,12 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
|||||||
'completeUpload',
|
'completeUpload',
|
||||||
'uploadState',
|
'uploadState',
|
||||||
'abortUpload',
|
'abortUpload',
|
||||||
|
'getSharePolicy',
|
||||||
|
'listSharesForPath',
|
||||||
|
'searchSharees',
|
||||||
|
'createShare',
|
||||||
|
'updateShare',
|
||||||
|
'deleteShare',
|
||||||
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
|
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||||
const fn = handler(NextcloudFilesController, name);
|
const fn = handler(NextcloudFilesController, name);
|
||||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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 `<?xml version="1.0"?><d:multistatus ${NS}><d:response>
|
||||||
|
<d:href>/nc/remote.php/dav/files/anna/${name}</d:href><d:propstat><d:prop>
|
||||||
|
<d:resourcetype>${folder ? '<d:collection/>' : ''}</d:resourcetype>
|
||||||
|
<oc:fileid>7</oc:fileid><oc:permissions>${letters}</oc:permissions>
|
||||||
|
${folder ? '' : '<d:getcontentlength>5</d:getcontentlength><d:getcontenttype>text/plain</d:getcontenttype>'}
|
||||||
|
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ocs = (data: unknown, status = 200, message = 'OK') =>
|
||||||
|
JSON.stringify({ ocs: { meta: { statuscode: status, message }, data } });
|
||||||
|
|
||||||
|
const CAPS = (over: Record<string, unknown> = {}) =>
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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<string, string> };
|
||||||
|
|
||||||
|
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<string, unknown> }).response;
|
||||||
|
const statusOf = (e: unknown) => (e as { getStatus(): number }).getStatus();
|
||||||
|
|
||||||
|
async function failure(promise: Promise<unknown>): Promise<unknown> {
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<NcSession> {
|
||||||
|
return this.account.getSession(tenantId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transportFail(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
result: { ok: boolean; kind?: string; status?: number; retryAfterSeconds?: number },
|
||||||
|
): Promise<never> {
|
||||||
|
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<never> {
|
||||||
|
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<unknown> {
|
||||||
|
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<NcSharePolicy> {
|
||||||
|
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<NcShareView> {
|
||||||
|
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<NcSharePolicy> {
|
||||||
|
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<NcShareView> {
|
||||||
|
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<NcShareView> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,15 @@ const ADMIN_HANDLERS = ['saveSettings'];
|
|||||||
/** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */
|
/** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */
|
||||||
const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle'];
|
const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle'];
|
||||||
const TRANSFER_PARAM = ['putChunk', 'completeUpload', 'uploadState', 'abortUpload'];
|
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];
|
const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM];
|
||||||
|
|
||||||
/** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */
|
/** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */
|
||||||
@@ -109,6 +118,34 @@ describe('NextcloudFilesController — Metadaten', () => {
|
|||||||
expect(route('abortUpload')).toEqual([3, 'uploads/:uploadId']);
|
expect(route('abortUpload')).toEqual([3, 'uploads/:uploadId']);
|
||||||
expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']);
|
expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']);
|
||||||
expect(route('cancelFlow')).toEqual([3, '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', () => {
|
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() {
|
function makeFiles() {
|
||||||
return {
|
return {
|
||||||
list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })),
|
list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })),
|
||||||
@@ -264,6 +312,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
files as any,
|
files as any,
|
||||||
makeTransfer() as any,
|
makeTransfer() as any,
|
||||||
makeServerInfo() as any,
|
makeServerInfo() as any,
|
||||||
|
makeShares() as any,
|
||||||
);
|
);
|
||||||
const r = userReq('t1', 'u1');
|
const r = userReq('t1', 'u1');
|
||||||
const res = { end: vi.fn() } as any;
|
const res = { end: vi.fn() } as any;
|
||||||
@@ -289,6 +338,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
makeFiles() as any,
|
makeFiles() as any,
|
||||||
transfer as any,
|
transfer as any,
|
||||||
makeServerInfo() as any,
|
makeServerInfo() as any,
|
||||||
|
makeShares() as any,
|
||||||
);
|
);
|
||||||
const r = userReq('t1', 'u1');
|
const r = userReq('t1', 'u1');
|
||||||
(r as any).headers = { range: 'bytes=0-99' };
|
(r as any).headers = { range: 'bytes=0-99' };
|
||||||
@@ -363,6 +413,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
makeFiles() as any,
|
makeFiles() as any,
|
||||||
transfer as any,
|
transfer as any,
|
||||||
makeServerInfo() as any,
|
makeServerInfo() as any,
|
||||||
|
makeShares() as any,
|
||||||
);
|
);
|
||||||
const r = userReq('t1', undefined);
|
const r = userReq('t1', undefined);
|
||||||
const res = {} as any;
|
const res = {} as any;
|
||||||
@@ -378,6 +429,51 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
expect(transfer.uploadState).not.toHaveBeenCalled();
|
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 () => {
|
it('Datei-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
|
||||||
const files = makeFiles();
|
const files = makeFiles();
|
||||||
const controller = new NextcloudFilesController(
|
const controller = new NextcloudFilesController(
|
||||||
@@ -386,6 +482,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
files as any,
|
files as any,
|
||||||
makeTransfer() as any,
|
makeTransfer() as any,
|
||||||
makeServerInfo() as any,
|
makeServerInfo() as any,
|
||||||
|
makeShares() as any,
|
||||||
);
|
);
|
||||||
const r = userReq('t1', undefined);
|
const r = userReq('t1', undefined);
|
||||||
await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
@@ -405,6 +502,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
makeFiles() as any,
|
makeFiles() as any,
|
||||||
makeTransfer() as any,
|
makeTransfer() as any,
|
||||||
makeServerInfo() as any,
|
makeServerInfo() as any,
|
||||||
|
makeShares() as any,
|
||||||
);
|
);
|
||||||
await controller.getStatus(userReq('t1', 'u1'));
|
await controller.getStatus(userReq('t1', 'u1'));
|
||||||
const admin = { tenantId: 't1', user: { id: 'a1', role: 'ADMIN' } } as any;
|
const admin = { tenantId: 't1', user: { id: 'a1', role: 'ADMIN' } } as any;
|
||||||
@@ -439,6 +537,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
makeFiles() as any,
|
makeFiles() as any,
|
||||||
makeTransfer() as any,
|
makeTransfer() as any,
|
||||||
makeServerInfo() as any,
|
makeServerInfo() as any,
|
||||||
|
makeShares() as any,
|
||||||
);
|
);
|
||||||
const manager = { tenantId: 't1', user: { id: 'm1', role: 'USER' } } as any;
|
const manager = { tenantId: 't1', user: { id: 'm1', role: 'USER' } } as any;
|
||||||
await controller.testSettings(manager, { baseUrl: 'https://CLOUD.example/' } as any);
|
await controller.testSettings(manager, { baseUrl: 'https://CLOUD.example/' } as any);
|
||||||
@@ -463,6 +562,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
makeFiles() as any,
|
makeFiles() as any,
|
||||||
makeTransfer() as any,
|
makeTransfer() as any,
|
||||||
makeServerInfo() as any,
|
makeServerInfo() as any,
|
||||||
|
makeShares() as any,
|
||||||
);
|
);
|
||||||
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
|
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
@@ -481,6 +581,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
makeFiles() as any,
|
makeFiles() as any,
|
||||||
makeTransfer() as any,
|
makeTransfer() as any,
|
||||||
serverInfo as any,
|
serverInfo as any,
|
||||||
|
makeShares() as any,
|
||||||
);
|
);
|
||||||
const res = { end: vi.fn() } as any;
|
const res = { end: vi.fn() } as any;
|
||||||
await controller.getServer(userReq('t1', 'u1'));
|
await controller.getServer(userReq('t1', 'u1'));
|
||||||
@@ -500,6 +601,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
makeFiles() as any,
|
makeFiles() as any,
|
||||||
makeTransfer() as any,
|
makeTransfer() as any,
|
||||||
makeServerInfo() as any,
|
makeServerInfo() as any,
|
||||||
|
makeShares() as any,
|
||||||
);
|
);
|
||||||
await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf(
|
await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf(
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import {
|
|||||||
PathQueryDto,
|
PathQueryDto,
|
||||||
PreviewQueryDto,
|
PreviewQueryDto,
|
||||||
} from './dto/nextcloud-files-ops.dto';
|
} from './dto/nextcloud-files-ops.dto';
|
||||||
|
import {
|
||||||
|
CreateShareDto,
|
||||||
|
ShareByPathQueryDto,
|
||||||
|
ShareeQueryDto,
|
||||||
|
UpdateShareDto,
|
||||||
|
} from './dto/nextcloud-files-shares.dto';
|
||||||
import {
|
import {
|
||||||
SaveNextcloudFilesSettingsDto,
|
SaveNextcloudFilesSettingsDto,
|
||||||
TestNextcloudFilesSettingsDto,
|
TestNextcloudFilesSettingsDto,
|
||||||
@@ -39,6 +45,7 @@ import {
|
|||||||
} from './dto/nextcloud-files-transfer.dto';
|
} from './dto/nextcloud-files-transfer.dto';
|
||||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||||
|
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
|
||||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||||
import {
|
import {
|
||||||
NextcloudFilesTransferService,
|
NextcloudFilesTransferService,
|
||||||
@@ -64,10 +71,12 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
|
|||||||
* GET server/logo, POST connect/password, POST connect/flow,
|
* GET server/logo, POST connect/password, POST connect/flow,
|
||||||
* DELETE connect, GET files, DELETE files, POST folders, POST move,
|
* DELETE connect, GET files, DELETE files, POST folders, POST move,
|
||||||
* GET preview, GET download, POST download/zip, POST uploads, PUT
|
* GET preview, GET download, POST download/zip, POST uploads, PUT
|
||||||
* uploads/file; danach die Parameterrouten am ENDE: GET/DELETE
|
* uploads/file, GET shares/policy, GET shares/by-path, GET sharees,
|
||||||
* connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST
|
* POST shares (quick-261009-dkv: Teilen); danach die Parameterrouten am
|
||||||
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
|
* ENDE: GET/DELETE connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n,
|
||||||
* uploads/:uploadId. Jeder Benutzer arbeitet nur im eigenen Konto.
|
* 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
|
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
|
||||||
* RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler
|
* RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler
|
||||||
* traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`).
|
* traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`).
|
||||||
@@ -104,6 +113,7 @@ export class NextcloudFilesController {
|
|||||||
private readonly files: NextcloudFilesService,
|
private readonly files: NextcloudFilesService,
|
||||||
private readonly transfer: NextcloudFilesTransferService,
|
private readonly transfer: NextcloudFilesTransferService,
|
||||||
private readonly serverInfo: NextcloudServerInfoService,
|
private readonly serverInfo: NextcloudServerInfoService,
|
||||||
|
private readonly shares: NextcloudFilesSharesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private requireTenantId(req: AuthenticatedRequest): string {
|
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) ---------------
|
// --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) ---------------
|
||||||
|
|
||||||
@Get('connect/flow/:flowId')
|
@Get('connect/flow/:flowId')
|
||||||
@@ -353,4 +396,19 @@ export class NextcloudFilesController {
|
|||||||
async abortUpload(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) {
|
async abortUpload(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) {
|
||||||
return this.transfer.abortUpload(this.requireTenantId(req), this.requireUserId(req), uploadId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NextcloudFilesController } from './nextcloud-files.controller';
|
|||||||
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
||||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||||
|
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
|
||||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||||
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
|
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
|
||||||
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
||||||
@@ -27,6 +28,7 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
|
|||||||
NextcloudFilesAccountService,
|
NextcloudFilesAccountService,
|
||||||
NextcloudFilesService,
|
NextcloudFilesService,
|
||||||
NextcloudFilesTransferService,
|
NextcloudFilesTransferService,
|
||||||
|
NextcloudFilesSharesService,
|
||||||
NextcloudServerInfoService,
|
NextcloudServerInfoService,
|
||||||
NextcloudLoginGuard,
|
NextcloudLoginGuard,
|
||||||
LoginFlowStore,
|
LoginFlowStore,
|
||||||
|
|||||||
@@ -41,7 +41,19 @@ export type NcErrorCode =
|
|||||||
| 'flowExpired'
|
| 'flowExpired'
|
||||||
| 'tooManyFlows'
|
| 'tooManyFlows'
|
||||||
| 'invalidUrl'
|
| 'invalidUrl'
|
||||||
| 'confirmReconnect';
|
| 'confirmReconnect'
|
||||||
|
| 'sharingDisabled'
|
||||||
|
| 'linkSharingDisabled'
|
||||||
|
| 'shareAccessInvalid'
|
||||||
|
| 'shareRecipientInvalid'
|
||||||
|
| 'shareAlreadyExists'
|
||||||
|
| 'sharePasswordRequired'
|
||||||
|
| 'sharePasswordRejected'
|
||||||
|
| 'shareExpiryRequired'
|
||||||
|
| 'shareExpiryInvalid'
|
||||||
|
| 'shareRejected'
|
||||||
|
| 'shareNotFound'
|
||||||
|
| 'tooManyShares';
|
||||||
|
|
||||||
interface ErrorDefault {
|
interface ErrorDefault {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -179,6 +191,56 @@ export const NC_ERROR_DEFAULTS: Record<NcErrorCode, ErrorDefault> = {
|
|||||||
message:
|
message:
|
||||||
'Bei einem Wechsel der Adresse müssen sich alle verbundenen Benutzer neu anmelden. Bitte bestätigen Sie den Wechsel.',
|
'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 }`. */
|
/** Baut die HttpException mit dem Koerper `{ code, message, ...extra }`. */
|
||||||
|
|||||||
@@ -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', () => {
|
describe('LoginFlowStore', () => {
|
||||||
function makeStore() {
|
function makeStore() {
|
||||||
const store = new LoginFlowStore();
|
const store = new LoginFlowStore();
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ export const SERVER_FAILURE_LIMIT = 8;
|
|||||||
export const SERVER_FAILURE_WINDOW_MS = 30 * 60 * 1000;
|
export const SERVER_FAILURE_WINDOW_MS = 30 * 60 * 1000;
|
||||||
export const FLOW_START_LIMIT = 10;
|
export const FLOW_START_LIMIT = 10;
|
||||||
export const FLOW_START_WINDOW_MS = 10 * 60 * 1000;
|
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. */
|
/** Ein gezaehlter Versuch; `inFlight` = Nextcloud hat noch nicht geantwortet. */
|
||||||
interface Attempt {
|
interface Attempt {
|
||||||
@@ -66,6 +74,12 @@ function removeAttempt(list: Attempt[] | undefined, attempt: Attempt): void {
|
|||||||
if (i >= 0) list.splice(i, 1);
|
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) {
|
function tooMany(retryAfterMs: number) {
|
||||||
return ncErrorDefault('tooManyAttempts', {
|
return ncErrorDefault('tooManyAttempts', {
|
||||||
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
||||||
@@ -80,6 +94,7 @@ export class NextcloudLoginGuard {
|
|||||||
private readonly userFailures = new Map<string, Attempt[]>();
|
private readonly userFailures = new Map<string, Attempt[]>();
|
||||||
private readonly serverFailures = new Map<string, Attempt[]>();
|
private readonly serverFailures = new Map<string, Attempt[]>();
|
||||||
private readonly flowStarts = new Map<string, number[]>();
|
private readonly flowStarts = new Map<string, number[]>();
|
||||||
|
private readonly shareCreates = new Map<string, number[]>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429
|
* Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429
|
||||||
@@ -167,6 +182,22 @@ export class NextcloudLoginGuard {
|
|||||||
starts.push(now);
|
starts.push(now);
|
||||||
this.flowStarts.set(userId, starts);
|
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) -----------------------------------------
|
// --- Browser-Anmeldung (Login Flow v2) -----------------------------------------
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const XML = `<?xml version="1.0"?>
|
|||||||
<oc:permissions>RGDNVCK</oc:permissions>
|
<oc:permissions>RGDNVCK</oc:permissions>
|
||||||
<oc:size>5000</oc:size>
|
<oc:size>5000</oc:size>
|
||||||
<oc:favorite>0</oc:favorite>
|
<oc:favorite>0</oc:favorite>
|
||||||
|
<oc:share-types/>
|
||||||
<nc:has-preview>false</nc:has-preview>
|
<nc:has-preview>false</nc:has-preview>
|
||||||
</d:prop>
|
</d:prop>
|
||||||
<d:status>HTTP/1.1 200 OK</d:status>
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
@@ -63,6 +64,7 @@ const XML = `<?xml version="1.0"?>
|
|||||||
<oc:permissions>RGDNVW</oc:permissions>
|
<oc:permissions>RGDNVW</oc:permissions>
|
||||||
<oc:size>42</oc:size>
|
<oc:size>42</oc:size>
|
||||||
<oc:favorite>1</oc:favorite>
|
<oc:favorite>1</oc:favorite>
|
||||||
|
<oc:share-types><oc:share-type>3</oc:share-type><oc:share-type>0</oc:share-type><oc:share-type>3</oc:share-type><oc:share-type>abc</oc:share-type></oc:share-types>
|
||||||
<nc:has-preview>true</nc:has-preview>
|
<nc:has-preview>true</nc:has-preview>
|
||||||
</d:prop>
|
</d:prop>
|
||||||
<d:status>HTTP/1.1 200 OK</d:status>
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
@@ -113,6 +115,7 @@ describe('parsePropfind', () => {
|
|||||||
permissions: 'RGDNVCK',
|
permissions: 'RGDNVCK',
|
||||||
hasPreview: false,
|
hasPreview: false,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
shareTypes: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '12345',
|
name: '12345',
|
||||||
@@ -126,6 +129,7 @@ describe('parsePropfind', () => {
|
|||||||
permissions: 'RGDNVW',
|
permissions: 'RGDNVW',
|
||||||
hasPreview: true,
|
hasPreview: true,
|
||||||
favorite: true,
|
favorite: true,
|
||||||
|
shareTypes: [0, 3],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '50%25.txt',
|
name: '50%25.txt',
|
||||||
@@ -139,10 +143,18 @@ describe('parsePropfind', () => {
|
|||||||
permissions: 'RGDNVW',
|
permissions: 'RGDNVW',
|
||||||
hasPreview: false,
|
hasPreview: false,
|
||||||
favorite: 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', () => {
|
it('der Name bleibt eine Zeichenkette (12345), nie eine Zahl', () => {
|
||||||
const entry = parsePropfind(XML, CTX).entries.find((e) => e.fileId === '00042');
|
const entry = parsePropfind(XML, CTX).entries.find((e) => e.fileId === '00042');
|
||||||
expect(typeof entry?.name).toBe('string');
|
expect(typeof entry?.name).toBe('string');
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export interface NcEntry {
|
|||||||
permissions: string;
|
permissions: string;
|
||||||
hasPreview: boolean;
|
hasPreview: boolean;
|
||||||
favorite: 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 {
|
export interface NcQuota {
|
||||||
@@ -173,6 +178,17 @@ function isoOf(value: unknown): string | null {
|
|||||||
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `<oc:share-types><oc:share-type>0</oc:share-type>...` -> ganze Zahlen 0..99, aufsteigend, ohne Doppelte. */
|
||||||
|
function shareTypesOf(value: unknown): number[] {
|
||||||
|
if (!isDict(value)) return [];
|
||||||
|
const found = new Set<number>();
|
||||||
|
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 {
|
function buildEntry(rel: readonly string[], props: Dict): NcEntry {
|
||||||
const folder = isFolder(props);
|
const folder = isFolder(props);
|
||||||
const sizeRaw = folder
|
const sizeRaw = folder
|
||||||
@@ -191,6 +207,7 @@ function buildEntry(rel: readonly string[], props: Dict): NcEntry {
|
|||||||
permissions: text(props.permissions) ?? '',
|
permissions: text(props.permissions) ?? '',
|
||||||
hasPreview: text(props['has-preview']) === 'true',
|
hasPreview: text(props['has-preview']) === 'true',
|
||||||
favorite: text(props.favorite) === '1',
|
favorite: text(props.favorite) === '1',
|
||||||
|
shareTypes: shareTypesOf(props['share-types']),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, string> };
|
||||||
|
|
||||||
|
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: '<html>' });
|
||||||
|
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: '<html>' });
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
/** JSON-Koerper (POST/PUT); Passwoerter stehen nie in einer Adresse. */
|
||||||
|
json?: Record<string, unknown>;
|
||||||
|
/** 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<OcsShareResult> {
|
||||||
|
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<string>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user