feat(nextcloud-files): Hoch- und Herunterladen als Datenstrom, große Dateien in Stücken, ZIP
- Hochladen ohne Pufferung: ganze Datei oder Chunked Upload v2 in 8-MiB-Stücken, Zusammenbau im Hintergrund mit abfragbarem Zustand - Überschreibschutz (If-None-Match / Entity-Tag-Prüfung), Download mit Range, Content-Disposition und Schutzkopfzeilen von Tessera selbst - Ordner- und Auswahl-ZIP; jeder Nextcloud-Fehler wird vor dem ersten Byte abgebildet, nie 401/403 - Browser-Hochlader mit Fortschritt, Wiederholung, Abbruch und Aufräumen Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+216
@@ -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 <text> — 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 <datei> <ausdruck> — 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"
|
||||
@@ -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();
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<string, string> = {}) {
|
||||
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=<JSON-Liste der Namen>, 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);
|
||||
});
|
||||
});
|
||||
@@ -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<NcResult> {
|
||||
const headers: Record<string, string> = { '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<NcResult> {
|
||||
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<NcResult> {
|
||||
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<NcResult> {
|
||||
const headers: Record<string, string> = {
|
||||
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<NcResult> {
|
||||
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<NcResult> {
|
||||
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<NcResult> {
|
||||
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=<JSON-Liste>`,
|
||||
* 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<NcResult> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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) => `<?xml version="1.0"?><d:multistatus ${NS}>
|
||||
<d:response><d:href>/nc/remote.php/dav/files/anna/Projekte/a.txt</d:href><d:propstat><d:prop>
|
||||
<d:getcontentlength>3</d:getcontentlength><d:getetag>"${etag}"</d:getetag>
|
||||
<d:getlastmodified>Tue, 06 Oct 2026 08:00:00 GMT</d:getlastmodified><d:resourcetype/></d:prop>
|
||||
<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>`;
|
||||
|
||||
type Reply = { status: number; text?: string; headers?: Record<string, string> };
|
||||
type Handler = (req: NcTransportRequest, n: number) => Reply | Promise<Reply>;
|
||||
|
||||
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<string, string> = {}) {
|
||||
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<string, string> = {};
|
||||
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<void>((resolve) => {
|
||||
if (res.writableFinished || res.destroyed) resolve();
|
||||
else {
|
||||
res.once('finish', () => resolve());
|
||||
res.once('close', () => resolve());
|
||||
}
|
||||
});
|
||||
|
||||
function parts(e: unknown): { status: number; body: Record<string, unknown> } {
|
||||
const ex = e as { getStatus(): number; getResponse(): Record<string, unknown> };
|
||||
return { status: ex.getStatus(), body: ex.getResponse() };
|
||||
}
|
||||
async function caught(p: Promise<unknown>): Promise<unknown> {
|
||||
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/<id>/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<Reply>((_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<Reply>((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<Reply>((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<Reply>((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<Reply>(() => {}));
|
||||
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 <Ordner>.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<unknown>;
|
||||
const ROUTES: Record<string, Route> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
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<string, AssemblyRecord>();
|
||||
|
||||
constructor(
|
||||
private readonly account: NextcloudFilesAccountService,
|
||||
private readonly gate: NextcloudCallGate,
|
||||
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
|
||||
) {}
|
||||
|
||||
// --- Hilfen ---------------------------------------------------------------------------------
|
||||
|
||||
private session(tenantId: string, userId: string): Promise<NcSession> {
|
||||
return this.account.getSession(tenantId, userId);
|
||||
}
|
||||
|
||||
private async fail(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
result: NcOutcome,
|
||||
existing?: ExistingView,
|
||||
): Promise<never> {
|
||||
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<ExistingView | undefined> {
|
||||
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<UploadStartView> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export class NextcloudFilesRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function failure(res: Response): Promise<NextcloudFilesRequestError> {
|
||||
export async function requestFailure(res: Response): Promise<NextcloudFilesRequestError> {
|
||||
let message = `Request failed (${res.status})`;
|
||||
let code: string | null = null;
|
||||
let extra: Record<string, unknown> = {};
|
||||
@@ -91,6 +91,11 @@ async function failure(res: Response): Promise<NextcloudFilesRequestError> {
|
||||
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<T>(
|
||||
path: string,
|
||||
init: { method?: string; json?: unknown } = {},
|
||||
@@ -109,7 +114,7 @@ async function request<T>(
|
||||
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 `<a href>` 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}`;
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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/<id> 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/<id>, 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);
|
||||
});
|
||||
});
|
||||
@@ -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/<id>`).
|
||||
*/
|
||||
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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<void>((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<NextcloudFilesRequestError> {
|
||||
// `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<XhrReply> {
|
||||
return new Promise<XhrReply>((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<UploadResult> {
|
||||
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<void> => {
|
||||
if (signal?.aborted) throw abortedError();
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_, 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<T>(attempt: () => Promise<T>): Promise<T> {
|
||||
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<T>(
|
||||
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<void> {
|
||||
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<void> {
|
||||
for (let i = 0; i < ASSEMBLY_MAX_POLLS; i++) {
|
||||
await pause(ASSEMBLY_POLL_MS);
|
||||
let state: StateReply;
|
||||
try {
|
||||
state = (await call<StateReply>('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<StartReply>('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<CompleteReply>('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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user