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:
@@ -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('startFlow')).toEqual([1, 'connect/flow']);
|
||||
expect(route('disconnect')).toEqual([3, 'connect']);
|
||||
expect(route('list')).toEqual([0, 'files']);
|
||||
expect(route('remove')).toEqual([3, 'files']);
|
||||
expect(route('createFolder')).toEqual([1, 'folders']);
|
||||
expect(route('move')).toEqual([1, 'move']);
|
||||
expect(route('preview')).toEqual([0, 'preview']);
|
||||
expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']);
|
||||
expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']);
|
||||
});
|
||||
@@ -87,6 +92,28 @@ describe('NextcloudFilesController — Metadaten', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('die Datei-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => {
|
||||
for (const name of ['list', 'remove', 'createFolder', 'move', 'preview']) {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('die Datei-Handler stehen vor dem Parameterblock', () => {
|
||||
const names = routeHandlers();
|
||||
const firstParam = names.findIndex((n) =>
|
||||
String(Reflect.getMetadata('path', proto[n])).includes(':'),
|
||||
);
|
||||
for (const name of ['list', 'remove', 'createFolder', 'move', 'preview']) {
|
||||
expect(names.indexOf(name), name).toBeLessThan(firstParam);
|
||||
}
|
||||
});
|
||||
|
||||
it('verschieben antwortet 200, Ordner anlegen bleibt bei 201', () => {
|
||||
expect(Reflect.getMetadata('__httpCode__', proto.move)).toBe(200);
|
||||
expect(Reflect.getMetadata('__httpCode__', proto.createFolder)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Passwort- und Browser-Anmeldung antworten 200, nicht 201', () => {
|
||||
expect(Reflect.getMetadata('__httpCode__', proto.connectPassword)).toBe(200);
|
||||
expect(Reflect.getMetadata('__httpCode__', proto.startFlow)).toBe(200);
|
||||
@@ -138,10 +165,59 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function makeFiles() {
|
||||
return {
|
||||
list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })),
|
||||
remove: vi.fn(async (..._a: unknown[]) => ({ deleted: true })),
|
||||
createFolder: vi.fn(async (..._a: unknown[]) => ({ path: '/x' })),
|
||||
move: vi.fn(async (..._a: unknown[]) => ({ from: '/a', to: '/b' })),
|
||||
preview: vi.fn(async (..._a: unknown[]) => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
it('Datei-Handler: Mandant und Benutzer aus dem Token, Pfade aus Query und Body', async () => {
|
||||
const files = makeFiles();
|
||||
const controller = new NextcloudFilesController(
|
||||
makeSettings() as any,
|
||||
makeAccount() as any,
|
||||
files as any,
|
||||
);
|
||||
const r = userReq('t1', 'u1');
|
||||
const res = { end: vi.fn() } as any;
|
||||
await controller.list(r, { path: '/Projekte' } as any);
|
||||
await controller.remove(r, { path: '/Projekte/alt.txt' } as any);
|
||||
await controller.remove(r, {} as any);
|
||||
await controller.createFolder(r, { path: '/Neu', userId: 'fremd' } as any);
|
||||
await controller.move(r, { from: '/a', to: '/b' } as any);
|
||||
await controller.preview(r, res, { fileId: '42', v: '"e"' } as any);
|
||||
expect(files.list).toHaveBeenCalledWith('t1', 'u1', '/Projekte');
|
||||
expect(files.remove).toHaveBeenNthCalledWith(1, 't1', 'u1', '/Projekte/alt.txt');
|
||||
expect(files.remove).toHaveBeenNthCalledWith(2, 't1', 'u1', '');
|
||||
expect(files.createFolder).toHaveBeenCalledWith('t1', 'u1', '/Neu');
|
||||
expect(files.move).toHaveBeenCalledWith('t1', 'u1', '/a', '/b');
|
||||
expect(files.preview).toHaveBeenCalledWith(res, 't1', 'u1', '42', '"e"');
|
||||
});
|
||||
|
||||
it('Datei-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
|
||||
const files = makeFiles();
|
||||
const controller = new NextcloudFilesController(
|
||||
makeSettings() as any,
|
||||
makeAccount() as any,
|
||||
files as any,
|
||||
);
|
||||
const r = userReq('t1', undefined);
|
||||
await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(controller.move(r, { from: '/a', to: '/b' } as any)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(files.list).not.toHaveBeenCalled();
|
||||
expect(files.move).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reicht Mandant und Benutzer aus dem Token weiter, nie aus dem Body', async () => {
|
||||
const settings = makeSettings();
|
||||
const account = makeAccount();
|
||||
const controller = new NextcloudFilesController(settings as any, account as any);
|
||||
const controller = new NextcloudFilesController(settings as any, account as any, makeFiles() as any);
|
||||
await controller.getStatus(userReq('t1', 'u1'));
|
||||
await controller.getSettings(req('t1'));
|
||||
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
|
||||
@@ -167,7 +243,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
});
|
||||
|
||||
it('ohne Mandantenkontext: ForbiddenException', async () => {
|
||||
const controller = new NextcloudFilesController(makeSettings() as any, makeAccount() as any);
|
||||
const controller = new NextcloudFilesController(makeSettings() as any, makeAccount() as any, makeFiles() as any);
|
||||
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
@@ -179,7 +255,7 @@ describe('NextcloudFilesController — Delegation', () => {
|
||||
|
||||
it('ohne Benutzer im Token: ForbiddenException, kein Aufruf des Kontodienstes', async () => {
|
||||
const account = makeAccount();
|
||||
const controller = new NextcloudFilesController(makeSettings() as any, account as any);
|
||||
const controller = new NextcloudFilesController(makeSettings() as any, account as any, makeFiles() as any);
|
||||
await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
@@ -9,15 +9,25 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||
import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto';
|
||||
import {
|
||||
CreateFolderDto,
|
||||
MoveDto,
|
||||
PathQueryDto,
|
||||
PreviewQueryDto,
|
||||
} from './dto/nextcloud-files-ops.dto';
|
||||
import {
|
||||
SaveNextcloudFilesSettingsDto,
|
||||
TestNextcloudFilesSettingsDto,
|
||||
} from './dto/nextcloud-files-settings.dto';
|
||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||
|
||||
@@ -58,6 +68,7 @@ export class NextcloudFilesController {
|
||||
constructor(
|
||||
private readonly settings: NextcloudFilesSettingsService,
|
||||
private readonly account: NextcloudFilesAccountService,
|
||||
private readonly files: NextcloudFilesService,
|
||||
) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
@@ -126,6 +137,45 @@ export class NextcloudFilesController {
|
||||
return this.account.disconnect(this.requireTenantId(req), this.requireUserId(req));
|
||||
}
|
||||
|
||||
// --- Dateien (Benutzen; jeder arbeitet nur im eigenen Konto) --------------------------
|
||||
|
||||
@Get('files')
|
||||
async list(@Req() req: AuthenticatedRequest, @Query() query: PathQueryDto) {
|
||||
return this.files.list(this.requireTenantId(req), this.requireUserId(req), query.path);
|
||||
}
|
||||
|
||||
@Delete('files')
|
||||
async remove(@Req() req: AuthenticatedRequest, @Query() query: PathQueryDto) {
|
||||
return this.files.remove(this.requireTenantId(req), this.requireUserId(req), query.path ?? '');
|
||||
}
|
||||
|
||||
@Post('folders')
|
||||
async createFolder(@Req() req: AuthenticatedRequest, @Body() dto: CreateFolderDto) {
|
||||
return this.files.createFolder(this.requireTenantId(req), this.requireUserId(req), dto.path);
|
||||
}
|
||||
|
||||
@Post('move')
|
||||
@HttpCode(200)
|
||||
async move(@Req() req: AuthenticatedRequest, @Body() dto: MoveDto) {
|
||||
return this.files.move(this.requireTenantId(req), this.requireUserId(req), dto.from, dto.to);
|
||||
}
|
||||
|
||||
/** `<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) ---------------
|
||||
|
||||
@Get('connect/flow/:flowId')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ModuleRegistryService } from '../module-registry/module-registry.servic
|
||||
import { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import { NextcloudFilesController } from './nextcloud-files.controller';
|
||||
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||
import {
|
||||
defaultStatusFetcher,
|
||||
@@ -26,6 +27,7 @@ import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
|
||||
providers: [
|
||||
NextcloudFilesSettingsService,
|
||||
NextcloudFilesAccountService,
|
||||
NextcloudFilesService,
|
||||
NextcloudLoginGuard,
|
||||
LoginFlowStore,
|
||||
NextcloudCallGate,
|
||||
|
||||
@@ -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,
|
||||
segments: readonly string[] = [],
|
||||
query?: Record<string, string>,
|
||||
trailingSlash = false,
|
||||
): string {
|
||||
if (!(ALLOWED_PREFIXES as readonly string[]).includes(prefix)) {
|
||||
throw new Error(`Nextcloud-Pfadanfang nicht erlaubt: ${prefix}`);
|
||||
@@ -179,6 +180,8 @@ export function buildNcUrl(
|
||||
throw new Error(`Nextcloud-Pfadanfang ${prefix} nimmt keine Segmente`);
|
||||
}
|
||||
let url = `${base}${prefix}${encodeSegments(segments)}`;
|
||||
// Ordner-Adressen enden fuer WebDAV mit einem Schraegstrich (PROPFIND auf Ordner).
|
||||
if (trailingSlash && !url.endsWith('/')) url += '/';
|
||||
if (query) {
|
||||
const pairs = Object.entries(query).map(
|
||||
([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
|
||||
@@ -200,6 +203,8 @@ export interface NcRequestOptions {
|
||||
prefix: NcPrefix;
|
||||
segments?: readonly string[];
|
||||
query?: Record<string, string>;
|
||||
/** Haengt der Adresse einen `/` an (Ordner bei WebDAV). */
|
||||
trailingSlash?: boolean;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: Readable | string | Buffer | null;
|
||||
@@ -297,7 +302,13 @@ export async function ncRequest(
|
||||
gate: NextcloudCallGate,
|
||||
opts: NcRequestOptions,
|
||||
): 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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user