feat(nextcloud-files): Dateien auflisten, Vorschau, Ordner anlegen, umbenennen, verschieben und löschen
- 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) <noreply@anthropic.com>
This commit is contained in:
+127
@@ -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 <jar> <endpoint> <pfad> — 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"
|
||||||
@@ -34,7 +34,11 @@ e2e_status() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# e2e_expect <erwartet> <ist> <beschreibung>
|
# e2e_expect <erwartet> <ist> <beschreibung>
|
||||||
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 <datei> <text> <beschreibung>
|
# e2e_contains <datei> <text> <beschreibung>
|
||||||
e2e_contains() { grep -q -- "$2" "$1" || { echo "--- Antwort:" >&2; cat "$1" >&2; echo >&2; e2e_fail "$3: '$2' fehlt"; }; }
|
e2e_contains() { grep -q -- "$2" "$1" || { echo "--- Antwort:" >&2; cat "$1" >&2; echo >&2; e2e_fail "$3: '$2' fehlt"; }; }
|
||||||
|
|||||||
@@ -150,6 +150,11 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
|||||||
'pollFlow',
|
'pollFlow',
|
||||||
'cancelFlow',
|
'cancelFlow',
|
||||||
'disconnect',
|
'disconnect',
|
||||||
|
'list',
|
||||||
|
'remove',
|
||||||
|
'createFolder',
|
||||||
|
'move',
|
||||||
|
'preview',
|
||||||
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
|
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||||
const fn = handler(NextcloudFilesController, name);
|
const fn = handler(NextcloudFilesController, name);
|
||||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -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 = `<?xml version="1.0"?><d:multistatus ${NS}>
|
||||||
|
<d:response><d:href>/nc/remote.php/dav/files/anna/Projekte/</d:href><d:propstat><d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype><d:quota-used-bytes>5</d:quota-used-bytes>
|
||||||
|
<d:quota-available-bytes>100</d:quota-available-bytes></d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
<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>"e1"</d:getetag><d:resourcetype/></d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
</d:multistatus>`;
|
||||||
|
|
||||||
|
function fake(status: number, text = '', headers: Record<string, string> = {}) {
|
||||||
|
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('<oc:fileid/>');
|
||||||
|
expect(String(calls[0].body)).toContain('<d:quota-available-bytes/>');
|
||||||
|
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 = `<?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>"e1"</d:getetag>
|
||||||
|
<d:getlastmodified>Mon, 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>`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>;
|
||||||
|
body?: Readable | string | Buffer | null;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
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<NcResult> {
|
||||||
|
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<NcResult> {
|
||||||
|
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<DavListResult> {
|
||||||
|
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<DavStatResult> {
|
||||||
|
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<NcResult> {
|
||||||
|
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<NcResult> {
|
||||||
|
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<NcResult> {
|
||||||
|
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<NcResult> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -76,6 +76,11 @@ describe('NextcloudFilesController — Metadaten', () => {
|
|||||||
expect(route('connectPassword')).toEqual([1, 'connect/password']);
|
expect(route('connectPassword')).toEqual([1, 'connect/password']);
|
||||||
expect(route('startFlow')).toEqual([1, 'connect/flow']);
|
expect(route('startFlow')).toEqual([1, 'connect/flow']);
|
||||||
expect(route('disconnect')).toEqual([3, 'connect']);
|
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('pollFlow')).toEqual([0, 'connect/flow/:flowId']);
|
||||||
expect(route('cancelFlow')).toEqual([3, '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', () => {
|
it('Passwort- und Browser-Anmeldung antworten 200, nicht 201', () => {
|
||||||
expect(Reflect.getMetadata('__httpCode__', proto.connectPassword)).toBe(200);
|
expect(Reflect.getMetadata('__httpCode__', proto.connectPassword)).toBe(200);
|
||||||
expect(Reflect.getMetadata('__httpCode__', proto.startFlow)).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 () => {
|
it('reicht Mandant und Benutzer aus dem Token weiter, nie aus dem Body', async () => {
|
||||||
const settings = makeSettings();
|
const settings = makeSettings();
|
||||||
const account = makeAccount();
|
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.getStatus(userReq('t1', 'u1'));
|
||||||
await controller.getSettings(req('t1'));
|
await controller.getSettings(req('t1'));
|
||||||
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
|
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
|
||||||
@@ -167,7 +243,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ohne Mandantenkontext: ForbiddenException', async () => {
|
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(
|
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
);
|
);
|
||||||
@@ -179,7 +255,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
|||||||
|
|
||||||
it('ohne Benutzer im Token: ForbiddenException, kein Aufruf des Kontodienstes', async () => {
|
it('ohne Benutzer im Token: ForbiddenException, kein Aufruf des Kontodienstes', async () => {
|
||||||
const account = makeAccount();
|
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(
|
await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf(
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,15 +9,25 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||||
import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto';
|
import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto';
|
||||||
|
import {
|
||||||
|
CreateFolderDto,
|
||||||
|
MoveDto,
|
||||||
|
PathQueryDto,
|
||||||
|
PreviewQueryDto,
|
||||||
|
} from './dto/nextcloud-files-ops.dto';
|
||||||
import {
|
import {
|
||||||
SaveNextcloudFilesSettingsDto,
|
SaveNextcloudFilesSettingsDto,
|
||||||
TestNextcloudFilesSettingsDto,
|
TestNextcloudFilesSettingsDto,
|
||||||
} from './dto/nextcloud-files-settings.dto';
|
} from './dto/nextcloud-files-settings.dto';
|
||||||
|
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||||
|
|
||||||
@@ -58,6 +68,7 @@ export class NextcloudFilesController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly settings: NextcloudFilesSettingsService,
|
private readonly settings: NextcloudFilesSettingsService,
|
||||||
private readonly account: NextcloudFilesAccountService,
|
private readonly account: NextcloudFilesAccountService,
|
||||||
|
private readonly files: NextcloudFilesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private requireTenantId(req: AuthenticatedRequest): string {
|
private requireTenantId(req: AuthenticatedRequest): string {
|
||||||
@@ -126,6 +137,45 @@ export class NextcloudFilesController {
|
|||||||
return this.account.disconnect(this.requireTenantId(req), this.requireUserId(req));
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<img>` 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) ---------------
|
// --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) ---------------
|
||||||
|
|
||||||
@Get('connect/flow/:flowId')
|
@Get('connect/flow/:flowId')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ModuleRegistryService } from '../module-registry/module-registry.servic
|
|||||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||||
import { NextcloudFilesController } from './nextcloud-files.controller';
|
import { NextcloudFilesController } from './nextcloud-files.controller';
|
||||||
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
||||||
|
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||||
import {
|
import {
|
||||||
defaultStatusFetcher,
|
defaultStatusFetcher,
|
||||||
@@ -26,6 +27,7 @@ import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
|
|||||||
providers: [
|
providers: [
|
||||||
NextcloudFilesSettingsService,
|
NextcloudFilesSettingsService,
|
||||||
NextcloudFilesAccountService,
|
NextcloudFilesAccountService,
|
||||||
|
NextcloudFilesService,
|
||||||
NextcloudLoginGuard,
|
NextcloudLoginGuard,
|
||||||
LoginFlowStore,
|
LoginFlowStore,
|
||||||
NextcloudCallGate,
|
NextcloudCallGate,
|
||||||
|
|||||||
@@ -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 = `<?xml version="1.0"?><d:multistatus ${NS}>
|
||||||
|
<d:response><d:href>/nc/remote.php/dav/files/anna/</d:href><d:propstat><d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype><d:quota-used-bytes>10</d:quota-used-bytes>
|
||||||
|
<d:quota-available-bytes>-3</d:quota-available-bytes></d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/nc/remote.php/dav/files/anna/%c3%84rger.txt</d:href><d:propstat><d:prop>
|
||||||
|
<d:getcontentlength>4</d:getcontentlength><d:resourcetype/><oc:fileid>9</oc:fileid></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> };
|
||||||
|
|
||||||
|
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<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 done = (res: Writable) =>
|
||||||
|
new Promise<void>((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: '<html>Anmeldung</html>',
|
||||||
|
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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<never> {
|
||||||
|
throw await mapNcFailure(result, {
|
||||||
|
onExpired: () => this.account.markExpired(tenantId, userId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private session(tenantId: string, userId: string): Promise<NcSession> {
|
||||||
|
return this.account.getSession(tenantId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(tenantId: string, userId: string, rawPath?: string): Promise<ListingView> {
|
||||||
|
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<void> {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,7 @@ export function buildNcUrl(
|
|||||||
prefix: NcPrefix,
|
prefix: NcPrefix,
|
||||||
segments: readonly string[] = [],
|
segments: readonly string[] = [],
|
||||||
query?: Record<string, string>,
|
query?: Record<string, string>,
|
||||||
|
trailingSlash = false,
|
||||||
): string {
|
): string {
|
||||||
if (!(ALLOWED_PREFIXES as readonly string[]).includes(prefix)) {
|
if (!(ALLOWED_PREFIXES as readonly string[]).includes(prefix)) {
|
||||||
throw new Error(`Nextcloud-Pfadanfang nicht erlaubt: ${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`);
|
throw new Error(`Nextcloud-Pfadanfang ${prefix} nimmt keine Segmente`);
|
||||||
}
|
}
|
||||||
let url = `${base}${prefix}${encodeSegments(segments)}`;
|
let url = `${base}${prefix}${encodeSegments(segments)}`;
|
||||||
|
// Ordner-Adressen enden fuer WebDAV mit einem Schraegstrich (PROPFIND auf Ordner).
|
||||||
|
if (trailingSlash && !url.endsWith('/')) url += '/';
|
||||||
if (query) {
|
if (query) {
|
||||||
const pairs = Object.entries(query).map(
|
const pairs = Object.entries(query).map(
|
||||||
([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
|
([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
|
||||||
@@ -200,6 +203,8 @@ export interface NcRequestOptions {
|
|||||||
prefix: NcPrefix;
|
prefix: NcPrefix;
|
||||||
segments?: readonly string[];
|
segments?: readonly string[];
|
||||||
query?: Record<string, string>;
|
query?: Record<string, string>;
|
||||||
|
/** Haengt der Adresse einen `/` an (Ordner bei WebDAV). */
|
||||||
|
trailingSlash?: boolean;
|
||||||
method: string;
|
method: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: Readable | string | Buffer | null;
|
body?: Readable | string | Buffer | null;
|
||||||
@@ -297,7 +302,13 @@ export async function ncRequest(
|
|||||||
gate: NextcloudCallGate,
|
gate: NextcloudCallGate,
|
||||||
opts: NcRequestOptions,
|
opts: NcRequestOptions,
|
||||||
): Promise<NcResult> {
|
): Promise<NcResult> {
|
||||||
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;
|
const origin = new URL(url).origin;
|
||||||
|
|
||||||
if (opts.credentialKey && gate.isDead(opts.credentialKey)) {
|
if (opts.credentialKey && gate.isDead(opts.credentialKey)) {
|
||||||
|
|||||||
@@ -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 = `<?xml version="1.0"?>
|
||||||
|
<d:multistatus ${NS}>
|
||||||
|
<d:response>
|
||||||
|
<d:href>${PREFIX}/Projekte/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Wed, 08 Oct 2026 10:00:00 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"root"</d:getetag>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<oc:fileid>7</oc:fileid>
|
||||||
|
<oc:permissions>RGDNVCK</oc:permissions>
|
||||||
|
<oc:size>9999</oc:size>
|
||||||
|
<d:quota-used-bytes>1234</d:quota-used-bytes>
|
||||||
|
<d:quota-available-bytes>-3</d:quota-available-bytes>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop><d:getcontenttype/><d:getcontentlength/></d:prop>
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>${PREFIX}/Projekte/%c3%84rger%20%26%20Co/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Tue, 07 Oct 2026 09:30:00 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"folderetag"</d:getetag>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype>
|
||||||
|
<oc:fileid>00011</oc:fileid>
|
||||||
|
<oc:permissions>RGDNVCK</oc:permissions>
|
||||||
|
<oc:size>5000</oc:size>
|
||||||
|
<oc:favorite>0</oc:favorite>
|
||||||
|
<nc:has-preview>false</nc:has-preview>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop><d:getcontenttype/><d:getcontentlength/></d:prop>
|
||||||
|
<d:status>HTTP/1.1 404 Not Found</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>${PREFIX}/Projekte/12345</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Mon, 06 Oct 2026 08:00:00 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"abc"</d:getetag>
|
||||||
|
<d:getcontenttype>text/plain</d:getcontenttype>
|
||||||
|
<d:getcontentlength>42</d:getcontentlength>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:fileid>00042</oc:fileid>
|
||||||
|
<oc:permissions>RGDNVW</oc:permissions>
|
||||||
|
<oc:size>42</oc:size>
|
||||||
|
<oc:favorite>1</oc:favorite>
|
||||||
|
<nc:has-preview>true</nc:has-preview>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>${PREFIX}/Projekte/50%2525.txt</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Sun, 05 Oct 2026 07:00:00 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"pct"</d:getetag>
|
||||||
|
<d:getcontenttype>text/plain</d:getcontenttype>
|
||||||
|
<d:getcontentlength>7</d:getcontentlength>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:fileid>43</oc:fileid>
|
||||||
|
<oc:permissions>RGDNVW</oc:permissions>
|
||||||
|
<nc:has-preview>false</nc:has-preview>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>/nc/remote.php/dav/files/zoe/Projekte/fremd.txt</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop><d:getcontentlength>1</d:getcontentlength><d:resourcetype/></d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>`;
|
||||||
|
|
||||||
|
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 = `<?xml version="1.0"?><d:multistatus ${NS}>
|
||||||
|
<d:response><d:href>${PREFIX}/</d:href><d:propstat><d:prop>
|
||||||
|
<d:quota-used-bytes>10</d:quota-used-bytes><d:quota-available-bytes>2048</d:quota-available-bytes>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
<d:response><d:href>${PREFIX}/a.txt</d:href><d:propstat><d:prop>
|
||||||
|
<d:getcontentlength>3</d:getcontentlength><d:resourcetype/></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
</d:multistatus>`;
|
||||||
|
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) =>
|
||||||
|
`<d:response><d:href>${PREFIX}/Projekte/f${i}.txt</d:href><d:propstat><d:prop>
|
||||||
|
<d:getcontentlength>1</d:getcontentlength><d:resourcetype/></d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`,
|
||||||
|
).join('');
|
||||||
|
const xml = `<?xml version="1.0"?><d:multistatus ${NS}>${rows}</d:multistatus>`;
|
||||||
|
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(`<?xml version="1.0"?><d:multistatus ${NS}/>`, 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}/>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = `<?xml version="1.0"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
<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/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
|
||||||
|
/** 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<string, unknown>;
|
||||||
|
|
||||||
|
const parser = new XMLParser({
|
||||||
|
removeNSPrefix: true,
|
||||||
|
parseTagValue: false,
|
||||||
|
parseAttributeValue: false,
|
||||||
|
isArray: (name) => ['response', 'propstat', 'share-type'].includes(name),
|
||||||
|
});
|
||||||
|
|
||||||
|
function asArray<T>(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 `<Basis-Unterpfad>/remote.php/dav/files/<uid>/`, 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 };
|
||||||
|
}
|
||||||
@@ -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<string, unknown> } {
|
||||||
|
const ex = e as { getStatus(): number; getResponse(): Record<string, unknown> };
|
||||||
|
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<string, string> = {};
|
||||||
|
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<string, string | string[]> = {},
|
||||||
|
): NcResult {
|
||||||
|
return { ok: true, status, headers, body: Readable.from([Buffer.from(text)]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const finished = (res: Writable) =>
|
||||||
|
new Promise<void>((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, '<html>Fehlerseite</html>', {
|
||||||
|
'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, '<html></html>', { '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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> | 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<HttpException> {
|
||||||
|
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<string, string>;
|
||||||
|
/** 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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string>)['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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -163,3 +163,67 @@ export function cancelLoginFlow(flowId: string): Promise<{ cancelled: true }> {
|
|||||||
export function disconnectNextcloud(): Promise<{ disconnected: true }> {
|
export function disconnectNextcloud(): Promise<{ disconnected: true }> {
|
||||||
return request<{ disconnected: true }>('/connect', { method: 'DELETE' });
|
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<NcListing> {
|
||||||
|
return request<NcListing>(`/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 `<img>` (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}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user