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:
2026-10-08 18:14:54 +02:00
parent d00b6ff79f
commit 8bee65c306
18 changed files with 2214 additions and 5 deletions
@@ -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>&quot;e1&quot;</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>&quot;e1&quot;</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),
});
}
}
+12 -1
View File
@@ -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>&quot;root&quot;</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>&quot;folderetag&quot;</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>&quot;abc&quot;</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>&quot;pct&quot;</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`);
});
});
+64
View File
@@ -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}`;
}