diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh new file mode 100644 index 0000000..9ba01b0 --- /dev/null +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Aufgabe 4: Hoch- und Herunterladen gegen die Test-Nextcloud — Umlautdatei, Ueberschreibschutz, +# 30 MB in 8-MiB-Stuecken DURCH den Next.js-Proxy (/api-proxy), Download byteidentisch mit +# Range, Kopfzeilen, 413, 411, 404-Abbildung, ZIP (Ordner und Auswahl). Setzt einen laufenden +# Stack und nc-test-setup.sh voraus. Nur Testwerte; liest keine .env-Dateien. +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/e2e-lib.sh" +trap 'rm -rf "$E2E_TMP"' EXIT + +FILES="$API/modules/nextcloud-files" +VIA_WEB="$WEB/modules/nextcloud-files" +NC_DAV="http://localhost:18080/remote.php/dav" +ANNA="anna:User1-Pass-12345" +UMLAUT_NAME='Ärger & Ölpreis 100%.txt' +CHUNK=8388608 + +# enc — vollstaendig prozentcodiert (jedes Zeichen ausser A-Za-z0-9._~-) +enc() { python3 -I -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1], safe=""))' "$1"; } +# jget — Wert aus einer JSON-Datei, z. B. 'd["mode"]' +jget() { python3 -I -c 'import json,sys;d=json.load(open(sys.argv[1]));print(eval(sys.argv[2]))' "$1" "$2"; } + +ADMIN="$E2E_TMP/admin.jar" +e2e_wait_health +e2e_login "$ADMIN" +e2e_activate "$ADMIN" +e2e_set_address "$ADMIN" +code=$(e2e_connect_anna "$ADMIN") +e2e_expect 200 "$code" "anna verbinden" + +F="/Tessera-E2E-T-$(date +%s)" +FNAME="${F#/}" +code=$(e2e_status "$ADMIN" POST "$FILES/folders" "{\"path\":\"$F\"}") +case "$code" in 200|201) ;; *) e2e_fail "Testordner anlegen -> $code" ;; esac + +# --- 1. Kleine Datei mit Umlauten, Sonderzeichen und Prozent im Namen ------------------------------ +printf 'Hallo Aerger\n' > "$E2E_TMP/small.txt" +SMALL_SIZE=$(wc -c < "$E2E_TMP/small.txt") +UPATH="$F/$UMLAUT_NAME" +code=$(e2e_status "$ADMIN" POST "$FILES/uploads" "{\"path\":\"$UPATH\",\"size\":$SMALL_SIZE}") +case "$code" in 200|201) ;; *) e2e_fail "Upload starten -> $code" ;; esac +e2e_contains "$E2E_TMP/body.out" '"mode":"single"' "kleine Datei: single" +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X PUT -H 'Content-Type: application/octet-stream' \ + --data-binary "@$E2E_TMP/small.txt" "$FILES/uploads/file?path=$(enc "$UPATH")&size=$SMALL_SIZE&mtime=1700000000") +e2e_expect 200 "$code" "kleine Datei hochladen" +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -G --data-urlencode "path=$F" "$FILES/files") +e2e_expect 200 "$code" "Testordner auflisten" +e2e_contains "$E2E_TMP/body.out" "\"name\":\"$UMLAUT_NAME\"" "Umlautname exakt in der Liste" +ETAG=$(python3 -I -c 'import json,sys +for e in json.load(open(sys.argv[1]))["entries"]: + if e["name"]==sys.argv[2]: print(e["etag"]); print(e["mtime"], file=sys.stderr)' "$E2E_TMP/body.out" "$UMLAUT_NAME" 2> "$E2E_TMP/mtime.out") +[ "$(cat "$E2E_TMP/mtime.out")" = "2023-11-14T22:13:20.000Z" ] || e2e_fail "Aenderungszeit uebernommen: $(cat "$E2E_TMP/mtime.out")" + +# --- 2. Ueberschreibschutz ------------------------------------------------------------------------------ +code=$(e2e_status "$ADMIN" POST "$FILES/uploads" "{\"path\":\"$UPATH\",\"size\":$SMALL_SIZE}") +e2e_expect 409 "$code" "gleicher Name noch einmal" +e2e_contains "$E2E_TMP/body.out" '"code":"nameTaken"' "Namenskonflikt: nameTaken" +e2e_contains "$E2E_TMP/body.out" '"existing"' "Namenskonflikt: existing" +# direkter PUT ohne vorherigen Start darf ebenfalls nichts ueberschreiben (If-None-Match) +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X PUT -H 'Content-Type: application/octet-stream' \ + --data-binary "@$E2E_TMP/small.txt" "$FILES/uploads/file?path=$(enc "$UPATH")&size=$SMALL_SIZE") +e2e_expect 409 "$code" "PUT auf vorhandenen Namen" +e2e_contains "$E2E_TMP/body.out" '"code":"nameTaken"' "PUT: nameTaken" +# Ersetzen mit der richtigen Version klappt, mit einer falschen nicht +printf 'Neuer Inhalt\n' > "$E2E_TMP/small2.txt" +SIZE2=$(wc -c < "$E2E_TMP/small2.txt") +code=$(e2e_status "$ADMIN" POST "$FILES/uploads" "{\"path\":\"$UPATH\",\"size\":$SIZE2,\"replaceEtag\":\"\\\"falsch\\\"\"}") +e2e_expect 409 "$code" "Ersetzen mit falscher Version" +e2e_contains "$E2E_TMP/body.out" '"code":"changedMeanwhile"' "falsche Version: changedMeanwhile" +ETAG_JSON=$(python3 -I -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$ETAG") +code=$(e2e_status "$ADMIN" POST "$FILES/uploads" "{\"path\":\"$UPATH\",\"size\":$SIZE2,\"replaceEtag\":$ETAG_JSON}") +case "$code" in 200|201) ;; *) e2e_fail "Ersetzen mit richtiger Version -> $code" ;; esac +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X PUT -H 'Content-Type: application/octet-stream' \ + --data-binary "@$E2E_TMP/small2.txt" "$FILES/uploads/file?path=$(enc "$UPATH")&size=$SIZE2&replaceEtag=$(enc "$ETAG")") +e2e_expect 200 "$code" "Ersetzen" +code=$(curl -s -o "$E2E_TMP/dl.out" -w '%{http_code}' -b "$ADMIN" "$FILES/download?path=$(enc "$UPATH")") +e2e_expect 200 "$code" "Umlautdatei laden" +cmp "$E2E_TMP/dl.out" "$E2E_TMP/small2.txt" || e2e_fail "ersetzte Datei hat nicht den neuen Inhalt" + +# --- 3. 30 MB in 8-MiB-Stuecken durch den Next.js-Proxy --------------------------------------------- +head -c 30000000 /dev/urandom > "$E2E_TMP/big.bin" +BIG="$F/gross.bin" +BIG_SIZE=30000000 +code=$(e2e_status "$ADMIN" POST "$VIA_WEB/uploads" "{\"path\":\"$BIG\",\"size\":$BIG_SIZE}") +case "$code" in 200|201) ;; *) e2e_fail "grosse Datei: Start -> $code" ;; esac +e2e_contains "$E2E_TMP/body.out" '"mode":"chunked"' "grosse Datei: chunked" +UP=$(jget "$E2E_TMP/body.out" 'd["uploadId"]') +[ "$(jget "$E2E_TMP/body.out" 'd["chunkSize"]')" = "$CHUNK" ] || e2e_fail "chunkSize ist nicht 8388608" +echo "$UP" | grep -Eq '^tessera-[0-9a-f-]{36}$' || e2e_fail "uploadId hat falsche Form: $UP" + +mkdir "$E2E_TMP/chunks" +split -b "$CHUNK" -d -a 3 "$E2E_TMP/big.bin" "$E2E_TMP/chunks/c" +n=0 +for part in "$E2E_TMP"/chunks/c*; do + n=$((n + 1)) + code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X PUT -H 'Content-Type: application/octet-stream' \ + --data-binary "@$part" "$VIA_WEB/uploads/$UP/chunks/$n?path=$(enc "$BIG")&size=$BIG_SIZE") + e2e_expect 200 "$code" "Stueck $n durch den Proxy" + [ "$(jget "$E2E_TMP/body.out" 'd["received"]')" = "$(wc -c < "$part")" ] || e2e_fail "Stueck $n: received stimmt nicht" +done +[ "$n" = "4" ] || e2e_fail "30 MB sollten 4 Stuecke sein, waren $n" + +code=$(e2e_status "$ADMIN" POST "$VIA_WEB/uploads/$UP/complete" "{\"path\":\"$BIG\",\"size\":$BIG_SIZE,\"mtime\":1700000000}") +case "$code" in + 200) e2e_contains "$E2E_TMP/body.out" '"state":"done"' "complete: done" ;; + 202) + for _ in $(seq 1 90); do + sleep 2 + code=$(e2e_status "$ADMIN" GET "$VIA_WEB/uploads/$UP/state") + e2e_expect 200 "$code" "Zustand abfragen" + grep -q '"state":"done"' "$E2E_TMP/body.out" && break + grep -q '"state":"failed"' "$E2E_TMP/body.out" && e2e_fail "Zusammenbau fehlgeschlagen: $(cat "$E2E_TMP/body.out")" + done + grep -q '"state":"done"' "$E2E_TMP/body.out" || e2e_fail "Zusammenbau wurde nicht fertig" + ;; + *) e2e_fail "complete -> $code" ;; +esac +code=$(e2e_status "$ADMIN" GET "$VIA_WEB/uploads/$UP/state") +e2e_expect 200 "$code" "Zustand nach dem Ende" +e2e_contains "$E2E_TMP/body.out" '"state":"done"' "Zustand done" + +# Groesse und Mtime bei der Nextcloud selbst pruefen +nc_size=$(curl -s -u "$ANNA" -I "$NC_DAV/files/anna$F/gross.bin" | tr -d '\r' | awk -F': ' 'tolower($1)=="content-length"{print $2}') +[ "$nc_size" = "$BIG_SIZE" ] || e2e_fail "Nextcloud meldet $nc_size Byte statt $BIG_SIZE" + +# Download durch den Proxy, byteidentisch; Kopfzeilen +code=$(curl -s -D "$E2E_TMP/dl.hdr" -o "$E2E_TMP/big.dl" -w '%{http_code}' -b "$ADMIN" "$VIA_WEB/download?path=$(enc "$BIG")") +e2e_expect 200 "$code" "grosse Datei laden (Proxy)" +cmp "$E2E_TMP/big.bin" "$E2E_TMP/big.dl" || e2e_fail "heruntergeladene Datei weicht ab" +grep -qi '^content-disposition: attachment; filename="gross.bin"' "$E2E_TMP/dl.hdr" || e2e_fail "content-disposition fehlt/falsch: $(grep -i content-disposition "$E2E_TMP/dl.hdr")" +grep -qi '^x-content-type-options: nosniff' "$E2E_TMP/dl.hdr" || e2e_fail "nosniff fehlt" +grep -qi '^content-security-policy: default-src .none.; sandbox' "$E2E_TMP/dl.hdr" || e2e_fail "CSP sandbox fehlt" +if grep -qi '^set-cookie' "$E2E_TMP/dl.hdr"; then e2e_fail "set-cookie erreichte den Browser"; fi + +# Umlautname im Content-Disposition +curl -s -D "$E2E_TMP/dl2.hdr" -o /dev/null -b "$ADMIN" "$FILES/download?path=$(enc "$UPATH")" +grep -qi "^content-disposition: attachment; filename=\"_rger & _lpreis 100_.txt\"; filename\\*=UTF-8''%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt" "$E2E_TMP/dl2.hdr" \ + || e2e_fail "content-disposition mit Umlauten: $(grep -i content-disposition "$E2E_TMP/dl2.hdr")" + +# Range +code=$(curl -s -D "$E2E_TMP/range.hdr" -o "$E2E_TMP/range.out" -w '%{http_code}' -b "$ADMIN" -H 'Range: bytes=0-99' "$VIA_WEB/download?path=$(enc "$BIG")") +e2e_expect 206 "$code" "Range" +[ "$(wc -c < "$E2E_TMP/range.out")" = "100" ] || e2e_fail "Range: nicht 100 Byte" +head -c 100 "$E2E_TMP/big.bin" | cmp - "$E2E_TMP/range.out" || e2e_fail "Range: Inhalt weicht ab" +grep -qi "^content-range: bytes 0-99/$BIG_SIZE" "$E2E_TMP/range.hdr" || e2e_fail "content-range fehlt: $(grep -i content-range "$E2E_TMP/range.hdr")" +code=$(curl -s -o "$E2E_TMP/range2.out" -w '%{http_code}' -b "$ADMIN" -H "Range: bytes=$((BIG_SIZE - 10))-" "$FILES/download?path=$(enc "$BIG")") +e2e_expect 206 "$code" "Range bis zum Ende" +tail -c 10 "$E2E_TMP/big.bin" | cmp - "$E2E_TMP/range2.out" || e2e_fail "Range bis zum Ende: Inhalt weicht ab" + +# --- 4. Grenzen: 413, 411 -------------------------------------------------------------------------------- +head -c 9000000 /dev/zero > "$E2E_TMP/nine.bin" +FAKE_UP="tessera-00000000-0000-0000-0000-000000000000" +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X PUT -H 'Content-Type: application/octet-stream' \ + --data-binary "@$E2E_TMP/nine.bin" "$VIA_WEB/uploads/$FAKE_UP/chunks/1?path=$(enc "$F/x.bin")&size=30000000") +e2e_expect 413 "$code" "9-MB-Stueck" +e2e_contains "$E2E_TMP/body.out" '"code":"chunkTooLarge"' "9-MB-Stueck: chunkTooLarge" +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X PUT -H 'Transfer-Encoding: chunked' -H 'Content-Type: application/octet-stream' \ + --data-binary "@$E2E_TMP/small.txt" "$FILES/uploads/$FAKE_UP/chunks/1?path=$(enc "$F/x.bin")&size=30000000") +e2e_expect 411 "$code" "PUT ohne content-length" +e2e_contains "$E2E_TMP/body.out" '"code":"lengthRequired"' "ohne content-length: lengthRequired" +code=$(e2e_status "$ADMIN" POST "$FILES/uploads" "{\"path\":\"$F/riesig.bin\",\"size\":99999999999999}") +e2e_expect 413 "$code" "zu grosse Datei" +e2e_contains "$E2E_TMP/body.out" '"code":"fileTooLarge"' "zu gross: fileTooLarge" +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X PUT -H 'Content-Type: application/octet-stream' \ + --data-binary "@$E2E_TMP/small.txt" "$FILES/uploads/tessera-..%2Fx/chunks/1?path=$(enc "$F/x.bin")&size=30000000") +e2e_expect 400 "$code" "ungueltige Upload-Kennung" + +# --- 5. Fehlerabbildung: nie 401/403 ----------------------------------------------------------------------- +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" "$FILES/download?path=$(enc "$F/gibt-es-nicht.bin")") +e2e_expect 404 "$code" "Download einer fehlenden Datei" +e2e_contains "$E2E_TMP/body.out" '"code":"notFound"' "fehlende Datei: notFound" + +# --- 6. ZIP ----------------------------------------------------------------------------------------------------- +code=$(curl -s -D "$E2E_TMP/zip.hdr" -o "$E2E_TMP/folder.zip" -w '%{http_code}' -b "$ADMIN" "$VIA_WEB/download?path=$(enc "$F")&zip=1") +e2e_expect 200 "$code" "Ordner als ZIP" +[ "$(head -c 2 "$E2E_TMP/folder.zip")" = "PK" ] || e2e_fail "Ordner-ZIP beginnt nicht mit PK" +grep -qi "^content-disposition: attachment; filename=\"$FNAME.zip\"" "$E2E_TMP/zip.hdr" || e2e_fail "ZIP-Name: $(grep -i content-disposition "$E2E_TMP/zip.hdr")" +unzip -l "$E2E_TMP/folder.zip" | grep -q 'gross.bin' || e2e_fail "Ordner-ZIP enthaelt gross.bin nicht" + +code=$(curl -s -D "$E2E_TMP/zip2.hdr" -o "$E2E_TMP/sel.zip" -w '%{http_code}' -b "$ADMIN" -G --data-urlencode "dir=$F" --data-urlencode "name=$UMLAUT_NAME" "$FILES/download/zip") +e2e_expect 200 "$code" "Auswahl als ZIP" +[ "$(head -c 2 "$E2E_TMP/sel.zip")" = "PK" ] || e2e_fail "Auswahl-ZIP beginnt nicht mit PK" +unzip -Z1 "$E2E_TMP/sel.zip" > "$E2E_TMP/sel.list" +[ "$(wc -l < "$E2E_TMP/sel.list")" = "1" ] || e2e_fail "Auswahl-ZIP enthaelt nicht genau einen Eintrag: $(cat "$E2E_TMP/sel.list")" +[ "$(cat "$E2E_TMP/sel.list")" = "$UMLAUT_NAME" ] || e2e_fail "Auswahl-ZIP: falscher Eintrag: $(cat "$E2E_TMP/sel.list")" +# zwei Namen (wiederholter Schluessel) +code=$(curl -s -o "$E2E_TMP/sel2.zip" -w '%{http_code}' -b "$ADMIN" -G --data-urlencode "dir=$F" --data-urlencode "name=$UMLAUT_NAME" --data-urlencode "name=gross.bin" "$FILES/download/zip") +e2e_expect 200 "$code" "Auswahl mit zwei Namen" +[ "$(unzip -Z1 "$E2E_TMP/sel2.zip" | wc -l)" = "2" ] || e2e_fail "Auswahl-ZIP mit zwei Namen hat nicht zwei Eintraege" +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -G --data-urlencode "dir=$F" --data-urlencode "name=.." "$FILES/download/zip") +e2e_expect 400 "$code" "ZIP mit Name .." +e2e_contains "$E2E_TMP/body.out" '"code":"invalidPath"' "ZIP-Name ..: invalidPath" + +# --- 7. Abbrechen raeumt auf ----------------------------------------------------------------------------------------- +code=$(e2e_status "$ADMIN" POST "$FILES/uploads" "{\"path\":\"$F/abbruch.bin\",\"size\":$BIG_SIZE}") +case "$code" in 200|201) ;; *) e2e_fail "Abbruch-Upload starten -> $code" ;; esac +UP2=$(jget "$E2E_TMP/body.out" 'd["uploadId"]') +code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X PROPFIND -H 'Depth: 0' "$NC_DAV/uploads/anna/$UP2") +e2e_expect 207 "$code" "Upload-Ordner liegt bei Nextcloud" +code=$(e2e_status "$ADMIN" DELETE "$FILES/uploads/$UP2") +e2e_expect 200 "$code" "Upload abbrechen" +code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X PROPFIND -H 'Depth: 0' "$NC_DAV/uploads/anna/$UP2") +e2e_expect 404 "$code" "Upload-Ordner nach dem Abbrechen" +code=$(e2e_status "$ADMIN" GET "$FILES/uploads/$UP2/state") +e2e_expect 404 "$code" "Zustand eines unbekannten Uploads" + +# --- 8. Zwei Benutzer teilen keinen Zustand: ohne Anmeldung gar nichts ------------------------------------------------ +code=$(curl -s -o /dev/null -w '%{http_code}' "$FILES/download?path=$(enc "$UPATH")") +e2e_expect 401 "$code" "Download ohne Tessera-Anmeldung" + +# --- Aufraeumen --------------------------------------------------------------------------------------------------------------- +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X DELETE -G --data-urlencode "path=$F" "$FILES/files") +e2e_expect 200 "$code" "Testordner loeschen" +code=$(e2e_status "$ADMIN" DELETE "$FILES/connect") +e2e_expect 200 "$code" "anna trennen" + +echo "e2e transfer ok" diff --git a/apps/api/src/module-registry/module-manage-handlers.spec.ts b/apps/api/src/module-registry/module-manage-handlers.spec.ts index 0156469..20e8b5f 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -155,6 +155,14 @@ describe('Umgestellte Handler (Verwalten)', () => { 'createFolder', 'move', 'preview', + 'download', + 'downloadZip', + 'startUpload', + 'putSingle', + 'putChunk', + 'completeUpload', + 'uploadState', + 'abortUpload', ])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => { const fn = handler(NextcloudFilesController, name); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined(); diff --git a/apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts b/apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts new file mode 100644 index 0000000..eb6e5c1 --- /dev/null +++ b/apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts @@ -0,0 +1,121 @@ +import { Transform, Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; + +/** + * Eingaben der Uebertragungsrouten (quick-261008-mzu, D-J/D-K). Pfade stehen nur + * in Query oder Body, nie im URL-Pfad der Tessera-Route. Die Segmentpruefung macht + * der Dienst (`parseUserPath`, `validateSegment`); hier stehen nur grobe Grenzen. + * + * Absichtlich KEIN `@Max` auf `size`: eine zu grosse Datei soll mit 413 + * `fileTooLarge` und lesbarem Text abgelehnt werden, nicht mit einem 400 der + * allgemeinen Pruefung. + */ + +/** Hoechster Unix-Zeitstempel (Sekunden), den eine Aenderungszeit haben darf (Jahr 2100). */ +export const MAX_MTIME_SECONDS = 4102444800; + +export class StartUploadDto { + @IsString() + @IsNotEmpty() + @MaxLength(4096) + path!: string; + + @IsInt() + @Min(0) + size!: number; + + /** Entity-Tag der Version, die ersetzt werden soll (nach Rueckfrage beim Benutzer). */ + @IsOptional() + @IsString() + @MaxLength(200) + replaceEtag?: string; +} + +export class CompleteUploadDto { + @IsString() + @IsNotEmpty() + @MaxLength(4096) + path!: string; + + @IsInt() + @Min(0) + size!: number; + + @IsOptional() + @IsInt() + @Min(0) + @Max(MAX_MTIME_SECONDS) + mtime?: number; + + @IsOptional() + @IsString() + @MaxLength(200) + replaceEtag?: string; +} + +/** Query der Raw-Body-Routen `PUT uploads/file` und `PUT uploads/:uploadId/chunks/:n`. */ +export class UploadQueryDto { + @IsString() + @IsNotEmpty() + @MaxLength(4096) + path!: string; + + /** Gesamtgroesse der Datei in Byte. */ + @Type(() => Number) + @IsInt() + @Min(0) + size!: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(MAX_MTIME_SECONDS) + mtime?: number; + + @IsOptional() + @IsString() + @MaxLength(200) + replaceEtag?: string; +} + +export class DownloadQueryDto { + @IsString() + @IsNotEmpty() + @MaxLength(4096) + path!: string; + + /** `1`: den Ordner als ZIP laden statt einer Datei. */ + @IsOptional() + @IsIn(['1']) + zip?: string; +} + +export class ZipQueryDto { + /** Ordner, aus dem die gewaehlten Eintraege kommen (`/` = Wurzel). */ + @IsOptional() + @IsString() + @MaxLength(4096) + dir?: string; + + /** Namen der gewaehlten Eintraege; ein einzelnes `name=...` wird zur Liste. */ + @Transform(({ value }) => (Array.isArray(value) ? value : value === undefined ? [] : [value])) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(500) + @IsString({ each: true }) + @MaxLength(255, { each: true }) + name!: string[]; +} diff --git a/apps/api/src/nextcloud-files/nextcloud-dav-transfer.spec.ts b/apps/api/src/nextcloud-files/nextcloud-dav-transfer.spec.ts new file mode 100644 index 0000000..2aabebb --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-dav-transfer.spec.ts @@ -0,0 +1,273 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import { + chunkName, + download, + downloadFolderZip, + downloadSelectionZip, + putFile, + sanitizeRange, + uploadAbort, + uploadAssemble, + uploadChunk, + uploadStart, +} from './nextcloud-dav-transfer'; +import type { NcSession } from './nextcloud-files.types'; +import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http'; + +const SESSION: NcSession = { + baseUrl: 'https://cloud.example/nc', + ncUserId: 'anna', + authorization: 'Basic YW5uYTphcHAtcHctMTIz', + credentialKey: 'k1', +}; +const UA = 'Tessera (Nextcloud-Dateien)'; +const AUTH = 'Basic YW5uYTphcHAtcHctMTIz'; +const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c'; +const DEST = 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/gro%C3%9F.bin'; + +function fake(status: number, headers: Record = {}) { + const calls: NcTransportRequest[] = []; + const transport: NextcloudTransport = async (req) => { + calls.push(req); + const res: NcTransportResponse = { statusCode: status, headers, body: Readable.from([]) }; + return res; + }; + return { transport, calls }; +} + +describe('nextcloud-dav-transfer — Hochladen', () => { + it('putFile: PUT mit If-None-Match: *, content-length, X-OC-Mtime und demselben Strom als Koerper', async () => { + const { transport, calls } = fake(201); + const body = Readable.from([Buffer.from('hallo')]); + const res = await putFile( + transport, + new NextcloudCallGate(), + SESSION, + ['Projekte', 'a.txt'], + body, + { + size: 5, + mtime: 1760000000, + }, + ); + expect(res).toMatchObject({ ok: true, status: 201 }); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('PUT'); + expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a.txt'); + expect(calls[0].headers).toEqual({ + 'user-agent': UA, + 'content-length': '5', + 'if-none-match': '*', + 'x-oc-mtime': '1760000000', + authorization: AUTH, + }); + // Identitaet: nichts wird gelesen oder kopiert, der Strom geht unveraendert durch. + expect(calls[0].body).toBe(body); + expect(calls[0].headersTimeoutMs).toBe(120_000); + expect(calls[0].bodyTimeoutMs).toBe(30_000); + }); + + it('putFile mit replaceEtag: If-Match statt If-None-Match, ohne Mtime keine Mtime-Kopfzeile', async () => { + const { transport, calls } = fake(204); + await putFile(transport, new NextcloudCallGate(), SESSION, ['a.txt'], Readable.from([]), { + size: 0, + replaceEtag: '"abc"', + }); + expect(calls[0].headers['if-match']).toBe('"abc"'); + expect(calls[0].headers['if-none-match']).toBeUndefined(); + expect(calls[0].headers['x-oc-mtime']).toBeUndefined(); + expect(calls[0].headers['content-length']).toBe('0'); + }); + + it('uploadStart: MKCOL des Upload-Ordners mit Destination aus der Basis der Sitzung', async () => { + const { transport, calls } = fake(201); + await uploadStart(transport, new NextcloudCallGate(), SESSION, UP, ['Projekte', 'groß.bin']); + expect(calls[0].method).toBe('MKCOL'); + expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`); + expect(calls[0].headers.destination).toBe(DEST); + expect(calls[0].body).toBeNull(); + }); + + it('uploadChunk: Teilstueck 3 heisst 00003, mit Destination, OC-Total-Length und content-length', async () => { + const { transport, calls } = fake(201); + const body = Readable.from([]); + await uploadChunk( + transport, + new NextcloudCallGate(), + SESSION, + UP, + 3, + ['Projekte', 'groß.bin'], + body, + { + size: 8388608, + totalLength: 30000000, + }, + ); + expect(calls[0].method).toBe('PUT'); + expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/00003`); + expect(calls[0].headers).toEqual({ + 'user-agent': UA, + destination: DEST, + 'oc-total-length': '30000000', + 'content-length': '8388608', + authorization: AUTH, + }); + expect(calls[0].body).toBe(body); + expect(calls[0].headersTimeoutMs).toBe(120_000); + expect(calls[0].bodyTimeoutMs).toBe(30_000); + }); + + it('chunkName: fuenfstellig; ausserhalb 1..10000 oder nicht ganzzahlig wirft', () => { + expect(chunkName(1)).toBe('00001'); + expect(chunkName(10000)).toBe('10000'); + expect(() => chunkName(0)).toThrow(); + expect(() => chunkName(10001)).toThrow(); + expect(() => chunkName(1.5)).toThrow(); + }); + + it('uploadAssemble: MOVE .file mit Destination, Gesamtgroesse, Mtime und Overwrite: F', async () => { + const { transport, calls } = fake(201); + await uploadAssemble( + transport, + new NextcloudCallGate(), + SESSION, + UP, + ['Projekte', 'groß.bin'], + { + totalLength: 30000000, + mtime: 1760000000, + }, + ); + expect(calls[0].method).toBe('MOVE'); + expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/.file`); + expect(calls[0].headers).toEqual({ + 'user-agent': UA, + destination: DEST, + 'oc-total-length': '30000000', + overwrite: 'F', + 'x-oc-mtime': '1760000000', + authorization: AUTH, + }); + expect(calls[0].headersTimeoutMs).toBe(30 * 60_000); + }); + + it('uploadAssemble: Overwrite T nur, wenn der Aufrufer ausdruecklich ersetzt', async () => { + const { transport, calls } = fake(204); + await uploadAssemble(transport, new NextcloudCallGate(), SESSION, UP, ['a'], { + totalLength: 1, + replace: true, + }); + expect(calls[0].headers.overwrite).toBe('T'); + expect(calls[0].headers['x-oc-mtime']).toBeUndefined(); + }); + + it('uploadAbort: DELETE des Upload-Ordners ohne Destination', async () => { + const { transport, calls } = fake(204); + await uploadAbort(transport, new NextcloudCallGate(), SESSION, UP); + expect(calls[0].method).toBe('DELETE'); + expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`); + expect(calls[0].headers.destination).toBeUndefined(); + }); + + it('eine ungueltige Upload-Kennung wirft vor jedem Aufruf', async () => { + const { transport, calls } = fake(204); + await expect( + uploadAbort(transport, new NextcloudCallGate(), SESSION, '../x'), + ).rejects.toMatchObject({ response: { code: 'invalidPath' } }); + expect(calls).toHaveLength(0); + }); + + it('die Aufrufsperre gilt: ein toter Zugangsschluessel ruft nichts auf', async () => { + const gate = new NextcloudCallGate(); + gate.markDead('k1'); + const { transport, calls } = fake(201); + const res = await uploadStart(transport, gate, SESSION, UP, ['a']); + expect(res).toEqual({ ok: false, kind: 'credential-dead' }); + expect(calls).toHaveLength(0); + }); +}); + +describe('nextcloud-dav-transfer — Herunterladen', () => { + it('download: GET mit durchgereichtem Range; der Koerper bleibt offen', async () => { + const { transport, calls } = fake(206, { 'content-range': 'bytes 0-99/500' }); + const res = await download( + transport, + new NextcloudCallGate(), + SESSION, + ['Projekte', 'a b.txt'], + { + range: 'bytes=0-99', + }, + ); + expect(res).toMatchObject({ ok: true, status: 206 }); + expect(calls[0].method).toBe('GET'); + expect(calls[0].url).toBe( + 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a%20b.txt', + ); + expect(calls[0].headers.range).toBe('bytes=0-99'); + expect(calls[0].headersTimeoutMs).toBe(30_000); + expect(calls[0].bodyTimeoutMs).toBe(60_000); + }); + + it('download: ohne Range keine Range-Kopfzeile; ein unsinniger Range wird verworfen', async () => { + const { transport, calls } = fake(200); + await download(transport, new NextcloudCallGate(), SESSION, ['a']); + await download(transport, new NextcloudCallGate(), SESSION, ['a'], { range: 'bytes=0-1,5-9' }); + await download(transport, new NextcloudCallGate(), SESSION, ['a'], { + range: 'x\r\nhost: evil', + }); + expect(calls.map((c) => c.headers.range)).toEqual([undefined, undefined, undefined]); + expect(sanitizeRange('bytes=100-')).toBe('bytes=100-'); + expect(sanitizeRange('bytes=-50')).toBe('bytes=-50'); + }); + + it('downloadFolderZip: GET auf den Ordner mit Schraegstrich und ?accept=zip', async () => { + const { transport, calls } = fake(200); + await downloadFolderZip(transport, new NextcloudCallGate(), SESSION, ['Projekte']); + expect(calls[0].method).toBe('GET'); + expect(calls[0].url).toBe( + 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/?accept=zip', + ); + }); + + it('downloadFolderZip der Wurzel: .../files/anna/?accept=zip', async () => { + const { transport, calls } = fake(200); + await downloadFolderZip(transport, new NextcloudCallGate(), SESSION, []); + expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/?accept=zip'); + }); + + it('downloadSelectionZip: ?accept=zip&files=, codiert', async () => { + const { transport, calls } = fake(200); + await downloadSelectionZip( + transport, + new NextcloudCallGate(), + SESSION, + ['Projekte'], + ['a.txt', 'Ärger'], + ); + const url = new URL(calls[0].url); + expect(url.pathname).toBe('/nc/remote.php/dav/files/anna/Projekte/'); + expect(url.searchParams.get('accept')).toBe('zip'); + expect(JSON.parse(url.searchParams.get('files') ?? '')).toEqual(['a.txt', 'Ärger']); + expect(calls[0].url).toContain('files=%5B%22a.txt%22%2C%22%C3%84rger%22%5D'); + }); + + it('downloadSelectionZip: ein Name wie .. wirft invalidPath, bevor ein Aufruf rausgeht', async () => { + const { transport, calls } = fake(200); + for (const bad of ['..', 'a/b', '', '.']) { + await expect( + downloadSelectionZip( + transport, + new NextcloudCallGate(), + SESSION, + ['Projekte'], + ['ok.txt', bad], + ), + ).rejects.toMatchObject({ response: { code: 'invalidPath' } }); + } + expect(calls).toHaveLength(0); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts b/apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts new file mode 100644 index 0000000..e1dcd62 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts @@ -0,0 +1,248 @@ +import type { Readable } from 'node:stream'; +import type { NextcloudCallGate } from './nextcloud-call-gate'; +import { davRequest, destinationUrl } from './nextcloud-dav'; +import type { NcSession } from './nextcloud-files.types'; +import { + discardBody, + type NcResult, + type NextcloudTransport, + validateSegment, +} from './nextcloud-http'; + +/** + * Uebertragungsschicht des Moduls "Nextcloud-Dateien" (quick-261008-mzu, D-C/D-J/D-K) + * auf Basis von `davRequest`: Hochladen als Strom (ganze Datei oder Chunked Upload v2), + * Herunterladen und ZIP. Frei von Nest; jeder Aufruf geht durch die Aufrufsperre und + * traegt den Zugangsschluessel der Sitzung. + * + * Nichts wird hier gepuffert: Anfragekoerper sind `Readable`s, die unveraendert an den + * Transport gehen, Antwortkoerper bleiben offen und gehoeren dem Aufrufer. Das + * `Destination` jedes Aufrufs wird aus der Basis der Sitzung gebaut, nie aus einer Eingabe. + */ + +/** Kopfzeilen-Wartezeit eines Teilstueck-PUT; danach 30 s Leerlauf im Koerper (D-C). */ +export const DAV_CHUNK_HEADERS_TIMEOUT_MS = 120_000; +export const DAV_CHUNK_BODY_TIMEOUT_MS = 30_000; +/** Der Zusammenbau (MOVE .file) kann bei grossen Dateien Minuten dauern (D-C: 30 min). */ +export const DAV_ASSEMBLE_HEADERS_TIMEOUT_MS = 30 * 60_000; +/** Downloads: 30 s bis zu den Kopfzeilen, 60 s Leerlauf im Koerper (D-C). */ +export const DAV_DOWNLOAD_HEADERS_TIMEOUT_MS = 30_000; +export const DAV_DOWNLOAD_BODY_TIMEOUT_MS = 60_000; + +/** Nextcloud-Teilstueck-Name: Zahl 1..10000, fuenfstellig mit fuehrenden Nullen (`00003`). */ +export function chunkName(n: number): string { + if (!Number.isInteger(n) || n < 1 || n > 10_000) + throw new RangeError('Teilstueck ausserhalb 1..10000'); + return String(n).padStart(5, '0'); +} + +function discard(result: NcResult): NcResult { + if (result.ok) discardBody(result.body); + return result; +} + +export interface PutFileOptions { + /** Laenge des Koerpers in Byte (wird als `content-length` gesendet). */ + size: number; + /** Aenderungszeit der Datei in Sekunden seit 1970 (`X-OC-Mtime`). */ + mtime?: number; + /** Entity-Tag der zu ersetzenden Version: `If-Match` statt `If-None-Match: *`. */ + replaceEtag?: string; + signal?: AbortSignal; +} + +/** + * Ganze Datei in einem Aufruf schreiben (PUT). Ohne `replaceEtag` sendet Tessera + * `If-None-Match: *` — ein vorhandenes Ziel wird nie still ueberschrieben (412); + * mit `replaceEtag` ersetzt `If-Match` genau die Version, die der Benutzer sah. + */ +export async function putFile( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + segments: readonly string[], + body: Readable, + opts: PutFileOptions, +): Promise { + const headers: Record = { 'content-length': String(opts.size) }; + if (opts.replaceEtag) headers['if-match'] = opts.replaceEtag; + else headers['if-none-match'] = '*'; + if (opts.mtime !== undefined) headers['x-oc-mtime'] = String(opts.mtime); + return discard( + await davRequest(transport, gate, session, 'PUT', '/remote.php/dav/files/', segments, { + headers, + body, + headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS, + bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS, + signal: opts.signal, + }), + ); +} + +/** Upload-Ordner anlegen (MKCOL) — der Anfang von Chunked Upload v2. */ +export async function uploadStart( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + uploadId: string, + target: readonly string[], +): Promise { + return discard( + await davRequest(transport, gate, session, 'MKCOL', '/remote.php/dav/uploads/', [uploadId], { + headers: { destination: destinationUrl(session, target) }, + }), + ); +} + +export interface UploadChunkOptions { + /** Laenge dieses Teilstuecks in Byte. */ + size: number; + /** Gesamtgroesse der Datei: loest die Speicherplatzpruefung von Nextcloud sofort aus. */ + totalLength: number; + signal?: AbortSignal; +} + +/** Teilstueck `n` (1..10000) schreiben; dieselbe Nummer ueberschreibt (Wiederholung ist sicher). */ +export async function uploadChunk( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + uploadId: string, + n: number, + target: readonly string[], + body: Readable, + opts: UploadChunkOptions, +): Promise { + return discard( + await davRequest( + transport, + gate, + session, + 'PUT', + '/remote.php/dav/uploads/', + [uploadId, chunkName(n)], + { + headers: { + destination: destinationUrl(session, target), + 'oc-total-length': String(opts.totalLength), + 'content-length': String(opts.size), + }, + body, + headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS, + bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS, + signal: opts.signal, + }, + ), + ); +} + +export interface UploadAssembleOptions { + totalLength: number; + mtime?: number; + /** true nur nach geprueftem Ersetzen (Entity-Tag stimmte): `Overwrite: T`, sonst `F`. */ + replace?: boolean; +} + +/** Teilstuecke zur Datei zusammenbauen (MOVE `.file`); kann bei grossen Dateien Minuten dauern. */ +export async function uploadAssemble( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + uploadId: string, + target: readonly string[], + opts: UploadAssembleOptions, +): Promise { + const headers: Record = { + destination: destinationUrl(session, target), + 'oc-total-length': String(opts.totalLength), + overwrite: opts.replace ? 'T' : 'F', + }; + if (opts.mtime !== undefined) headers['x-oc-mtime'] = String(opts.mtime); + return discard( + await davRequest( + transport, + gate, + session, + 'MOVE', + '/remote.php/dav/uploads/', + [uploadId, '.file'], + { headers, headersTimeoutMs: DAV_ASSEMBLE_HEADERS_TIMEOUT_MS }, + ), + ); +} + +/** Upload-Ordner loeschen (ohne `Destination`); auch nach einem 412 beim Zusammenbau noetig. */ +export async function uploadAbort( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + uploadId: string, +): Promise { + return discard( + await davRequest(transport, gate, session, 'DELETE', '/remote.php/dav/uploads/', [uploadId]), + ); +} + +/** Nur ein einzelner Bytebereich (`bytes=0-99`, `bytes=100-`, `bytes=-50`) wird durchgereicht. */ +const RANGE_RE = /^bytes=(\d+-\d*|-\d+)$/; + +export function sanitizeRange(range: string | undefined): string | undefined { + return range !== undefined && RANGE_RE.test(range) ? range : undefined; +} + +/** Datei herunterladen (GET); ein gueltiger `Range` geht unveraendert mit. Der Koerper bleibt offen. */ +export function download( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + segments: readonly string[], + opts: { range?: string; signal?: AbortSignal } = {}, +): Promise { + const range = sanitizeRange(opts.range); + return davRequest(transport, gate, session, 'GET', '/remote.php/dav/files/', segments, { + headers: range ? { range } : undefined, + headersTimeoutMs: DAV_DOWNLOAD_HEADERS_TIMEOUT_MS, + bodyTimeoutMs: DAV_DOWNLOAD_BODY_TIMEOUT_MS, + signal: opts.signal, + }); +} + +/** Ganzen Ordner als ZIP (`?accept=zip`); die Wurzel (`[]`) ist der ganze Dateibereich. */ +export function downloadFolderZip( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + segments: readonly string[], + signal?: AbortSignal, +): Promise { + return davRequest(transport, gate, session, 'GET', '/remote.php/dav/files/', segments, { + query: { accept: 'zip' }, + trailingSlash: true, + headersTimeoutMs: DAV_DOWNLOAD_HEADERS_TIMEOUT_MS, + bodyTimeoutMs: DAV_DOWNLOAD_BODY_TIMEOUT_MS, + signal, + }); +} + +/** + * Nur die gewaehlten Eintraege eines Ordners als ZIP (`?accept=zip&files=`, + * gemessen: Nextcloud packt genau diese Eintraege ohne Ordnerhuelle). Jeder Name laeuft + * VOR dem Aufruf durch `validateSegment` — ein Name wie `..` wirft `invalidPath`. + */ +export async function downloadSelectionZip( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + dir: readonly string[], + names: readonly string[], + signal?: AbortSignal, +): Promise { + const checked = names.map(validateSegment); + return davRequest(transport, gate, session, 'GET', '/remote.php/dav/files/', dir, { + query: { accept: 'zip', files: JSON.stringify(checked) }, + trailingSlash: true, + headersTimeoutMs: DAV_DOWNLOAD_HEADERS_TIMEOUT_MS, + bodyTimeoutMs: DAV_DOWNLOAD_BODY_TIMEOUT_MS, + signal, + }); +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.spec.ts new file mode 100644 index 0000000..4aebf58 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.spec.ts @@ -0,0 +1,804 @@ +import { PassThrough, Readable, Writable } from 'node:stream'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import { type NcSession, ncErrorDefault } from './nextcloud-files.types'; +import { + ASSEMBLY_STATE_TTL_MS, + ASSEMBLY_WAIT_MS, + contentDispositionFor, + MAX_UPLOAD_BYTES, + NextcloudFilesTransferService, + type RawUploadRequest, +} from './nextcloud-files-transfer.service'; +import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http'; + +const SESSION: NcSession = { + baseUrl: 'https://cloud.example/nc', + ncUserId: 'anna', + authorization: 'Basic YW5uYTphcHAtcHctMTIz', + credentialKey: 'k1', +}; +const CHUNK = 8388608; +const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c'; +const NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"'; +const statXml = (etag: string) => ` + /nc/remote.php/dav/files/anna/Projekte/a.txt + 3"${etag}" + Tue, 06 Oct 2026 08:00:00 GMT + HTTP/1.1 200 OK`; + +type Reply = { status: number; text?: string; headers?: Record }; +type Handler = (req: NcTransportRequest, n: number) => Reply | Promise; + +function reply(r: Reply): NcTransportResponse { + return { + statusCode: r.status, + headers: r.headers ?? {}, + body: Readable.from(r.text ? [Buffer.from(r.text)] : []), + }; +} + +function setup(handler: Handler = () => ({ status: 201 })) { + const calls: NcTransportRequest[] = []; + const transport: NextcloudTransport = async (req) => { + calls.push(req); + return reply(await handler(req, calls.length)); + }; + const account = { + getSession: vi.fn(async (..._a: unknown[]) => SESSION), + markExpired: vi.fn(async (..._a: unknown[]) => {}), + }; + const gate = new NextcloudCallGate(); + const service = new NextcloudFilesTransferService(account as never, gate, transport); + return { service, account, calls, gate }; +} + +/** Rohe Anfrage: ein Strom mit Kopfzeilen; `length` undefiniert = keine content-length-Kopfzeile. */ +function rawReq(length: number | undefined, extra: Record = {}) { + const stream = new PassThrough() as unknown as RawUploadRequest; + stream.headers = { + ...(length === undefined ? {} : { 'content-length': String(length) }), + ...extra, + }; + return stream; +} + +class FakeRes extends Writable { + code = 200; + headers: Record = {}; + written: Buffer[] = []; + status(c: number) { + this.code = c; + return this; + } + setHeader(n: string, v: string) { + this.headers[n.toLowerCase()] = String(v); + return this; + } + _write(chunk: Buffer, _e: BufferEncoding, cb: () => void) { + this.written.push(Buffer.from(chunk)); + cb(); + } +} +const finished = (res: Writable) => + new Promise((resolve) => { + if (res.writableFinished || res.destroyed) resolve(); + else { + res.once('finish', () => resolve()); + res.once('close', () => resolve()); + } + }); + +function parts(e: unknown): { status: number; body: Record } { + const ex = e as { getStatus(): number; getResponse(): Record }; + return { status: ex.getStatus(), body: ex.getResponse() }; +} +async function caught(p: Promise): Promise { + try { + await p; + } catch (e) { + return e; + } + throw new Error('es wurde keine Ausnahme geworfen'); +} +const urlsOf = (calls: NcTransportRequest[]) => calls.map((c) => `${c.method} ${c.url}`); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('contentDispositionFor', () => { + it('Umlaut und Sonderzeichen: ASCII-Rueckfall und UTF-8-Name nach RFC 5987', () => { + expect(contentDispositionFor('Ärger & Co.txt')).toBe( + `attachment; filename="_rger & Co.txt"; filename*=UTF-8''%C3%84rger%20%26%20Co.txt`, + ); + }); + + it('Anfuehrungszeichen, Backslash, Prozent und Klammern werden entschaerft', () => { + const cd = contentDispositionFor('a"b\\c%d(e)\'f*.txt'); + expect(cd).toBe( + `attachment; filename="a_b_c_d(e)'f*.txt"; filename*=UTF-8''a%22b%5Cc%25d%28e%29%27f%2A.txt`, + ); + }); +}); + +describe('NextcloudFilesTransferService — startUpload', () => { + it('kleine Datei auf freiem Ziel (stat 404): single, ohne weiteren Aufruf', async () => { + const { service, calls } = setup(() => ({ status: 404 })); + expect(await service.startUpload('t1', 'u1', { path: '/Projekte/a.txt', size: 1000 })).toEqual({ + mode: 'single', + }); + expect(urlsOf(calls)).toEqual([ + 'PROPFIND https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a.txt', + ]); + }); + + it('vorhandenes Ziel ohne replaceEtag: 409 nameTaken mit dem vorhandenen Eintrag', async () => { + const { service } = setup(() => ({ status: 207, text: statXml('e1') })); + const e = await caught(service.startUpload('t1', 'u1', { path: '/Projekte/a.txt', size: 5 })); + const { status, body } = parts(e); + expect(status).toBe(409); + expect(body).toMatchObject({ + code: 'nameTaken', + existing: { etag: '"e1"', size: 3, mtime: '2026-10-06T08:00:00.000Z' }, + }); + }); + + it('replaceEtag gleich -> single; abweichend -> 409 changedMeanwhile; Ziel weg -> changedMeanwhile', async () => { + const same = setup(() => ({ status: 207, text: statXml('e1') })); + expect( + await same.service.startUpload('t1', 'u1', { + path: '/Projekte/a.txt', + size: 5, + replaceEtag: '"e1"', + }), + ).toEqual({ mode: 'single' }); + + const diff = setup(() => ({ status: 207, text: statXml('e2') })); + const { status, body } = parts( + await caught( + diff.service.startUpload('t1', 'u1', { + path: '/Projekte/a.txt', + size: 5, + replaceEtag: '"e1"', + }), + ), + ); + expect(status).toBe(409); + expect(body.code).toBe('changedMeanwhile'); + + const gone = setup(() => ({ status: 404 })); + await expect( + gone.service.startUpload('t1', 'u1', { + path: '/Projekte/a.txt', + size: 5, + replaceEtag: '"e1"', + }), + ).rejects.toMatchObject({ response: { code: 'changedMeanwhile' } }); + }); + + it('30 MB: legt den Upload-Ordner an und liefert uploadId und chunkSize 8 MiB', async () => { + const { service, calls } = setup((req) => + req.method === 'PROPFIND' ? { status: 404 } : { status: 201 }, + ); + const out = await service.startUpload('t1', 'u1', { + path: '/Projekte/groß.bin', + size: 30000000, + }); + expect(out).toMatchObject({ mode: 'chunked', chunkSize: CHUNK }); + const id = (out as { uploadId: string }).uploadId; + expect(id).toMatch(/^tessera-[0-9a-f-]{36}$/); + expect(calls[1].method).toBe('MKCOL'); + expect(calls[1].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${id}`); + expect(calls[1].headers.destination).toBe( + 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/gro%C3%9F.bin', + ); + }); + + it('genau ein Stueck gross ist noch single, ein Byte mehr ist chunked', async () => { + const { service } = setup((req) => + req.method === 'PROPFIND' ? { status: 404 } : { status: 201 }, + ); + expect(await service.startUpload('t1', 'u1', { path: '/a.bin', size: CHUNK })).toEqual({ + mode: 'single', + }); + expect( + await service.startUpload('t1', 'u1', { path: '/a.bin', size: CHUNK + 1 }), + ).toMatchObject({ + mode: 'chunked', + }); + }); + + it('ueber 10000 Stuecke: 413 fileTooLarge ohne jeden Aufruf', async () => { + const { service, calls, account } = setup(); + const { status, body } = parts( + await caught(service.startUpload('t1', 'u1', { path: '/a.bin', size: MAX_UPLOAD_BYTES + 1 })), + ); + expect(status).toBe(413); + expect(body.code).toBe('fileTooLarge'); + expect(calls).toHaveLength(0); + expect(account.getSession).not.toHaveBeenCalled(); + expect(MAX_UPLOAD_BYTES).toBe(10000 * CHUNK); + }); + + it('ungueltiger Pfad, Wurzel oder verbotener Name ruft nichts auf', async () => { + const { service, calls } = setup(); + await expect(service.startUpload('t1', 'u1', { path: '/../x', size: 1 })).rejects.toMatchObject( + { + response: { code: 'invalidPath' }, + }, + ); + await expect(service.startUpload('t1', 'u1', { path: '/', size: 1 })).rejects.toMatchObject({ + response: { code: 'invalidPath' }, + }); + await expect( + service.startUpload('t1', 'u1', { path: '/x.part', size: 1 }), + ).rejects.toMatchObject({ + response: { code: 'invalidName' }, + }); + expect(calls).toHaveLength(0); + }); + + it('MKCOL scheitert (507): quotaExceeded', async () => { + const { service } = setup((req) => + req.method === 'PROPFIND' ? { status: 404 } : { status: 507 }, + ); + await expect( + service.startUpload('t1', 'u1', { path: '/a.bin', size: 30000000 }), + ).rejects.toMatchObject({ response: { code: 'quotaExceeded' }, status: 507 }); + }); +}); + +describe('NextcloudFilesTransferService — putSingle / putChunk', () => { + it('putSingle reicht den Anfragestrom unveraendert und mit seiner content-length weiter', async () => { + const { service, calls } = setup(); + const req = rawReq(5); + const out = await service.putSingle('t1', 'u1', req, { + path: '/Projekte/a.txt', + size: 5, + mtime: 1760000000, + }); + expect(out).toEqual({ path: '/Projekte/a.txt', size: 5 }); + expect(calls[0].method).toBe('PUT'); + expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a.txt'); + expect(calls[0].body).toBe(req); + expect(calls[0].headers['content-length']).toBe('5'); + expect(calls[0].headers['if-none-match']).toBe('*'); + expect(calls[0].headers['x-oc-mtime']).toBe('1760000000'); + }); + + it('putSingle: 412 -> 409 nameTaken mit dem vorhandenen Eintrag', async () => { + const { service } = setup((req) => + req.method === 'PUT' ? { status: 412 } : { status: 207, text: statXml('e9') }, + ); + const { status, body } = parts( + await caught(service.putSingle('t1', 'u1', rawReq(5), { path: '/Projekte/a.txt', size: 5 })), + ); + expect(status).toBe(409); + expect(body).toMatchObject({ code: 'nameTaken', existing: { etag: '"e9"' } }); + }); + + it('putChunk: Stueck 3 -> .../uploads/anna//00003 mit Destination und OC-Total-Length', async () => { + const { service, calls } = setup(); + const req = rawReq(CHUNK); + const out = await service.putChunk('t1', 'u1', req, UP, '3', { + path: '/Projekte/groß.bin', + size: 30000000, + }); + expect(out).toEqual({ n: 3, received: CHUNK }); + expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/00003`); + expect(calls[0].headers['oc-total-length']).toBe('30000000'); + expect(calls[0].headers['content-length']).toBe(String(CHUNK)); + expect(calls[0].body).toBe(req); + }); + + it('putChunk: content-length 8388609 -> 413 chunkTooLarge, Transport nie aufgerufen', async () => { + const { service, calls, account } = setup(); + const { status, body } = parts( + await caught( + service.putChunk('t1', 'u1', rawReq(CHUNK + 1), UP, '1', { + path: '/a.bin', + size: 30000000, + }), + ), + ); + expect(status).toBe(413); + expect(body.code).toBe('chunkTooLarge'); + expect(calls).toHaveLength(0); + expect(account.getSession).not.toHaveBeenCalled(); + }); + + it('ohne content-length (auch bei chunked Transfer-Encoding): 411 lengthRequired, kein Aufruf', async () => { + const { service, calls } = setup(); + const chunked = rawReq(undefined, { 'transfer-encoding': 'chunked' }); + const { status, body } = parts( + await caught( + service.putChunk('t1', 'u1', chunked, UP, '1', { path: '/a.bin', size: 30000000 }), + ), + ); + expect(status).toBe(411); + expect(body.code).toBe('lengthRequired'); + await expect( + service.putSingle('t1', 'u1', rawReq(undefined), { path: '/a.txt', size: 1 }), + ).rejects.toMatchObject({ status: 411 }); + expect(calls).toHaveLength(0); + }); + + it('putSingle ueber ein Stueck: 413 chunkTooLarge', async () => { + const { service, calls } = setup(); + await expect( + service.putSingle('t1', 'u1', rawReq(CHUNK + 1), { path: '/a.txt', size: CHUNK + 1 }), + ).rejects.toMatchObject({ response: { code: 'chunkTooLarge' }, status: 413 }); + expect(calls).toHaveLength(0); + }); + + it.each([ + ['tessera-../x'], + ['x'], + [`tessera-${'a'.repeat(35)}`], + [`tessera-${'g'.repeat(36)}`], + ])('Upload-Kennung %s -> 400 und kein Aufruf', async (id) => { + const { service, calls } = setup(); + const query = { path: '/a.bin', size: 30000000 }; + expect( + parts(await caught(service.putChunk('t1', 'u1', rawReq(1), id, '1', query))).status, + ).toBe(400); + expect( + parts(await caught(service.completeUpload('t1', 'u1', id, { path: '/a.bin', size: 1 }))) + .status, + ).toBe(400); + expect(parts(await caught(service.abortUpload('t1', 'u1', id))).status).toBe(400); + expect(() => service.uploadState('t1', 'u1', id)).toThrow(); + expect(calls).toHaveLength(0); + }); + + it.each([ + ['0'], + ['10001'], + ['-1'], + ['1.5'], + ['abc'], + [''], + ])('Stuecknummer "%s" -> 400, kein Aufruf', async (n) => { + const { service, calls } = setup(); + const { status } = parts( + await caught( + service.putChunk('t1', 'u1', rawReq(1), UP, n, { path: '/a.bin', size: 30000000 }), + ), + ); + expect(status).toBe(400); + expect(calls).toHaveLength(0); + }); + + it('Stuecknummern 1 und 10000 sind erlaubt (00001, 10000)', async () => { + const { service, calls } = setup(); + await service.putChunk('t1', 'u1', rawReq(1), UP, '1', { path: '/a.bin', size: 30000000 }); + await service.putChunk('t1', 'u1', rawReq(1), UP, '10000', { path: '/a.bin', size: 30000000 }); + expect(calls.map((c) => c.url.split('/').pop())).toEqual(['00001', '10000']); + }); + + it('Anfrage bricht vor dem Ende ab: das Signal des Aufrufs an Nextcloud wird abgebrochen', async () => { + const { service, calls } = setup( + (req) => + new Promise((_resolve, reject) => { + req.signal.addEventListener('abort', () => reject(new Error('abgebrochen'))); + }), + ); + const req = rawReq(100); + const pending = service.putSingle('t1', 'u1', req, { path: '/a.txt', size: 100 }); + await vi.waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].signal.aborted).toBe(false); + req.emit('close'); + const { status, body } = parts(await caught(pending)); + expect(calls[0].signal.aborted).toBe(true); + expect(status).toBe(504); + expect(body.code).toBe('nextcloudUnavailable'); + }); + + it('Anfrage vollstaendig gelesen (complete): ein close danach bricht den Aufruf NICHT ab', async () => { + let release: (() => void) | undefined; + const { service, calls } = setup( + () => + new Promise((resolve) => { + release = () => resolve({ status: 201 }); + }), + ); + const req = rawReq(100); + req.complete = true; + const pending = service.putSingle('t1', 'u1', req, { path: '/a.txt', size: 100 }); + await vi.waitFor(() => expect(calls).toHaveLength(1)); + req.emit('close'); + expect(calls[0].signal.aborted).toBe(false); + release?.(); + expect(await pending).toEqual({ path: '/a.txt', size: 100 }); + }); +}); + +describe('NextcloudFilesTransferService — Zusammenbau', () => { + const dto = { path: '/Projekte/groß.bin', size: 30000000, mtime: 1760000000 }; + + it('schneller MOVE -> { state: done }; MOVE .file mit Destination, Overwrite: F, Mtime', async () => { + const { service, calls } = setup(); + expect(await service.completeUpload('t1', 'u1', UP, dto)).toEqual({ state: 'done' }); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('MOVE'); + expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/.file`); + expect(calls[0].headers.overwrite).toBe('F'); + expect(calls[0].headers['x-oc-mtime']).toBe('1760000000'); + expect(calls[0].headers['oc-total-length']).toBe('30000000'); + expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'done' }); + }); + + it('mit replaceEtag: erst pruefen (stat), dann Overwrite: T; weicht der Tag ab -> changedMeanwhile', async () => { + const ok = setup((req) => + req.method === 'PROPFIND' ? { status: 207, text: statXml('e1') } : { status: 204 }, + ); + expect( + await ok.service.completeUpload('t1', 'u1', UP, { + ...dto, + path: '/Projekte/a.txt', + replaceEtag: '"e1"', + }), + ).toEqual({ state: 'done' }); + expect(ok.calls.map((c) => c.method)).toEqual(['PROPFIND', 'MOVE']); + expect(ok.calls[1].headers.overwrite).toBe('T'); + + const changed = setup((req) => + req.method === 'PROPFIND' ? { status: 207, text: statXml('e2') } : { status: 204 }, + ); + await expect( + changed.service.completeUpload('t1', 'u1', UP, { + ...dto, + path: '/Projekte/a.txt', + replaceEtag: '"e1"', + }), + ).rejects.toMatchObject({ response: { code: 'changedMeanwhile' }, status: 409 }); + expect(changed.calls.map((c) => c.method)).toEqual(['PROPFIND']); + }); + + it('MOVE dauert laenger als 20 s: 202-Zustand assembling, spaeter done', async () => { + vi.useFakeTimers(); + let release: (() => void) | undefined; + const { service } = setup( + () => + new Promise((resolve) => { + release = () => resolve({ status: 201 }); + }), + ); + const pending = service.completeUpload('t1', 'u1', UP, dto); + await vi.advanceTimersByTimeAsync(ASSEMBLY_WAIT_MS - 1); + expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'assembling' }); + await vi.advanceTimersByTimeAsync(1); + expect(await pending).toEqual({ state: 'assembling' }); + expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'assembling' }); + // erneuter Aufruf waehrend des Zusammenbaus startet keinen zweiten MOVE + expect(await service.completeUpload('t1', 'u1', UP, dto)).toEqual({ state: 'assembling' }); + + release?.(); + await vi.advanceTimersByTimeAsync(0); + expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'done' }); + + // 10 Minuten nach dem Ende ist der Zustand weg + await vi.advanceTimersByTimeAsync(ASSEMBLY_STATE_TTL_MS + 1000); + expect(() => service.uploadState('t1', 'u1', UP)).toThrow(); + }); + + it('MOVE scheitert nach dem Fenster (507): Zustand failed quotaExceeded; es gibt keine unbehandelte Ablehnung', async () => { + vi.useFakeTimers(); + let release: (() => void) | undefined; + const { service } = setup( + () => + new Promise((resolve) => { + release = () => resolve({ status: 507 }); + }), + ); + const pending = service.completeUpload('t1', 'u1', UP, dto); + await vi.advanceTimersByTimeAsync(ASSEMBLY_WAIT_MS); + expect(await pending).toEqual({ state: 'assembling' }); + release?.(); + await vi.advanceTimersByTimeAsync(0); + expect(service.uploadState('t1', 'u1', UP)).toMatchObject({ + state: 'failed', + code: 'quotaExceeded', + }); + }); + + it('MOVE 412: Fehler nameTaken, Zustand failed, und der Upload-Ordner wird geloescht', async () => { + const { service, calls } = setup((req) => { + if (req.method === 'MOVE') return { status: 412 }; + if (req.method === 'PROPFIND') return { status: 207, text: statXml('e5') }; + return { status: 204 }; + }); + const e = await caught( + service.completeUpload('t1', 'u1', UP, { ...dto, path: '/Projekte/a.txt' }), + ); + const { status, body } = parts(e); + expect(status).toBe(409); + expect(body).toMatchObject({ code: 'nameTaken', existing: { etag: '"e5"' } }); + expect(service.uploadState('t1', 'u1', UP)).toMatchObject({ + state: 'failed', + code: 'nameTaken', + }); + const del = calls.find((c) => c.method === 'DELETE'); + expect(del?.url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`); + expect(del?.headers.destination).toBeUndefined(); + }); + + it('MOVE 507 im Fenster: 507 quotaExceeded', async () => { + const { service } = setup(() => ({ status: 507 })); + const e = await caught(service.completeUpload('t1', 'u1', UP, dto)); + expect(parts(e)).toMatchObject({ status: 507, body: { code: 'quotaExceeded' } }); + expect(service.uploadState('t1', 'u1', UP)).toMatchObject({ + state: 'failed', + code: 'quotaExceeded', + }); + }); + + it('der Zustand gehoert zu Mandant und Benutzer: ein anderer Benutzer sieht ihn nicht', async () => { + const { service } = setup(); + await service.completeUpload('t1', 'u1', UP, dto); + expect(() => service.uploadState('t1', 'u2', UP)).toThrow(); + expect(() => service.uploadState('t2', 'u1', UP)).toThrow(); + expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'done' }); + }); + + it('abortUpload: DELETE des Upload-Ordners; 404 gilt auch als erledigt; mitten im Zusammenbau keiner', async () => { + const { service, calls } = setup((req) => + req.method === 'DELETE' ? { status: 204 } : { status: 201 }, + ); + expect(await service.abortUpload('t1', 'u1', UP)).toEqual({ aborted: true }); + expect(calls[0].method).toBe('DELETE'); + expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`); + + const gone = setup(() => ({ status: 404 })); + expect(await gone.service.abortUpload('t1', 'u1', UP)).toEqual({ aborted: true }); + + vi.useFakeTimers(); + const busy = setup(() => new Promise(() => {})); + void busy.service.completeUpload('t1', 'u1', UP, dto); + await vi.advanceTimersByTimeAsync(0); + expect(await busy.service.abortUpload('t1', 'u1', UP)).toEqual({ aborted: false }); + expect(busy.calls.map((c) => c.method)).toEqual(['MOVE']); + }); +}); + +describe('NextcloudFilesTransferService — Herunterladen', () => { + const FILE = { + status: 200, + text: 'inhalt', + headers: { 'content-type': 'text/plain', 'content-length': '6' }, + }; + + it('Datei: Content-Disposition attachment mit UTF-8-Name, nosniff, CSP sandbox, kein set-cookie', async () => { + const { service, calls } = setup(() => ({ + ...FILE, + headers: { + ...FILE.headers, + 'set-cookie': 'oc_sessionPassphrase=geheim', + 'content-disposition': 'inline; filename="boese.html"', + 'x-powered-by': 'PHP', + }, + })); + const res = new FakeRes(); + await service.download(res as never, 't1', 'u1', '/Projekte/Ärger & Co.txt'); + await finished(res); + expect(calls[0].method).toBe('GET'); + expect(calls[0].url).toBe( + 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/%C3%84rger%20%26%20Co.txt', + ); + expect(res.code).toBe(200); + expect(res.headers['content-disposition']).toBe( + `attachment; filename="_rger & Co.txt"; filename*=UTF-8''%C3%84rger%20%26%20Co.txt`, + ); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['content-security-policy']).toBe("default-src 'none'; sandbox"); + expect(res.headers['cache-control']).toBe('private, no-store'); + expect(res.headers['content-type']).toBe('text/plain'); + expect(Object.keys(res.headers).sort()).toEqual([ + 'cache-control', + 'content-disposition', + 'content-length', + 'content-security-policy', + 'content-type', + 'x-content-type-options', + ]); + expect(Buffer.concat(res.written).toString()).toBe('inhalt'); + }); + + it('Range geht an Nextcloud, die Antwort ist 206 mit content-range', async () => { + const { service, calls } = setup(() => ({ + status: 206, + text: 'abc', + headers: { 'content-range': 'bytes 0-2/6', 'accept-ranges': 'bytes', 'content-length': '3' }, + })); + const res = new FakeRes(); + await service.download(res as never, 't1', 'u1', '/a.txt', { range: 'bytes=0-2' }); + await finished(res); + expect(calls[0].headers.range).toBe('bytes=0-2'); + expect(res.code).toBe(206); + expect(res.headers['content-range']).toBe('bytes 0-2/6'); + expect(res.headers['accept-ranges']).toBe('bytes'); + }); + + it('Ordner als ZIP: ?accept=zip, Name .zip, Wurzel -> Dateien.zip', async () => { + const { service, calls } = setup(() => ({ + status: 200, + text: 'PK', + headers: { 'content-type': 'application/zip' }, + })); + const res1 = new FakeRes(); + await service.download(res1 as never, 't1', 'u1', '/Projekte', { zip: true }); + await finished(res1); + expect(calls[0].url).toBe( + 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/?accept=zip', + ); + expect(res1.headers['content-disposition']).toContain('filename="Projekte.zip"'); + const res2 = new FakeRes(); + await service.download(res2 as never, 't1', 'u1', '/', { zip: true }); + await finished(res2); + expect(res2.headers['content-disposition']).toContain('filename="Dateien.zip"'); + expect(calls[1].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/?accept=zip'); + }); + + it('die Wurzel ohne zip ist keine Datei: 400, kein Aufruf', async () => { + const { service, calls } = setup(); + await expect(service.download(new FakeRes() as never, 't1', 'u1', '/')).rejects.toMatchObject({ + response: { code: 'invalidPath' }, + }); + expect(calls).toHaveLength(0); + }); + + it('Auswahl-ZIP: files-Liste, Name aus dem Ordner; ein Name .. -> 400 invalidPath ohne Aufruf', async () => { + const { service, calls } = setup(() => ({ status: 200, text: 'PK' })); + const res = new FakeRes(); + await service.downloadZip(res as never, 't1', 'u1', '/Projekte', ['a.txt', 'b']); + await finished(res); + expect(JSON.parse(new URL(calls[0].url).searchParams.get('files') ?? '')).toEqual([ + 'a.txt', + 'b', + ]); + expect(res.headers['content-disposition']).toContain('filename="Projekte.zip"'); + + const callsBefore = calls.length; + const e = await caught( + service.downloadZip(new FakeRes() as never, 't1', 'u1', '/Projekte', ['a', '..']), + ); + expect(parts(e)).toMatchObject({ status: 400, body: { code: 'invalidPath' } }); + expect(calls).toHaveLength(callsBefore); + }); + + it('Browser bricht den Download ab: das Signal des Aufrufs an Nextcloud wird abgebrochen', async () => { + let body: PassThrough | undefined; + const calls: NcTransportRequest[] = []; + const transport: NextcloudTransport = async (req) => { + calls.push(req); + body = new PassThrough(); + body.write('anfang'); + return { statusCode: 200, headers: {}, body }; + }; + const account = { getSession: vi.fn(async () => SESSION), markExpired: vi.fn() }; + const service = new NextcloudFilesTransferService( + account as never, + new NextcloudCallGate(), + transport, + ); + const res = new FakeRes(); + const running = service.download(res as never, 't1', 'u1', '/gross.bin'); + await vi.waitFor(() => expect(res.written.length).toBeGreaterThan(0)); + expect(calls[0].signal.aborted).toBe(false); + res.destroy(); + await running; + expect(calls[0].signal.aborted).toBe(true); + }); +}); + +describe('NextcloudFilesTransferService — Fehlervertrag auf jeder Transferroute', () => { + const UPSTREAM: Array<[number, number, string]> = [ + [401, 409, 'connectionExpired'], + [403, 422, 'notAllowed'], + [404, 404, 'notFound'], + [409, 409, 'pathConflict'], + [412, 409, 'nameTaken'], + [423, 409, 'locked'], + [429, 503, 'nextcloudLocked'], + [507, 507, 'quotaExceeded'], + [500, 502, 'nextcloudError'], + [502, 502, 'nextcloudError'], + [503, 503, 'nextcloudMaintenance'], + ]; + + type Route = (s: NextcloudFilesTransferService, res: FakeRes) => Promise; + const ROUTES: Record = { + download: (s, res) => s.download(res as never, 't1', 'u1', '/Projekte/a.txt'), + downloadZip: (s, res) => s.downloadZip(res as never, 't1', 'u1', '/Projekte', ['a.txt']), + putSingle: (s) => s.putSingle('t1', 'u1', rawReq(10), { path: '/Projekte/a.txt', size: 10 }), + putChunk: (s) => + s.putChunk('t1', 'u1', rawReq(10), UP, '2', { path: '/Projekte/a.txt', size: 30000000 }), + completeUpload: (s) => + s.completeUpload('t1', 'u1', UP, { path: '/Projekte/a.txt', size: 30000000 }), + startUpload: (s) => s.startUpload('t1', 'u1', { path: '/Projekte/a.txt', size: 30000000 }), + }; + + const cases = Object.keys(ROUTES).flatMap((route) => + UPSTREAM.map(([up, http, code]) => [route, up, http, code] as const), + ); + + it.each( + cases, + )('%s: Nextcloud %i -> %i %s, nie 401/403, kein Byte und keine Kopfzeile vorab', async (route, up, http, code) => { + // startUpload: ein 404 beim Pruefen des Ziels heisst "Ziel frei" und ist KEIN Fehler. + const { service, account } = setup(() => ({ + status: up, + text: 'nextcloud-fehlerseite', + headers: { + 'content-type': 'text/html', + etag: '"x"', + 'set-cookie': 'a=b', + 'content-length': '21', + }, + })); + const res = new FakeRes(); + if (route === 'startUpload' && up === 404) { + // Ziel frei, dann scheitert der naechste Aufruf (MKCOL) nicht an 404 -> Ordner angelegt? Hier 404 auch fuer MKCOL. + const e = await caught(ROUTES[route](service, res)); + expect(parts(e)).toMatchObject({ status: 404, body: { code: 'notFound' } }); + return; + } + const e = await caught(ROUTES[route](service, res)); + const { status, body } = parts(e); + expect(status).toBe(http); + expect(status).not.toBe(401); + expect(status).not.toBe(403); + expect(body.code).toBe(code); + expect(typeof body.message).toBe('string'); + // Es wurde nichts an den Browser geschrieben oder gesetzt. + expect(res.written).toHaveLength(0); + expect(res.headers).toEqual({}); + expect(res.code).toBe(200); + expect(account.markExpired).toHaveBeenCalledTimes(up === 401 ? 1 : 0); + }); + + it('Transportfehler (Netz weg) -> 504 nextcloudUnavailable auf jeder Route', async () => { + for (const route of Object.keys(ROUTES)) { + const calls: NcTransportRequest[] = []; + const transport: NextcloudTransport = async (req) => { + calls.push(req); + throw Object.assign(new Error('x'), { code: 'ECONNREFUSED' }); + }; + const service = new NextcloudFilesTransferService( + { getSession: async () => SESSION, markExpired: async () => {} } as never, + new NextcloudCallGate(), + transport, + ); + const res = new FakeRes(); + const { status, body } = parts(await caught(ROUTES[route](service, res))); + expect(status, route).toBe(504); + expect(body.code, route).toBe('nextcloudUnavailable'); + expect(res.written, route).toHaveLength(0); + } + }); + + it('429 haelt den Ursprung an: der naechste Transferaufruf liefert 503 ohne Transportaufruf', async () => { + const { service, calls } = setup(() => ({ status: 429, headers: { 'retry-after': '120' } })); + const first = parts(await caught(ROUTES.download(service, new FakeRes()))); + expect(first.status).toBe(503); + expect(calls).toHaveLength(1); + for (const route of Object.keys(ROUTES)) { + const { status, body } = parts(await caught(ROUTES[route](service, new FakeRes()))); + expect(status, route).toBe(503); + expect(body.code, route).toBe('nextcloudLocked'); + } + expect(calls).toHaveLength(1); + }); + + it('Benutzer ohne Konto: notConnected, es geht kein Aufruf raus', async () => { + const { service, account, calls } = setup(); + account.getSession.mockRejectedValue(ncErrorDefault('notConnected')); + for (const route of Object.keys(ROUTES)) { + const { status, body } = parts(await caught(ROUTES[route](service, new FakeRes()))); + expect(status, route).toBe(409); + expect(body.code, route).toBe('notConnected'); + } + expect(calls).toHaveLength(0); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts b/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts new file mode 100644 index 0000000..7ef82d4 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts @@ -0,0 +1,593 @@ +import { randomUUID } from 'node:crypto'; +import type { Readable } from 'node:stream'; +import { Inject, Injectable } from '@nestjs/common'; +import { NEXTCLOUD_FILES_CHUNK_SIZE, NEXTCLOUD_FILES_MAX_CHUNKS } from '@tessera/shared'; +import type { Response } from 'express'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import * as dav from './nextcloud-dav'; +import * as transfer from './nextcloud-dav-transfer'; +import { type NcSession, ncErrorDefault } from './nextcloud-files.types'; +import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; +import { + type NcResult, + NEXTCLOUD_TRANSPORT, + type NextcloudTransport, + parseUserPath, + validateNewName, + validateSegment, +} from './nextcloud-http'; +import type { NcEntry } from './nextcloud-propfind'; +import { mapNcFailure, type NcOutcome, sendUpstreamStream } from './nextcloud-upstream'; + +/** + * Hoch- und Herunterladen im Konto des angemeldeten Benutzers + * (quick-261008-mzu, D-D/D-J/D-K). Jede Methode beginnt mit der Sitzung des + * Aufrufers (Benutzerkennung aus dem Token, nie aus der Eingabe). Dieser Dienst + * greift nicht auf die Datenbank zu. + * + * NICHTS WIRD GEPUFFERT. Die vorhandenen Upload-Routen der API (die Datei-Annahme + * mit multers Speicher im Arbeitsspeicher, Grenzen von 1 bis 5 MB) halten ganze + * Dateien im RAM — fuer Dateien in Gigabyte-Groesse ungeeignet. Hier wird der + * Anfragestrom (`req`) selbst als Koerper an Nextcloud weitergereicht und eine + * Antwort als Strom an den Browser; im Speicher liegt immer nur, was gerade + * unterwegs ist. Darum steht in diesem Modul kein multer-Interceptor. + * + * WARUM IN STUECKEN (8 MiB): der `/api-proxy`-Rewrite von Next.js puffert + * Anfragekoerper und schneidet sie bei 10 MiB STILL ab — eine groessere Datei + * kaeme verstuemmelt an. Der Browser zerlegt darum in 8-MiB-Stuecke + * (`NEXTCLOUD_FILES_CHUNK_SIZE`: unter 10 MiB, ueber den 5 MiB, die Nextcloud + * pro Stueck mindestens verlangt). Die API lehnt groessere Koerper ab. + * + * WARUM DER ZUSAMMENBAU IM HINTERGRUND LAEUFT: Nextcloud baut die Stuecke erst + * beim abschliessenden MOVE zusammen — bei Dateien in Gigabyte-Groesse dauert + * das Minuten, und Zwischenstationen (der Next.js-Proxy, der Nginx Proxy Manager + * vor Tessera) brechen Anfragen ohne Antwort nach 30 bis 60 s ab. Darum wartet + * `completeUpload` hoechstens 20 s auf das Ende und antwortet sonst 202 + * `assembling`; der Browser fragt dann den Zustand ab. Der Zustand liegt im + * Arbeitsspeicher (je Mandant, Benutzer und Upload-Kennung, 10 Minuten nach dem + * Ende); ein Neustart der API verliert ihn, die Weboberflaeche meldet dann einen + * Fehler und der Benutzer wiederholt. + * + * WARUM TESSERA DEN `Content-Disposition` SELBST BAUT und stets `attachment` + * erzwingt: hochgeladene HTML- oder SVG-Dateien wuerden sonst auf der Tessera- + * Adresse im Browser ausgefuehrt (gespeichertes Cross-Site-Scripting). Der Name + * kommt aus dem angefragten Pfad, nie aus einer Nextcloud-Kopfzeile; dazu + * `nosniff` und eine `sandbox`-Richtlinie. + * + * WARUM FEHLER VOR DEM ERSTEN BYTE ABGEBILDET WERDEN: sobald ein Byte oder eine + * Kopfzeile an den Browser ging, laesst sich der Status nicht mehr aendern. Darum + * wird jede Antwort von Nextcloud zuerst geprueft (`sendUpstreamStream`) und jeder + * Fehler ueber `mapNcFailure` zu `{ code, message }` — nie 401 oder 403. + */ + +const CHUNK_SIZE = NEXTCLOUD_FILES_CHUNK_SIZE; +/** Groesste uebertragbare Datei: 10000 Stuecke zu 8 MiB. */ +export const MAX_UPLOAD_BYTES = NEXTCLOUD_FILES_MAX_CHUNKS * CHUNK_SIZE; +/** So lange wartet `completeUpload` auf den Zusammenbau, bevor es mit 202 antwortet. */ +export const ASSEMBLY_WAIT_MS = 20_000; +/** So lange bleibt der Ausgang eines Zusammenbaus abfragbar. */ +export const ASSEMBLY_STATE_TTL_MS = 10 * 60_000; +/** Obergrenze fuer gleichzeitig gemerkte Zusammenbauten (Schutz des Arbeitsspeichers). */ +const ASSEMBLY_STATE_MAX = 2000; + +const UPLOAD_ID_RE = /^tessera-[0-9a-f-]{36}$/; +const MAX_MTIME_SECONDS = 4102444800; + +/** Was der Dienst von der Anfrage braucht: den Strom, die Kopfzeilen und ob er vollstaendig kam. */ +export interface RawUploadRequest extends Readable { + headers: Record; + complete?: boolean; +} + +export type UploadStartView = + | { mode: 'single' } + | { mode: 'chunked'; uploadId: string; chunkSize: number }; + +export type UploadStateView = + | { state: 'assembling' } + | { state: 'done' } + | { state: 'failed'; code: string; message: string }; + +interface AssemblyRecord { + state: 'assembling' | 'done' | 'failed'; + code?: string; + message?: string; + finishedAt?: number; +} + +interface ExistingView { + etag: string | null; + size: number; + mtime: string | null; +} + +/** ASCII-Rueckfallname: alles ausser druckbarem ASCII (und `"`, `\`, `%`) wird `_`. */ +function asciiFallback(name: string): string { + let out = ''; + for (const ch of name) { + const code = ch.codePointAt(0) ?? 0; + out += code < 0x20 || code > 0x7e || ch === '"' || ch === '\\' || ch === '%' ? '_' : ch; + } + return out; +} + +/** RFC 5987: `encodeURIComponent` laesst `!'()*` stehen, die hier ebenfalls codiert werden muessen. */ +function rfc5987(name: string): string { + return encodeURIComponent(name).replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +/** `Content-Disposition` fuer einen Download: immer `attachment`, nie aus Nextcloud uebernommen. */ +export function contentDispositionFor(name: string): string { + return `attachment; filename="${asciiFallback(name)}"; filename*=UTF-8''${rfc5987(name)}`; +} + +function pathOf(segments: readonly string[]): string { + return `/${segments.join('/')}`; +} + +function headerOne(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +@Injectable() +export class NextcloudFilesTransferService { + private readonly assemblies = new Map(); + + constructor( + private readonly account: NextcloudFilesAccountService, + private readonly gate: NextcloudCallGate, + @Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport, + ) {} + + // --- Hilfen --------------------------------------------------------------------------------- + + private session(tenantId: string, userId: string): Promise { + return this.account.getSession(tenantId, userId); + } + + private async fail( + tenantId: string, + userId: string, + result: NcOutcome, + existing?: ExistingView, + ): Promise { + throw await mapNcFailure(result, { + onExpired: () => this.account.markExpired(tenantId, userId), + existing, + }); + } + + private static ok(result: NcOutcome): boolean { + return ( + result.ok && typeof result.status === 'number' && result.status >= 200 && result.status < 300 + ); + } + + private static uploadId(raw: string): string { + if (typeof raw !== 'string' || !UPLOAD_ID_RE.test(raw)) throw ncErrorDefault('invalidPath'); + return raw; + } + + /** Zielpfad einer Datei: nicht die Wurzel, letzter Teil ein erlaubter neuer Name. */ + private static targetOf(rawPath: string): string[] { + const segments = parseUserPath(rawPath); + if (segments.length === 0) throw ncErrorDefault('invalidPath'); + validateNewName(segments[segments.length - 1]); + return segments; + } + + private static mtimeOf(mtime: number | undefined): number | undefined { + if (mtime === undefined) return undefined; + if (!Number.isInteger(mtime) || mtime < 0 || mtime > MAX_MTIME_SECONDS) { + throw ncErrorDefault('invalidPath'); + } + return mtime; + } + + private static sizeOf(size: number): number { + if (!Number.isInteger(size) || size < 0) throw ncErrorDefault('invalidPath'); + if (size > MAX_UPLOAD_BYTES) throw ncErrorDefault('fileTooLarge'); + return size; + } + + /** + * `content-length` des Raw-Body-Aufrufs: fehlt er (auch bei chunked Transfer-Encoding) + * -> 411, ist er groesser als ein Stueck -> 413. Beides VOR jedem Nextcloud-Aufruf. + */ + private static declaredLength(req: RawUploadRequest): number { + const raw = headerOne(req.headers['content-length']); + if (raw === undefined || !/^\d{1,15}$/.test(raw)) throw ncErrorDefault('lengthRequired'); + const length = Number(raw); + if (length > CHUNK_SIZE) throw ncErrorDefault('chunkTooLarge'); + return length; + } + + /** + * Bricht der Browser ab, bevor der Koerper vollstaendig da ist, wird auch der Aufruf an + * Nextcloud abgebrochen. `req.complete` unterscheidet das vom normalen Ende der Anfrage + * (Node meldet `close` auch nach dem vollstaendigen Lesen). + */ + private static abortOnClose(req: RawUploadRequest): { signal: AbortSignal; settle(): void } { + const abort = new AbortController(); + let settled = false; + const onClose = () => { + if (!settled && !req.complete) abort.abort(); + }; + req.once('close', onClose); + return { + signal: abort.signal, + settle: () => { + settled = true; + req.off('close', onClose); + }, + }; + } + + /** Vorhandener Eintrag fuer die Rueckfrage "Ersetzen?" — bestmoeglich, nie ein Fehlergrund. */ + private async existingOf( + session: NcSession, + segments: readonly string[], + ): Promise { + try { + const res = await dav.stat(this.transport, this.gate, session, segments); + if (res.ok && res.entry) return viewOf(res.entry); + } catch { + // ohne Angaben geht es auch + } + return undefined; + } + + // --- Hochladen: Start ------------------------------------------------------------------------- + + async startUpload( + tenantId: string, + userId: string, + dto: { path: string; size: number; replaceEtag?: string }, + ): Promise { + const segments = NextcloudFilesTransferService.targetOf(dto.path); + const size = NextcloudFilesTransferService.sizeOf(dto.size); + const session = await this.session(tenantId, userId); + + // Schutz vor stillem Ueberschreiben: das Ziel pruefen, BEVOR irgendetwas uebertragen wird. + const probe = await dav.stat(this.transport, this.gate, session, segments); + if (!probe.ok) return this.fail(tenantId, userId, probe); + if (probe.status !== 404) { + if (!NextcloudFilesTransferService.ok(probe)) return this.fail(tenantId, userId, probe); + if (!probe.entry) return this.fail(tenantId, userId, { ok: false, kind: 'invalid-response' }); + if (!dto.replaceEtag) { + throw ncErrorDefault('nameTaken', { existing: viewOf(probe.entry) }); + } + if (probe.entry.etag !== dto.replaceEtag) { + throw ncErrorDefault('changedMeanwhile', { existing: viewOf(probe.entry) }); + } + } else if (dto.replaceEtag) { + // Der Eintrag, den der Benutzer ersetzen wollte, ist inzwischen weg. + throw ncErrorDefault('changedMeanwhile'); + } + + if (size <= CHUNK_SIZE) return { mode: 'single' }; + + const uploadId = `tessera-${randomUUID()}`; + const made = await transfer.uploadStart(this.transport, this.gate, session, uploadId, segments); + if (!NextcloudFilesTransferService.ok(made)) return this.fail(tenantId, userId, made); + return { mode: 'chunked', uploadId, chunkSize: CHUNK_SIZE }; + } + + // --- Hochladen: ganze Datei ------------------------------------------------------------------- + + async putSingle( + tenantId: string, + userId: string, + req: RawUploadRequest, + query: { path: string; size?: number; mtime?: number; replaceEtag?: string }, + ): Promise<{ path: string; size: number }> { + const length = NextcloudFilesTransferService.declaredLength(req); + const segments = NextcloudFilesTransferService.targetOf(query.path); + const mtime = NextcloudFilesTransferService.mtimeOf(query.mtime); + const session = await this.session(tenantId, userId); + + const guard = NextcloudFilesTransferService.abortOnClose(req); + let result: NcResult; + try { + result = await transfer.putFile(this.transport, this.gate, session, segments, req, { + size: length, + mtime, + replaceEtag: query.replaceEtag, + signal: guard.signal, + }); + } finally { + guard.settle(); + } + if (!NextcloudFilesTransferService.ok(result)) { + const existing = + result.ok && result.status === 412 ? await this.existingOf(session, segments) : undefined; + return this.fail(tenantId, userId, result, existing); + } + return { path: pathOf(segments), size: length }; + } + + // --- Hochladen: Stuecke ----------------------------------------------------------------------- + + async putChunk( + tenantId: string, + userId: string, + req: RawUploadRequest, + uploadId: string, + rawN: string | number, + query: { path: string; size: number }, + ): Promise<{ n: number; received: number }> { + NextcloudFilesTransferService.uploadId(uploadId); + const n = typeof rawN === 'number' ? rawN : /^\d{1,5}$/.test(rawN) ? Number(rawN) : Number.NaN; + if (!Number.isInteger(n) || n < 1 || n > NEXTCLOUD_FILES_MAX_CHUNKS) { + throw ncErrorDefault('invalidPath'); + } + const length = NextcloudFilesTransferService.declaredLength(req); + const segments = NextcloudFilesTransferService.targetOf(query.path); + const total = NextcloudFilesTransferService.sizeOf(query.size); + const session = await this.session(tenantId, userId); + + const guard = NextcloudFilesTransferService.abortOnClose(req); + let result: NcResult; + try { + result = await transfer.uploadChunk( + this.transport, + this.gate, + session, + uploadId, + n, + segments, + req, + { size: length, totalLength: total, signal: guard.signal }, + ); + } finally { + guard.settle(); + } + if (!NextcloudFilesTransferService.ok(result)) return this.fail(tenantId, userId, result); + return { n, received: length }; + } + + // --- Hochladen: Zusammenbau ------------------------------------------------------------------- + + private key(tenantId: string, userId: string, uploadId: string): string { + return `${tenantId}:${userId}:${uploadId}`; + } + + private prune(): void { + const now = Date.now(); + for (const [key, rec] of this.assemblies) { + if (rec.finishedAt !== undefined && now - rec.finishedAt > ASSEMBLY_STATE_TTL_MS) { + this.assemblies.delete(key); + } + } + // Aeltestes zuerst entfernen, falls (durch viele Aufrufe) zu viel gemerkt wird. + while (this.assemblies.size > ASSEMBLY_STATE_MAX) { + const oldest = this.assemblies.keys().next().value; + if (oldest === undefined) break; + this.assemblies.delete(oldest); + } + } + + async completeUpload( + tenantId: string, + userId: string, + uploadId: string, + dto: { path: string; size: number; mtime?: number; replaceEtag?: string }, + ): Promise<{ state: 'done' | 'assembling' }> { + NextcloudFilesTransferService.uploadId(uploadId); + const segments = NextcloudFilesTransferService.targetOf(dto.path); + const total = NextcloudFilesTransferService.sizeOf(dto.size); + const mtime = NextcloudFilesTransferService.mtimeOf(dto.mtime); + const session = await this.session(tenantId, userId); + + this.prune(); + const key = this.key(tenantId, userId, uploadId); + const known = this.assemblies.get(key); + if (known?.state === 'assembling') return { state: 'assembling' }; + if (known?.state === 'done') return { state: 'done' }; + + // Ersetzen: unmittelbar vor dem Zusammenbau noch einmal pruefen, ob noch die Version + // da ist, die der Benutzer ersetzen wollte. + if (dto.replaceEtag) { + const probe = await dav.stat(this.transport, this.gate, session, segments); + if (!probe.ok) return this.fail(tenantId, userId, probe); + if (probe.status === 404 || (NextcloudFilesTransferService.ok(probe) && !probe.entry)) { + throw ncErrorDefault('changedMeanwhile'); + } + if (!NextcloudFilesTransferService.ok(probe)) return this.fail(tenantId, userId, probe); + if (probe.entry?.etag !== dto.replaceEtag) { + throw ncErrorDefault( + 'changedMeanwhile', + probe.entry ? { existing: viewOf(probe.entry) } : undefined, + ); + } + } + + const record: AssemblyRecord = { state: 'assembling' }; + this.assemblies.set(key, record); + + // Der Zusammenbau gehoert nicht zur Anfrage: er laeuft weiter, auch wenn der Browser + // aufgibt, und haelt sein Ergebnis in `record` fest. + const job = this.assemble(tenantId, userId, session, uploadId, segments, { + totalLength: total, + mtime, + replace: Boolean(dto.replaceEtag), + }).then( + () => { + record.state = 'done'; + record.finishedAt = Date.now(); + }, + (err: unknown) => { + const body = (err as { getResponse?: () => unknown }).getResponse?.() as + | { code?: string; message?: string } + | undefined; + record.state = 'failed'; + record.code = body?.code ?? 'nextcloudError'; + record.message = body?.message ?? ncErrorDefault('nextcloudError').message; + record.finishedAt = Date.now(); + throw err; + }, + ); + // Ein spaeter Fehler darf nie als unbehandelt enden; der Browser fragt den Zustand ab. + job.catch(() => {}); + + let timer: NodeJS.Timeout | undefined; + const window = new Promise<'pending'>((resolve) => { + timer = setTimeout(() => resolve('pending'), ASSEMBLY_WAIT_MS); + }); + try { + const outcome = await Promise.race([job.then(() => 'finished' as const), window]); + if (outcome === 'pending') return { state: 'assembling' }; + return { state: 'done' }; + } finally { + clearTimeout(timer); + } + } + + private async assemble( + tenantId: string, + userId: string, + session: NcSession, + uploadId: string, + segments: readonly string[], + opts: { totalLength: number; mtime?: number; replace: boolean }, + ): Promise { + const result = await transfer.uploadAssemble( + this.transport, + this.gate, + session, + uploadId, + segments, + opts, + ); + if (NextcloudFilesTransferService.ok(result)) return; + if (result.ok && result.status === 412) { + // Das Ziel ist inzwischen vergeben (gemessen: der Upload-Ordner BLEIBT) -> aufraeumen. + const existing = await this.existingOf(session, segments); + await transfer.uploadAbort(this.transport, this.gate, session, uploadId).catch(() => {}); + return this.fail(tenantId, userId, result, existing); + } + return this.fail(tenantId, userId, result); + } + + uploadState(tenantId: string, userId: string, uploadId: string): UploadStateView { + NextcloudFilesTransferService.uploadId(uploadId); + this.prune(); + const rec = this.assemblies.get(this.key(tenantId, userId, uploadId)); + if (!rec) throw ncErrorDefault('notFound'); + if (rec.state === 'failed') { + return { + state: 'failed', + code: rec.code ?? 'nextcloudError', + message: rec.message ?? ncErrorDefault('nextcloudError').message, + }; + } + return { state: rec.state }; + } + + async abortUpload( + tenantId: string, + userId: string, + uploadId: string, + ): Promise<{ aborted: boolean }> { + NextcloudFilesTransferService.uploadId(uploadId); + const session = await this.session(tenantId, userId); + const rec = this.assemblies.get(this.key(tenantId, userId, uploadId)); + // Mitten im Zusammenbau wird nichts geloescht; Nextcloud raeumt selbst auf. + if (rec?.state === 'assembling') return { aborted: false }; + const result = await transfer.uploadAbort(this.transport, this.gate, session, uploadId); + // 404: der Ordner ist schon weg (Zusammenbau fertig oder abgelaufen) — auch gut. + if (!NextcloudFilesTransferService.ok(result) && !(result.ok && result.status === 404)) { + return this.fail(tenantId, userId, result); + } + this.assemblies.delete(this.key(tenantId, userId, uploadId)); + return { aborted: true }; + } + + // --- Herunterladen ---------------------------------------------------------------------------- + + /** Datei (oder mit `zip` ein Ordner als ZIP) als Strom an den Browser; `range` geht mit. */ + async download( + res: Response, + tenantId: string, + userId: string, + rawPath: string, + opts: { zip?: boolean; range?: string } = {}, + ): Promise { + const segments = parseUserPath(rawPath); + if (segments.length === 0 && !opts.zip) throw ncErrorDefault('invalidPath'); + const session = await this.session(tenantId, userId); + const abort = this.abortOnResponseClose(res); + + const upstream = opts.zip + ? await transfer.downloadFolderZip(this.transport, this.gate, session, segments, abort) + : await transfer.download(this.transport, this.gate, session, segments, { + range: opts.range, + signal: abort, + }); + const name = opts.zip + ? segments.length > 0 + ? `${segments[segments.length - 1]}.zip` + : 'Dateien.zip' + : segments[segments.length - 1]; + await this.send(res, upstream, name, tenantId, userId); + } + + /** Nur die gewaehlten Eintraege eines Ordners als ZIP. */ + async downloadZip( + res: Response, + tenantId: string, + userId: string, + rawDir: string | undefined, + names: readonly string[], + ): Promise { + const dir = parseUserPath(rawDir); + // Alle Namen vor dem ersten Aufruf pruefen (`..` -> 400 invalidPath). + for (const name of names) validateSegment(name); + if (names.length === 0) throw ncErrorDefault('invalidPath'); + const session = await this.session(tenantId, userId); + const abort = this.abortOnResponseClose(res); + const upstream = await transfer.downloadSelectionZip( + this.transport, + this.gate, + session, + dir, + names, + abort, + ); + const name = dir.length > 0 ? `${dir[dir.length - 1]}.zip` : 'Dateien.zip'; + await this.send(res, upstream, name, tenantId, userId); + } + + private abortOnResponseClose(res: Response): AbortSignal { + const abort = new AbortController(); + res.on('close', () => { + if (!res.writableFinished) abort.abort(); + }); + return abort.signal; + } + + private send( + res: Response, + upstream: NcResult, + fileName: string, + tenantId: string, + userId: string, + ): Promise { + return sendUpstreamStream(res, upstream, { + extraHeaders: { + 'content-disposition': contentDispositionFor(fileName), + 'x-content-type-options': 'nosniff', + 'content-security-policy': "default-src 'none'; sandbox", + 'cache-control': 'private, no-store', + }, + onExpired: () => this.account.markExpired(tenantId, userId), + }); + } +} + +function viewOf(entry: NcEntry): ExistingView { + return { etag: entry.etag, size: entry.size, mtime: entry.mtime }; +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts index 7fc1749..5ca1377 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts @@ -11,6 +11,11 @@ const req = (tenantId?: string) => ({ tenantId }) as any; /** Verwalten (Administratoren und Freigabestufe Verwalten). Spaetere Aufgaben ergaenzen nichts hier. */ const MANAGE_HANDLERS = ['getSettings', 'saveSettings', 'testSettings']; +/** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */ +const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle']; +const TRANSFER_PARAM = ['putChunk', 'completeUpload', 'uploadState', 'abortUpload']; +const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM]; + /** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */ function routeHandlers(): string[] { return Object.getOwnPropertyNames(NextcloudFilesController.prototype).filter( @@ -81,6 +86,14 @@ describe('NextcloudFilesController — Metadaten', () => { expect(route('createFolder')).toEqual([1, 'folders']); expect(route('move')).toEqual([1, 'move']); expect(route('preview')).toEqual([0, 'preview']); + expect(route('download')).toEqual([0, 'download']); + expect(route('downloadZip')).toEqual([0, 'download/zip']); + expect(route('startUpload')).toEqual([1, 'uploads']); + expect(route('putSingle')).toEqual([2, 'uploads/file']); + expect(route('putChunk')).toEqual([2, 'uploads/:uploadId/chunks/:n']); + expect(route('completeUpload')).toEqual([1, 'uploads/:uploadId/complete']); + expect(route('uploadState')).toEqual([0, 'uploads/:uploadId/state']); + expect(route('abortUpload')).toEqual([3, 'uploads/:uploadId']); expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']); expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']); }); @@ -93,7 +106,13 @@ describe('NextcloudFilesController — Metadaten', () => { }); it('die Datei-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => { - for (const name of ['list', 'remove', 'createFolder', 'move', 'preview']) { + for (const name of TRANSFER_HANDLERS.concat([ + 'list', + 'remove', + 'createFolder', + 'move', + 'preview', + ])) { expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined(); } @@ -109,6 +128,29 @@ describe('NextcloudFilesController — Metadaten', () => { } }); + it('die statischen Transfer-Handler stehen vor dem Parameterblock, die mit :uploadId danach', () => { + const names = routeHandlers(); + const staticLast = Math.max( + ...names + .filter((n) => !String(Reflect.getMetadata('path', proto[n])).includes(':')) + .map((n) => names.indexOf(n)), + ); + for (const name of TRANSFER_STATIC) { + expect(names.indexOf(name), name).toBeLessThan(staticLast + 1); + expect(String(Reflect.getMetadata('path', proto[name])), name).not.toContain(':'); + } + for (const name of TRANSFER_PARAM) { + expect(names.indexOf(name), name).toBeGreaterThan(staticLast); + } + // uploads/:uploadId/... steht ganz am Ende, nach den Anmelde-Parameterrouten + expect(names.indexOf('putChunk')).toBeGreaterThan(names.indexOf('cancelFlow')); + }); + + it('der Zusammenbau antwortet 200 (oder 202 bei laufendem Zusammenbau), Start bleibt bei 201', () => { + expect(Reflect.getMetadata('__httpCode__', proto.completeUpload)).toBe(200); + expect(Reflect.getMetadata('__httpCode__', proto.startUpload)).toBeUndefined(); + }); + it('verschieben antwortet 200, Ordner anlegen bleibt bei 201', () => { expect(Reflect.getMetadata('__httpCode__', proto.move)).toBe(200); expect(Reflect.getMetadata('__httpCode__', proto.createFolder)).toBeUndefined(); @@ -165,6 +207,19 @@ describe('NextcloudFilesController — Delegation', () => { }; } + function makeTransfer() { + return { + download: vi.fn(async (..._a: unknown[]) => undefined), + downloadZip: vi.fn(async (..._a: unknown[]) => undefined), + startUpload: vi.fn(async (..._a: unknown[]) => ({ mode: 'single' })), + putSingle: vi.fn(async (..._a: unknown[]) => ({ path: '/a', size: 1 })), + putChunk: vi.fn(async (..._a: unknown[]) => ({ n: 1, received: 1 })), + completeUpload: vi.fn(async (..._a: unknown[]) => ({ state: 'done' })), + uploadState: vi.fn((..._a: unknown[]) => ({ state: 'done' })), + abortUpload: vi.fn(async (..._a: unknown[]) => ({ aborted: true })), + }; + } + function makeFiles() { return { list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })), @@ -181,6 +236,7 @@ describe('NextcloudFilesController — Delegation', () => { makeSettings() as any, makeAccount() as any, files as any, + makeTransfer() as any, ); const r = userReq('t1', 'u1'); const res = { end: vi.fn() } as any; @@ -198,12 +254,86 @@ describe('NextcloudFilesController — Delegation', () => { expect(files.preview).toHaveBeenCalledWith(res, 't1', 'u1', '42', '"e"'); }); + it('Transfer-Handler: Mandant und Benutzer aus dem Token, Pfade aus Query und Body, 202 bei laufendem Zusammenbau', async () => { + const transfer = makeTransfer(); + const controller = new NextcloudFilesController( + makeSettings() as any, + makeAccount() as any, + makeFiles() as any, + transfer as any, + ); + const r = userReq('t1', 'u1'); + (r as any).headers = { range: 'bytes=0-99' }; + const res = { status: vi.fn() } as any; + const raw = { headers: {} } as any; + const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c'; + await controller.download(r, res, { path: '/a.txt' } as any); + await controller.download(r, res, { path: '/Ordner', zip: '1' } as any); + await controller.downloadZip(r, res, { dir: '/Ordner', name: ['a', 'b'] } as any); + await controller.startUpload(r, { path: '/x', size: 5, userId: 'fremd' } as any); + const rawReq = Object.assign(raw, r); + await controller.putSingle(rawReq, { path: '/x', size: 5 } as any); + await controller.putChunk(rawReq, UP, '2', { path: '/x', size: 30 } as any); + expect(await controller.completeUpload(r, res, UP, { path: '/x', size: 30 } as any)).toEqual({ + state: 'done', + }); + expect(res.status).not.toHaveBeenCalled(); + transfer.completeUpload.mockResolvedValueOnce({ state: 'assembling' }); + await controller.completeUpload(r, res, UP, { path: '/x', size: 30 } as any); + expect(res.status).toHaveBeenCalledWith(202); + await controller.uploadState(r, UP); + await controller.abortUpload(r, UP); + expect(transfer.download).toHaveBeenNthCalledWith(1, res, 't1', 'u1', '/a.txt', { + zip: false, + range: 'bytes=0-99', + }); + expect(transfer.download).toHaveBeenNthCalledWith(2, res, 't1', 'u1', '/Ordner', { + zip: true, + range: 'bytes=0-99', + }); + expect(transfer.downloadZip).toHaveBeenCalledWith(res, 't1', 'u1', '/Ordner', ['a', 'b']); + expect(transfer.startUpload).toHaveBeenCalledWith('t1', 'u1', { + path: '/x', + size: 5, + userId: 'fremd', + }); + expect(transfer.putChunk).toHaveBeenCalledWith('t1', 'u1', rawReq, UP, '2', { + path: '/x', + size: 30, + }); + expect(transfer.uploadState).toHaveBeenCalledWith('t1', 'u1', UP); + expect(transfer.abortUpload).toHaveBeenCalledWith('t1', 'u1', UP); + }); + + it('Transfer-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => { + const transfer = makeTransfer(); + const controller = new NextcloudFilesController( + makeSettings() as any, + makeAccount() as any, + makeFiles() as any, + transfer as any, + ); + const r = userReq('t1', undefined); + const res = {} as any; + await expect(controller.download(r, res, { path: '/a' } as any)).rejects.toBeInstanceOf( + ForbiddenException, + ); + await expect(controller.startUpload(r, { path: '/a', size: 1 } as any)).rejects.toBeInstanceOf( + ForbiddenException, + ); + await expect(controller.uploadState(r, 'x')).rejects.toBeInstanceOf(ForbiddenException); + expect(transfer.download).not.toHaveBeenCalled(); + expect(transfer.startUpload).not.toHaveBeenCalled(); + expect(transfer.uploadState).not.toHaveBeenCalled(); + }); + it('Datei-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => { const files = makeFiles(); const controller = new NextcloudFilesController( makeSettings() as any, makeAccount() as any, files as any, + makeTransfer() as any, ); const r = userReq('t1', undefined); await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException); @@ -217,7 +347,12 @@ describe('NextcloudFilesController — Delegation', () => { it('reicht Mandant und Benutzer aus dem Token weiter, nie aus dem Body', async () => { const settings = makeSettings(); const account = makeAccount(); - const controller = new NextcloudFilesController(settings as any, account as any, makeFiles() as any); + const controller = new NextcloudFilesController( + settings as any, + account as any, + makeFiles() as any, + makeTransfer() as any, + ); await controller.getStatus(userReq('t1', 'u1')); await controller.getSettings(req('t1')); await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any); @@ -243,7 +378,12 @@ describe('NextcloudFilesController — Delegation', () => { }); it('ohne Mandantenkontext: ForbiddenException', async () => { - const controller = new NextcloudFilesController(makeSettings() as any, makeAccount() as any, makeFiles() as any); + const controller = new NextcloudFilesController( + makeSettings() as any, + makeAccount() as any, + makeFiles() as any, + makeTransfer() as any, + ); await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf( ForbiddenException, ); @@ -255,7 +395,12 @@ describe('NextcloudFilesController — Delegation', () => { it('ohne Benutzer im Token: ForbiddenException, kein Aufruf des Kontodienstes', async () => { const account = makeAccount(); - const controller = new NextcloudFilesController(makeSettings() as any, account as any, makeFiles() as any); + const controller = new NextcloudFilesController( + makeSettings() as any, + account as any, + makeFiles() as any, + makeTransfer() as any, + ); await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf( ForbiddenException, ); diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts index b0f847f..e80d5e2 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts @@ -27,9 +27,20 @@ import { SaveNextcloudFilesSettingsDto, TestNextcloudFilesSettingsDto, } from './dto/nextcloud-files-settings.dto'; +import { + CompleteUploadDto, + DownloadQueryDto, + StartUploadDto, + UploadQueryDto, + ZipQueryDto, +} from './dto/nextcloud-files-transfer.dto'; import { NextcloudFilesService } from './nextcloud-files.service'; import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; +import { + NextcloudFilesTransferService, + type RawUploadRequest, +} from './nextcloud-files-transfer.service'; /** * `@UseModule('nextcloud-files')` auf Klassenebene — Aktivierung UND Freigabe. @@ -62,6 +73,10 @@ import { NextcloudFilesSettingsService } from './nextcloud-files-settings.servic * Tessera-Recht), sondern mit `{ code, message }` und einem anderen Status * (siehe `nextcloud-files.types.ts`). */ +function headerOf(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + @Controller('modules/nextcloud-files') @UseModule('nextcloud-files') export class NextcloudFilesController { @@ -69,6 +84,7 @@ export class NextcloudFilesController { private readonly settings: NextcloudFilesSettingsService, private readonly account: NextcloudFilesAccountService, private readonly files: NextcloudFilesService, + private readonly transfer: NextcloudFilesTransferService, ) {} private requireTenantId(req: AuthenticatedRequest): string { @@ -176,6 +192,59 @@ export class NextcloudFilesController { ); } + // --- Herunterladen und Hochladen (statisch; Benutzen) ---------------------------------- + + /** Datei (oder mit `zip=1` ein Ordner als ZIP) als Datenstrom; `Range` wird durchgereicht. */ + @Get('download') + async download( + @Req() req: AuthenticatedRequest, + @Res() res: Response, + @Query() query: DownloadQueryDto, + ): Promise { + await this.transfer.download( + res, + this.requireTenantId(req), + this.requireUserId(req), + query.path, + { + zip: query.zip === '1', + range: headerOf(req.headers.range), + }, + ); + } + + /** Nur die gewaehlten Eintraege eines Ordners als ZIP (`name` darf mehrfach vorkommen). */ + @Get('download/zip') + async downloadZip( + @Req() req: AuthenticatedRequest, + @Res() res: Response, + @Query() query: ZipQueryDto, + ): Promise { + await this.transfer.downloadZip( + res, + this.requireTenantId(req), + this.requireUserId(req), + query.dir, + query.name, + ); + } + + @Post('uploads') + async startUpload(@Req() req: AuthenticatedRequest, @Body() dto: StartUploadDto) { + return this.transfer.startUpload(this.requireTenantId(req), this.requireUserId(req), dto); + } + + /** Ganze Datei (bis ein Stueck gross) als roher Datenstrom — kein Interceptor, nichts gepuffert. */ + @Put('uploads/file') + async putSingle(@Req() req: AuthenticatedRequest, @Query() query: UploadQueryDto) { + return this.transfer.putSingle( + this.requireTenantId(req), + this.requireUserId(req), + req as unknown as RawUploadRequest, + query, + ); + } + // --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) --------------- @Get('connect/flow/:flowId') @@ -193,4 +262,50 @@ export class NextcloudFilesController { ) { return this.account.cancelFlow(this.requireTenantId(req), this.requireUserId(req), flowId); } + + @Put('uploads/:uploadId/chunks/:n') + async putChunk( + @Req() req: AuthenticatedRequest, + @Param('uploadId') uploadId: string, + @Param('n') n: string, + @Query() query: UploadQueryDto, + ) { + return this.transfer.putChunk( + this.requireTenantId(req), + this.requireUserId(req), + req as unknown as RawUploadRequest, + uploadId, + n, + query, + ); + } + + /** 200 `done`, oder 202 `assembling`, wenn der Zusammenbau laenger als 20 s dauert. */ + @Post('uploads/:uploadId/complete') + @HttpCode(200) + async completeUpload( + @Req() req: AuthenticatedRequest, + @Res({ passthrough: true }) res: Response, + @Param('uploadId') uploadId: string, + @Body() dto: CompleteUploadDto, + ) { + const out = await this.transfer.completeUpload( + this.requireTenantId(req), + this.requireUserId(req), + uploadId, + dto, + ); + if (out.state === 'assembling') res.status(202); + return out; + } + + @Get('uploads/:uploadId/state') + async uploadState(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) { + return this.transfer.uploadState(this.requireTenantId(req), this.requireUserId(req), uploadId); + } + + @Delete('uploads/:uploadId') + async abortUpload(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) { + return this.transfer.abortUpload(this.requireTenantId(req), this.requireUserId(req), uploadId); + } } diff --git a/apps/api/src/nextcloud-files/nextcloud-files.module.ts b/apps/api/src/nextcloud-files/nextcloud-files.module.ts index 273e28d..6a7c179 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.module.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.module.ts @@ -11,6 +11,7 @@ import { NEXTCLOUD_STATUS_FETCHER, NextcloudFilesSettingsService, } from './nextcloud-files-settings.service'; +import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service'; import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http'; import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard'; @@ -28,6 +29,7 @@ import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard'; NextcloudFilesSettingsService, NextcloudFilesAccountService, NextcloudFilesService, + NextcloudFilesTransferService, NextcloudLoginGuard, LoginFlowStore, NextcloudCallGate, diff --git a/apps/web/src/lib/nextcloud-files-api.test.ts b/apps/web/src/lib/nextcloud-files-api.test.ts index 94fa48c..e0922c4 100644 --- a/apps/web/src/lib/nextcloud-files-api.test.ts +++ b/apps/web/src/lib/nextcloud-files-api.test.ts @@ -2,10 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createFolder, deleteEntry, + downloadUrl, listFolder, moveEntry, NextcloudFilesRequestError, previewUrl, + zipUrl, } from './nextcloud-files-api'; const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; @@ -89,3 +91,24 @@ describe('nextcloud-files-api — Dateien', () => { expect(previewUrl('42', null)).toBe(`${API}/modules/nextcloud-files/preview?fileId=42`); }); }); + +describe('nextcloud-files-api — Herunterladen', () => { + it('downloadUrl codiert den Pfad in der Query; mit zip kommt zip=1 dazu', () => { + expect(downloadUrl('/Ärger & Co/a b.txt')).toBe( + `${API}/modules/nextcloud-files/download?path=%2F%C3%84rger%20%26%20Co%2Fa%20b.txt`, + ); + expect(downloadUrl('/Projekte', { zip: true })).toBe( + `${API}/modules/nextcloud-files/download?path=%2FProjekte&zip=1`, + ); + expect(downloadUrl('/Projekte', { zip: false })).toBe( + `${API}/modules/nextcloud-files/download?path=%2FProjekte`, + ); + }); + + it('zipUrl: Ordner in dir, jeder Name als eigener wiederholter name-Schluessel, einzeln codiert', () => { + expect(zipUrl('/Projekte', ['a.txt', 'Ärger & Co', '100%'])).toBe( + `${API}/modules/nextcloud-files/download/zip?dir=%2FProjekte&name=a.txt&name=%C3%84rger%20%26%20Co&name=100%25`, + ); + expect(zipUrl('/', ['x'])).toBe(`${API}/modules/nextcloud-files/download/zip?dir=%2F&name=x`); + }); +}); diff --git a/apps/web/src/lib/nextcloud-files-api.ts b/apps/web/src/lib/nextcloud-files-api.ts index 9f1a6cb..03afd89 100644 --- a/apps/web/src/lib/nextcloud-files-api.ts +++ b/apps/web/src/lib/nextcloud-files-api.ts @@ -65,7 +65,7 @@ export class NextcloudFilesRequestError extends Error { } } -async function failure(res: Response): Promise { +export async function requestFailure(res: Response): Promise { let message = `Request failed (${res.status})`; let code: string | null = null; let extra: Record = {}; @@ -91,6 +91,11 @@ async function failure(res: Response): Promise { return new NextcloudFilesRequestError(res.status, code, message, extra); } +/** Vollstaendige Adresse einer Route dieses Moduls (fuer Aufrufe, die nicht ueber `request` laufen). */ +export function nextcloudFilesUrl(path: string): string { + return `${API_URL}${BASE}${path}`; +} + async function request( path: string, init: { method?: string; json?: unknown } = {}, @@ -109,7 +114,7 @@ async function request( body, ...(method === 'GET' ? { cache: 'no-store' as const } : {}), }); - if (!res.ok) throw await failure(res); + if (!res.ok) throw await requestFailure(res); if (res.status === 204) return undefined as T; return (await res.json()) as T; } @@ -227,3 +232,25 @@ export function previewUrl(fileId: string, etag: string | null): string { const version = etag ? `&v=${encodeURIComponent(etag)}` : ''; return `${API_URL}${BASE}/preview?fileId=${encodeURIComponent(fileId)}${version}`; } + +// --- Herunterladen (Aufgabe 4) ------------------------------------------------------------------ + +/** + * Adresse zum Herunterladen einer Datei — oder mit `zip` eines Ordners als ZIP. Ein + * Link in `` genuegt (Tessera-Cookie); die API setzt `Content-Disposition: + * attachment`, der Browser speichert also nur und zeigt nichts an. Der Pfad geht nur + * in der Query. + */ +export function downloadUrl(path: string, opts: { zip?: boolean } = {}): string { + const zip = opts.zip ? '&zip=1' : ''; + return `${API_URL}${BASE}/download?path=${encodeURIComponent(path)}${zip}`; +} + +/** + * Adresse zum Herunterladen mehrerer Eintraege eines Ordners als ein ZIP. Jeder Name + * steht als eigener `name`-Parameter (wiederholter Schluessel), einzeln codiert. + */ +export function zipUrl(dir: string, names: readonly string[]): string { + const list = names.map((n) => `name=${encodeURIComponent(n)}`).join('&'); + return `${API_URL}${BASE}/download/zip?dir=${encodeURIComponent(dir)}&${list}`; +} diff --git a/apps/web/src/lib/nextcloud-files-upload.test.ts b/apps/web/src/lib/nextcloud-files-upload.test.ts new file mode 100644 index 0000000..3924e73 --- /dev/null +++ b/apps/web/src/lib/nextcloud-files-upload.test.ts @@ -0,0 +1,503 @@ +import { NEXTCLOUD_FILES_CHUNK_SIZE } from '@tessera/shared'; +import { describe, expect, it, vi } from 'vitest'; +import { NextcloudFilesRequestError } from './nextcloud-files-api'; +import { isRetryable, type UploadXhr, uploadFile } from './nextcloud-files-upload'; + +const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +const BASE = `${API}/modules/nextcloud-files`; +const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c'; +const PATH = '/Projekte/Ärger.bin'; +const PATH_ENC = '%2FProjekte%2F%C3%84rger.bin'; +const MTIME_MS = 1760000000123; + +function makeFile(size: number): File { + return new File([new Uint8Array(size)], 'Ärger.bin', { lastModified: MTIME_MS }); +} + +class FakeXhr implements UploadXhr { + method = ''; + url = ''; + headers: Record = {}; + body: Blob | null = null; + aborted = false; + withCredentials = false; + timeout = 0; + status = 0; + responseText = ''; + upload: UploadXhr['upload'] = { onprogress: null }; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + ontimeout: (() => void) | null = null; + constructor( + private readonly script: (x: FakeXhr, index: number) => void, + readonly index: number, + ) {} + open(method: string, url: string) { + this.method = method; + this.url = url; + } + setRequestHeader(name: string, value: string) { + this.headers[name.toLowerCase()] = value; + } + send(body: Blob) { + this.body = body; + // Nach dem Senden antwortet das Skript (asynchron wie ein echter Browser). + queueMicrotask(() => this.script(this, this.index)); + } + abort() { + this.aborted = true; + this.onabort?.(); + } + progress(loaded: number) { + this.upload.onprogress?.({ loaded, total: this.body?.size ?? 0 }); + } + respond(status: number, json?: unknown) { + this.status = status; + this.responseText = json === undefined ? '' : JSON.stringify(json); + this.onload?.(); + } + fail() { + this.onerror?.(); + } +} + +interface FetchCall { + method: string; + url: string; + body: unknown; +} + +function harness(opts: { + xhr?: (x: FakeXhr, index: number) => void; + fetch?: (call: FetchCall, n: number) => { status: number; json?: unknown }; +}) { + const xhrs: FakeXhr[] = []; + const fetches: FetchCall[] = []; + const sleeps: number[] = []; + const xhrFactory = () => { + const x = new FakeXhr(opts.xhr ?? ((self) => self.respond(201, {})), xhrs.length); + xhrs.push(x); + return x; + }; + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const call: FetchCall = { + method: init?.method ?? 'GET', + url: String(input), + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + fetches.push(call); + const out = (opts.fetch ?? defaultFetch)(call, fetches.length) ?? { status: 200, json: {} }; + return { + ok: out.status >= 200 && out.status < 300, + status: out.status, + json: async () => out.json, + } as Response; + }) as unknown as typeof fetch; + const sleep = async (ms: number) => { + sleeps.push(ms); + }; + return { xhrs, fetches, sleeps, xhrFactory, fetchImpl, sleep }; +} + +function defaultFetch(call: FetchCall): { status: number; json?: unknown } { + if (call.method === 'POST' && call.url === `${BASE}/uploads`) { + const size = (call.body as { size: number }).size; + return { + status: 201, + json: + size <= NEXTCLOUD_FILES_CHUNK_SIZE + ? { mode: 'single' } + : { mode: 'chunked', uploadId: UP, chunkSize: NEXTCLOUD_FILES_CHUNK_SIZE }, + }; + } + if (call.url.endsWith('/complete')) return { status: 200, json: { state: 'done' } }; + if (call.method === 'DELETE') return { status: 200, json: { aborted: true } }; + return { status: 200, json: {} }; +} + +const deletes = (h: { fetches: FetchCall[] }) => h.fetches.filter((f) => f.method === 'DELETE'); + +describe('uploadFile — kleine Datei', () => { + it('5 MB: start liefert single, genau ein PUT uploads/file mit der Datei als Koerper', async () => { + const h = harness({ + xhr: (x) => { + x.progress(2_500_000); + x.respond(200, { path: PATH, size: 5_000_000 }); + }, + }); + const file = makeFile(5_000_000); + const progress: Array<[number, number]> = []; + const out = await uploadFile(file, PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + onProgress: (f, b) => progress.push([f, b]), + }); + expect(out).toEqual({ path: PATH, size: 5_000_000 }); + expect(h.fetches).toHaveLength(1); + expect(h.fetches[0]).toMatchObject({ + method: 'POST', + url: `${BASE}/uploads`, + body: { path: PATH, size: 5_000_000 }, + }); + expect(h.xhrs).toHaveLength(1); + expect(h.xhrs[0].method).toBe('PUT'); + expect(h.xhrs[0].url).toBe( + `${BASE}/uploads/file?path=${PATH_ENC}&size=5000000&mtime=1760000000`, + ); + expect(h.xhrs[0].body).toBe(file); + expect(h.xhrs[0].withCredentials).toBe(true); + expect(h.xhrs[0].headers['content-type']).toBe('application/octet-stream'); + expect(progress[progress.length - 1]).toEqual([1, 5_000_000]); + expect(progress.map((p) => p[0])).toContain(0.5); + expect(deletes(h)).toHaveLength(0); + }); + + it('mit replaceEtag: im Start-Koerper und in der Query', async () => { + const h = harness({}); + await uploadFile(makeFile(10), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + replaceEtag: '"abc"', + }); + expect(h.fetches[0].body).toEqual({ path: PATH, size: 10, replaceEtag: '"abc"' }); + expect(h.xhrs[0].url).toContain('&replaceEtag=%22abc%22'); + }); + + it('ein Fehler bei der kleinen Datei wird nicht wiederholt und raeumt nichts auf (kein Upload-Ordner)', async () => { + const h = harness({ xhr: (x) => x.fail() }); + const err = await uploadFile(makeFile(10), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }).catch((e) => e); + expect(err).toBeInstanceOf(NextcloudFilesRequestError); + expect(err.code).toBe('network'); + expect(h.xhrs).toHaveLength(1); + expect(deletes(h)).toHaveLength(0); + }); +}); + +describe('uploadFile — grosse Datei in Stuecken', () => { + it('30 MB: vier Stuecke zu 8388608, 8388608, 8388608 und 4834176 Byte der Reihe nach, dann complete', async () => { + const h = harness({}); + const file = makeFile(30_000_000); + const out = await uploadFile(file, PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }); + expect(out.size).toBe(30_000_000); + expect(h.xhrs.map((x) => x.body?.size)).toEqual([8388608, 8388608, 8388608, 4834176]); + expect(h.xhrs.map((x) => x.url)).toEqual( + [1, 2, 3, 4].map((n) => `${BASE}/uploads/${UP}/chunks/${n}?path=${PATH_ENC}&size=30000000`), + ); + expect(h.xhrs.every((x) => x.method === 'PUT' && x.withCredentials)).toBe(true); + expect(h.xhrs.every((x) => (x.body?.size ?? 0) <= NEXTCLOUD_FILES_CHUNK_SIZE)).toBe(true); + expect(h.fetches.map((f) => `${f.method} ${f.url.replace(BASE, '')}`)).toEqual([ + 'POST /uploads', + `POST /uploads/${UP}/complete`, + ]); + expect(h.fetches[1].body).toEqual({ path: PATH, size: 30_000_000, mtime: 1760000000 }); + expect(h.sleeps).toEqual([]); + }); + + it('Fortschritt ist der Anteil an der GANZEN Datei und endet bei 1', async () => { + const h = harness({ + xhr: (x) => { + x.progress((x.body?.size ?? 0) / 2); + x.respond(201, {}); + }, + }); + const seen: number[] = []; + await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + onProgress: (f) => seen.push(f), + }); + expect(seen[seen.length - 1]).toBe(1); + for (let i = 1; i < seen.length; i++) expect(seen[i]).toBeGreaterThanOrEqual(seen[i - 1]); + expect(seen.every((f) => f >= 0 && f <= 1)).toBe(true); + }); + + it('Netzfehler bei Stueck 2: Wiederholung nach 1 s und 3 s, gelingt im dritten Versuch', async () => { + let failures = 0; + const h = harness({ + xhr: (x) => { + if (x.url.includes('/chunks/2?') && failures < 2) { + failures++; + x.fail(); + } else x.respond(201, {}); + }, + }); + await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }); + expect(h.sleeps).toEqual([1000, 3000]); + expect(h.xhrs.filter((x) => x.url.includes('/chunks/2?'))).toHaveLength(3); + expect(h.xhrs).toHaveLength(6); + expect(deletes(h)).toHaveLength(0); + }); + + it('nach drei Wiederholungen (1 s, 3 s, 9 s) gibt es auf: DELETE uploads/ und der Fehler', async () => { + const h = harness({ + xhr: (x) => (x.url.includes('/chunks/1?') ? x.fail() : x.respond(201, {})), + }); + const err = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }).catch((e) => e); + expect(err.code).toBe('network'); + expect(h.sleeps).toEqual([1000, 3000, 9000]); + expect(h.xhrs).toHaveLength(4); + expect(deletes(h)).toHaveLength(1); + expect(deletes(h)[0].url).toBe(`${BASE}/uploads/${UP}`); + }); + + it('504 und 500 ohne eigenen Code werden wiederholt', async () => { + let n = 0; + const h = harness({ + xhr: (x) => { + n++; + if (n === 1) x.respond(504, { code: 'nextcloudUnavailable', message: 'x' }); + else if (n === 2) x.respond(500, { message: 'Internal' }); + else x.respond(201, {}); + }, + }); + await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }); + expect(h.sleeps).toEqual([1000, 3000]); + }); + + it('503 nextcloudLocked wird NICHT wiederholt: DELETE einmal und Ablehnung mit dem Code', async () => { + const h = harness({ + xhr: (x) => + x.respond(503, { code: 'nextcloudLocked', message: 'gesperrt', retryAfterSeconds: 900 }), + }); + const err = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }).catch((e) => e); + expect(err).toBeInstanceOf(NextcloudFilesRequestError); + expect(err.code).toBe('nextcloudLocked'); + expect(err.extra).toEqual({ retryAfterSeconds: 900 }); + expect(h.xhrs).toHaveLength(1); + expect(h.sleeps).toEqual([]); + expect(deletes(h)).toHaveLength(1); + }); + + it.each([ + [413, 'chunkTooLarge'], + [400, 'invalidPath'], + [409, 'pathConflict'], + [507, 'quotaExceeded'], + [404, 'notFound'], + ])('Stueck mit %i %s: keine Wiederholung, DELETE uploads/, Ablehnung', async (status, code) => { + const h = harness({ xhr: (x) => x.respond(status, { code, message: 'm' }) }); + const err = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }).catch((e) => e); + expect(err.status).toBe(status); + expect(err.code).toBe(code); + expect(h.xhrs).toHaveLength(1); + expect(h.sleeps).toEqual([]); + expect(deletes(h)).toHaveLength(1); + }); + + it('409 nameTaken beim Start: Ablehnung mit existing, kein Stueck gesendet, nichts zum Aufraeumen', async () => { + const existing = { etag: '"e1"', size: 3, mtime: '2026-10-06T08:00:00.000Z' }; + const h = harness({ + fetch: () => ({ status: 409, json: { code: 'nameTaken', message: 'vergeben', existing } }), + }); + const err = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }).catch((e) => e); + expect(err).toBeInstanceOf(NextcloudFilesRequestError); + expect(err.code).toBe('nameTaken'); + expect(err.extra).toEqual({ existing }); + expect(h.xhrs).toHaveLength(0); + expect(h.fetches).toHaveLength(1); + }); + + it('Abbruch waehrend Stueck 3: XHR abgebrochen, DELETE einmal, Ablehnung mit Code aborted', async () => { + const controller = new AbortController(); + const h = harness({ + xhr: (x) => { + if (x.url.includes('/chunks/3?')) controller.abort(); + else x.respond(201, {}); + }, + }); + const err = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + signal: controller.signal, + }).catch((e) => e); + expect(err).toBeInstanceOf(NextcloudFilesRequestError); + expect(err.code).toBe('aborted'); + expect(h.xhrs).toHaveLength(3); + expect(h.xhrs[2].aborted).toBe(true); + expect(deletes(h)).toHaveLength(1); + expect(deletes(h)[0].url).toBe(`${BASE}/uploads/${UP}`); + expect(h.sleeps).toEqual([]); + }); + + it('Abbruch vor dem Start: nichts wird gesendet', async () => { + const controller = new AbortController(); + controller.abort(); + const h = harness({}); + const fetchImpl = vi.fn(async () => { + throw new DOMException('abgebrochen', 'AbortError'); + }) as unknown as typeof fetch; + const err = await uploadFile(makeFile(10), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl, + sleep: h.sleep, + signal: controller.signal, + }).catch((e) => e); + expect(err.code).toBe('aborted'); + expect(h.xhrs).toHaveLength(0); + }); +}); + +describe('uploadFile — Zusammenbau', () => { + const completeAssembling = (states: unknown[]) => { + let polls = 0; + return (call: FetchCall): { status: number; json?: unknown } => { + if (call.url.endsWith('/complete')) return { status: 202, json: { state: 'assembling' } }; + if (call.url.endsWith('/state')) + return { status: 200, json: states[Math.min(polls++, states.length - 1)] }; + return defaultFetch(call); + }; + }; + + it('202 assembling: fragt alle 2 s den Zustand ab, bis done', async () => { + const h = harness({ + fetch: completeAssembling([ + { state: 'assembling' }, + { state: 'assembling' }, + { state: 'done' }, + ]), + }); + const out = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }); + expect(out.size).toBe(30_000_000); + expect(h.sleeps).toEqual([2000, 2000, 2000]); + expect(h.fetches.filter((f) => f.url.endsWith('/state'))).toHaveLength(3); + expect(deletes(h)).toHaveLength(0); + }); + + it('Zustand failed: Ablehnung mit dem Code der API', async () => { + const h = harness({ + fetch: completeAssembling([ + { state: 'assembling' }, + { state: 'failed', code: 'quotaExceeded', message: 'Speicher voll' }, + ]), + }); + const err = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }).catch((e) => e); + expect(err).toBeInstanceOf(NextcloudFilesRequestError); + expect(err.code).toBe('quotaExceeded'); + expect(err.message).toBe('Speicher voll'); + expect(h.sleeps).toEqual([2000, 2000]); + }); + + it('der Zustand ist weg (404, etwa nach einem Neustart der API): Ablehnung', async () => { + const h = harness({ + fetch: (call) => + call.url.endsWith('/complete') + ? { status: 202, json: { state: 'assembling' } } + : call.url.endsWith('/state') + ? { status: 404, json: { code: 'notFound', message: 'weg' } } + : defaultFetch(call), + }); + const err = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }).catch((e) => e); + expect(err.code).toBe('notFound'); + }); + + it('complete mit 409 nameTaken (Ziel inzwischen vergeben): Ablehnung und DELETE', async () => { + const h = harness({ + fetch: (call) => + call.url.endsWith('/complete') + ? { + status: 409, + json: { + code: 'nameTaken', + message: 'vergeben', + existing: { etag: null, size: 1, mtime: null }, + }, + } + : defaultFetch(call), + }); + const err = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep: h.sleep, + }).catch((e) => e); + expect(err.code).toBe('nameTaken'); + expect(deletes(h)).toHaveLength(1); + }); + + it('Abbruch waehrend des Wartens auf den Zusammenbau: aborted und DELETE einmal', async () => { + const controller = new AbortController(); + const h = harness({ + fetch: completeAssembling([{ state: 'assembling' }]), + }); + let sleepCalls = 0; + const sleep = async () => { + if (++sleepCalls === 2) controller.abort(); + }; + const err = await uploadFile(makeFile(30_000_000), PATH, { + xhrFactory: h.xhrFactory, + fetchImpl: h.fetchImpl, + sleep, + signal: controller.signal, + }).catch((e) => e); + expect(err.code).toBe('aborted'); + expect(deletes(h)).toHaveLength(1); + }); +}); + +describe('isRetryable', () => { + const e = (status: number, code: string | null) => + new NextcloudFilesRequestError(status, code, 'x'); + it('wiederholt nur Netzfehler und 500/502/504 ohne eigene Bedeutung', () => { + expect(isRetryable(e(0, 'network'))).toBe(true); + expect(isRetryable(e(504, 'nextcloudUnavailable'))).toBe(true); + expect(isRetryable(e(502, 'nextcloudError'))).toBe(true); + expect(isRetryable(e(502, null))).toBe(true); + expect(isRetryable(e(500, null))).toBe(true); + expect(isRetryable(e(502, 'nextcloudRedirect'))).toBe(false); + expect(isRetryable(e(500, 'accountBroken'))).toBe(false); + expect(isRetryable(e(503, 'nextcloudLocked'))).toBe(false); + expect(isRetryable(e(503, 'nextcloudMaintenance'))).toBe(false); + expect(isRetryable(e(409, 'nameTaken'))).toBe(false); + expect(isRetryable(e(413, 'chunkTooLarge'))).toBe(false); + expect(isRetryable(e(0, 'aborted'))).toBe(false); + expect(isRetryable(new Error('x'))).toBe(false); + }); +}); diff --git a/apps/web/src/lib/nextcloud-files-upload.ts b/apps/web/src/lib/nextcloud-files-upload.ts new file mode 100644 index 0000000..1c7dcc1 --- /dev/null +++ b/apps/web/src/lib/nextcloud-files-upload.ts @@ -0,0 +1,328 @@ +import { NEXTCLOUD_FILES_CHUNK_SIZE } from '@tessera/shared'; +import { + NextcloudFilesRequestError, + nextcloudFilesUrl, + requestFailure, +} from './nextcloud-files-api'; + +/** + * Hochladen im Browser (quick-261008-mzu, L-06/D-J): die Datei geht NIE direkt an die + * Nextcloud, sondern ueber die Tessera-API, die sie als Datenstrom weiterreicht. + * + * - Kleine Dateien (bis ein Stueck gross): ein einziger Aufruf `PUT uploads/file`. + * - Grosse Dateien: in Stuecken zu 8 MiB (`NEXTCLOUD_FILES_CHUNK_SIZE`, `Blob.slice`). + * Der `/api-proxy` von Next.js schneidet Anfragekoerper ueber 10 MiB STILL ab — + * ein groesseres Stueck kaeme verstuemmelt an. Nextcloud verlangt mindestens 5 MiB + * je Stueck (ausser dem letzten); 8 MiB passen zu beidem. + * - Der Zusammenbau der Stuecke bei Nextcloud kann bei grossen Dateien Minuten dauern; + * die API antwortet dann 202 "assembling" und wir fragen den Zustand ab, statt eine + * Anfrage minutenlang offen zu halten (Zwischenstationen brechen nach 30 bis 60 s ab). + * + * Gesendet wird mit `XMLHttpRequest`, weil nur er den Fortschritt des HOCHLADENS meldet + * (`upload.onprogress`). Wiederholt wird nur, was sich lohnt: Netzfehler und 500/502/504 + * (nach 1, 3 und 9 s) — nie ein 4xx und nie `nextcloudLocked` (503), das die Sperre der + * Nextcloud nur verlaengern wuerde. Bei Abbruch oder endgueltigem Fehler wird der + * angefangene Upload bei Nextcloud aufgeraeumt (`DELETE uploads/`). + */ + +export const RETRY_DELAYS_MS = [1000, 3000, 9000] as const; +export const ASSEMBLY_POLL_MS = 2000; +/** Hoechstens 30 Minuten auf den Zusammenbau warten. */ +export const ASSEMBLY_MAX_POLLS = (30 * 60_000) / ASSEMBLY_POLL_MS; +/** Ein Stueck, das nach so langer Zeit nicht fertig ist, gilt als Netzfehler. */ +const XHR_TIMEOUT_MS = 180_000; + +/** Der Teil von `XMLHttpRequest`, den der Hochlader braucht (Tests setzen eine Attrappe ein). */ +export interface UploadXhr { + open(method: string, url: string, async?: boolean): void; + setRequestHeader(name: string, value: string): void; + send(body: Blob): void; + abort(): void; + withCredentials: boolean; + timeout: number; + status: number; + responseText: string; + upload: { onprogress: ((e: { loaded: number; total: number }) => void) | null }; + onload: (() => void) | null; + onerror: (() => void) | null; + onabort: (() => void) | null; + ontimeout: (() => void) | null; +} +export type UploadXhrFactory = () => UploadXhr; + +export interface UploadOptions { + /** `fraction` 0..1 des GESAMTEN Uploads, `bytes` bereits uebertragene Byte. */ + onProgress?: (fraction: number, bytes: number) => void; + signal?: AbortSignal; + /** Entity-Tag der Version, die ersetzt werden soll (nach Rueckfrage "Ersetzen"). */ + replaceEtag?: string; + xhrFactory?: UploadXhrFactory; + fetchImpl?: typeof fetch; + sleep?: (ms: number) => Promise; +} + +export interface UploadResult { + path: string; + size: number; +} + +type StartReply = { mode: 'single' } | { mode: 'chunked'; uploadId: string; chunkSize: number }; +type CompleteReply = { state: 'done' | 'assembling' }; +type StateReply = + | { state: 'done' } + | { state: 'assembling' } + | { state: 'failed'; code: string; message: string }; + +const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function abortedError(): NextcloudFilesRequestError { + return new NextcloudFilesRequestError(0, 'aborted', 'Der Upload wurde abgebrochen.'); +} + +function networkError(): NextcloudFilesRequestError { + return new NextcloudFilesRequestError(0, 'network', 'Die Verbindung wurde unterbrochen.'); +} + +/** Ob ein Fehler eine Wiederholung lohnt: Netz weg oder ein Serverfehler ohne eigene Bedeutung. */ +export function isRetryable(err: unknown): boolean { + if (!(err instanceof NextcloudFilesRequestError)) return false; + if (err.code === 'network') return true; + if (err.status === 500 || err.status === 502 || err.status === 504) { + return ( + err.code === null || err.code === 'nextcloudUnavailable' || err.code === 'nextcloudError' + ); + } + return false; +} + +interface XhrReply { + status: number; + text: string; +} + +function replyError(reply: XhrReply): Promise { + // `requestFailure` liest nur Status und JSON-Koerper der Antwort. + return requestFailure({ + status: reply.status, + json: async () => JSON.parse(reply.text), + } as unknown as Response); +} + +/** Eine Anfrage mit Fortschritt; lehnt mit `network` oder `aborted` ab, liefert sonst die Antwort. */ +function sendXhr( + factory: UploadXhrFactory, + method: string, + url: string, + body: Blob, + signal: AbortSignal | undefined, + onLoaded: (loaded: number) => void, +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortedError()); + return; + } + const xhr = factory(); + const abort = () => xhr.abort(); + const done = () => signal?.removeEventListener('abort', abort); + xhr.open(method, url, true); + xhr.withCredentials = true; + xhr.timeout = XHR_TIMEOUT_MS; + // Der Koerper ist roh; ohne diese Angabe wuerde der Browser den Typ der Datei senden + // (zum Beispiel application/json), und die API wuerde den Strom als JSON lesen wollen. + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.upload.onprogress = (e) => onLoaded(e.loaded); + xhr.onload = () => { + done(); + resolve({ status: xhr.status, text: xhr.responseText }); + }; + xhr.onerror = () => { + done(); + reject(networkError()); + }; + xhr.ontimeout = xhr.onerror; + xhr.onabort = () => { + done(); + reject(abortedError()); + }; + signal?.addEventListener('abort', abort, { once: true }); + xhr.send(body); + }); +} + +export async function uploadFile( + file: File | Blob, + targetPath: string, + options: UploadOptions = {}, +): Promise { + const { signal, replaceEtag } = options; + const xhrFactory: UploadXhrFactory = + options.xhrFactory ?? (() => new XMLHttpRequest() as unknown as UploadXhr); + const doFetch: typeof fetch = options.fetchImpl ?? ((input, init) => fetch(input, init)); + const sleep = options.sleep ?? defaultSleep; + const size = file.size; + const lastModified = (file as File).lastModified; + const mtime = + typeof lastModified === 'number' && Number.isFinite(lastModified) && lastModified > 0 + ? Math.floor(lastModified / 1000) + : undefined; + const report = (bytes: number) => { + const sent = Math.min(bytes, size); + options.onProgress?.(size === 0 ? 0 : sent / size, sent); + }; + + /** Wartet `ms`; ein Abbruch beendet das Warten sofort. */ + const pause = async (ms: number): Promise => { + if (signal?.aborted) throw abortedError(); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(abortedError()); + signal?.addEventListener('abort', onAbort, { once: true }); + }); + aborted.catch(() => {}); + try { + await Promise.race([sleep(ms), aborted]); + } finally { + if (onAbort) signal?.removeEventListener('abort', onAbort); + } + if (signal?.aborted) throw abortedError(); + }; + + /** Wiederholt nur Netzfehler und Serverfehler; Wartezeiten 1 s, 3 s, 9 s. */ + async function withRetry(attempt: () => Promise): Promise { + for (let i = 0; ; i++) { + try { + return await attempt(); + } catch (err) { + if (signal?.aborted) throw abortedError(); + if (i >= RETRY_DELAYS_MS.length || !isRetryable(err)) throw err; + await pause(RETRY_DELAYS_MS[i]); + } + } + } + + async function call( + method: string, + path: string, + json?: unknown, + ): Promise<{ status: number; data: T }> { + let res: Response; + try { + res = await doFetch(nextcloudFilesUrl(path), { + method, + credentials: 'include', + headers: json === undefined ? {} : { 'Content-Type': 'application/json' }, + body: json === undefined ? undefined : JSON.stringify(json), + signal, + }); + } catch { + throw signal?.aborted ? abortedError() : networkError(); + } + if (!res.ok) throw await requestFailure(res); + return { status: res.status, data: (await res.json()) as T }; + } + + /** Ein Stueck oder die ganze Datei senden und bei Erfolg die Byte melden. */ + async function put(path: string, body: Blob, base: number): Promise { + report(base); + const reply = await sendXhr( + xhrFactory, + 'PUT', + nextcloudFilesUrl(path), + body, + signal, + (loaded) => report(base + loaded), + ); + if (reply.status < 200 || reply.status >= 300) throw await replyError(reply); + report(base + body.size); + } + + /** Fragt alle 2 s den Zustand des Zusammenbaus ab (hoechstens 30 Minuten). */ + async function waitForAssembly(id: string): Promise { + for (let i = 0; i < ASSEMBLY_MAX_POLLS; i++) { + await pause(ASSEMBLY_POLL_MS); + let state: StateReply; + try { + state = (await call('GET', `/uploads/${q(id)}/state`)).data; + } catch (err) { + // Ein kurzer Netzausfall beim Nachfragen beendet den Zusammenbau nicht. + if (err instanceof NextcloudFilesRequestError && err.code === 'network') continue; + throw err; + } + if (state.state === 'done') return; + if (state.state === 'failed') { + throw new NextcloudFilesRequestError(409, state.code, state.message); + } + } + throw new NextcloudFilesRequestError( + 504, + 'nextcloudUnavailable', + 'Nextcloud braucht zu lange, um die Datei zusammenzusetzen.', + ); + } + + const q = encodeURIComponent; + let uploadId: string | null = null; + let cleaned = false; + const cleanup = async () => { + if (!uploadId || cleaned) return; + cleaned = true; + try { + // Bewusst ohne `signal`: nach einem Abbruch soll das Aufraeumen trotzdem laufen. + await doFetch(nextcloudFilesUrl(`/uploads/${q(uploadId)}`), { + method: 'DELETE', + credentials: 'include', + }); + } catch { + // Nextcloud verwirft Upload-Ordner nach 24 Stunden ohnehin. + } + }; + + try { + const { data: start } = await withRetry(() => + call('POST', '/uploads', { + path: targetPath, + size, + ...(replaceEtag ? { replaceEtag } : {}), + }), + ); + + if (start.mode === 'single') { + const params = [`path=${q(targetPath)}`, `size=${size}`]; + if (mtime !== undefined) params.push(`mtime=${mtime}`); + if (replaceEtag) params.push(`replaceEtag=${q(replaceEtag)}`); + // Ohne Wiederholung: ein Netzfehler NACH dem Schreiben wuerde sonst als "Name vergeben" enden. + await put(`/uploads/file?${params.join('&')}`, file, 0); + options.onProgress?.(1, size); + return { path: targetPath, size }; + } + + uploadId = start.uploadId; + const chunkSize = start.chunkSize || NEXTCLOUD_FILES_CHUNK_SIZE; + const chunks = Math.ceil(size / chunkSize); + for (let i = 0; i < chunks; i++) { + const from = i * chunkSize; + const piece = file.slice(from, Math.min(from + chunkSize, size)); + const path = `/uploads/${q(uploadId)}/chunks/${i + 1}?path=${q(targetPath)}&size=${size}`; + await withRetry(() => put(path, piece, from)); + } + + const { status, data } = await withRetry(() => + call('POST', `/uploads/${q(uploadId as string)}/complete`, { + path: targetPath, + size, + ...(mtime !== undefined ? { mtime } : {}), + ...(replaceEtag ? { replaceEtag } : {}), + }), + ); + if (status === 202 || data.state === 'assembling') { + await waitForAssembly(uploadId); + } + options.onProgress?.(1, size); + return { path: targetPath, size }; + } catch (err) { + await cleanup(); + if (signal?.aborted) throw abortedError(); + throw err; + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8ad8e7b..8591eda 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -383,3 +383,22 @@ export function findUnknownWelcomeMailPlaceholders(text: string): string[] { } return unknown; } + +// --- Nextcloud-Dateien: Uebertragung in Stuecken (quick-261008-mzu, L-06) ----------------------- + +/** + * Groesse eines Teilstuecks beim Hochladen grosser Dateien (8 MiB). Der Browser + * zerlegt die Datei in Stuecke dieser Groesse, die Tessera-API reicht jedes 1:1 + * als Nextcloud-Chunk weiter. Der Wert liegt UNTER den 10 MiB, die der + * `/api-proxy`-Rewrite von Next.js pro Anfragekoerper puffert (ein groesserer + * Koerper wird dort still abgeschnitten) und UEBER den 5 MiB, die Nextcloud als + * Mindestgroesse eines Stuecks (ausser dem letzten) verlangt. Die API lehnt + * Koerper, die groesser sind, mit 413 ab. + */ +export const NEXTCLOUD_FILES_CHUNK_SIZE = 8 * 1024 * 1024; + +/** + * Hoechstzahl an Stuecken je Datei (Grenze des Nextcloud-Protokolls: Namen + * 00001 bis 10000). Mit 8-MiB-Stuecken sind das rund 78 GiB je Datei. + */ +export const NEXTCLOUD_FILES_MAX_CHUNKS = 10000;