From 8bee65c3065e0001afc0934a571c834ae2090a3e Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 8 Oct 2026 18:14:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(nextcloud-files):=20Dateien=20auflisten,?= =?UTF-8?q?=20Vorschau,=20Ordner=20anlegen,=20umbenennen,=20verschieben=20?= =?UTF-8?q?und=20l=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PROPFIND-Auswertung (mehrere propstat, Kleinbuchstaben-Hex, Speicher -3 = unbegrenzt, nur Zeichenketten) - WebDAV-Schicht mit Zugangsschluessel und Aufrufsperre, Destination immer aus der Basis, Overwrite: F - mapNcFailure als einzige Fehlerabbildung (nie 401/403 an den Browser), sendUpstreamStream prueft vor dem ersten Byte - Dateidienst und Routen files, folders, move, preview im eigenen Konto; zweiter Benutzer bekommt notConnected - Web-API-Funktionen listFolder, createFolder, moveEntry, deleteEntry, previewUrl - e2e-files.sh gegen die Test-Nextcloud Co-Authored-By: Claude Opus 5.5 (1M context) --- .../e2e/e2e-files.sh | 127 +++++++++ .../e2e/e2e-lib.sh | 6 +- .../module-manage-handlers.spec.ts | 5 + .../dto/nextcloud-files-ops.dto.ts | 46 ++++ .../src/nextcloud-files/nextcloud-dav.spec.ts | 187 +++++++++++++ apps/api/src/nextcloud-files/nextcloud-dav.ts | 256 +++++++++++++++++ .../nextcloud-files.controller.spec.ts | 82 +++++- .../nextcloud-files.controller.ts | 50 ++++ .../nextcloud-files/nextcloud-files.module.ts | 2 + .../nextcloud-files.service.spec.ts | 250 +++++++++++++++++ .../nextcloud-files.service.ts | 142 ++++++++++ .../api/src/nextcloud-files/nextcloud-http.ts | 13 +- .../nextcloud-propfind.spec.ts | 220 +++++++++++++++ .../src/nextcloud-files/nextcloud-propfind.ts | 259 ++++++++++++++++++ .../nextcloud-upstream.spec.ts | 231 ++++++++++++++++ .../src/nextcloud-files/nextcloud-upstream.ts | 188 +++++++++++++ apps/web/src/lib/nextcloud-files-api.test.ts | 91 ++++++ apps/web/src/lib/nextcloud-files-api.ts | 64 +++++ 18 files changed, 2214 insertions(+), 5 deletions(-) create mode 100755 .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-files.sh create mode 100644 apps/api/src/nextcloud-files/dto/nextcloud-files-ops.dto.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-dav.spec.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-dav.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-files.service.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-propfind.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-upstream.spec.ts create mode 100644 apps/api/src/nextcloud-files/nextcloud-upstream.ts create mode 100644 apps/web/src/lib/nextcloud-files-api.test.ts diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-files.sh b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-files.sh new file mode 100755 index 0000000..c28f387 --- /dev/null +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-files.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Aufgabe 3: Datei-Schnittstelle gegen die Test-Nextcloud (Liste mit Speicher, Vorschau, Ordner, +# Verschieben/Umbenennen, Papierkorb, zweiter Benutzer). 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" +NC_DAV="http://localhost:18080/remote.php/dav" +ANNA="anna:User1-Pass-12345" +# 1x1-PNG als Base64 (Vorschau) +PNG_B64='iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' +UMLAUT_NAME='Ärger & Ölpreis 100%.txt' +UMLAUT_ENC='%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt' + +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" + +# GET — Pfad als Query-Parameter, sauber codiert; Status auf stdout. +qget() { curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$1" -G --data-urlencode "path=$3" "$FILES/$2"; } +qdel() { curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$1" -X DELETE -G --data-urlencode "path=$3" "$FILES/$2"; } +post() { e2e_status "$1" POST "$FILES/$2" "$3"; } +# JSON-Body: die Umlautdatei im Testordner ins Unterverzeichnis Ziel verschieben +move_umlaut_json() { + python3 -I -c 'import json,sys +print(json.dumps({"from": sys.argv[1] + "/" + sys.argv[2], "to": sys.argv[1] + "/Ziel/" + sys.argv[2]}))' "$F" "$UMLAUT_NAME" +} + +F="/Tessera-E2E-$(date +%s)" +FNAME="${F#/}" + +# --- Vorbereitung direkt in der Nextcloud (mit annas Passwort) -------------------------------- +nc_put() { curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X PUT --data-binary "$2" "$NC_DAV/files/anna$1"; } +code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X MKCOL "$NC_DAV/files/anna$F"); e2e_expect 201 "$code" "Fixture-Ordner" +code=$(nc_put "$F/$UMLAUT_ENC" "hallo"); e2e_expect 201 "$code" "Fixture Umlautdatei" +echo "$PNG_B64" | base64 -d > "$E2E_TMP/pixel.png" +code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X PUT -H 'Content-Type: image/png' \ + --data-binary "@$E2E_TMP/pixel.png" "$NC_DAV/files/anna$F/pixel.png"); e2e_expect 201 "$code" "Fixture PNG" + +# --- Liste der Wurzel mit Speicherangabe --------------------------------------------------------- +code=$(qget "$ADMIN" files "/"); e2e_expect 200 "$code" "Wurzel auflisten" +e2e_contains "$E2E_TMP/body.out" '"entries"' "Wurzel: entries" +e2e_contains "$E2E_TMP/body.out" '"quota"' "Wurzel: quota" +e2e_contains "$E2E_TMP/body.out" "\"name\":\"$FNAME\"" "Wurzel enthaelt den Testordner" + +# --- Liste des Testordners: Umlautname, PNG mit Vorschau ------------------------------------------- +code=$(qget "$ADMIN" files "$F"); e2e_expect 200 "$code" "Testordner auflisten" +e2e_contains "$E2E_TMP/body.out" "\"name\":\"$UMLAUT_NAME\"" "Umlautname exakt" +e2e_contains "$E2E_TMP/body.out" '"name":"pixel.png"' "PNG vorhanden" +PNG_ID=$(python3 -I -c 'import json,sys +for e in json.load(open(sys.argv[1]))["entries"]: + if e["name"]=="pixel.png": + assert e["hasPreview"] is True, "hasPreview" + print(e["fileId"])' "$E2E_TMP/body.out") +[ -n "$PNG_ID" ] || e2e_fail "PNG-Kennung fehlt" + +# --- Vorschau --------------------------------------------------------------------------------------- +code=$(curl -s -D "$E2E_TMP/prev.hdr" -o "$E2E_TMP/prev.png" -w '%{http_code}' -b "$ADMIN" "$FILES/preview?fileId=$PNG_ID&v=%22x%22") +e2e_expect 200 "$code" "Vorschau" +grep -qi '^content-type: image/png' "$E2E_TMP/prev.hdr" || e2e_fail "Vorschau: kein image/png" +grep -qi '^cache-control: private, max-age=86400' "$E2E_TMP/prev.hdr" || e2e_fail "Vorschau: cache-control" +grep -qi '^set-cookie' "$E2E_TMP/prev.hdr" && e2e_fail "Vorschau: set-cookie aus Nextcloud weitergereicht" || true +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" "$FILES/preview?fileId=abc") +e2e_expect 400 "$code" "Vorschau mit ungueltiger Kennung" +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" "$FILES/preview?fileId=99999999") +e2e_expect 404 "$code" "Vorschau ohne Datei" + +# --- Neuer Ordner ----------------------------------------------------------------------------------- +code=$(post "$ADMIN" folders "{\"path\":\"$F/Ziel\"}") +case "$code" in 200|201) ;; *) e2e_fail "Ordner anlegen -> $code" ;; esac +code=$(post "$ADMIN" folders "{\"path\":\"$F/Ziel\"}") +e2e_expect 409 "$code" "Ordner doppelt anlegen" +e2e_contains "$E2E_TMP/body.out" '"code":"nameTaken"' "Ordner doppelt: nameTaken" + +# --- Verschieben und Konflikt ------------------------------------------------------------------------ +code=$(post "$ADMIN" move "$(move_umlaut_json)") +e2e_expect 200 "$code" "Umlautdatei verschieben" +code=$(nc_put "$F/$UMLAUT_ENC" "zweite"); e2e_expect 201 "$code" "zweite Umlautdatei" +code=$(post "$ADMIN" move "$(move_umlaut_json)") +e2e_expect 409 "$code" "Verschieben auf vorhandenen Namen" +e2e_contains "$E2E_TMP/body.out" '"code":"nameTaken"' "Verschieben: nameTaken" +# die verschobene Datei im Ziel ist unveraendert (nicht ueberschrieben) +body=$(curl -s -u "$ANNA" "$NC_DAV/files/anna$F/Ziel/$UMLAUT_ENC") +[ "$body" = "hallo" ] || e2e_fail "Ziel wurde ueberschrieben: '$body'" + +# --- Umbenennen ---------------------------------------------------------------------------------------- +code=$(post "$ADMIN" move "{\"from\":\"$F/Ziel\",\"to\":\"$F/Ziel2\"}") +e2e_expect 200 "$code" "Ordner umbenennen" +code=$(qget "$ADMIN" files "$F/Ziel2"); e2e_expect 200 "$code" "umbenannter Ordner" +e2e_contains "$E2E_TMP/body.out" "\"name\":\"$UMLAUT_NAME\"" "Datei im umbenannten Ordner" +code=$(post "$ADMIN" move "{\"from\":\"$F\",\"to\":\"$F/Ziel2/innen\"}") +e2e_expect 400 "$code" "Ordner in sich selbst" +e2e_contains "$E2E_TMP/body.out" '"code":"moveIntoItself"' "moveIntoItself" + +# --- Ungueltiger Pfad ------------------------------------------------------------------------------------ +code=$(qget "$ADMIN" files "/../x"); e2e_expect 400 "$code" "Pfad mit .." +e2e_contains "$E2E_TMP/body.out" '"code":"invalidPath"' "invalidPath" +code=$(qget "$ADMIN" files "/gibt-es-nicht-$(date +%s)"); e2e_expect 404 "$code" "fehlender Ordner" +e2e_contains "$E2E_TMP/body.out" '"code":"notFound"' "notFound" + +# --- Zweiter Tessera-Benutzer: kein Zugriff auf annas Konto ------------------------------------------- +J2="$E2E_TMP/zweit.jar" +e2e_second_user "nc-e2e-zweit" "$J2" +e2e_grant_use "$ADMIN" "$J2" +code=$(e2e_status "$J2" GET "$FILES/status"); e2e_expect 200 "$code" "Status zweiter Benutzer" +e2e_contains "$E2E_TMP/body.out" '"account":null' "zweiter Benutzer: kein Konto" +code=$(qget "$J2" files "/"); e2e_expect 409 "$code" "Dateien zweiter Benutzer" +e2e_contains "$E2E_TMP/body.out" '"code":"notConnected"' "zweiter Benutzer: notConnected" +code=$(qget "$J2" files "$F"); e2e_expect 409 "$code" "fremder Ordner, zweiter Benutzer" +code=$(qdel "$J2" files "$F"); e2e_expect 409 "$code" "Loeschen, zweiter Benutzer" +code=$(qget "$ADMIN" files "$F"); e2e_expect 200 "$code" "Admin sieht weiter seine Dateien" + +# --- Loeschen in den Papierkorb --------------------------------------------------------------------------- +code=$(qdel "$ADMIN" files "$F"); e2e_expect 200 "$code" "Testordner loeschen" +code=$(qget "$ADMIN" files "$F"); e2e_expect 404 "$code" "Testordner ist weg" +curl -s -u "$ANNA" -X PROPFIND -H 'Depth: 1' "$NC_DAV/trashbin/anna/trash" > "$E2E_TMP/trash.xml" +grep -q "$FNAME" "$E2E_TMP/trash.xml" || e2e_fail "Papierkorb enthaelt $FNAME nicht" + +# --- Aufraeumen --------------------------------------------------------------------------------------------- +code=$(e2e_status "$ADMIN" DELETE "$FILES/connect"); e2e_expect 200 "$code" "Abmelden" +echo "e2e files ok" diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh index 3ff53f6..a2a17f3 100755 --- a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh @@ -34,7 +34,11 @@ e2e_status() { } # e2e_expect -e2e_expect() { [ "$1" = "$2" ] || e2e_fail "$3: erwartet $1, war $2"; } +e2e_expect() { + [ "$1" = "$2" ] && return 0 + [ -s "$E2E_TMP/body.out" ] && { echo "--- letzte Antwort:" >&2; head -c 600 "$E2E_TMP/body.out" >&2; echo >&2; } + e2e_fail "$3: erwartet $1, war $2" +} # e2e_contains e2e_contains() { grep -q -- "$2" "$1" || { echo "--- Antwort:" >&2; cat "$1" >&2; echo >&2; e2e_fail "$3: '$2' fehlt"; }; } diff --git a/apps/api/src/module-registry/module-manage-handlers.spec.ts b/apps/api/src/module-registry/module-manage-handlers.spec.ts index e7ccbbc..0156469 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -150,6 +150,11 @@ describe('Umgestellte Handler (Verwalten)', () => { 'pollFlow', 'cancelFlow', 'disconnect', + 'list', + 'remove', + 'createFolder', + 'move', + 'preview', ])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => { const fn = handler(NextcloudFilesController, name); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined(); diff --git a/apps/api/src/nextcloud-files/dto/nextcloud-files-ops.dto.ts b/apps/api/src/nextcloud-files/dto/nextcloud-files-ops.dto.ts new file mode 100644 index 0000000..20b9211 --- /dev/null +++ b/apps/api/src/nextcloud-files/dto/nextcloud-files-ops.dto.ts @@ -0,0 +1,46 @@ +import { IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; + +/** + * Eingaben der Dateirouten (quick-261008-mzu, D-H/D-I). Pfade kommen nur in + * Query oder Body, nie im URL-Pfad der Tessera-Route. Die Segmentpruefung + * (`..`, Steuerzeichen, Laenge) macht `parseUserPath` im Dienst; hier stehen + * nur grobe Grenzen. + */ + +export class PathQueryDto { + @IsOptional() + @IsString() + @MaxLength(4096) + path?: string; +} + +export class CreateFolderDto { + @IsString() + @IsNotEmpty() + @MaxLength(4096) + path!: string; +} + +export class MoveDto { + @IsString() + @IsNotEmpty() + @MaxLength(4096) + from!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(4096) + to!: string; +} + +export class PreviewQueryDto { + @IsString() + @Matches(/^\d{1,20}$/) + fileId!: string; + + /** Entity-Tag des Eintrags; macht die Adresse je Version eindeutig (Browser-Cache). */ + @IsOptional() + @IsString() + @MaxLength(200) + v?: string; +} diff --git a/apps/api/src/nextcloud-files/nextcloud-dav.spec.ts b/apps/api/src/nextcloud-files/nextcloud-dav.spec.ts new file mode 100644 index 0000000..0f921e6 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-dav.spec.ts @@ -0,0 +1,187 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import { destinationUrl, list, mkdir, move, preview, remove, stat } from './nextcloud-dav'; +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 NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"'; +const LISTING_XML = ` + /nc/remote.php/dav/files/anna/Projekte/ + 5 + 100 + HTTP/1.1 200 OK + /nc/remote.php/dav/files/anna/Projekte/a.txt + 3"e1" + HTTP/1.1 200 OK +`; + +function fake(status: number, text = '', headers: Record = {}) { + const calls: NcTransportRequest[] = []; + const transport: NextcloudTransport = async (req) => { + calls.push(req); + const res: NcTransportResponse = { + statusCode: status, + headers, + body: Readable.from(text === '' ? [] : [Buffer.from(text)]), + }; + return res; + }; + return { transport, calls }; +} + +describe('nextcloud-dav', () => { + it('list: PROPFIND Depth 1 auf den Ordner mit Schraegstrich am Ende und dem Anfragekoerper', async () => { + const { transport, calls } = fake(207, LISTING_XML); + const res = await list(transport, new NextcloudCallGate(), SESSION, ['Projekte']); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('PROPFIND'); + expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/Projekte/'); + expect(calls[0].headers).toEqual({ + 'user-agent': UA, + depth: '1', + 'content-type': 'application/xml', + authorization: 'Basic YW5uYTphcHAtcHctMTIz', + }); + expect(String(calls[0].body)).toContain(''); + expect(String(calls[0].body)).toContain(''); + expect(res.ok && res.listing?.entries.map((e) => e.name)).toEqual(['a.txt']); + expect(res.ok && res.listing?.quota).toEqual({ used: 5, available: 100 }); + }); + + it('list der Wurzel: .../files/anna/', async () => { + const { transport, calls } = fake(207, LISTING_XML); + await list(transport, new NextcloudCallGate(), SESSION, []); + expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/'); + }); + + it('list: 404 kommt als Antwort mit Status und ohne Liste zurueck', async () => { + const { transport } = fake(404); + const res = await list(transport, new NextcloudCallGate(), SESSION, ['Weg']); + expect(res).toEqual({ ok: true, status: 404, listing: null }); + }); + + it('list: unlesbares XML ist eine ungueltige Antwort, keine Ausnahme', async () => { + const { transport } = fake(207, 'das ist kein xml <<<'); + const res = await list(transport, new NextcloudCallGate(), SESSION, ['Projekte']); + expect(res).toEqual({ ok: false, kind: 'invalid-response' }); + }); + + it('stat: PROPFIND Depth 0 und der Eintrag selbst', async () => { + const xml = ` + /nc/remote.php/dav/files/anna/Projekte/a.txt + 3"e1" + Mon, 06 Oct 2026 08:00:00 GMT + HTTP/1.1 200 OK`; + const { transport, calls } = fake(207, xml); + const res = await stat(transport, new NextcloudCallGate(), SESSION, ['Projekte', 'a.txt']); + expect(calls[0].method).toBe('PROPFIND'); + expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a.txt'); + expect(calls[0].headers.depth).toBe('0'); + expect(res.ok && res.entry).toMatchObject({ + etag: '"e1"', + size: 3, + mtime: '2026-10-06T08:00:00.000Z', + }); + }); + + it('stat: 404 -> kein Eintrag', async () => { + const { transport } = fake(404); + expect(await stat(transport, new NextcloudCallGate(), SESSION, ['x'])).toEqual({ + ok: true, + status: 404, + entry: null, + }); + }); + + it('mkdir: MKCOL', async () => { + const { transport, calls } = fake(201); + await mkdir(transport, new NextcloudCallGate(), SESSION, ['Projekte', 'Neu']); + expect(calls[0].method).toBe('MKCOL'); + expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/Projekte/Neu'); + expect(calls[0].headers).toEqual({ + 'user-agent': UA, + authorization: 'Basic YW5uYTphcHAtcHctMTIz', + }); + }); + + it('move: MOVE mit absolutem Destination aus der Basis und Overwrite: F', async () => { + const { transport, calls } = fake(201); + await move( + transport, + new NextcloudCallGate(), + SESSION, + ['Alt', 'a b.txt'], + ['Ziel', 'Ärger & Ölpreis 100%.txt'], + ); + expect(calls[0].method).toBe('MOVE'); + expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/Alt/a%20b.txt'); + expect(calls[0].headers.destination).toBe( + 'https://cloud.example/nc/remote.php/dav/files/anna/Ziel/%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt', + ); + expect(calls[0].headers.overwrite).toBe('F'); + }); + + it('move: nur auf ausdruecklichen Wunsch Overwrite: T', async () => { + const { transport, calls } = fake(204); + await move(transport, new NextcloudCallGate(), SESSION, ['a'], ['b'], { overwrite: true }); + expect(calls[0].headers.overwrite).toBe('T'); + }); + + it('remove: DELETE', async () => { + const { transport, calls } = fake(204); + await remove(transport, new NextcloudCallGate(), SESSION, ['Projekte', 'alt.txt']); + expect(calls[0].method).toBe('DELETE'); + expect(calls[0].url).toBe( + 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/alt.txt', + ); + }); + + it('preview: GET core/preview mit fileId und 256x256', async () => { + const { transport, calls } = fake(200, 'png', { 'content-type': 'image/png' }); + await preview(transport, new NextcloudCallGate(), SESSION, '42'); + expect(calls[0].method).toBe('GET'); + expect(calls[0].url).toBe( + 'https://cloud.example/nc/index.php/core/preview?fileId=42&x=256&y=256&a=1&forceIcon=0', + ); + }); + + it('destinationUrl baut aus der Basis der Sitzung', () => { + expect(destinationUrl(SESSION, ['a', 'b c'])).toBe( + 'https://cloud.example/nc/remote.php/dav/files/anna/a/b%20c', + ); + }); + + it('ein Pfadsegment ".." wirft, bevor ein Aufruf rausgeht', async () => { + const { transport, calls } = fake(204); + await expect( + remove(transport, new NextcloudCallGate(), SESSION, ['..', 'x']), + ).rejects.toBeTruthy(); + expect(calls).toHaveLength(0); + }); + + it('jeder Aufruf traegt den Zugangsschluessel: ein 401 macht k1 tot', async () => { + const gate = new NextcloudCallGate(); + const { transport } = fake(401); + const res = await remove(transport, gate, SESSION, ['x']); + expect(res).toMatchObject({ ok: false, kind: 'credential-dead' }); + expect(gate.isDead('k1')).toBe(true); + }); + + it('ein toter Schluessel ruft Nextcloud gar nicht erst an', async () => { + const gate = new NextcloudCallGate(); + gate.markDead('k1'); + const { transport, calls } = fake(200); + const res = await mkdir(transport, gate, SESSION, ['x']); + expect(res).toMatchObject({ ok: false, kind: 'credential-dead' }); + expect(calls).toHaveLength(0); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-dav.ts b/apps/api/src/nextcloud-files/nextcloud-dav.ts new file mode 100644 index 0000000..0e11156 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-dav.ts @@ -0,0 +1,256 @@ +import type { Readable } from 'node:stream'; +import type { NextcloudCallGate } from './nextcloud-call-gate'; +import type { NcSession } from './nextcloud-files.types'; +import { + buildNcUrl, + discardBody, + type NcFailure, + type NcResult, + type NextcloudTransport, + ncRequest, + readCappedText, +} from './nextcloud-http'; +import { + type NcEntry, + type ParsedListing, + PROPFIND_BODY, + parsePropfind, + parsePropfindSelf, +} from './nextcloud-propfind'; + +/** + * WebDAV-Schicht des Moduls "Nextcloud-Dateien" (quick-261008-mzu, D-C/D-I) auf + * Basis von `ncRequest`: jeder Aufruf geht durch die Aufrufsperre und traegt den + * Zugangsschluessel (`credentialKey`) der Sitzung. Frei von Nest, damit alle + * Spezifikationen mit einer Attrappe als Transport laufen. + * + * `davRequest` ist die allgemeine Form (spaeter z. B. SEARCH und PROPPATCH fuer + * Suche und Favoriten); die benannten Funktionen darunter sind die Etappe-1- + * Aktionen. Die Benutzerkennung (`ncUserId`, nie der Anmeldename) wird hier + * jedem Pfad vorangestellt; alle Pfadsegmente laufen in `ncRequest` durch + * `validateSegment` und werden einzeln codiert. Das `Destination` einer + * Verschiebung wird IMMER aus der Basis der Sitzung gebaut, nie aus einer + * Eingabe — so kann kein Ziel auf einen fremden Host zeigen. + */ + +export type DavPrefix = '/remote.php/dav/files/' | '/remote.php/dav/uploads/'; + +/** Kurze Aufrufe (PROPFIND, MKCOL, MOVE, DELETE): 15 s auf die Kopfzeilen, 15 s Leerlauf. */ +export const DAV_SMALL_TIMEOUT_MS = 15_000; +/** Obergrenze fuer die PROPFIND-Antwort (D-C). */ +export const PROPFIND_MAX_BYTES = 32 * 1024 * 1024; + +export interface DavExtra { + headers?: Record; + body?: Readable | string | Buffer | null; + query?: Record; + trailingSlash?: boolean; + headersTimeoutMs?: number; + bodyTimeoutMs?: number; + signal?: AbortSignal; +} + +/** Allgemeiner WebDAV-Aufruf im Benutzerbereich; `segments` sind OHNE die Benutzerkennung. */ +export function davRequest( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + method: string, + prefix: DavPrefix, + segments: readonly string[], + extra: DavExtra = {}, +): Promise { + return ncRequest(transport, gate, { + baseUrl: session.baseUrl, + prefix, + segments: [session.ncUserId, ...segments], + method, + authorization: session.authorization, + credentialKey: session.credentialKey, + headers: extra.headers, + body: extra.body, + query: extra.query, + trailingSlash: extra.trailingSlash, + headersTimeoutMs: extra.headersTimeoutMs ?? DAV_SMALL_TIMEOUT_MS, + bodyTimeoutMs: extra.bodyTimeoutMs ?? DAV_SMALL_TIMEOUT_MS, + signal: extra.signal, + }); +} + +/** Absolute Zieladresse im Dateibereich des Benutzers, gebaut aus der Basis der Sitzung. */ +export function destinationUrl(session: NcSession, segments: readonly string[]): string { + return buildNcUrl(session.baseUrl, '/remote.php/dav/files/', [session.ncUserId, ...segments]); +} + +function isSuccess(status: number): boolean { + return status >= 200 && status < 300; +} + +/** Aufrufe, deren Antwortkoerper uns nicht interessiert (verwirft ihn). */ +async function bodyless(result: NcResult): Promise { + if (result.ok) discardBody(result.body); + return result; +} + +export type DavListResult = { ok: true; status: number; listing: ParsedListing | null } | NcFailure; + +/** Ordnerinhalt (PROPFIND Depth 1) samt Speicherangaben des Ordners. */ +export async function list( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + segments: readonly string[], + signal?: AbortSignal, +): Promise { + const res = await davRequest( + transport, + gate, + session, + 'PROPFIND', + '/remote.php/dav/files/', + segments, + { + headers: { depth: '1', 'content-type': 'application/xml' }, + body: PROPFIND_BODY, + trailingSlash: true, + signal, + }, + ); + if (!res.ok) return res; + if (!isSuccess(res.status)) { + discardBody(res.body); + return { ok: true, status: res.status, listing: null }; + } + const text = await readCappedText(res.body, PROPFIND_MAX_BYTES); + if (!text.ok) return { ok: false, kind: text.kind, detail: text.detail }; + try { + return { + ok: true, + status: res.status, + listing: parsePropfind(text.text, { + baseUrl: session.baseUrl, + ncUserId: session.ncUserId, + requested: segments, + }), + }; + } catch { + return { ok: false, kind: 'invalid-response' }; + } +} + +export type DavStatResult = { ok: true; status: number; entry: NcEntry | null } | NcFailure; + +/** Ein einzelner Eintrag (PROPFIND Depth 0); 404 -> `{ ok: true, status: 404, entry: null }`. */ +export async function stat( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + segments: readonly string[], + signal?: AbortSignal, +): Promise { + const res = await davRequest( + transport, + gate, + session, + 'PROPFIND', + '/remote.php/dav/files/', + segments, + { + headers: { depth: '0', 'content-type': 'application/xml' }, + body: PROPFIND_BODY, + signal, + }, + ); + if (!res.ok) return res; + if (!isSuccess(res.status)) { + discardBody(res.body); + return { ok: true, status: res.status, entry: null }; + } + const text = await readCappedText(res.body, PROPFIND_MAX_BYTES); + if (!text.ok) return { ok: false, kind: text.kind, detail: text.detail }; + try { + return { + ok: true, + status: res.status, + entry: parsePropfindSelf(text.text, { + baseUrl: session.baseUrl, + ncUserId: session.ncUserId, + requested: segments, + }), + }; + } catch { + return { ok: false, kind: 'invalid-response' }; + } +} + +/** Neuen Ordner anlegen (MKCOL): 201; 405 = Name vergeben; 409 = Elternordner fehlt. */ +export async function mkdir( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + segments: readonly string[], +): Promise { + return bodyless( + await davRequest(transport, gate, session, 'MKCOL', '/remote.php/dav/files/', segments), + ); +} + +/** + * Verschieben oder Umbenennen (MOVE). Ohne `Overwrite: F` wuerde Nextcloud ein + * vorhandenes Ziel still ueberschreiben (gemessen) — Tessera sendet es immer, + * ausser der Aufrufer verlangt ausdruecklich ein geprueftes Ersetzen. + */ +export async function move( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + from: readonly string[], + to: readonly string[], + opts: { overwrite?: boolean } = {}, +): Promise { + return bodyless( + await davRequest(transport, gate, session, 'MOVE', '/remote.php/dav/files/', from, { + headers: { + destination: destinationUrl(session, to), + overwrite: opts.overwrite ? 'T' : 'F', + }, + }), + ); +} + +/** Loeschen (DELETE): die Nextcloud legt den Eintrag in ihren Papierkorb. */ +export async function remove( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + segments: readonly string[], +): Promise { + return bodyless( + await davRequest(transport, gate, session, 'DELETE', '/remote.php/dav/files/', segments), + ); +} + +/** + * Vorschaubild ueber die Datei-Kennung (kein Pfad noetig). Nextcloud rundet auf + * Groessenstufen; 256 x 256 fest, der Browser skaliert per CSS. Der Antwortkoerper + * bleibt offen und gehoert dem Aufrufer. + */ +export function preview( + transport: NextcloudTransport, + gate: NextcloudCallGate, + session: NcSession, + fileId: string, + signal?: AbortSignal, +): Promise { + return ncRequest(transport, gate, { + baseUrl: session.baseUrl, + prefix: '/index.php/core/preview', + query: { fileId, x: '256', y: '256', a: '1', forceIcon: '0' }, + method: 'GET', + authorization: session.authorization, + credentialKey: session.credentialKey, + headersTimeoutMs: DAV_SMALL_TIMEOUT_MS, + bodyTimeoutMs: 30_000, + signal, + }); +} diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts index e20a05e..7fc1749 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts @@ -76,6 +76,11 @@ describe('NextcloudFilesController — Metadaten', () => { expect(route('connectPassword')).toEqual([1, 'connect/password']); expect(route('startFlow')).toEqual([1, 'connect/flow']); expect(route('disconnect')).toEqual([3, 'connect']); + expect(route('list')).toEqual([0, 'files']); + expect(route('remove')).toEqual([3, 'files']); + expect(route('createFolder')).toEqual([1, 'folders']); + expect(route('move')).toEqual([1, 'move']); + expect(route('preview')).toEqual([0, 'preview']); expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']); expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']); }); @@ -87,6 +92,28 @@ describe('NextcloudFilesController — Metadaten', () => { } }); + it('die Datei-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => { + for (const name of ['list', 'remove', 'createFolder', 'move', 'preview']) { + expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined(); + expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined(); + } + }); + + it('die Datei-Handler stehen vor dem Parameterblock', () => { + const names = routeHandlers(); + const firstParam = names.findIndex((n) => + String(Reflect.getMetadata('path', proto[n])).includes(':'), + ); + for (const name of ['list', 'remove', 'createFolder', 'move', 'preview']) { + expect(names.indexOf(name), name).toBeLessThan(firstParam); + } + }); + + it('verschieben antwortet 200, Ordner anlegen bleibt bei 201', () => { + expect(Reflect.getMetadata('__httpCode__', proto.move)).toBe(200); + expect(Reflect.getMetadata('__httpCode__', proto.createFolder)).toBeUndefined(); + }); + it('Passwort- und Browser-Anmeldung antworten 200, nicht 201', () => { expect(Reflect.getMetadata('__httpCode__', proto.connectPassword)).toBe(200); expect(Reflect.getMetadata('__httpCode__', proto.startFlow)).toBe(200); @@ -138,10 +165,59 @@ describe('NextcloudFilesController — Delegation', () => { }; } + function makeFiles() { + return { + list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })), + remove: vi.fn(async (..._a: unknown[]) => ({ deleted: true })), + createFolder: vi.fn(async (..._a: unknown[]) => ({ path: '/x' })), + move: vi.fn(async (..._a: unknown[]) => ({ from: '/a', to: '/b' })), + preview: vi.fn(async (..._a: unknown[]) => undefined), + }; + } + + it('Datei-Handler: Mandant und Benutzer aus dem Token, Pfade aus Query und Body', async () => { + const files = makeFiles(); + const controller = new NextcloudFilesController( + makeSettings() as any, + makeAccount() as any, + files as any, + ); + const r = userReq('t1', 'u1'); + const res = { end: vi.fn() } as any; + await controller.list(r, { path: '/Projekte' } as any); + await controller.remove(r, { path: '/Projekte/alt.txt' } as any); + await controller.remove(r, {} as any); + await controller.createFolder(r, { path: '/Neu', userId: 'fremd' } as any); + await controller.move(r, { from: '/a', to: '/b' } as any); + await controller.preview(r, res, { fileId: '42', v: '"e"' } as any); + expect(files.list).toHaveBeenCalledWith('t1', 'u1', '/Projekte'); + expect(files.remove).toHaveBeenNthCalledWith(1, 't1', 'u1', '/Projekte/alt.txt'); + expect(files.remove).toHaveBeenNthCalledWith(2, 't1', 'u1', ''); + expect(files.createFolder).toHaveBeenCalledWith('t1', 'u1', '/Neu'); + expect(files.move).toHaveBeenCalledWith('t1', 'u1', '/a', '/b'); + expect(files.preview).toHaveBeenCalledWith(res, 't1', 'u1', '42', '"e"'); + }); + + 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, + ); + const r = userReq('t1', undefined); + await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException); + await expect(controller.move(r, { from: '/a', to: '/b' } as any)).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(files.list).not.toHaveBeenCalled(); + expect(files.move).not.toHaveBeenCalled(); + }); + 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); + const controller = new NextcloudFilesController(settings as any, account as any, makeFiles() as any); await controller.getStatus(userReq('t1', 'u1')); await controller.getSettings(req('t1')); await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any); @@ -167,7 +243,7 @@ describe('NextcloudFilesController — Delegation', () => { }); it('ohne Mandantenkontext: ForbiddenException', async () => { - const controller = new NextcloudFilesController(makeSettings() as any, makeAccount() as any); + const controller = new NextcloudFilesController(makeSettings() as any, makeAccount() as any, makeFiles() as any); await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf( ForbiddenException, ); @@ -179,7 +255,7 @@ 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); + const controller = new NextcloudFilesController(makeSettings() as any, account as any, makeFiles() as any); await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf( ForbiddenException, ); diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts index f549b3b..b0f847f 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts @@ -9,15 +9,25 @@ import { ParseUUIDPipe, Post, Put, + Query, Req, + Res, } from '@nestjs/common'; +import type { Response } from 'express'; import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { ModuleManage, UseModule } from '../module-registry/module.guard'; import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto'; +import { + CreateFolderDto, + MoveDto, + PathQueryDto, + PreviewQueryDto, +} from './dto/nextcloud-files-ops.dto'; import { SaveNextcloudFilesSettingsDto, TestNextcloudFilesSettingsDto, } from './dto/nextcloud-files-settings.dto'; +import { NextcloudFilesService } from './nextcloud-files.service'; import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; @@ -58,6 +68,7 @@ export class NextcloudFilesController { constructor( private readonly settings: NextcloudFilesSettingsService, private readonly account: NextcloudFilesAccountService, + private readonly files: NextcloudFilesService, ) {} private requireTenantId(req: AuthenticatedRequest): string { @@ -126,6 +137,45 @@ export class NextcloudFilesController { return this.account.disconnect(this.requireTenantId(req), this.requireUserId(req)); } + // --- Dateien (Benutzen; jeder arbeitet nur im eigenen Konto) -------------------------- + + @Get('files') + async list(@Req() req: AuthenticatedRequest, @Query() query: PathQueryDto) { + return this.files.list(this.requireTenantId(req), this.requireUserId(req), query.path); + } + + @Delete('files') + async remove(@Req() req: AuthenticatedRequest, @Query() query: PathQueryDto) { + return this.files.remove(this.requireTenantId(req), this.requireUserId(req), query.path ?? ''); + } + + @Post('folders') + async createFolder(@Req() req: AuthenticatedRequest, @Body() dto: CreateFolderDto) { + return this.files.createFolder(this.requireTenantId(req), this.requireUserId(req), dto.path); + } + + @Post('move') + @HttpCode(200) + async move(@Req() req: AuthenticatedRequest, @Body() dto: MoveDto) { + return this.files.move(this.requireTenantId(req), this.requireUserId(req), dto.from, dto.to); + } + + /** `` laedt mit dem Tessera-Cookie; die Antwort ist ein Bilddatenstrom (kein JSON). */ + @Get('preview') + async preview( + @Req() req: AuthenticatedRequest, + @Res() res: Response, + @Query() query: PreviewQueryDto, + ) { + await this.files.preview( + res, + this.requireTenantId(req), + this.requireUserId(req), + query.fileId, + query.v, + ); + } + // --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) --------------- @Get('connect/flow/:flowId') diff --git a/apps/api/src/nextcloud-files/nextcloud-files.module.ts b/apps/api/src/nextcloud-files/nextcloud-files.module.ts index 3eb887c..273e28d 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.module.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.module.ts @@ -4,6 +4,7 @@ import { ModuleRegistryService } from '../module-registry/module-registry.servic import { NextcloudCallGate } from './nextcloud-call-gate'; import { NextcloudFilesController } from './nextcloud-files.controller'; import { seedNextcloudFilesModule } from './nextcloud-files.seed'; +import { NextcloudFilesService } from './nextcloud-files.service'; import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; import { defaultStatusFetcher, @@ -26,6 +27,7 @@ import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard'; providers: [ NextcloudFilesSettingsService, NextcloudFilesAccountService, + NextcloudFilesService, NextcloudLoginGuard, LoginFlowStore, NextcloudCallGate, diff --git a/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts new file mode 100644 index 0000000..0e08f73 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts @@ -0,0 +1,250 @@ +import { Readable, Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import { NextcloudFilesService } from './nextcloud-files.service'; +import { type NcSession, ncErrorDefault } 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 NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"'; +const LIST_XML = ` + /nc/remote.php/dav/files/anna/ + 10 + -3 + HTTP/1.1 200 OK + /nc/remote.php/dav/files/anna/%c3%84rger.txt + 49 + HTTP/1.1 200 OK +`; + +type Reply = { status: number; text?: string; headers?: Record }; + +function setup(reply: Reply = { status: 207, text: LIST_XML }) { + const calls: NcTransportRequest[] = []; + const transport: NextcloudTransport = async (req) => { + calls.push(req); + const res: NcTransportResponse = { + statusCode: reply.status, + headers: reply.headers ?? {}, + body: Readable.from(reply.text ? [Buffer.from(reply.text)] : []), + }; + return res; + }; + const account = { + getSession: vi.fn(async (..._a: unknown[]) => SESSION), + markExpired: vi.fn(async (..._a: unknown[]) => {}), + }; + const service = new NextcloudFilesService(account as never, new NextcloudCallGate(), transport); + return { service, account, calls }; +} + +class FakeRes extends Writable { + code = 200; + headers: Record = {}; + written: Buffer[] = []; + status(c: number) { + this.code = c; + return this; + } + setHeader(n: string, v: string) { + this.headers[n.toLowerCase()] = String(v); + return this; + } + _write(chunk: Buffer, _e: BufferEncoding, cb: () => void) { + this.written.push(Buffer.from(chunk)); + cb(); + } +} +const done = (res: Writable) => + new Promise((resolve) => { + if (res.writableFinished || res.destroyed) resolve(); + else res.once('finish', () => resolve()); + }); + +const codeOf = (e: unknown) => (e as { response: { code: string } }).response.code; + +describe('NextcloudFilesService — list', () => { + it('holt die Sitzung des Aufrufers und liefert Pfad, Eintraege, Speicher', async () => { + const { service, account, calls } = setup(); + const out = await service.list('t1', 'u1', '/'); + expect(account.getSession).toHaveBeenCalledWith('t1', 'u1'); + expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/'); + expect(out.path).toBe('/'); + expect(out.quota).toEqual({ used: 10, available: null }); + expect(out.entries.map((e) => [e.name, e.path, e.fileId])).toEqual([ + ['Ärger.txt', '/Ärger.txt', '9'], + ]); + }); + + it('ohne Pfad ist es die Wurzel; ein ungueltiger Pfad ruft nichts auf', async () => { + const { service, account, calls } = setup(); + expect((await service.list('t1', 'u1', undefined)).path).toBe('/'); + await expect(service.list('t1', 'u1', '/../x')).rejects.toMatchObject({ + response: { code: 'invalidPath' }, + }); + expect(account.getSession).toHaveBeenCalledTimes(1); + expect(calls).toHaveLength(1); + }); + + it('401 von Nextcloud: Verbindung als abgelaufen markiert und 409 connectionExpired', async () => { + const { service, account } = setup({ status: 401 }); + let thrown: unknown; + try { + await service.list('t1', 'u1', '/'); + } catch (e) { + thrown = e; + } + expect(codeOf(thrown)).toBe('connectionExpired'); + expect((thrown as { getStatus(): number }).getStatus()).toBe(409); + expect(account.markExpired).toHaveBeenCalledWith('t1', 'u1'); + }); + + it('404 -> notFound', async () => { + const { service } = setup({ status: 404 }); + await expect(service.list('t1', 'u1', '/weg')).rejects.toMatchObject({ + response: { code: 'notFound' }, + }); + }); + + it('ein Benutzer ohne Konto bekommt notConnected und es geht kein Aufruf raus', async () => { + const { service, account, calls } = setup(); + account.getSession.mockRejectedValueOnce(ncErrorDefault('notConnected')); + await expect(service.list('t1', 'u2', '/')).rejects.toMatchObject({ + response: { code: 'notConnected' }, + status: 409, + }); + expect(account.getSession).toHaveBeenCalledWith('t1', 'u2'); + expect(calls).toHaveLength(0); + }); +}); + +describe('NextcloudFilesService — createFolder / move / remove', () => { + it('createFolder: x.part -> 400 invalidName ohne Aufruf', async () => { + const { service, calls } = setup({ status: 201 }); + await expect(service.createFolder('t1', 'u1', '/a/x.part')).rejects.toMatchObject({ + response: { code: 'invalidName' }, + }); + await expect(service.createFolder('t1', 'u1', '/')).rejects.toMatchObject({ + response: { code: 'invalidPath' }, + }); + expect(calls).toHaveLength(0); + }); + + it('createFolder: MKCOL 201; 405 -> nameTaken', async () => { + const ok = setup({ status: 201 }); + expect(await ok.service.createFolder('t1', 'u1', '/Projekte/Neu')).toEqual({ + path: '/Projekte/Neu', + }); + expect(ok.calls[0].method).toBe('MKCOL'); + const taken = setup({ status: 405 }); + await expect(taken.service.createFolder('t1', 'u1', '/Projekte')).rejects.toMatchObject({ + response: { code: 'nameTaken' }, + }); + }); + + it('move: Ordner in sich selbst oder in einen Unterordner -> 400 moveIntoItself ohne Aufruf', async () => { + const { service, calls } = setup({ status: 201 }); + await expect(service.move('t1', 'u1', '/A', '/A/B')).rejects.toMatchObject({ + response: { code: 'moveIntoItself' }, + status: 400, + }); + await expect(service.move('t1', 'u1', '/A', '/A')).rejects.toMatchObject({ + response: { code: 'moveIntoItself' }, + }); + expect(calls).toHaveLength(0); + // ein Name, der nur wie ein Praefix aussieht, ist erlaubt + await service.move('t1', 'u1', '/A', '/AB'); + expect(calls).toHaveLength(1); + }); + + it('move: sendet MOVE mit Overwrite F; 412 -> nameTaken; neuer Name .part -> invalidName', async () => { + const ok = setup({ status: 201 }); + expect(await ok.service.move('t1', 'u1', '/Alt/a.txt', '/Ziel/b.txt')).toEqual({ + from: '/Alt/a.txt', + to: '/Ziel/b.txt', + }); + expect(ok.calls[0].headers.overwrite).toBe('F'); + expect(ok.calls[0].headers.destination).toBe( + 'https://cloud.example/nc/remote.php/dav/files/anna/Ziel/b.txt', + ); + const taken = setup({ status: 412 }); + await expect(taken.service.move('t1', 'u1', '/a', '/b')).rejects.toMatchObject({ + response: { code: 'nameTaken' }, + }); + await expect(ok.service.move('t1', 'u1', '/a', '/b.part')).rejects.toMatchObject({ + response: { code: 'invalidName' }, + }); + await expect(ok.service.move('t1', 'u1', '/', '/b')).rejects.toMatchObject({ + response: { code: 'invalidPath' }, + }); + }); + + it('remove: 204 -> ok; Wurzel -> invalidPath; 404 -> notFound', async () => { + const ok = setup({ status: 204 }); + expect(await ok.service.remove('t1', 'u1', '/Projekte/alt.txt')).toEqual({ deleted: true }); + expect(ok.calls[0].method).toBe('DELETE'); + await expect(ok.service.remove('t1', 'u1', '/')).rejects.toMatchObject({ + response: { code: 'invalidPath' }, + }); + const missing = setup({ status: 404 }); + await expect(missing.service.remove('t1', 'u1', '/x')).rejects.toMatchObject({ + response: { code: 'notFound' }, + }); + }); +}); + +describe('NextcloudFilesService — preview', () => { + it('fileId ohne Ziffern -> 400 ohne Aufruf', async () => { + const { service, account, calls } = setup(); + await expect(service.preview(new FakeRes() as never, 't1', 'u1', 'abc')).rejects.toMatchObject({ + response: { code: 'invalidPath' }, + }); + expect(account.getSession).not.toHaveBeenCalled(); + expect(calls).toHaveLength(0); + }); + + it('text/html als Antwort -> 404, nichts geschrieben', async () => { + const { service } = setup({ + status: 200, + text: 'Anmeldung', + headers: { 'content-type': 'text/html' }, + }); + const res = new FakeRes(); + await expect(service.preview(res as never, 't1', 'u1', '42')).rejects.toMatchObject({ + response: { code: 'notFound' }, + }); + expect(res.written).toHaveLength(0); + expect(res.headers).toEqual({}); + }); + + it('image/png wird gestreamt; mit Version einen Tag gecacht, ohne eine Stunde', async () => { + const png = { status: 200, text: 'PNG', headers: { 'content-type': 'image/png' } }; + const withV = setup(png); + const res = new FakeRes(); + await withV.service.preview(res as never, 't1', 'u1', '42', '"abc"'); + await done(res); + expect(withV.calls[0].url).toBe( + 'https://cloud.example/nc/index.php/core/preview?fileId=42&x=256&y=256&a=1&forceIcon=0', + ); + expect(res.headers['cache-control']).toBe('private, max-age=86400'); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(Buffer.concat(res.written).toString()).toBe('PNG'); + const without = setup(png); + const res2 = new FakeRes(); + await without.service.preview(res2 as never, 't1', 'u1', '42'); + expect(res2.headers['cache-control']).toBe('private, max-age=3600'); + }); + + it('Nextcloud 404 (kein Vorschaubild) -> notFound', async () => { + const { service } = setup({ status: 404 }); + await expect(service.preview(new FakeRes() as never, 't1', 'u1', '42')).rejects.toMatchObject({ + response: { code: 'notFound' }, + }); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-files.service.ts b/apps/api/src/nextcloud-files/nextcloud-files.service.ts new file mode 100644 index 0000000..6efb45a --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-files.service.ts @@ -0,0 +1,142 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { Response } from 'express'; +import { NextcloudCallGate } from './nextcloud-call-gate'; +import * as dav from './nextcloud-dav'; +import { type NcSession, ncErrorDefault } from './nextcloud-files.types'; +import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; +import { + NEXTCLOUD_TRANSPORT, + type NextcloudTransport, + parseUserPath, + validateNewName, +} from './nextcloud-http'; +import type { NcEntry, NcQuota } from './nextcloud-propfind'; +import { mapNcFailure, type NcOutcome, sendUpstreamStream } from './nextcloud-upstream'; + +export interface ListingView { + path: string; + entries: NcEntry[]; + quota: NcQuota; + truncated: boolean; +} + +/** Vorschaubilder: hoechstens 5 MiB, nur Bilder (D-K). */ +const PREVIEW_MAX_BYTES = 5 * 1024 * 1024; +const FILE_ID_RE = /^\d{1,20}$/; + +function pathOf(segments: readonly string[]): string { + return `/${segments.join('/')}`; +} + +/** + * Dateiaktionen im Konto des angemeldeten Benutzers (quick-261008-mzu, D-I): + * auflisten, Ordner anlegen, verschieben/umbenennen, loeschen, Vorschau. Jede + * Methode beginnt mit `getSession(tenantId, userId)` — die Benutzerkennung + * kommt aus dem Token, nie aus der Eingabe; ein Benutzer ohne Konto bekommt + * `notConnected` und kann nie ueber das Konto eines anderen arbeiten. Dieser + * Dienst greift nicht auf die Datenbank zu. Jeder Fehler laeuft durch + * `mapNcFailure` (nie 401/403 an den Browser; ein 401 von Nextcloud markiert die + * Verbindung als abgelaufen). + */ +@Injectable() +export class NextcloudFilesService { + constructor( + private readonly account: NextcloudFilesAccountService, + private readonly gate: NextcloudCallGate, + @Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport, + ) {} + + private async fail(tenantId: string, userId: string, result: NcOutcome): Promise { + throw await mapNcFailure(result, { + onExpired: () => this.account.markExpired(tenantId, userId), + }); + } + + private session(tenantId: string, userId: string): Promise { + return this.account.getSession(tenantId, userId); + } + + async list(tenantId: string, userId: string, rawPath?: string): Promise { + const segments = parseUserPath(rawPath); + const session = await this.session(tenantId, userId); + const result = await dav.list(this.transport, this.gate, session, segments); + if (!result.ok || result.listing === null) return this.fail(tenantId, userId, result); + const { entries, quota, truncated } = result.listing; + return { path: pathOf(segments), entries, quota, truncated }; + } + + async createFolder(tenantId: string, userId: string, rawPath: string): Promise<{ path: string }> { + const segments = parseUserPath(rawPath); + if (segments.length === 0) throw ncErrorDefault('invalidPath'); + validateNewName(segments[segments.length - 1]); + const session = await this.session(tenantId, userId); + const result = await dav.mkdir(this.transport, this.gate, session, segments); + if (!result.ok || result.status < 200 || result.status >= 300) { + return this.fail(tenantId, userId, result); + } + return { path: pathOf(segments) }; + } + + async move( + tenantId: string, + userId: string, + rawFrom: string, + rawTo: string, + ): Promise<{ from: string; to: string }> { + const from = parseUserPath(rawFrom); + const to = parseUserPath(rawTo); + if (from.length === 0 || to.length === 0) throw ncErrorDefault('invalidPath'); + validateNewName(to[to.length - 1]); + // Ziel gleich oder innerhalb der Quelle: vor jedem Aufruf ablehnen. + if (to.length >= from.length && from.every((s, i) => to[i] === s)) { + throw ncErrorDefault('moveIntoItself'); + } + const session = await this.session(tenantId, userId); + const result = await dav.move(this.transport, this.gate, session, from, to); + if (!result.ok || result.status < 200 || result.status >= 300) { + return this.fail(tenantId, userId, result); + } + return { from: pathOf(from), to: pathOf(to) }; + } + + async remove(tenantId: string, userId: string, rawPath: string): Promise<{ deleted: true }> { + const segments = parseUserPath(rawPath); + if (segments.length === 0) throw ncErrorDefault('invalidPath'); + const session = await this.session(tenantId, userId); + const result = await dav.remove(this.transport, this.gate, session, segments); + if (!result.ok || result.status < 200 || result.status >= 300) { + return this.fail(tenantId, userId, result); + } + return { deleted: true }; + } + + /** Vorschaubild streamen; `version` (Entity-Tag) erlaubt einen Tag Browser-Cache. */ + async preview( + res: Response, + tenantId: string, + userId: string, + fileId: string, + version?: string, + ): Promise { + if (!FILE_ID_RE.test(fileId)) throw ncErrorDefault('invalidPath'); + const session = await this.session(tenantId, userId); + + // Bricht der Browser ab, wird auch der Aufruf an Nextcloud abgebrochen. + const abort = new AbortController(); + res.on('close', () => { + if (!res.writableFinished) abort.abort(); + }); + + const upstream = await dav.preview(this.transport, this.gate, session, fileId, abort.signal); + await sendUpstreamStream(res, upstream, { + extraHeaders: { + 'cache-control': version ? 'private, max-age=86400' : 'private, max-age=3600', + 'x-content-type-options': 'nosniff', + 'content-security-policy': "default-src 'none'; sandbox", + }, + accept: (contentType) => contentType.toLowerCase().startsWith('image/'), + maxBytes: PREVIEW_MAX_BYTES, + onExpired: () => this.account.markExpired(tenantId, userId), + }); + } +} diff --git a/apps/api/src/nextcloud-files/nextcloud-http.ts b/apps/api/src/nextcloud-files/nextcloud-http.ts index cfcc91d..285dc26 100644 --- a/apps/api/src/nextcloud-files/nextcloud-http.ts +++ b/apps/api/src/nextcloud-files/nextcloud-http.ts @@ -169,6 +169,7 @@ export function buildNcUrl( prefix: NcPrefix, segments: readonly string[] = [], query?: Record, + trailingSlash = false, ): string { if (!(ALLOWED_PREFIXES as readonly string[]).includes(prefix)) { throw new Error(`Nextcloud-Pfadanfang nicht erlaubt: ${prefix}`); @@ -179,6 +180,8 @@ export function buildNcUrl( throw new Error(`Nextcloud-Pfadanfang ${prefix} nimmt keine Segmente`); } let url = `${base}${prefix}${encodeSegments(segments)}`; + // Ordner-Adressen enden fuer WebDAV mit einem Schraegstrich (PROPFIND auf Ordner). + if (trailingSlash && !url.endsWith('/')) url += '/'; if (query) { const pairs = Object.entries(query).map( ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`, @@ -200,6 +203,8 @@ export interface NcRequestOptions { prefix: NcPrefix; segments?: readonly string[]; query?: Record; + /** Haengt der Adresse einen `/` an (Ordner bei WebDAV). */ + trailingSlash?: boolean; method: string; headers?: Record; body?: Readable | string | Buffer | null; @@ -297,7 +302,13 @@ export async function ncRequest( gate: NextcloudCallGate, opts: NcRequestOptions, ): Promise { - const url = buildNcUrl(opts.baseUrl, opts.prefix, opts.segments ?? [], opts.query); + const url = buildNcUrl( + opts.baseUrl, + opts.prefix, + opts.segments ?? [], + opts.query, + opts.trailingSlash ?? false, + ); const origin = new URL(url).origin; if (opts.credentialKey && gate.isDead(opts.credentialKey)) { diff --git a/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts b/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts new file mode 100644 index 0000000..1113605 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_LIST_ENTRIES, PROPFIND_BODY, parsePropfind } from './nextcloud-propfind'; + +const BASE = 'https://cloud.example/nc'; +const PREFIX = '/nc/remote.php/dav/files/anna'; + +const NS = + 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns" xmlns:s="http://sabredav.org/ns"'; + +/** Fixture nach der Messung: Wurzelordner zuerst, mehrere propstat, Kleinbuchstaben-Hex. */ +const XML = ` + + + ${PREFIX}/Projekte/ + + + Wed, 08 Oct 2026 10:00:00 GMT + "root" + + 7 + RGDNVCK + 9999 + 1234 + -3 + + HTTP/1.1 200 OK + + + + HTTP/1.1 404 Not Found + + + + ${PREFIX}/Projekte/%c3%84rger%20%26%20Co/ + + + Tue, 07 Oct 2026 09:30:00 GMT + "folderetag" + + 00011 + RGDNVCK + 5000 + 0 + false + + HTTP/1.1 200 OK + + + + HTTP/1.1 404 Not Found + + + + ${PREFIX}/Projekte/12345 + + + Mon, 06 Oct 2026 08:00:00 GMT + "abc" + text/plain + 42 + + 00042 + RGDNVW + 42 + 1 + true + + HTTP/1.1 200 OK + + + + ${PREFIX}/Projekte/50%2525.txt + + + Sun, 05 Oct 2026 07:00:00 GMT + "pct" + text/plain + 7 + + 43 + RGDNVW + false + + HTTP/1.1 200 OK + + + + /nc/remote.php/dav/files/zoe/Projekte/fremd.txt + + 1 + HTTP/1.1 200 OK + + +`; + +const CTX = { baseUrl: BASE, ncUserId: 'anna', requested: ['Projekte'] }; + +describe('parsePropfind', () => { + it('liest Speicherangaben des Ordners selbst (-3 = unbegrenzt) und alle Eintraege', () => { + const listing = parsePropfind(XML, CTX); + expect(listing.quota).toEqual({ used: 1234, available: null }); + expect(listing.truncated).toBe(false); + expect(listing.entries).toEqual([ + { + name: 'Ärger & Co', + path: '/Projekte/Ärger & Co', + type: 'folder', + size: 5000, + mime: null, + mtime: '2026-10-07T09:30:00.000Z', + etag: '"folderetag"', + fileId: '00011', + permissions: 'RGDNVCK', + hasPreview: false, + favorite: false, + }, + { + name: '12345', + path: '/Projekte/12345', + type: 'file', + size: 42, + mime: 'text/plain', + mtime: '2026-10-06T08:00:00.000Z', + etag: '"abc"', + fileId: '00042', + permissions: 'RGDNVW', + hasPreview: true, + favorite: true, + }, + { + name: '50%25.txt', + path: '/Projekte/50%25.txt', + type: 'file', + size: 7, + mime: 'text/plain', + mtime: '2026-10-05T07:00:00.000Z', + etag: '"pct"', + fileId: '43', + permissions: 'RGDNVW', + hasPreview: false, + favorite: false, + }, + ]); + }); + + it('der Name bleibt eine Zeichenkette (12345), nie eine Zahl', () => { + const entry = parsePropfind(XML, CTX).entries.find((e) => e.fileId === '00042'); + expect(typeof entry?.name).toBe('string'); + expect(entry?.name).toBe('12345'); + }); + + it('ueberspringt Antworten ausserhalb der Dateien des Benutzers', () => { + const names = parsePropfind(XML, CTX).entries.map((e) => e.name); + expect(names).not.toContain('fremd.txt'); + }); + + it('Wurzel: angefragter Pfad leer, Eintraege direkt darunter', () => { + const xml = ` + ${PREFIX}/ + 102048 + HTTP/1.1 200 OK + ${PREFIX}/a.txt + 3HTTP/1.1 200 OK + `; + const listing = parsePropfind(xml, { ...CTX, requested: [] }); + expect(listing.quota).toEqual({ used: 10, available: 2048 }); + expect(listing.entries.map((e) => e.path)).toEqual(['/a.txt']); + expect(listing.entries[0].mtime).toBeNull(); + expect(listing.entries[0].etag).toBeNull(); + }); + + it('mehr als 5000 Eintraege: die ersten 5000 und truncated', () => { + const rows = Array.from( + { length: MAX_LIST_ENTRIES + 3 }, + (_, i) => + `${PREFIX}/Projekte/f${i}.txt + 1 + HTTP/1.1 200 OK`, + ).join(''); + const xml = `${rows}`; + const listing = parsePropfind(xml, CTX); + expect(listing.entries).toHaveLength(MAX_LIST_ENTRIES); + expect(listing.truncated).toBe(true); + expect(listing.entries[0].name).toBe('f0.txt'); + }); + + it('ein Unterpfad in der Basis wird abgezogen und leere Antworten sind leer', () => { + expect(parsePropfind(``, CTX)).toEqual({ + entries: [], + quota: { used: 0, available: null }, + truncated: false, + }); + // Basis ohne Unterpfad: das Praefix des href darf dann keinen /nc enthalten. + const flat = XML.replaceAll('/nc/remote.php', '/remote.php'); + expect(parsePropfind(flat, { ...CTX, baseUrl: 'https://cloud.example' }).entries).toHaveLength( + 3, + ); + }); + + it('der Anfragekoerper enthaelt alle Felder der Messung', () => { + for (const field of [ + 'd:getlastmodified', + 'd:getetag', + 'd:getcontenttype', + 'd:getcontentlength', + 'd:resourcetype', + 'oc:fileid', + 'oc:permissions', + 'oc:size', + 'oc:favorite', + 'oc:owner-display-name', + 'oc:share-types', + 'nc:has-preview', + 'd:quota-available-bytes', + 'd:quota-used-bytes', + ]) { + expect(PROPFIND_BODY).toContain(`<${field}/>`); + } + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-propfind.ts b/apps/api/src/nextcloud-files/nextcloud-propfind.ts new file mode 100644 index 0000000..644668f --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-propfind.ts @@ -0,0 +1,259 @@ +import { XMLParser } from 'fast-xml-parser'; + +/** + * Auswertung der PROPFIND-Antwort einer Nextcloud (quick-261008-mzu, D-H/D-I). + * Die Eigenheiten sind gegen eine echte Nextcloud 34 gemessen: + * - Je Eintrag stehen MEHRERE `propstat`: Felder, die es nicht gibt (z. B. + * `getcontenttype` bei Ordnern), stehen in einem zweiten `propstat` mit + * `404 Not Found`. Nur `propstat` mit Status 200 zaehlt. + * - Das `href` ist prozentcodiert, der Hex-Teil teils in Kleinbuchstaben + * (`%c3%84rger`). Jedes Segment wird einzeln dekodiert; der Name kommt aus + * dem `href`, nie aus `displayname`. + * - Der angefragte Ordner steht selbst in der Antwort (Depth 1). Er wird nicht + * als Eintrag gefuehrt, liefert aber die Speicherangaben fuer die Kopfzeile. + * `quota-available-bytes` ist bei unbegrenztem Speicher negativ (gemessen -3). + * - Nur Zeichenketten parsen (`parseTagValue: false`): sonst werden Namen wie + * `12345` oder Kennungen wie `00042` zu Zahlen. Groessen wandeln wir selbst. + */ + +export const PROPFIND_BODY = ` + + + + + + +`; + +/** Hoechstzahl der Eintraege einer Ordnerliste; darueber wird abgeschnitten. */ +export const MAX_LIST_ENTRIES = 5000; + +export interface NcEntry { + name: string; + /** Pfad ab der Wurzel, z. B. `/Projekte/Bericht.pdf` (Segmente unveraendert). */ + path: string; + type: 'folder' | 'file'; + size: number; + mime: string | null; + /** ISO-Zeitstempel oder null. */ + mtime: string | null; + etag: string | null; + /** Kennung als Zeichenkette (kann fuehrende Nullen haben). */ + fileId: string | null; + /** Berechtigungsbuchstaben der Nextcloud (R teilbar, D loeschbar, N umbenennbar ...). */ + permissions: string; + hasPreview: boolean; + favorite: boolean; +} + +export interface NcQuota { + used: number; + /** null = unbegrenzt oder unbekannt (negativer Wert der Nextcloud). */ + available: number | null; +} + +export interface ParsedListing { + entries: NcEntry[]; + quota: NcQuota; + truncated: boolean; +} + +export interface ParsePropfindContext { + /** Normalisierte Basisadresse der Nextcloud (kann einen Unterpfad enthalten). */ + baseUrl: string; + ncUserId: string; + /** Angefragter Ordner als Segmente (Wurzel = leer). */ + requested: readonly string[]; +} + +type Dict = Record; + +const parser = new XMLParser({ + removeNSPrefix: true, + parseTagValue: false, + parseAttributeValue: false, + isArray: (name) => ['response', 'propstat', 'share-type'].includes(name), +}); + +function asArray(value: T | T[] | undefined | null): T[] { + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +} + +function isDict(value: unknown): value is Dict { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | null { + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return null; +} + +function numberOf(value: unknown): number | null { + const t = text(value); + if (t === null || t.trim() === '') return null; + const n = Number(t); + return Number.isFinite(n) ? n : null; +} + +function decodeSegment(raw: string): string | null { + try { + return decodeURIComponent(raw); + } catch { + return null; + } +} + +/** Zerlegt einen Pfad-Anteil in dekodierte, nicht leere Segmente; null bei Fehlern. */ +function decodedSegments(path: string): string[] | null { + const out: string[] = []; + for (const part of path.split('/')) { + if (part === '') continue; + const decoded = decodeSegment(part); + if (decoded === null) return null; + out.push(decoded); + } + return out; +} + +/** `href` -> Segmente unterhalb von `/remote.php/dav/files//`, sonst null. */ +function relativeSegments(href: string, baseUrl: string, ncUserId: string): string[] | null { + let hrefPath = href; + // Manche Server liefern absolute Adressen im href. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href)) { + try { + hrefPath = new URL(href).pathname; + } catch { + return null; + } + } + const segments = decodedSegments(hrefPath); + if (segments === null) return null; + let basePath: string; + try { + basePath = new URL(baseUrl).pathname; + } catch { + return null; + } + const prefix = [...(decodedSegments(basePath) ?? []), 'remote.php', 'dav', 'files', ncUserId]; + if (segments.length < prefix.length) return null; + for (let i = 0; i < prefix.length; i += 1) { + if (segments[i] !== prefix[i]) return null; + } + return segments.slice(prefix.length); +} + +/** Alle Eigenschaften mit Status 200 aus den propstat-Bloecken eines Eintrags. */ +function okProps(response: Dict): Dict { + const merged: Dict = {}; + for (const stat of asArray(response.propstat as unknown)) { + if (!isDict(stat)) continue; + const status = text(stat.status) ?? ''; + if (!/^HTTP\/\d(?:\.\d)?\s+200\b/.test(status.trim())) continue; + if (!isDict(stat.prop)) continue; + Object.assign(merged, stat.prop); + } + return merged; +} + +function isFolder(props: Dict): boolean { + const rt = props.resourcetype; + return isDict(rt) && 'collection' in rt; +} + +function sameSegments(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((s, i) => s === b[i]); +} + +function isoOf(value: unknown): string | null { + const t = text(value); + if (!t) return null; + const time = new Date(t).getTime(); + return Number.isFinite(time) ? new Date(time).toISOString() : null; +} + +function buildEntry(rel: readonly string[], props: Dict): NcEntry { + const folder = isFolder(props); + const sizeRaw = folder + ? (numberOf(props.size) ?? numberOf(props.getcontentlength)) + : (numberOf(props.getcontentlength) ?? numberOf(props.size)); + const mime = folder ? null : text(props.getcontenttype); + return { + name: rel[rel.length - 1], + path: `/${rel.join('/')}`, + type: folder ? 'folder' : 'file', + size: sizeRaw !== null && sizeRaw >= 0 ? sizeRaw : 0, + mime: mime ? mime : null, + mtime: isoOf(props.getlastmodified), + etag: text(props.getetag) || null, + fileId: text(props.fileid) || null, + permissions: text(props.permissions) ?? '', + hasPreview: text(props['has-preview']) === 'true', + favorite: text(props.favorite) === '1', + }; +} + +/** + * Antwort einer PROPFIND-Anfrage mit Depth 0 auf eine Datei oder einen Ordner: + * der Eintrag selbst (oder null, wenn die Antwort ihn nicht enthaelt). Der + * angefragte Pfad darf nicht die Wurzel sein. + */ +export function parsePropfindSelf(xml: string, ctx: ParsePropfindContext): NcEntry | null { + if (ctx.requested.length === 0) return null; + const doc = parser.parse(xml) as Dict; + const multistatus = isDict(doc.multistatus) ? doc.multistatus : {}; + for (const response of asArray(multistatus.response as unknown)) { + if (!isDict(response)) continue; + const href = text(response.href); + if (href === null) continue; + const rel = relativeSegments(href, ctx.baseUrl, ctx.ncUserId); + if (rel !== null && sameSegments(rel, ctx.requested)) { + return buildEntry(rel, okProps(response)); + } + } + return null; +} + +/** Wertet die 207-Antwort einer PROPFIND-Anfrage (Depth 1) aus. */ +export function parsePropfind(xml: string, ctx: ParsePropfindContext): ParsedListing { + const doc = parser.parse(xml) as Dict; + const multistatus = isDict(doc.multistatus) ? doc.multistatus : {}; + const responses = asArray(multistatus.response as unknown); + + const entries: NcEntry[] = []; + let quota: NcQuota = { used: 0, available: null }; + let truncated = false; + + for (const response of responses) { + if (!isDict(response)) continue; + const href = text(response.href); + if (href === null) continue; + const rel = relativeSegments(href, ctx.baseUrl, ctx.ncUserId); + if (rel === null) continue; // ausserhalb der Dateien des Benutzers + const props = okProps(response); + + if (sameSegments(rel, ctx.requested)) { + const used = numberOf(props['quota-used-bytes']); + const available = numberOf(props['quota-available-bytes']); + quota = { + used: used !== null && used >= 0 ? used : 0, + available: available !== null && available >= 0 ? available : null, + }; + continue; + } + // Nur direkte Kinder des angefragten Ordners (Depth 1). + if (rel.length !== ctx.requested.length + 1 || !ctx.requested.every((s, i) => rel[i] === s)) { + continue; + } + + if (entries.length >= MAX_LIST_ENTRIES) { + truncated = true; + continue; + } + + entries.push(buildEntry(rel, props)); + } + + return { entries, quota, truncated }; +} diff --git a/apps/api/src/nextcloud-files/nextcloud-upstream.spec.ts b/apps/api/src/nextcloud-files/nextcloud-upstream.spec.ts new file mode 100644 index 0000000..5d51690 --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-upstream.spec.ts @@ -0,0 +1,231 @@ +import { Readable, Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import type { NcResult } from './nextcloud-http'; +import { mapNcFailure, sendUpstreamStream } from './nextcloud-upstream'; + +function parts(e: unknown): { status: number; body: Record } { + const ex = e as { getStatus(): number; getResponse(): Record }; + return { status: ex.getStatus(), body: ex.getResponse() }; +} + +describe('mapNcFailure — HTTP-Status der Nextcloud', () => { + it.each([ + [401, 409, 'connectionExpired'], + [403, 422, 'notAllowed'], + [404, 404, 'notFound'], + [405, 409, 'nameTaken'], + [409, 409, 'pathConflict'], + [412, 409, 'nameTaken'], + [415, 400, 'invalidName'], + [400, 400, 'invalidName'], + [423, 409, 'locked'], + [429, 503, 'nextcloudLocked'], + [500, 502, 'nextcloudError'], + [502, 502, 'nextcloudError'], + [503, 503, 'nextcloudMaintenance'], + [507, 507, 'quotaExceeded'], + ])('Nextcloud %i -> HTTP %i %s', async (upstream, httpStatus, code) => { + const { status, body } = parts(await mapNcFailure({ ok: true, status: upstream })); + expect(status).toBe(httpStatus); + expect(body.code).toBe(code); + expect(typeof body.message).toBe('string'); + expect(status).not.toBe(401); + expect(status).not.toBe(403); + }); + + it('401 ruft onExpired; andere Status nicht', async () => { + const onExpired = vi.fn(async () => {}); + await mapNcFailure({ ok: true, status: 401 }, { onExpired }); + expect(onExpired).toHaveBeenCalledTimes(1); + await mapNcFailure({ ok: true, status: 404 }, { onExpired }); + await mapNcFailure({ ok: true, status: 403 }, { onExpired }); + expect(onExpired).toHaveBeenCalledTimes(1); + }); + + it('429 traegt retryAfterSeconds', async () => { + const { body } = parts( + await mapNcFailure({ ok: false, kind: 'http', status: 429, retryAfterSeconds: 900 }), + ); + expect(body).toMatchObject({ code: 'nextcloudLocked', retryAfterSeconds: 900 }); + }); + + it('nameTaken traegt den vorhandenen Eintrag, wenn bekannt', async () => { + const existing = { etag: '"abc"', size: 3, mtime: '2026-10-06T08:00:00.000Z' }; + const { body } = parts(await mapNcFailure({ ok: true, status: 412 }, { existing })); + expect(body).toMatchObject({ code: 'nameTaken', existing }); + }); +}); + +describe('mapNcFailure — Fehlerarten der Transportschicht', () => { + it.each([ + ['paused', 503, 'nextcloudLocked'], + ['credential-dead', 409, 'connectionExpired'], + ['redirect', 502, 'nextcloudRedirect'], + ['timeout', 504, 'nextcloudUnavailable'], + ['network', 504, 'nextcloudUnavailable'], + ['tls', 504, 'nextcloudUnavailable'], + ['too-large', 502, 'nextcloudError'], + ])('%s -> HTTP %i %s', async (kind, httpStatus, code) => { + const onExpired = vi.fn(async () => {}); + const { status, body } = parts( + await mapNcFailure({ ok: false, kind, retryAfterSeconds: 120 }, { onExpired }), + ); + expect(status).toBe(httpStatus); + expect(body.code).toBe(code); + expect(onExpired).toHaveBeenCalledTimes(kind === 'credential-dead' ? 1 : 0); + if (kind === 'paused') expect(body.retryAfterSeconds).toBe(120); + }); +}); + +/** Attrappe fuer die Express-Antwort: merkt sich Status, Kopfzeilen und geschriebene Bytes. */ +class FakeRes extends Writable { + code = 200; + headers: Record = {}; + written: Buffer[] = []; + status(c: number) { + this.code = c; + return this; + } + setHeader(name: string, value: string) { + this.headers[name.toLowerCase()] = String(value); + return this; + } + _write(chunk: Buffer, _enc: BufferEncoding, cb: () => void) { + this.written.push(Buffer.from(chunk)); + cb(); + } + get bytes(): string { + return Buffer.concat(this.written).toString('utf8'); + } +} + +function upstreamOf( + status: number, + text: string, + headers: Record = {}, +): NcResult { + return { ok: true, status, headers, body: Readable.from([Buffer.from(text)]) }; +} + +const finished = (res: Writable) => + new Promise((resolve) => { + if (res.writableFinished || res.destroyed) resolve(); + else { + res.once('finish', () => resolve()); + res.once('close', () => resolve()); + } + }); + +describe('sendUpstreamStream', () => { + it('2xx: nur die Positivliste plus eigene Kopfzeilen, Koerper durchgereicht, nie set-cookie', async () => { + const res = new FakeRes(); + await sendUpstreamStream( + res as never, + upstreamOf(200, 'PNGDATA', { + 'content-type': 'image/png', + 'content-length': '7', + etag: '"e"', + 'set-cookie': ['oc_sessionPassphrase=geheim'], + 'x-powered-by': 'PHP', + 'cache-control': 'public', + }), + { extraHeaders: { 'cache-control': 'private, max-age=86400' } }, + ); + await finished(res); + expect(res.code).toBe(200); + expect(res.headers).toEqual({ + 'content-type': 'image/png', + 'content-length': '7', + etag: '"e"', + 'cache-control': 'private, max-age=86400', + }); + expect(res.bytes).toBe('PNGDATA'); + }); + + it('206 mit content-range wird durchgereicht', async () => { + const res = new FakeRes(); + await sendUpstreamStream( + res as never, + upstreamOf(206, 'ab', { 'content-range': 'bytes 0-1/10', 'accept-ranges': 'bytes' }), + ); + expect(res.code).toBe(206); + expect(res.headers['content-range']).toBe('bytes 0-1/10'); + }); + + it.each([ + [404, 404, 'notFound'], + [403, 422, 'notAllowed'], + [412, 409, 'nameTaken'], + [507, 507, 'quotaExceeded'], + ])('Nextcloud %i: wird abgebildet, ehe eine Kopfzeile oder ein Byte geschrieben ist', async (up, httpStatus, code) => { + const res = new FakeRes(); + const onExpired = vi.fn(); + let thrown: unknown; + try { + await sendUpstreamStream( + res as never, + upstreamOf(up, 'Fehlerseite', { + 'content-type': 'text/html', + 'set-cookie': 'x=1', + }), + { onExpired, extraHeaders: { 'cache-control': 'x' } }, + ); + } catch (e) { + thrown = e; + } + expect(parts(thrown)).toMatchObject({ status: httpStatus, body: { code } }); + expect(res.headers).toEqual({}); + expect(res.written).toHaveLength(0); + expect(res.code).toBe(200); + expect(onExpired).not.toHaveBeenCalled(); + }); + + it('401 vom Strom markiert die Verbindung als abgelaufen', async () => { + const onExpired = vi.fn(async () => {}); + await expect( + sendUpstreamStream(new FakeRes() as never, upstreamOf(401, ''), { onExpired }), + ).rejects.toMatchObject({ response: { code: 'connectionExpired' } }); + expect(onExpired).toHaveBeenCalledTimes(1); + }); + + it('ein Fehlerergebnis der Aufrufsperre wird abgebildet, nichts geschrieben', async () => { + const res = new FakeRes(); + await expect( + sendUpstreamStream(res as never, { ok: false, kind: 'paused', retryAfterSeconds: 60 }), + ).rejects.toMatchObject({ response: { code: 'nextcloudLocked', retryAfterSeconds: 60 } }); + expect(res.written).toHaveLength(0); + expect(res.headers).toEqual({}); + }); + + it('accept: text/html statt image/* wird 404, ohne zu streamen', async () => { + const res = new FakeRes(); + await expect( + sendUpstreamStream( + res as never, + upstreamOf(200, '', { 'content-type': 'text/html' }), + { + accept: (ct) => ct.startsWith('image/'), + }, + ), + ).rejects.toMatchObject({ response: { code: 'notFound' } }); + expect(res.written).toHaveLength(0); + expect(res.headers).toEqual({}); + }); + + it('maxBytes: zu grosse Angabe wird vor dem Schreiben abgelehnt', async () => { + const res = new FakeRes(); + await expect( + sendUpstreamStream(res as never, upstreamOf(200, 'x', { 'content-length': '999' }), { + maxBytes: 10, + }), + ).rejects.toMatchObject({ response: { code: 'nextcloudError' } }); + expect(res.headers).toEqual({}); + }); + + it('maxBytes: laeuft der Strom ueber die Grenze, wird die Antwort zerstoert', async () => { + const res = new FakeRes(); + await sendUpstreamStream(res as never, upstreamOf(200, 'x'.repeat(100)), { maxBytes: 10 }); + expect(res.destroyed).toBe(true); + expect(res.bytes.length).toBeLessThanOrEqual(10); + }); +}); diff --git a/apps/api/src/nextcloud-files/nextcloud-upstream.ts b/apps/api/src/nextcloud-files/nextcloud-upstream.ts new file mode 100644 index 0000000..1d2011a --- /dev/null +++ b/apps/api/src/nextcloud-files/nextcloud-upstream.ts @@ -0,0 +1,188 @@ +import { Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import type { HttpException } from '@nestjs/common'; +import type { Response } from 'express'; +import { type NcErrorCode, ncErrorDefault } from './nextcloud-files.types'; +import { discardBody, type NcResult } from './nextcloud-http'; + +/** + * Uebersetzung von Nextcloud-Antworten in Fehler fuer den Browser + * (quick-261008-mzu, D-D/D-K). Das ist die EINZIGE Stelle, die einen + * fehlgeschlagenen Nextcloud-Aufruf in eine HTTP-Antwort der Tessera-API + * verwandelt — alle Datei- und Uebertragungsrouten gehen hier durch. + * + * Nie 401 oder 403 an den Browser: die Weboberflaeche liest beides als Problem + * mit der Tessera-Anmeldung bzw. den Tessera-Rechten und wuerde den Benutzer + * abmelden oder aussperren, obwohl nur die gespeicherte Nextcloud-Verbindung + * abgelaufen ist (dafuer gibt es `connectionExpired` mit 409) oder Nextcloud + * eine Aktion nicht erlaubt (`notAllowed` mit 422). + */ + +/** Was `mapNcFailure` braucht: ein Fehlerergebnis oder eine Antwort mit Statuscode. */ +export interface NcOutcome { + ok: boolean; + kind?: string; + status?: number; + retryAfterSeconds?: number; +} + +export interface MapOptions { + /** Wird bei einer abgelaufenen Verbindung aufgerufen (Konto als abgelaufen markieren). */ + onExpired?: () => Promise | void; + /** Zusatzfelder fuer `nameTaken` (vorhandener Eintrag). */ + existing?: { etag: string | null; size: number; mtime: string | null }; +} + +function lockedError(retryAfterSeconds: number | undefined): HttpException { + return ncErrorDefault( + 'nextcloudLocked', + retryAfterSeconds !== undefined ? { retryAfterSeconds } : undefined, + ); +} + +/** Fehlercode fuer einen HTTP-Status von Nextcloud (ohne 401/429, die Sonderwege haben). */ +function codeForStatus(status: number): NcErrorCode { + switch (status) { + case 400: + case 415: + return 'invalidName'; + case 403: + return 'notAllowed'; + case 404: + return 'notFound'; + case 405: + case 412: + return 'nameTaken'; + case 409: + return 'pathConflict'; + case 423: + return 'locked'; + case 503: + return 'nextcloudMaintenance'; + case 507: + return 'quotaExceeded'; + default: + return 'nextcloudError'; + } +} + +/** + * Baut die Ausnahme fuer eine fehlgeschlagene oder nicht erfolgreiche Antwort. + * Wirft nichts selbst: der Aufrufer schreibt `throw await mapNcFailure(...)`. + */ +export async function mapNcFailure( + result: NcOutcome, + options: MapOptions = {}, +): Promise { + const { kind, status } = result; + + if (kind === 'credential-dead' || status === 401) { + if (options.onExpired) await options.onExpired(); + return ncErrorDefault('connectionExpired'); + } + if (kind === 'paused') return lockedError(result.retryAfterSeconds); + if (status === 429) return lockedError(result.retryAfterSeconds); + if (kind === 'redirect') return ncErrorDefault('nextcloudRedirect'); + if (kind === 'timeout' || kind === 'network' || kind === 'tls' || kind === 'aborted') { + return ncErrorDefault('nextcloudUnavailable'); + } + if (kind === 'too-large' || kind === 'invalid-response') { + return ncErrorDefault('nextcloudError'); + } + if (typeof status === 'number') { + const code = codeForStatus(status); + if (code === 'nameTaken' && options.existing) { + return ncErrorDefault('nameTaken', { existing: options.existing }); + } + return ncErrorDefault(code); + } + return ncErrorDefault('nextcloudError'); +} + +// --- Antwort an den Browser durchreichen ---------------------------------------------------------- + +/** Nur diese Kopfzeilen der Nextcloud-Antwort erreichen den Browser (nie `set-cookie`). */ +export const UPSTREAM_HEADER_ALLOWLIST = [ + 'content-type', + 'content-length', + 'content-range', + 'accept-ranges', + 'etag', + 'last-modified', +] as const; + +export interface StreamOptions extends MapOptions { + /** Kopfzeilen, die Tessera selbst setzt (Content-Disposition, Cache-Control ...). */ + extraHeaders?: Record; + /** Prueft den Inhaltstyp der Antwort; sonst `notFound` ohne einen Byte zu schreiben. */ + accept?: (contentType: string) => boolean; + /** Obergrenze fuer die Antwortgroesse; darueber wird der Strom abgeschnitten. */ + maxBytes?: number; +} + +function headerValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +/** + * Reicht eine erfolgreiche Nextcloud-Antwort als Datenstrom an den Browser durch. + * ERST pruefen, DANN schreiben: jede Antwort ausser 2xx (und jeder Fehler der + * Aufrufsperre) wird ueber `mapNcFailure` zu einer JSON-Fehlerantwort, bevor + * eine Kopfzeile gesetzt oder ein Byte geschrieben wird — danach liesse sich der + * Status nicht mehr aendern. Wirft die zugeordnete Ausnahme in dem Fall. + */ +export async function sendUpstreamStream( + res: Response, + upstream: NcResult, + options: StreamOptions = {}, +): Promise { + if (!upstream.ok) throw await mapNcFailure(upstream, options); + const { status, headers, body } = upstream; + if (status < 200 || status >= 300) { + discardBody(body); + throw await mapNcFailure({ ok: true, status }, options); + } + + const contentType = headerValue(headers['content-type']) ?? ''; + if (options.accept && !options.accept(contentType)) { + discardBody(body); + throw ncErrorDefault('notFound'); + } + const declared = Number(headerValue(headers['content-length'])); + if (options.maxBytes !== undefined && Number.isFinite(declared) && declared > options.maxBytes) { + discardBody(body); + throw ncErrorDefault('nextcloudError'); + } + + res.status(status); + for (const name of UPSTREAM_HEADER_ALLOWLIST) { + const value = headerValue(headers[name]); + if (value !== undefined) res.setHeader(name, value); + } + for (const [name, value] of Object.entries(options.extraHeaders ?? {})) { + res.setHeader(name, value); + } + + const streams: Transform[] = []; + if (options.maxBytes !== undefined) { + const limit = options.maxBytes; + let seen = 0; + streams.push( + new Transform({ + transform(chunk: Buffer, _enc, cb) { + seen += chunk.length; + if (seen > limit) cb(new Error('Antwort ist groesser als erlaubt')); + else cb(null, chunk); + }, + }), + ); + } + + try { + // pipeline zerstoert beide Seiten, wenn eine abbricht (Browser weg oder Nextcloud-Fehler). + await pipeline([body, ...streams, res]); + } catch { + // Die Antwort ist schon begonnen: nur noch die Verbindung beenden. + if (!res.destroyed) res.destroy(); + } +} diff --git a/apps/web/src/lib/nextcloud-files-api.test.ts b/apps/web/src/lib/nextcloud-files-api.test.ts new file mode 100644 index 0000000..94fa48c --- /dev/null +++ b/apps/web/src/lib/nextcloud-files-api.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createFolder, + deleteEntry, + listFolder, + moveEntry, + NextcloudFilesRequestError, + previewUrl, +} from './nextcloud-files-api'; + +const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +function mockFetch(status: number, body: unknown) { + const fn = vi.fn(async (..._args: unknown[]) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + })); + vi.stubGlobal('fetch', fn); + return fn; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('nextcloud-files-api — Dateien', () => { + it('listFolder codiert den Pfad in der Query und sendet das Cookie mit', async () => { + const fetchMock = mockFetch(200, { + path: '/Ärger & Co', + entries: [], + quota: {}, + truncated: false, + }); + await listFolder('/Ärger & Co'); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${API}/modules/nextcloud-files/files?path=%2F%C3%84rger%20%26%20Co`); + expect(init.method).toBe('GET'); + expect(init.credentials).toBe('include'); + expect(init.cache).toBe('no-store'); + }); + + it('Fehlerkoerper werden zu NextcloudFilesRequestError mit Code und Zusatzfeldern', async () => { + mockFetch(409, { + code: 'nameTaken', + message: 'Ein Eintrag mit diesem Namen existiert bereits.', + existing: { etag: '"a"', size: 3, mtime: null }, + }); + const err = await moveEntry('/a', '/b').catch((e) => e); + expect(err).toBeInstanceOf(NextcloudFilesRequestError); + expect(err.status).toBe(409); + expect(err.code).toBe('nameTaken'); + expect(err.message).toBe('Ein Eintrag mit diesem Namen existiert bereits.'); + expect(err.extra).toEqual({ existing: { etag: '"a"', size: 3, mtime: null } }); + }); + + it('createFolder sendet POST /folders mit { path }', async () => { + const fetchMock = mockFetch(201, { path: '/Neu' }); + await createFolder('/Neu'); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${API}/modules/nextcloud-files/folders`); + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"path":"/Neu"}'); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + }); + + it('moveEntry sendet POST /move mit { from, to }', async () => { + const fetchMock = mockFetch(200, { from: '/a', to: '/b' }); + await moveEntry('/a', '/b'); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${API}/modules/nextcloud-files/move`); + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"from":"/a","to":"/b"}'); + }); + + it('deleteEntry sendet DELETE /files?path= (codiert), ohne Body', async () => { + const fetchMock = mockFetch(200, { deleted: true }); + await deleteEntry('/Ordner/a b#c.txt'); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${API}/modules/nextcloud-files/files?path=%2FOrdner%2Fa%20b%23c.txt`); + expect(init.method).toBe('DELETE'); + expect(init.body).toBeUndefined(); + }); + + it('previewUrl baut die Query mit encodeURIComponent; ohne Version fehlt v', () => { + expect(previewUrl('42', '"abc 1"')).toBe( + `${API}/modules/nextcloud-files/preview?fileId=42&v=%22abc%201%22`, + ); + expect(previewUrl('42', null)).toBe(`${API}/modules/nextcloud-files/preview?fileId=42`); + }); +}); diff --git a/apps/web/src/lib/nextcloud-files-api.ts b/apps/web/src/lib/nextcloud-files-api.ts index e491d9d..9f1a6cb 100644 --- a/apps/web/src/lib/nextcloud-files-api.ts +++ b/apps/web/src/lib/nextcloud-files-api.ts @@ -163,3 +163,67 @@ export function cancelLoginFlow(flowId: string): Promise<{ cancelled: true }> { export function disconnectNextcloud(): Promise<{ disconnected: true }> { return request<{ disconnected: true }>('/connect', { method: 'DELETE' }); } + +// --- Dateien (Etappe 1, Aufgabe 3) ------------------------------------------------------------- + +export interface NcEntry { + name: string; + /** Pfad ab der Wurzel, z. B. `/Projekte/Bericht.pdf`. */ + path: string; + type: 'folder' | 'file'; + size: number; + mime: string | null; + /** ISO-Zeitstempel. */ + mtime: string | null; + etag: string | null; + fileId: string | null; + /** Berechtigungsbuchstaben der Nextcloud (R teilbar, D loeschbar, N umbenennbar ...). */ + permissions: string; + hasPreview: boolean; + favorite: boolean; +} + +export interface NcQuota { + used: number; + /** null = unbegrenzt oder unbekannt. */ + available: number | null; +} + +export interface NcListing { + path: string; + entries: NcEntry[]; + quota: NcQuota; + /** true, wenn der Ordner mehr als 5000 Eintraege hat und abgeschnitten wurde. */ + truncated: boolean; +} + +/** Ordnerinhalt samt Speicherangaben. Der Pfad geht nur in der Query, nie im URL-Pfad. */ +export function listFolder(path: string): Promise { + return request(`/files?path=${encodeURIComponent(path)}`); +} + +export function createFolder(path: string): Promise<{ path: string }> { + return request<{ path: string }>('/folders', { method: 'POST', json: { path } }); +} + +/** Verschieben oder Umbenennen; ein vorhandener Name im Ziel wird nie ueberschrieben (nameTaken). */ +export function moveEntry(from: string, to: string): Promise<{ from: string; to: string }> { + return request<{ from: string; to: string }>('/move', { method: 'POST', json: { from, to } }); +} + +/** Loescht in den Papierkorb der Nextcloud. */ +export function deleteEntry(path: string): Promise<{ deleted: true }> { + return request<{ deleted: true }>(`/files?path=${encodeURIComponent(path)}`, { + method: 'DELETE', + }); +} + +/** + * Adresse des Vorschaubildes fuer ein `` (Tessera-Cookie genuegt). Die + * Version (Entity-Tag) macht die Adresse je Dateiversion eindeutig und erlaubt + * dem Browser, das Bild zu cachen. + */ +export function previewUrl(fileId: string, etag: string | null): string { + const version = etag ? `&v=${encodeURIComponent(etag)}` : ''; + return `${API_URL}${BASE}/preview?fileId=${encodeURIComponent(fileId)}${version}`; +}