feat(nextcloud-files): Hoch- und Herunterladen als Datenstrom, große Dateien in Stücken, ZIP

- Hochladen ohne Pufferung: ganze Datei oder Chunked Upload v2 in 8-MiB-Stücken, Zusammenbau im Hintergrund mit abfragbarem Zustand
- Überschreibschutz (If-None-Match / Entity-Tag-Prüfung), Download mit Range, Content-Disposition und Schutzkopfzeilen von Tessera selbst
- Ordner- und Auswahl-ZIP; jeder Nextcloud-Fehler wird vor dem ersten Byte abgebildet, nie 401/403
- Browser-Hochlader mit Fortschritt, Wiederholung, Abbruch und Aufräumen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:30:02 +02:00
parent 8bee65c306
commit 4d4a0b31cb
15 changed files with 3431 additions and 6 deletions
@@ -155,6 +155,14 @@ describe('Umgestellte Handler (Verwalten)', () => {
'createFolder',
'move',
'preview',
'download',
'downloadZip',
'startUpload',
'putSingle',
'putChunk',
'completeUpload',
'uploadState',
'abortUpload',
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(NextcloudFilesController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
@@ -0,0 +1,121 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
/**
* Eingaben der Uebertragungsrouten (quick-261008-mzu, D-J/D-K). Pfade stehen nur
* in Query oder Body, nie im URL-Pfad der Tessera-Route. Die Segmentpruefung macht
* der Dienst (`parseUserPath`, `validateSegment`); hier stehen nur grobe Grenzen.
*
* Absichtlich KEIN `@Max` auf `size`: eine zu grosse Datei soll mit 413
* `fileTooLarge` und lesbarem Text abgelehnt werden, nicht mit einem 400 der
* allgemeinen Pruefung.
*/
/** Hoechster Unix-Zeitstempel (Sekunden), den eine Aenderungszeit haben darf (Jahr 2100). */
export const MAX_MTIME_SECONDS = 4102444800;
export class StartUploadDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
@IsInt()
@Min(0)
size!: number;
/** Entity-Tag der Version, die ersetzt werden soll (nach Rueckfrage beim Benutzer). */
@IsOptional()
@IsString()
@MaxLength(200)
replaceEtag?: string;
}
export class CompleteUploadDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
@IsInt()
@Min(0)
size!: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(MAX_MTIME_SECONDS)
mtime?: number;
@IsOptional()
@IsString()
@MaxLength(200)
replaceEtag?: string;
}
/** Query der Raw-Body-Routen `PUT uploads/file` und `PUT uploads/:uploadId/chunks/:n`. */
export class UploadQueryDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
/** Gesamtgroesse der Datei in Byte. */
@Type(() => Number)
@IsInt()
@Min(0)
size!: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(MAX_MTIME_SECONDS)
mtime?: number;
@IsOptional()
@IsString()
@MaxLength(200)
replaceEtag?: string;
}
export class DownloadQueryDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
/** `1`: den Ordner als ZIP laden statt einer Datei. */
@IsOptional()
@IsIn(['1'])
zip?: string;
}
export class ZipQueryDto {
/** Ordner, aus dem die gewaehlten Eintraege kommen (`/` = Wurzel). */
@IsOptional()
@IsString()
@MaxLength(4096)
dir?: string;
/** Namen der gewaehlten Eintraege; ein einzelnes `name=...` wird zur Liste. */
@Transform(({ value }) => (Array.isArray(value) ? value : value === undefined ? [] : [value]))
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@IsString({ each: true })
@MaxLength(255, { each: true })
name!: string[];
}
@@ -0,0 +1,273 @@
import { Readable } from 'node:stream';
import { describe, expect, it } from 'vitest';
import { NextcloudCallGate } from './nextcloud-call-gate';
import {
chunkName,
download,
downloadFolderZip,
downloadSelectionZip,
putFile,
sanitizeRange,
uploadAbort,
uploadAssemble,
uploadChunk,
uploadStart,
} from './nextcloud-dav-transfer';
import type { NcSession } from './nextcloud-files.types';
import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http';
const SESSION: NcSession = {
baseUrl: 'https://cloud.example/nc',
ncUserId: 'anna',
authorization: 'Basic YW5uYTphcHAtcHctMTIz',
credentialKey: 'k1',
};
const UA = 'Tessera (Nextcloud-Dateien)';
const AUTH = 'Basic YW5uYTphcHAtcHctMTIz';
const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c';
const DEST = 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/gro%C3%9F.bin';
function fake(status: number, headers: Record<string, string> = {}) {
const calls: NcTransportRequest[] = [];
const transport: NextcloudTransport = async (req) => {
calls.push(req);
const res: NcTransportResponse = { statusCode: status, headers, body: Readable.from([]) };
return res;
};
return { transport, calls };
}
describe('nextcloud-dav-transfer — Hochladen', () => {
it('putFile: PUT mit If-None-Match: *, content-length, X-OC-Mtime und demselben Strom als Koerper', async () => {
const { transport, calls } = fake(201);
const body = Readable.from([Buffer.from('hallo')]);
const res = await putFile(
transport,
new NextcloudCallGate(),
SESSION,
['Projekte', 'a.txt'],
body,
{
size: 5,
mtime: 1760000000,
},
);
expect(res).toMatchObject({ ok: true, status: 201 });
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('PUT');
expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a.txt');
expect(calls[0].headers).toEqual({
'user-agent': UA,
'content-length': '5',
'if-none-match': '*',
'x-oc-mtime': '1760000000',
authorization: AUTH,
});
// Identitaet: nichts wird gelesen oder kopiert, der Strom geht unveraendert durch.
expect(calls[0].body).toBe(body);
expect(calls[0].headersTimeoutMs).toBe(120_000);
expect(calls[0].bodyTimeoutMs).toBe(30_000);
});
it('putFile mit replaceEtag: If-Match statt If-None-Match, ohne Mtime keine Mtime-Kopfzeile', async () => {
const { transport, calls } = fake(204);
await putFile(transport, new NextcloudCallGate(), SESSION, ['a.txt'], Readable.from([]), {
size: 0,
replaceEtag: '"abc"',
});
expect(calls[0].headers['if-match']).toBe('"abc"');
expect(calls[0].headers['if-none-match']).toBeUndefined();
expect(calls[0].headers['x-oc-mtime']).toBeUndefined();
expect(calls[0].headers['content-length']).toBe('0');
});
it('uploadStart: MKCOL des Upload-Ordners mit Destination aus der Basis der Sitzung', async () => {
const { transport, calls } = fake(201);
await uploadStart(transport, new NextcloudCallGate(), SESSION, UP, ['Projekte', 'groß.bin']);
expect(calls[0].method).toBe('MKCOL');
expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`);
expect(calls[0].headers.destination).toBe(DEST);
expect(calls[0].body).toBeNull();
});
it('uploadChunk: Teilstueck 3 heisst 00003, mit Destination, OC-Total-Length und content-length', async () => {
const { transport, calls } = fake(201);
const body = Readable.from([]);
await uploadChunk(
transport,
new NextcloudCallGate(),
SESSION,
UP,
3,
['Projekte', 'groß.bin'],
body,
{
size: 8388608,
totalLength: 30000000,
},
);
expect(calls[0].method).toBe('PUT');
expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/00003`);
expect(calls[0].headers).toEqual({
'user-agent': UA,
destination: DEST,
'oc-total-length': '30000000',
'content-length': '8388608',
authorization: AUTH,
});
expect(calls[0].body).toBe(body);
expect(calls[0].headersTimeoutMs).toBe(120_000);
expect(calls[0].bodyTimeoutMs).toBe(30_000);
});
it('chunkName: fuenfstellig; ausserhalb 1..10000 oder nicht ganzzahlig wirft', () => {
expect(chunkName(1)).toBe('00001');
expect(chunkName(10000)).toBe('10000');
expect(() => chunkName(0)).toThrow();
expect(() => chunkName(10001)).toThrow();
expect(() => chunkName(1.5)).toThrow();
});
it('uploadAssemble: MOVE .file mit Destination, Gesamtgroesse, Mtime und Overwrite: F', async () => {
const { transport, calls } = fake(201);
await uploadAssemble(
transport,
new NextcloudCallGate(),
SESSION,
UP,
['Projekte', 'groß.bin'],
{
totalLength: 30000000,
mtime: 1760000000,
},
);
expect(calls[0].method).toBe('MOVE');
expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/.file`);
expect(calls[0].headers).toEqual({
'user-agent': UA,
destination: DEST,
'oc-total-length': '30000000',
overwrite: 'F',
'x-oc-mtime': '1760000000',
authorization: AUTH,
});
expect(calls[0].headersTimeoutMs).toBe(30 * 60_000);
});
it('uploadAssemble: Overwrite T nur, wenn der Aufrufer ausdruecklich ersetzt', async () => {
const { transport, calls } = fake(204);
await uploadAssemble(transport, new NextcloudCallGate(), SESSION, UP, ['a'], {
totalLength: 1,
replace: true,
});
expect(calls[0].headers.overwrite).toBe('T');
expect(calls[0].headers['x-oc-mtime']).toBeUndefined();
});
it('uploadAbort: DELETE des Upload-Ordners ohne Destination', async () => {
const { transport, calls } = fake(204);
await uploadAbort(transport, new NextcloudCallGate(), SESSION, UP);
expect(calls[0].method).toBe('DELETE');
expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`);
expect(calls[0].headers.destination).toBeUndefined();
});
it('eine ungueltige Upload-Kennung wirft vor jedem Aufruf', async () => {
const { transport, calls } = fake(204);
await expect(
uploadAbort(transport, new NextcloudCallGate(), SESSION, '../x'),
).rejects.toMatchObject({ response: { code: 'invalidPath' } });
expect(calls).toHaveLength(0);
});
it('die Aufrufsperre gilt: ein toter Zugangsschluessel ruft nichts auf', async () => {
const gate = new NextcloudCallGate();
gate.markDead('k1');
const { transport, calls } = fake(201);
const res = await uploadStart(transport, gate, SESSION, UP, ['a']);
expect(res).toEqual({ ok: false, kind: 'credential-dead' });
expect(calls).toHaveLength(0);
});
});
describe('nextcloud-dav-transfer — Herunterladen', () => {
it('download: GET mit durchgereichtem Range; der Koerper bleibt offen', async () => {
const { transport, calls } = fake(206, { 'content-range': 'bytes 0-99/500' });
const res = await download(
transport,
new NextcloudCallGate(),
SESSION,
['Projekte', 'a b.txt'],
{
range: 'bytes=0-99',
},
);
expect(res).toMatchObject({ ok: true, status: 206 });
expect(calls[0].method).toBe('GET');
expect(calls[0].url).toBe(
'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a%20b.txt',
);
expect(calls[0].headers.range).toBe('bytes=0-99');
expect(calls[0].headersTimeoutMs).toBe(30_000);
expect(calls[0].bodyTimeoutMs).toBe(60_000);
});
it('download: ohne Range keine Range-Kopfzeile; ein unsinniger Range wird verworfen', async () => {
const { transport, calls } = fake(200);
await download(transport, new NextcloudCallGate(), SESSION, ['a']);
await download(transport, new NextcloudCallGate(), SESSION, ['a'], { range: 'bytes=0-1,5-9' });
await download(transport, new NextcloudCallGate(), SESSION, ['a'], {
range: 'x\r\nhost: evil',
});
expect(calls.map((c) => c.headers.range)).toEqual([undefined, undefined, undefined]);
expect(sanitizeRange('bytes=100-')).toBe('bytes=100-');
expect(sanitizeRange('bytes=-50')).toBe('bytes=-50');
});
it('downloadFolderZip: GET auf den Ordner mit Schraegstrich und ?accept=zip', async () => {
const { transport, calls } = fake(200);
await downloadFolderZip(transport, new NextcloudCallGate(), SESSION, ['Projekte']);
expect(calls[0].method).toBe('GET');
expect(calls[0].url).toBe(
'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/?accept=zip',
);
});
it('downloadFolderZip der Wurzel: .../files/anna/?accept=zip', async () => {
const { transport, calls } = fake(200);
await downloadFolderZip(transport, new NextcloudCallGate(), SESSION, []);
expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/?accept=zip');
});
it('downloadSelectionZip: ?accept=zip&files=<JSON-Liste der Namen>, codiert', async () => {
const { transport, calls } = fake(200);
await downloadSelectionZip(
transport,
new NextcloudCallGate(),
SESSION,
['Projekte'],
['a.txt', 'Ärger'],
);
const url = new URL(calls[0].url);
expect(url.pathname).toBe('/nc/remote.php/dav/files/anna/Projekte/');
expect(url.searchParams.get('accept')).toBe('zip');
expect(JSON.parse(url.searchParams.get('files') ?? '')).toEqual(['a.txt', 'Ärger']);
expect(calls[0].url).toContain('files=%5B%22a.txt%22%2C%22%C3%84rger%22%5D');
});
it('downloadSelectionZip: ein Name wie .. wirft invalidPath, bevor ein Aufruf rausgeht', async () => {
const { transport, calls } = fake(200);
for (const bad of ['..', 'a/b', '', '.']) {
await expect(
downloadSelectionZip(
transport,
new NextcloudCallGate(),
SESSION,
['Projekte'],
['ok.txt', bad],
),
).rejects.toMatchObject({ response: { code: 'invalidPath' } });
}
expect(calls).toHaveLength(0);
});
});
@@ -0,0 +1,248 @@
import type { Readable } from 'node:stream';
import type { NextcloudCallGate } from './nextcloud-call-gate';
import { davRequest, destinationUrl } from './nextcloud-dav';
import type { NcSession } from './nextcloud-files.types';
import {
discardBody,
type NcResult,
type NextcloudTransport,
validateSegment,
} from './nextcloud-http';
/**
* Uebertragungsschicht des Moduls "Nextcloud-Dateien" (quick-261008-mzu, D-C/D-J/D-K)
* auf Basis von `davRequest`: Hochladen als Strom (ganze Datei oder Chunked Upload v2),
* Herunterladen und ZIP. Frei von Nest; jeder Aufruf geht durch die Aufrufsperre und
* traegt den Zugangsschluessel der Sitzung.
*
* Nichts wird hier gepuffert: Anfragekoerper sind `Readable`s, die unveraendert an den
* Transport gehen, Antwortkoerper bleiben offen und gehoeren dem Aufrufer. Das
* `Destination` jedes Aufrufs wird aus der Basis der Sitzung gebaut, nie aus einer Eingabe.
*/
/** Kopfzeilen-Wartezeit eines Teilstueck-PUT; danach 30 s Leerlauf im Koerper (D-C). */
export const DAV_CHUNK_HEADERS_TIMEOUT_MS = 120_000;
export const DAV_CHUNK_BODY_TIMEOUT_MS = 30_000;
/** Der Zusammenbau (MOVE .file) kann bei grossen Dateien Minuten dauern (D-C: 30 min). */
export const DAV_ASSEMBLE_HEADERS_TIMEOUT_MS = 30 * 60_000;
/** Downloads: 30 s bis zu den Kopfzeilen, 60 s Leerlauf im Koerper (D-C). */
export const DAV_DOWNLOAD_HEADERS_TIMEOUT_MS = 30_000;
export const DAV_DOWNLOAD_BODY_TIMEOUT_MS = 60_000;
/** Nextcloud-Teilstueck-Name: Zahl 1..10000, fuenfstellig mit fuehrenden Nullen (`00003`). */
export function chunkName(n: number): string {
if (!Number.isInteger(n) || n < 1 || n > 10_000)
throw new RangeError('Teilstueck ausserhalb 1..10000');
return String(n).padStart(5, '0');
}
function discard(result: NcResult): NcResult {
if (result.ok) discardBody(result.body);
return result;
}
export interface PutFileOptions {
/** Laenge des Koerpers in Byte (wird als `content-length` gesendet). */
size: number;
/** Aenderungszeit der Datei in Sekunden seit 1970 (`X-OC-Mtime`). */
mtime?: number;
/** Entity-Tag der zu ersetzenden Version: `If-Match` statt `If-None-Match: *`. */
replaceEtag?: string;
signal?: AbortSignal;
}
/**
* Ganze Datei in einem Aufruf schreiben (PUT). Ohne `replaceEtag` sendet Tessera
* `If-None-Match: *` — ein vorhandenes Ziel wird nie still ueberschrieben (412);
* mit `replaceEtag` ersetzt `If-Match` genau die Version, die der Benutzer sah.
*/
export async function putFile(
transport: NextcloudTransport,
gate: NextcloudCallGate,
session: NcSession,
segments: readonly string[],
body: Readable,
opts: PutFileOptions,
): Promise<NcResult> {
const headers: Record<string, string> = { 'content-length': String(opts.size) };
if (opts.replaceEtag) headers['if-match'] = opts.replaceEtag;
else headers['if-none-match'] = '*';
if (opts.mtime !== undefined) headers['x-oc-mtime'] = String(opts.mtime);
return discard(
await davRequest(transport, gate, session, 'PUT', '/remote.php/dav/files/', segments, {
headers,
body,
headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS,
bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS,
signal: opts.signal,
}),
);
}
/** Upload-Ordner anlegen (MKCOL) — der Anfang von Chunked Upload v2. */
export async function uploadStart(
transport: NextcloudTransport,
gate: NextcloudCallGate,
session: NcSession,
uploadId: string,
target: readonly string[],
): Promise<NcResult> {
return discard(
await davRequest(transport, gate, session, 'MKCOL', '/remote.php/dav/uploads/', [uploadId], {
headers: { destination: destinationUrl(session, target) },
}),
);
}
export interface UploadChunkOptions {
/** Laenge dieses Teilstuecks in Byte. */
size: number;
/** Gesamtgroesse der Datei: loest die Speicherplatzpruefung von Nextcloud sofort aus. */
totalLength: number;
signal?: AbortSignal;
}
/** Teilstueck `n` (1..10000) schreiben; dieselbe Nummer ueberschreibt (Wiederholung ist sicher). */
export async function uploadChunk(
transport: NextcloudTransport,
gate: NextcloudCallGate,
session: NcSession,
uploadId: string,
n: number,
target: readonly string[],
body: Readable,
opts: UploadChunkOptions,
): Promise<NcResult> {
return discard(
await davRequest(
transport,
gate,
session,
'PUT',
'/remote.php/dav/uploads/',
[uploadId, chunkName(n)],
{
headers: {
destination: destinationUrl(session, target),
'oc-total-length': String(opts.totalLength),
'content-length': String(opts.size),
},
body,
headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS,
bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS,
signal: opts.signal,
},
),
);
}
export interface UploadAssembleOptions {
totalLength: number;
mtime?: number;
/** true nur nach geprueftem Ersetzen (Entity-Tag stimmte): `Overwrite: T`, sonst `F`. */
replace?: boolean;
}
/** Teilstuecke zur Datei zusammenbauen (MOVE `.file`); kann bei grossen Dateien Minuten dauern. */
export async function uploadAssemble(
transport: NextcloudTransport,
gate: NextcloudCallGate,
session: NcSession,
uploadId: string,
target: readonly string[],
opts: UploadAssembleOptions,
): Promise<NcResult> {
const headers: Record<string, string> = {
destination: destinationUrl(session, target),
'oc-total-length': String(opts.totalLength),
overwrite: opts.replace ? 'T' : 'F',
};
if (opts.mtime !== undefined) headers['x-oc-mtime'] = String(opts.mtime);
return discard(
await davRequest(
transport,
gate,
session,
'MOVE',
'/remote.php/dav/uploads/',
[uploadId, '.file'],
{ headers, headersTimeoutMs: DAV_ASSEMBLE_HEADERS_TIMEOUT_MS },
),
);
}
/** Upload-Ordner loeschen (ohne `Destination`); auch nach einem 412 beim Zusammenbau noetig. */
export async function uploadAbort(
transport: NextcloudTransport,
gate: NextcloudCallGate,
session: NcSession,
uploadId: string,
): Promise<NcResult> {
return discard(
await davRequest(transport, gate, session, 'DELETE', '/remote.php/dav/uploads/', [uploadId]),
);
}
/** Nur ein einzelner Bytebereich (`bytes=0-99`, `bytes=100-`, `bytes=-50`) wird durchgereicht. */
const RANGE_RE = /^bytes=(\d+-\d*|-\d+)$/;
export function sanitizeRange(range: string | undefined): string | undefined {
return range !== undefined && RANGE_RE.test(range) ? range : undefined;
}
/** Datei herunterladen (GET); ein gueltiger `Range` geht unveraendert mit. Der Koerper bleibt offen. */
export function download(
transport: NextcloudTransport,
gate: NextcloudCallGate,
session: NcSession,
segments: readonly string[],
opts: { range?: string; signal?: AbortSignal } = {},
): Promise<NcResult> {
const range = sanitizeRange(opts.range);
return davRequest(transport, gate, session, 'GET', '/remote.php/dav/files/', segments, {
headers: range ? { range } : undefined,
headersTimeoutMs: DAV_DOWNLOAD_HEADERS_TIMEOUT_MS,
bodyTimeoutMs: DAV_DOWNLOAD_BODY_TIMEOUT_MS,
signal: opts.signal,
});
}
/** Ganzen Ordner als ZIP (`?accept=zip`); die Wurzel (`[]`) ist der ganze Dateibereich. */
export function downloadFolderZip(
transport: NextcloudTransport,
gate: NextcloudCallGate,
session: NcSession,
segments: readonly string[],
signal?: AbortSignal,
): Promise<NcResult> {
return davRequest(transport, gate, session, 'GET', '/remote.php/dav/files/', segments, {
query: { accept: 'zip' },
trailingSlash: true,
headersTimeoutMs: DAV_DOWNLOAD_HEADERS_TIMEOUT_MS,
bodyTimeoutMs: DAV_DOWNLOAD_BODY_TIMEOUT_MS,
signal,
});
}
/**
* Nur die gewaehlten Eintraege eines Ordners als ZIP (`?accept=zip&files=<JSON-Liste>`,
* gemessen: Nextcloud packt genau diese Eintraege ohne Ordnerhuelle). Jeder Name laeuft
* VOR dem Aufruf durch `validateSegment` — ein Name wie `..` wirft `invalidPath`.
*/
export async function downloadSelectionZip(
transport: NextcloudTransport,
gate: NextcloudCallGate,
session: NcSession,
dir: readonly string[],
names: readonly string[],
signal?: AbortSignal,
): Promise<NcResult> {
const checked = names.map(validateSegment);
return davRequest(transport, gate, session, 'GET', '/remote.php/dav/files/', dir, {
query: { accept: 'zip', files: JSON.stringify(checked) },
trailingSlash: true,
headersTimeoutMs: DAV_DOWNLOAD_HEADERS_TIMEOUT_MS,
bodyTimeoutMs: DAV_DOWNLOAD_BODY_TIMEOUT_MS,
signal,
});
}
@@ -0,0 +1,804 @@
import { PassThrough, Readable, Writable } from 'node:stream';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { NextcloudCallGate } from './nextcloud-call-gate';
import { type NcSession, ncErrorDefault } from './nextcloud-files.types';
import {
ASSEMBLY_STATE_TTL_MS,
ASSEMBLY_WAIT_MS,
contentDispositionFor,
MAX_UPLOAD_BYTES,
NextcloudFilesTransferService,
type RawUploadRequest,
} from './nextcloud-files-transfer.service';
import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http';
const SESSION: NcSession = {
baseUrl: 'https://cloud.example/nc',
ncUserId: 'anna',
authorization: 'Basic YW5uYTphcHAtcHctMTIz',
credentialKey: 'k1',
};
const CHUNK = 8388608;
const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c';
const NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"';
const statXml = (etag: string) => `<?xml version="1.0"?><d:multistatus ${NS}>
<d:response><d:href>/nc/remote.php/dav/files/anna/Projekte/a.txt</d:href><d:propstat><d:prop>
<d:getcontentlength>3</d:getcontentlength><d:getetag>&quot;${etag}&quot;</d:getetag>
<d:getlastmodified>Tue, 06 Oct 2026 08:00:00 GMT</d:getlastmodified><d:resourcetype/></d:prop>
<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>`;
type Reply = { status: number; text?: string; headers?: Record<string, string> };
type Handler = (req: NcTransportRequest, n: number) => Reply | Promise<Reply>;
function reply(r: Reply): NcTransportResponse {
return {
statusCode: r.status,
headers: r.headers ?? {},
body: Readable.from(r.text ? [Buffer.from(r.text)] : []),
};
}
function setup(handler: Handler = () => ({ status: 201 })) {
const calls: NcTransportRequest[] = [];
const transport: NextcloudTransport = async (req) => {
calls.push(req);
return reply(await handler(req, calls.length));
};
const account = {
getSession: vi.fn(async (..._a: unknown[]) => SESSION),
markExpired: vi.fn(async (..._a: unknown[]) => {}),
};
const gate = new NextcloudCallGate();
const service = new NextcloudFilesTransferService(account as never, gate, transport);
return { service, account, calls, gate };
}
/** Rohe Anfrage: ein Strom mit Kopfzeilen; `length` undefiniert = keine content-length-Kopfzeile. */
function rawReq(length: number | undefined, extra: Record<string, string> = {}) {
const stream = new PassThrough() as unknown as RawUploadRequest;
stream.headers = {
...(length === undefined ? {} : { 'content-length': String(length) }),
...extra,
};
return stream;
}
class FakeRes extends Writable {
code = 200;
headers: Record<string, string> = {};
written: Buffer[] = [];
status(c: number) {
this.code = c;
return this;
}
setHeader(n: string, v: string) {
this.headers[n.toLowerCase()] = String(v);
return this;
}
_write(chunk: Buffer, _e: BufferEncoding, cb: () => void) {
this.written.push(Buffer.from(chunk));
cb();
}
}
const finished = (res: Writable) =>
new Promise<void>((resolve) => {
if (res.writableFinished || res.destroyed) resolve();
else {
res.once('finish', () => resolve());
res.once('close', () => resolve());
}
});
function parts(e: unknown): { status: number; body: Record<string, unknown> } {
const ex = e as { getStatus(): number; getResponse(): Record<string, unknown> };
return { status: ex.getStatus(), body: ex.getResponse() };
}
async function caught(p: Promise<unknown>): Promise<unknown> {
try {
await p;
} catch (e) {
return e;
}
throw new Error('es wurde keine Ausnahme geworfen');
}
const urlsOf = (calls: NcTransportRequest[]) => calls.map((c) => `${c.method} ${c.url}`);
afterEach(() => {
vi.useRealTimers();
});
describe('contentDispositionFor', () => {
it('Umlaut und Sonderzeichen: ASCII-Rueckfall und UTF-8-Name nach RFC 5987', () => {
expect(contentDispositionFor('Ärger & Co.txt')).toBe(
`attachment; filename="_rger & Co.txt"; filename*=UTF-8''%C3%84rger%20%26%20Co.txt`,
);
});
it('Anfuehrungszeichen, Backslash, Prozent und Klammern werden entschaerft', () => {
const cd = contentDispositionFor('a"b\\c%d(e)\'f*.txt');
expect(cd).toBe(
`attachment; filename="a_b_c_d(e)'f*.txt"; filename*=UTF-8''a%22b%5Cc%25d%28e%29%27f%2A.txt`,
);
});
});
describe('NextcloudFilesTransferService — startUpload', () => {
it('kleine Datei auf freiem Ziel (stat 404): single, ohne weiteren Aufruf', async () => {
const { service, calls } = setup(() => ({ status: 404 }));
expect(await service.startUpload('t1', 'u1', { path: '/Projekte/a.txt', size: 1000 })).toEqual({
mode: 'single',
});
expect(urlsOf(calls)).toEqual([
'PROPFIND https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a.txt',
]);
});
it('vorhandenes Ziel ohne replaceEtag: 409 nameTaken mit dem vorhandenen Eintrag', async () => {
const { service } = setup(() => ({ status: 207, text: statXml('e1') }));
const e = await caught(service.startUpload('t1', 'u1', { path: '/Projekte/a.txt', size: 5 }));
const { status, body } = parts(e);
expect(status).toBe(409);
expect(body).toMatchObject({
code: 'nameTaken',
existing: { etag: '"e1"', size: 3, mtime: '2026-10-06T08:00:00.000Z' },
});
});
it('replaceEtag gleich -> single; abweichend -> 409 changedMeanwhile; Ziel weg -> changedMeanwhile', async () => {
const same = setup(() => ({ status: 207, text: statXml('e1') }));
expect(
await same.service.startUpload('t1', 'u1', {
path: '/Projekte/a.txt',
size: 5,
replaceEtag: '"e1"',
}),
).toEqual({ mode: 'single' });
const diff = setup(() => ({ status: 207, text: statXml('e2') }));
const { status, body } = parts(
await caught(
diff.service.startUpload('t1', 'u1', {
path: '/Projekte/a.txt',
size: 5,
replaceEtag: '"e1"',
}),
),
);
expect(status).toBe(409);
expect(body.code).toBe('changedMeanwhile');
const gone = setup(() => ({ status: 404 }));
await expect(
gone.service.startUpload('t1', 'u1', {
path: '/Projekte/a.txt',
size: 5,
replaceEtag: '"e1"',
}),
).rejects.toMatchObject({ response: { code: 'changedMeanwhile' } });
});
it('30 MB: legt den Upload-Ordner an und liefert uploadId und chunkSize 8 MiB', async () => {
const { service, calls } = setup((req) =>
req.method === 'PROPFIND' ? { status: 404 } : { status: 201 },
);
const out = await service.startUpload('t1', 'u1', {
path: '/Projekte/groß.bin',
size: 30000000,
});
expect(out).toMatchObject({ mode: 'chunked', chunkSize: CHUNK });
const id = (out as { uploadId: string }).uploadId;
expect(id).toMatch(/^tessera-[0-9a-f-]{36}$/);
expect(calls[1].method).toBe('MKCOL');
expect(calls[1].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${id}`);
expect(calls[1].headers.destination).toBe(
'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/gro%C3%9F.bin',
);
});
it('genau ein Stueck gross ist noch single, ein Byte mehr ist chunked', async () => {
const { service } = setup((req) =>
req.method === 'PROPFIND' ? { status: 404 } : { status: 201 },
);
expect(await service.startUpload('t1', 'u1', { path: '/a.bin', size: CHUNK })).toEqual({
mode: 'single',
});
expect(
await service.startUpload('t1', 'u1', { path: '/a.bin', size: CHUNK + 1 }),
).toMatchObject({
mode: 'chunked',
});
});
it('ueber 10000 Stuecke: 413 fileTooLarge ohne jeden Aufruf', async () => {
const { service, calls, account } = setup();
const { status, body } = parts(
await caught(service.startUpload('t1', 'u1', { path: '/a.bin', size: MAX_UPLOAD_BYTES + 1 })),
);
expect(status).toBe(413);
expect(body.code).toBe('fileTooLarge');
expect(calls).toHaveLength(0);
expect(account.getSession).not.toHaveBeenCalled();
expect(MAX_UPLOAD_BYTES).toBe(10000 * CHUNK);
});
it('ungueltiger Pfad, Wurzel oder verbotener Name ruft nichts auf', async () => {
const { service, calls } = setup();
await expect(service.startUpload('t1', 'u1', { path: '/../x', size: 1 })).rejects.toMatchObject(
{
response: { code: 'invalidPath' },
},
);
await expect(service.startUpload('t1', 'u1', { path: '/', size: 1 })).rejects.toMatchObject({
response: { code: 'invalidPath' },
});
await expect(
service.startUpload('t1', 'u1', { path: '/x.part', size: 1 }),
).rejects.toMatchObject({
response: { code: 'invalidName' },
});
expect(calls).toHaveLength(0);
});
it('MKCOL scheitert (507): quotaExceeded', async () => {
const { service } = setup((req) =>
req.method === 'PROPFIND' ? { status: 404 } : { status: 507 },
);
await expect(
service.startUpload('t1', 'u1', { path: '/a.bin', size: 30000000 }),
).rejects.toMatchObject({ response: { code: 'quotaExceeded' }, status: 507 });
});
});
describe('NextcloudFilesTransferService — putSingle / putChunk', () => {
it('putSingle reicht den Anfragestrom unveraendert und mit seiner content-length weiter', async () => {
const { service, calls } = setup();
const req = rawReq(5);
const out = await service.putSingle('t1', 'u1', req, {
path: '/Projekte/a.txt',
size: 5,
mtime: 1760000000,
});
expect(out).toEqual({ path: '/Projekte/a.txt', size: 5 });
expect(calls[0].method).toBe('PUT');
expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a.txt');
expect(calls[0].body).toBe(req);
expect(calls[0].headers['content-length']).toBe('5');
expect(calls[0].headers['if-none-match']).toBe('*');
expect(calls[0].headers['x-oc-mtime']).toBe('1760000000');
});
it('putSingle: 412 -> 409 nameTaken mit dem vorhandenen Eintrag', async () => {
const { service } = setup((req) =>
req.method === 'PUT' ? { status: 412 } : { status: 207, text: statXml('e9') },
);
const { status, body } = parts(
await caught(service.putSingle('t1', 'u1', rawReq(5), { path: '/Projekte/a.txt', size: 5 })),
);
expect(status).toBe(409);
expect(body).toMatchObject({ code: 'nameTaken', existing: { etag: '"e9"' } });
});
it('putChunk: Stueck 3 -> .../uploads/anna/<id>/00003 mit Destination und OC-Total-Length', async () => {
const { service, calls } = setup();
const req = rawReq(CHUNK);
const out = await service.putChunk('t1', 'u1', req, UP, '3', {
path: '/Projekte/groß.bin',
size: 30000000,
});
expect(out).toEqual({ n: 3, received: CHUNK });
expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/00003`);
expect(calls[0].headers['oc-total-length']).toBe('30000000');
expect(calls[0].headers['content-length']).toBe(String(CHUNK));
expect(calls[0].body).toBe(req);
});
it('putChunk: content-length 8388609 -> 413 chunkTooLarge, Transport nie aufgerufen', async () => {
const { service, calls, account } = setup();
const { status, body } = parts(
await caught(
service.putChunk('t1', 'u1', rawReq(CHUNK + 1), UP, '1', {
path: '/a.bin',
size: 30000000,
}),
),
);
expect(status).toBe(413);
expect(body.code).toBe('chunkTooLarge');
expect(calls).toHaveLength(0);
expect(account.getSession).not.toHaveBeenCalled();
});
it('ohne content-length (auch bei chunked Transfer-Encoding): 411 lengthRequired, kein Aufruf', async () => {
const { service, calls } = setup();
const chunked = rawReq(undefined, { 'transfer-encoding': 'chunked' });
const { status, body } = parts(
await caught(
service.putChunk('t1', 'u1', chunked, UP, '1', { path: '/a.bin', size: 30000000 }),
),
);
expect(status).toBe(411);
expect(body.code).toBe('lengthRequired');
await expect(
service.putSingle('t1', 'u1', rawReq(undefined), { path: '/a.txt', size: 1 }),
).rejects.toMatchObject({ status: 411 });
expect(calls).toHaveLength(0);
});
it('putSingle ueber ein Stueck: 413 chunkTooLarge', async () => {
const { service, calls } = setup();
await expect(
service.putSingle('t1', 'u1', rawReq(CHUNK + 1), { path: '/a.txt', size: CHUNK + 1 }),
).rejects.toMatchObject({ response: { code: 'chunkTooLarge' }, status: 413 });
expect(calls).toHaveLength(0);
});
it.each([
['tessera-../x'],
['x'],
[`tessera-${'a'.repeat(35)}`],
[`tessera-${'g'.repeat(36)}`],
])('Upload-Kennung %s -> 400 und kein Aufruf', async (id) => {
const { service, calls } = setup();
const query = { path: '/a.bin', size: 30000000 };
expect(
parts(await caught(service.putChunk('t1', 'u1', rawReq(1), id, '1', query))).status,
).toBe(400);
expect(
parts(await caught(service.completeUpload('t1', 'u1', id, { path: '/a.bin', size: 1 })))
.status,
).toBe(400);
expect(parts(await caught(service.abortUpload('t1', 'u1', id))).status).toBe(400);
expect(() => service.uploadState('t1', 'u1', id)).toThrow();
expect(calls).toHaveLength(0);
});
it.each([
['0'],
['10001'],
['-1'],
['1.5'],
['abc'],
[''],
])('Stuecknummer "%s" -> 400, kein Aufruf', async (n) => {
const { service, calls } = setup();
const { status } = parts(
await caught(
service.putChunk('t1', 'u1', rawReq(1), UP, n, { path: '/a.bin', size: 30000000 }),
),
);
expect(status).toBe(400);
expect(calls).toHaveLength(0);
});
it('Stuecknummern 1 und 10000 sind erlaubt (00001, 10000)', async () => {
const { service, calls } = setup();
await service.putChunk('t1', 'u1', rawReq(1), UP, '1', { path: '/a.bin', size: 30000000 });
await service.putChunk('t1', 'u1', rawReq(1), UP, '10000', { path: '/a.bin', size: 30000000 });
expect(calls.map((c) => c.url.split('/').pop())).toEqual(['00001', '10000']);
});
it('Anfrage bricht vor dem Ende ab: das Signal des Aufrufs an Nextcloud wird abgebrochen', async () => {
const { service, calls } = setup(
(req) =>
new Promise<Reply>((_resolve, reject) => {
req.signal.addEventListener('abort', () => reject(new Error('abgebrochen')));
}),
);
const req = rawReq(100);
const pending = service.putSingle('t1', 'u1', req, { path: '/a.txt', size: 100 });
await vi.waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0].signal.aborted).toBe(false);
req.emit('close');
const { status, body } = parts(await caught(pending));
expect(calls[0].signal.aborted).toBe(true);
expect(status).toBe(504);
expect(body.code).toBe('nextcloudUnavailable');
});
it('Anfrage vollstaendig gelesen (complete): ein close danach bricht den Aufruf NICHT ab', async () => {
let release: (() => void) | undefined;
const { service, calls } = setup(
() =>
new Promise<Reply>((resolve) => {
release = () => resolve({ status: 201 });
}),
);
const req = rawReq(100);
req.complete = true;
const pending = service.putSingle('t1', 'u1', req, { path: '/a.txt', size: 100 });
await vi.waitFor(() => expect(calls).toHaveLength(1));
req.emit('close');
expect(calls[0].signal.aborted).toBe(false);
release?.();
expect(await pending).toEqual({ path: '/a.txt', size: 100 });
});
});
describe('NextcloudFilesTransferService — Zusammenbau', () => {
const dto = { path: '/Projekte/groß.bin', size: 30000000, mtime: 1760000000 };
it('schneller MOVE -> { state: done }; MOVE .file mit Destination, Overwrite: F, Mtime', async () => {
const { service, calls } = setup();
expect(await service.completeUpload('t1', 'u1', UP, dto)).toEqual({ state: 'done' });
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('MOVE');
expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/.file`);
expect(calls[0].headers.overwrite).toBe('F');
expect(calls[0].headers['x-oc-mtime']).toBe('1760000000');
expect(calls[0].headers['oc-total-length']).toBe('30000000');
expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'done' });
});
it('mit replaceEtag: erst pruefen (stat), dann Overwrite: T; weicht der Tag ab -> changedMeanwhile', async () => {
const ok = setup((req) =>
req.method === 'PROPFIND' ? { status: 207, text: statXml('e1') } : { status: 204 },
);
expect(
await ok.service.completeUpload('t1', 'u1', UP, {
...dto,
path: '/Projekte/a.txt',
replaceEtag: '"e1"',
}),
).toEqual({ state: 'done' });
expect(ok.calls.map((c) => c.method)).toEqual(['PROPFIND', 'MOVE']);
expect(ok.calls[1].headers.overwrite).toBe('T');
const changed = setup((req) =>
req.method === 'PROPFIND' ? { status: 207, text: statXml('e2') } : { status: 204 },
);
await expect(
changed.service.completeUpload('t1', 'u1', UP, {
...dto,
path: '/Projekte/a.txt',
replaceEtag: '"e1"',
}),
).rejects.toMatchObject({ response: { code: 'changedMeanwhile' }, status: 409 });
expect(changed.calls.map((c) => c.method)).toEqual(['PROPFIND']);
});
it('MOVE dauert laenger als 20 s: 202-Zustand assembling, spaeter done', async () => {
vi.useFakeTimers();
let release: (() => void) | undefined;
const { service } = setup(
() =>
new Promise<Reply>((resolve) => {
release = () => resolve({ status: 201 });
}),
);
const pending = service.completeUpload('t1', 'u1', UP, dto);
await vi.advanceTimersByTimeAsync(ASSEMBLY_WAIT_MS - 1);
expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'assembling' });
await vi.advanceTimersByTimeAsync(1);
expect(await pending).toEqual({ state: 'assembling' });
expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'assembling' });
// erneuter Aufruf waehrend des Zusammenbaus startet keinen zweiten MOVE
expect(await service.completeUpload('t1', 'u1', UP, dto)).toEqual({ state: 'assembling' });
release?.();
await vi.advanceTimersByTimeAsync(0);
expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'done' });
// 10 Minuten nach dem Ende ist der Zustand weg
await vi.advanceTimersByTimeAsync(ASSEMBLY_STATE_TTL_MS + 1000);
expect(() => service.uploadState('t1', 'u1', UP)).toThrow();
});
it('MOVE scheitert nach dem Fenster (507): Zustand failed quotaExceeded; es gibt keine unbehandelte Ablehnung', async () => {
vi.useFakeTimers();
let release: (() => void) | undefined;
const { service } = setup(
() =>
new Promise<Reply>((resolve) => {
release = () => resolve({ status: 507 });
}),
);
const pending = service.completeUpload('t1', 'u1', UP, dto);
await vi.advanceTimersByTimeAsync(ASSEMBLY_WAIT_MS);
expect(await pending).toEqual({ state: 'assembling' });
release?.();
await vi.advanceTimersByTimeAsync(0);
expect(service.uploadState('t1', 'u1', UP)).toMatchObject({
state: 'failed',
code: 'quotaExceeded',
});
});
it('MOVE 412: Fehler nameTaken, Zustand failed, und der Upload-Ordner wird geloescht', async () => {
const { service, calls } = setup((req) => {
if (req.method === 'MOVE') return { status: 412 };
if (req.method === 'PROPFIND') return { status: 207, text: statXml('e5') };
return { status: 204 };
});
const e = await caught(
service.completeUpload('t1', 'u1', UP, { ...dto, path: '/Projekte/a.txt' }),
);
const { status, body } = parts(e);
expect(status).toBe(409);
expect(body).toMatchObject({ code: 'nameTaken', existing: { etag: '"e5"' } });
expect(service.uploadState('t1', 'u1', UP)).toMatchObject({
state: 'failed',
code: 'nameTaken',
});
const del = calls.find((c) => c.method === 'DELETE');
expect(del?.url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`);
expect(del?.headers.destination).toBeUndefined();
});
it('MOVE 507 im Fenster: 507 quotaExceeded', async () => {
const { service } = setup(() => ({ status: 507 }));
const e = await caught(service.completeUpload('t1', 'u1', UP, dto));
expect(parts(e)).toMatchObject({ status: 507, body: { code: 'quotaExceeded' } });
expect(service.uploadState('t1', 'u1', UP)).toMatchObject({
state: 'failed',
code: 'quotaExceeded',
});
});
it('der Zustand gehoert zu Mandant und Benutzer: ein anderer Benutzer sieht ihn nicht', async () => {
const { service } = setup();
await service.completeUpload('t1', 'u1', UP, dto);
expect(() => service.uploadState('t1', 'u2', UP)).toThrow();
expect(() => service.uploadState('t2', 'u1', UP)).toThrow();
expect(service.uploadState('t1', 'u1', UP)).toEqual({ state: 'done' });
});
it('abortUpload: DELETE des Upload-Ordners; 404 gilt auch als erledigt; mitten im Zusammenbau keiner', async () => {
const { service, calls } = setup((req) =>
req.method === 'DELETE' ? { status: 204 } : { status: 201 },
);
expect(await service.abortUpload('t1', 'u1', UP)).toEqual({ aborted: true });
expect(calls[0].method).toBe('DELETE');
expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`);
const gone = setup(() => ({ status: 404 }));
expect(await gone.service.abortUpload('t1', 'u1', UP)).toEqual({ aborted: true });
vi.useFakeTimers();
const busy = setup(() => new Promise<Reply>(() => {}));
void busy.service.completeUpload('t1', 'u1', UP, dto);
await vi.advanceTimersByTimeAsync(0);
expect(await busy.service.abortUpload('t1', 'u1', UP)).toEqual({ aborted: false });
expect(busy.calls.map((c) => c.method)).toEqual(['MOVE']);
});
});
describe('NextcloudFilesTransferService — Herunterladen', () => {
const FILE = {
status: 200,
text: 'inhalt',
headers: { 'content-type': 'text/plain', 'content-length': '6' },
};
it('Datei: Content-Disposition attachment mit UTF-8-Name, nosniff, CSP sandbox, kein set-cookie', async () => {
const { service, calls } = setup(() => ({
...FILE,
headers: {
...FILE.headers,
'set-cookie': 'oc_sessionPassphrase=geheim',
'content-disposition': 'inline; filename="boese.html"',
'x-powered-by': 'PHP',
},
}));
const res = new FakeRes();
await service.download(res as never, 't1', 'u1', '/Projekte/Ärger & Co.txt');
await finished(res);
expect(calls[0].method).toBe('GET');
expect(calls[0].url).toBe(
'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/%C3%84rger%20%26%20Co.txt',
);
expect(res.code).toBe(200);
expect(res.headers['content-disposition']).toBe(
`attachment; filename="_rger & Co.txt"; filename*=UTF-8''%C3%84rger%20%26%20Co.txt`,
);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['content-security-policy']).toBe("default-src 'none'; sandbox");
expect(res.headers['cache-control']).toBe('private, no-store');
expect(res.headers['content-type']).toBe('text/plain');
expect(Object.keys(res.headers).sort()).toEqual([
'cache-control',
'content-disposition',
'content-length',
'content-security-policy',
'content-type',
'x-content-type-options',
]);
expect(Buffer.concat(res.written).toString()).toBe('inhalt');
});
it('Range geht an Nextcloud, die Antwort ist 206 mit content-range', async () => {
const { service, calls } = setup(() => ({
status: 206,
text: 'abc',
headers: { 'content-range': 'bytes 0-2/6', 'accept-ranges': 'bytes', 'content-length': '3' },
}));
const res = new FakeRes();
await service.download(res as never, 't1', 'u1', '/a.txt', { range: 'bytes=0-2' });
await finished(res);
expect(calls[0].headers.range).toBe('bytes=0-2');
expect(res.code).toBe(206);
expect(res.headers['content-range']).toBe('bytes 0-2/6');
expect(res.headers['accept-ranges']).toBe('bytes');
});
it('Ordner als ZIP: ?accept=zip, Name <Ordner>.zip, Wurzel -> Dateien.zip', async () => {
const { service, calls } = setup(() => ({
status: 200,
text: 'PK',
headers: { 'content-type': 'application/zip' },
}));
const res1 = new FakeRes();
await service.download(res1 as never, 't1', 'u1', '/Projekte', { zip: true });
await finished(res1);
expect(calls[0].url).toBe(
'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/?accept=zip',
);
expect(res1.headers['content-disposition']).toContain('filename="Projekte.zip"');
const res2 = new FakeRes();
await service.download(res2 as never, 't1', 'u1', '/', { zip: true });
await finished(res2);
expect(res2.headers['content-disposition']).toContain('filename="Dateien.zip"');
expect(calls[1].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/?accept=zip');
});
it('die Wurzel ohne zip ist keine Datei: 400, kein Aufruf', async () => {
const { service, calls } = setup();
await expect(service.download(new FakeRes() as never, 't1', 'u1', '/')).rejects.toMatchObject({
response: { code: 'invalidPath' },
});
expect(calls).toHaveLength(0);
});
it('Auswahl-ZIP: files-Liste, Name aus dem Ordner; ein Name .. -> 400 invalidPath ohne Aufruf', async () => {
const { service, calls } = setup(() => ({ status: 200, text: 'PK' }));
const res = new FakeRes();
await service.downloadZip(res as never, 't1', 'u1', '/Projekte', ['a.txt', 'b']);
await finished(res);
expect(JSON.parse(new URL(calls[0].url).searchParams.get('files') ?? '')).toEqual([
'a.txt',
'b',
]);
expect(res.headers['content-disposition']).toContain('filename="Projekte.zip"');
const callsBefore = calls.length;
const e = await caught(
service.downloadZip(new FakeRes() as never, 't1', 'u1', '/Projekte', ['a', '..']),
);
expect(parts(e)).toMatchObject({ status: 400, body: { code: 'invalidPath' } });
expect(calls).toHaveLength(callsBefore);
});
it('Browser bricht den Download ab: das Signal des Aufrufs an Nextcloud wird abgebrochen', async () => {
let body: PassThrough | undefined;
const calls: NcTransportRequest[] = [];
const transport: NextcloudTransport = async (req) => {
calls.push(req);
body = new PassThrough();
body.write('anfang');
return { statusCode: 200, headers: {}, body };
};
const account = { getSession: vi.fn(async () => SESSION), markExpired: vi.fn() };
const service = new NextcloudFilesTransferService(
account as never,
new NextcloudCallGate(),
transport,
);
const res = new FakeRes();
const running = service.download(res as never, 't1', 'u1', '/gross.bin');
await vi.waitFor(() => expect(res.written.length).toBeGreaterThan(0));
expect(calls[0].signal.aborted).toBe(false);
res.destroy();
await running;
expect(calls[0].signal.aborted).toBe(true);
});
});
describe('NextcloudFilesTransferService — Fehlervertrag auf jeder Transferroute', () => {
const UPSTREAM: Array<[number, number, string]> = [
[401, 409, 'connectionExpired'],
[403, 422, 'notAllowed'],
[404, 404, 'notFound'],
[409, 409, 'pathConflict'],
[412, 409, 'nameTaken'],
[423, 409, 'locked'],
[429, 503, 'nextcloudLocked'],
[507, 507, 'quotaExceeded'],
[500, 502, 'nextcloudError'],
[502, 502, 'nextcloudError'],
[503, 503, 'nextcloudMaintenance'],
];
type Route = (s: NextcloudFilesTransferService, res: FakeRes) => Promise<unknown>;
const ROUTES: Record<string, Route> = {
download: (s, res) => s.download(res as never, 't1', 'u1', '/Projekte/a.txt'),
downloadZip: (s, res) => s.downloadZip(res as never, 't1', 'u1', '/Projekte', ['a.txt']),
putSingle: (s) => s.putSingle('t1', 'u1', rawReq(10), { path: '/Projekte/a.txt', size: 10 }),
putChunk: (s) =>
s.putChunk('t1', 'u1', rawReq(10), UP, '2', { path: '/Projekte/a.txt', size: 30000000 }),
completeUpload: (s) =>
s.completeUpload('t1', 'u1', UP, { path: '/Projekte/a.txt', size: 30000000 }),
startUpload: (s) => s.startUpload('t1', 'u1', { path: '/Projekte/a.txt', size: 30000000 }),
};
const cases = Object.keys(ROUTES).flatMap((route) =>
UPSTREAM.map(([up, http, code]) => [route, up, http, code] as const),
);
it.each(
cases,
)('%s: Nextcloud %i -> %i %s, nie 401/403, kein Byte und keine Kopfzeile vorab', async (route, up, http, code) => {
// startUpload: ein 404 beim Pruefen des Ziels heisst "Ziel frei" und ist KEIN Fehler.
const { service, account } = setup(() => ({
status: up,
text: 'nextcloud-fehlerseite',
headers: {
'content-type': 'text/html',
etag: '"x"',
'set-cookie': 'a=b',
'content-length': '21',
},
}));
const res = new FakeRes();
if (route === 'startUpload' && up === 404) {
// Ziel frei, dann scheitert der naechste Aufruf (MKCOL) nicht an 404 -> Ordner angelegt? Hier 404 auch fuer MKCOL.
const e = await caught(ROUTES[route](service, res));
expect(parts(e)).toMatchObject({ status: 404, body: { code: 'notFound' } });
return;
}
const e = await caught(ROUTES[route](service, res));
const { status, body } = parts(e);
expect(status).toBe(http);
expect(status).not.toBe(401);
expect(status).not.toBe(403);
expect(body.code).toBe(code);
expect(typeof body.message).toBe('string');
// Es wurde nichts an den Browser geschrieben oder gesetzt.
expect(res.written).toHaveLength(0);
expect(res.headers).toEqual({});
expect(res.code).toBe(200);
expect(account.markExpired).toHaveBeenCalledTimes(up === 401 ? 1 : 0);
});
it('Transportfehler (Netz weg) -> 504 nextcloudUnavailable auf jeder Route', async () => {
for (const route of Object.keys(ROUTES)) {
const calls: NcTransportRequest[] = [];
const transport: NextcloudTransport = async (req) => {
calls.push(req);
throw Object.assign(new Error('x'), { code: 'ECONNREFUSED' });
};
const service = new NextcloudFilesTransferService(
{ getSession: async () => SESSION, markExpired: async () => {} } as never,
new NextcloudCallGate(),
transport,
);
const res = new FakeRes();
const { status, body } = parts(await caught(ROUTES[route](service, res)));
expect(status, route).toBe(504);
expect(body.code, route).toBe('nextcloudUnavailable');
expect(res.written, route).toHaveLength(0);
}
});
it('429 haelt den Ursprung an: der naechste Transferaufruf liefert 503 ohne Transportaufruf', async () => {
const { service, calls } = setup(() => ({ status: 429, headers: { 'retry-after': '120' } }));
const first = parts(await caught(ROUTES.download(service, new FakeRes())));
expect(first.status).toBe(503);
expect(calls).toHaveLength(1);
for (const route of Object.keys(ROUTES)) {
const { status, body } = parts(await caught(ROUTES[route](service, new FakeRes())));
expect(status, route).toBe(503);
expect(body.code, route).toBe('nextcloudLocked');
}
expect(calls).toHaveLength(1);
});
it('Benutzer ohne Konto: notConnected, es geht kein Aufruf raus', async () => {
const { service, account, calls } = setup();
account.getSession.mockRejectedValue(ncErrorDefault('notConnected'));
for (const route of Object.keys(ROUTES)) {
const { status, body } = parts(await caught(ROUTES[route](service, new FakeRes())));
expect(status, route).toBe(409);
expect(body.code, route).toBe('notConnected');
}
expect(calls).toHaveLength(0);
});
});
@@ -0,0 +1,593 @@
import { randomUUID } from 'node:crypto';
import type { Readable } from 'node:stream';
import { Inject, Injectable } from '@nestjs/common';
import { NEXTCLOUD_FILES_CHUNK_SIZE, NEXTCLOUD_FILES_MAX_CHUNKS } from '@tessera/shared';
import type { Response } from 'express';
import { NextcloudCallGate } from './nextcloud-call-gate';
import * as dav from './nextcloud-dav';
import * as transfer from './nextcloud-dav-transfer';
import { type NcSession, ncErrorDefault } from './nextcloud-files.types';
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
import {
type NcResult,
NEXTCLOUD_TRANSPORT,
type NextcloudTransport,
parseUserPath,
validateNewName,
validateSegment,
} from './nextcloud-http';
import type { NcEntry } from './nextcloud-propfind';
import { mapNcFailure, type NcOutcome, sendUpstreamStream } from './nextcloud-upstream';
/**
* Hoch- und Herunterladen im Konto des angemeldeten Benutzers
* (quick-261008-mzu, D-D/D-J/D-K). Jede Methode beginnt mit der Sitzung des
* Aufrufers (Benutzerkennung aus dem Token, nie aus der Eingabe). Dieser Dienst
* greift nicht auf die Datenbank zu.
*
* NICHTS WIRD GEPUFFERT. Die vorhandenen Upload-Routen der API (die Datei-Annahme
* mit multers Speicher im Arbeitsspeicher, Grenzen von 1 bis 5 MB) halten ganze
* Dateien im RAM — fuer Dateien in Gigabyte-Groesse ungeeignet. Hier wird der
* Anfragestrom (`req`) selbst als Koerper an Nextcloud weitergereicht und eine
* Antwort als Strom an den Browser; im Speicher liegt immer nur, was gerade
* unterwegs ist. Darum steht in diesem Modul kein multer-Interceptor.
*
* WARUM IN STUECKEN (8 MiB): der `/api-proxy`-Rewrite von Next.js puffert
* Anfragekoerper und schneidet sie bei 10 MiB STILL ab — eine groessere Datei
* kaeme verstuemmelt an. Der Browser zerlegt darum in 8-MiB-Stuecke
* (`NEXTCLOUD_FILES_CHUNK_SIZE`: unter 10 MiB, ueber den 5 MiB, die Nextcloud
* pro Stueck mindestens verlangt). Die API lehnt groessere Koerper ab.
*
* WARUM DER ZUSAMMENBAU IM HINTERGRUND LAEUFT: Nextcloud baut die Stuecke erst
* beim abschliessenden MOVE zusammen — bei Dateien in Gigabyte-Groesse dauert
* das Minuten, und Zwischenstationen (der Next.js-Proxy, der Nginx Proxy Manager
* vor Tessera) brechen Anfragen ohne Antwort nach 30 bis 60 s ab. Darum wartet
* `completeUpload` hoechstens 20 s auf das Ende und antwortet sonst 202
* `assembling`; der Browser fragt dann den Zustand ab. Der Zustand liegt im
* Arbeitsspeicher (je Mandant, Benutzer und Upload-Kennung, 10 Minuten nach dem
* Ende); ein Neustart der API verliert ihn, die Weboberflaeche meldet dann einen
* Fehler und der Benutzer wiederholt.
*
* WARUM TESSERA DEN `Content-Disposition` SELBST BAUT und stets `attachment`
* erzwingt: hochgeladene HTML- oder SVG-Dateien wuerden sonst auf der Tessera-
* Adresse im Browser ausgefuehrt (gespeichertes Cross-Site-Scripting). Der Name
* kommt aus dem angefragten Pfad, nie aus einer Nextcloud-Kopfzeile; dazu
* `nosniff` und eine `sandbox`-Richtlinie.
*
* WARUM FEHLER VOR DEM ERSTEN BYTE ABGEBILDET WERDEN: sobald ein Byte oder eine
* Kopfzeile an den Browser ging, laesst sich der Status nicht mehr aendern. Darum
* wird jede Antwort von Nextcloud zuerst geprueft (`sendUpstreamStream`) und jeder
* Fehler ueber `mapNcFailure` zu `{ code, message }` — nie 401 oder 403.
*/
const CHUNK_SIZE = NEXTCLOUD_FILES_CHUNK_SIZE;
/** Groesste uebertragbare Datei: 10000 Stuecke zu 8 MiB. */
export const MAX_UPLOAD_BYTES = NEXTCLOUD_FILES_MAX_CHUNKS * CHUNK_SIZE;
/** So lange wartet `completeUpload` auf den Zusammenbau, bevor es mit 202 antwortet. */
export const ASSEMBLY_WAIT_MS = 20_000;
/** So lange bleibt der Ausgang eines Zusammenbaus abfragbar. */
export const ASSEMBLY_STATE_TTL_MS = 10 * 60_000;
/** Obergrenze fuer gleichzeitig gemerkte Zusammenbauten (Schutz des Arbeitsspeichers). */
const ASSEMBLY_STATE_MAX = 2000;
const UPLOAD_ID_RE = /^tessera-[0-9a-f-]{36}$/;
const MAX_MTIME_SECONDS = 4102444800;
/** Was der Dienst von der Anfrage braucht: den Strom, die Kopfzeilen und ob er vollstaendig kam. */
export interface RawUploadRequest extends Readable {
headers: Record<string, string | string[] | undefined>;
complete?: boolean;
}
export type UploadStartView =
| { mode: 'single' }
| { mode: 'chunked'; uploadId: string; chunkSize: number };
export type UploadStateView =
| { state: 'assembling' }
| { state: 'done' }
| { state: 'failed'; code: string; message: string };
interface AssemblyRecord {
state: 'assembling' | 'done' | 'failed';
code?: string;
message?: string;
finishedAt?: number;
}
interface ExistingView {
etag: string | null;
size: number;
mtime: string | null;
}
/** ASCII-Rueckfallname: alles ausser druckbarem ASCII (und `"`, `\`, `%`) wird `_`. */
function asciiFallback(name: string): string {
let out = '';
for (const ch of name) {
const code = ch.codePointAt(0) ?? 0;
out += code < 0x20 || code > 0x7e || ch === '"' || ch === '\\' || ch === '%' ? '_' : ch;
}
return out;
}
/** RFC 5987: `encodeURIComponent` laesst `!'()*` stehen, die hier ebenfalls codiert werden muessen. */
function rfc5987(name: string): string {
return encodeURIComponent(name).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
/** `Content-Disposition` fuer einen Download: immer `attachment`, nie aus Nextcloud uebernommen. */
export function contentDispositionFor(name: string): string {
return `attachment; filename="${asciiFallback(name)}"; filename*=UTF-8''${rfc5987(name)}`;
}
function pathOf(segments: readonly string[]): string {
return `/${segments.join('/')}`;
}
function headerOne(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
@Injectable()
export class NextcloudFilesTransferService {
private readonly assemblies = new Map<string, AssemblyRecord>();
constructor(
private readonly account: NextcloudFilesAccountService,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
) {}
// --- Hilfen ---------------------------------------------------------------------------------
private session(tenantId: string, userId: string): Promise<NcSession> {
return this.account.getSession(tenantId, userId);
}
private async fail(
tenantId: string,
userId: string,
result: NcOutcome,
existing?: ExistingView,
): Promise<never> {
throw await mapNcFailure(result, {
onExpired: () => this.account.markExpired(tenantId, userId),
existing,
});
}
private static ok(result: NcOutcome): boolean {
return (
result.ok && typeof result.status === 'number' && result.status >= 200 && result.status < 300
);
}
private static uploadId(raw: string): string {
if (typeof raw !== 'string' || !UPLOAD_ID_RE.test(raw)) throw ncErrorDefault('invalidPath');
return raw;
}
/** Zielpfad einer Datei: nicht die Wurzel, letzter Teil ein erlaubter neuer Name. */
private static targetOf(rawPath: string): string[] {
const segments = parseUserPath(rawPath);
if (segments.length === 0) throw ncErrorDefault('invalidPath');
validateNewName(segments[segments.length - 1]);
return segments;
}
private static mtimeOf(mtime: number | undefined): number | undefined {
if (mtime === undefined) return undefined;
if (!Number.isInteger(mtime) || mtime < 0 || mtime > MAX_MTIME_SECONDS) {
throw ncErrorDefault('invalidPath');
}
return mtime;
}
private static sizeOf(size: number): number {
if (!Number.isInteger(size) || size < 0) throw ncErrorDefault('invalidPath');
if (size > MAX_UPLOAD_BYTES) throw ncErrorDefault('fileTooLarge');
return size;
}
/**
* `content-length` des Raw-Body-Aufrufs: fehlt er (auch bei chunked Transfer-Encoding)
* -> 411, ist er groesser als ein Stueck -> 413. Beides VOR jedem Nextcloud-Aufruf.
*/
private static declaredLength(req: RawUploadRequest): number {
const raw = headerOne(req.headers['content-length']);
if (raw === undefined || !/^\d{1,15}$/.test(raw)) throw ncErrorDefault('lengthRequired');
const length = Number(raw);
if (length > CHUNK_SIZE) throw ncErrorDefault('chunkTooLarge');
return length;
}
/**
* Bricht der Browser ab, bevor der Koerper vollstaendig da ist, wird auch der Aufruf an
* Nextcloud abgebrochen. `req.complete` unterscheidet das vom normalen Ende der Anfrage
* (Node meldet `close` auch nach dem vollstaendigen Lesen).
*/
private static abortOnClose(req: RawUploadRequest): { signal: AbortSignal; settle(): void } {
const abort = new AbortController();
let settled = false;
const onClose = () => {
if (!settled && !req.complete) abort.abort();
};
req.once('close', onClose);
return {
signal: abort.signal,
settle: () => {
settled = true;
req.off('close', onClose);
},
};
}
/** Vorhandener Eintrag fuer die Rueckfrage "Ersetzen?" — bestmoeglich, nie ein Fehlergrund. */
private async existingOf(
session: NcSession,
segments: readonly string[],
): Promise<ExistingView | undefined> {
try {
const res = await dav.stat(this.transport, this.gate, session, segments);
if (res.ok && res.entry) return viewOf(res.entry);
} catch {
// ohne Angaben geht es auch
}
return undefined;
}
// --- Hochladen: Start -------------------------------------------------------------------------
async startUpload(
tenantId: string,
userId: string,
dto: { path: string; size: number; replaceEtag?: string },
): Promise<UploadStartView> {
const segments = NextcloudFilesTransferService.targetOf(dto.path);
const size = NextcloudFilesTransferService.sizeOf(dto.size);
const session = await this.session(tenantId, userId);
// Schutz vor stillem Ueberschreiben: das Ziel pruefen, BEVOR irgendetwas uebertragen wird.
const probe = await dav.stat(this.transport, this.gate, session, segments);
if (!probe.ok) return this.fail(tenantId, userId, probe);
if (probe.status !== 404) {
if (!NextcloudFilesTransferService.ok(probe)) return this.fail(tenantId, userId, probe);
if (!probe.entry) return this.fail(tenantId, userId, { ok: false, kind: 'invalid-response' });
if (!dto.replaceEtag) {
throw ncErrorDefault('nameTaken', { existing: viewOf(probe.entry) });
}
if (probe.entry.etag !== dto.replaceEtag) {
throw ncErrorDefault('changedMeanwhile', { existing: viewOf(probe.entry) });
}
} else if (dto.replaceEtag) {
// Der Eintrag, den der Benutzer ersetzen wollte, ist inzwischen weg.
throw ncErrorDefault('changedMeanwhile');
}
if (size <= CHUNK_SIZE) return { mode: 'single' };
const uploadId = `tessera-${randomUUID()}`;
const made = await transfer.uploadStart(this.transport, this.gate, session, uploadId, segments);
if (!NextcloudFilesTransferService.ok(made)) return this.fail(tenantId, userId, made);
return { mode: 'chunked', uploadId, chunkSize: CHUNK_SIZE };
}
// --- Hochladen: ganze Datei -------------------------------------------------------------------
async putSingle(
tenantId: string,
userId: string,
req: RawUploadRequest,
query: { path: string; size?: number; mtime?: number; replaceEtag?: string },
): Promise<{ path: string; size: number }> {
const length = NextcloudFilesTransferService.declaredLength(req);
const segments = NextcloudFilesTransferService.targetOf(query.path);
const mtime = NextcloudFilesTransferService.mtimeOf(query.mtime);
const session = await this.session(tenantId, userId);
const guard = NextcloudFilesTransferService.abortOnClose(req);
let result: NcResult;
try {
result = await transfer.putFile(this.transport, this.gate, session, segments, req, {
size: length,
mtime,
replaceEtag: query.replaceEtag,
signal: guard.signal,
});
} finally {
guard.settle();
}
if (!NextcloudFilesTransferService.ok(result)) {
const existing =
result.ok && result.status === 412 ? await this.existingOf(session, segments) : undefined;
return this.fail(tenantId, userId, result, existing);
}
return { path: pathOf(segments), size: length };
}
// --- Hochladen: Stuecke -----------------------------------------------------------------------
async putChunk(
tenantId: string,
userId: string,
req: RawUploadRequest,
uploadId: string,
rawN: string | number,
query: { path: string; size: number },
): Promise<{ n: number; received: number }> {
NextcloudFilesTransferService.uploadId(uploadId);
const n = typeof rawN === 'number' ? rawN : /^\d{1,5}$/.test(rawN) ? Number(rawN) : Number.NaN;
if (!Number.isInteger(n) || n < 1 || n > NEXTCLOUD_FILES_MAX_CHUNKS) {
throw ncErrorDefault('invalidPath');
}
const length = NextcloudFilesTransferService.declaredLength(req);
const segments = NextcloudFilesTransferService.targetOf(query.path);
const total = NextcloudFilesTransferService.sizeOf(query.size);
const session = await this.session(tenantId, userId);
const guard = NextcloudFilesTransferService.abortOnClose(req);
let result: NcResult;
try {
result = await transfer.uploadChunk(
this.transport,
this.gate,
session,
uploadId,
n,
segments,
req,
{ size: length, totalLength: total, signal: guard.signal },
);
} finally {
guard.settle();
}
if (!NextcloudFilesTransferService.ok(result)) return this.fail(tenantId, userId, result);
return { n, received: length };
}
// --- Hochladen: Zusammenbau -------------------------------------------------------------------
private key(tenantId: string, userId: string, uploadId: string): string {
return `${tenantId}:${userId}:${uploadId}`;
}
private prune(): void {
const now = Date.now();
for (const [key, rec] of this.assemblies) {
if (rec.finishedAt !== undefined && now - rec.finishedAt > ASSEMBLY_STATE_TTL_MS) {
this.assemblies.delete(key);
}
}
// Aeltestes zuerst entfernen, falls (durch viele Aufrufe) zu viel gemerkt wird.
while (this.assemblies.size > ASSEMBLY_STATE_MAX) {
const oldest = this.assemblies.keys().next().value;
if (oldest === undefined) break;
this.assemblies.delete(oldest);
}
}
async completeUpload(
tenantId: string,
userId: string,
uploadId: string,
dto: { path: string; size: number; mtime?: number; replaceEtag?: string },
): Promise<{ state: 'done' | 'assembling' }> {
NextcloudFilesTransferService.uploadId(uploadId);
const segments = NextcloudFilesTransferService.targetOf(dto.path);
const total = NextcloudFilesTransferService.sizeOf(dto.size);
const mtime = NextcloudFilesTransferService.mtimeOf(dto.mtime);
const session = await this.session(tenantId, userId);
this.prune();
const key = this.key(tenantId, userId, uploadId);
const known = this.assemblies.get(key);
if (known?.state === 'assembling') return { state: 'assembling' };
if (known?.state === 'done') return { state: 'done' };
// Ersetzen: unmittelbar vor dem Zusammenbau noch einmal pruefen, ob noch die Version
// da ist, die der Benutzer ersetzen wollte.
if (dto.replaceEtag) {
const probe = await dav.stat(this.transport, this.gate, session, segments);
if (!probe.ok) return this.fail(tenantId, userId, probe);
if (probe.status === 404 || (NextcloudFilesTransferService.ok(probe) && !probe.entry)) {
throw ncErrorDefault('changedMeanwhile');
}
if (!NextcloudFilesTransferService.ok(probe)) return this.fail(tenantId, userId, probe);
if (probe.entry?.etag !== dto.replaceEtag) {
throw ncErrorDefault(
'changedMeanwhile',
probe.entry ? { existing: viewOf(probe.entry) } : undefined,
);
}
}
const record: AssemblyRecord = { state: 'assembling' };
this.assemblies.set(key, record);
// Der Zusammenbau gehoert nicht zur Anfrage: er laeuft weiter, auch wenn der Browser
// aufgibt, und haelt sein Ergebnis in `record` fest.
const job = this.assemble(tenantId, userId, session, uploadId, segments, {
totalLength: total,
mtime,
replace: Boolean(dto.replaceEtag),
}).then(
() => {
record.state = 'done';
record.finishedAt = Date.now();
},
(err: unknown) => {
const body = (err as { getResponse?: () => unknown }).getResponse?.() as
| { code?: string; message?: string }
| undefined;
record.state = 'failed';
record.code = body?.code ?? 'nextcloudError';
record.message = body?.message ?? ncErrorDefault('nextcloudError').message;
record.finishedAt = Date.now();
throw err;
},
);
// Ein spaeter Fehler darf nie als unbehandelt enden; der Browser fragt den Zustand ab.
job.catch(() => {});
let timer: NodeJS.Timeout | undefined;
const window = new Promise<'pending'>((resolve) => {
timer = setTimeout(() => resolve('pending'), ASSEMBLY_WAIT_MS);
});
try {
const outcome = await Promise.race([job.then(() => 'finished' as const), window]);
if (outcome === 'pending') return { state: 'assembling' };
return { state: 'done' };
} finally {
clearTimeout(timer);
}
}
private async assemble(
tenantId: string,
userId: string,
session: NcSession,
uploadId: string,
segments: readonly string[],
opts: { totalLength: number; mtime?: number; replace: boolean },
): Promise<void> {
const result = await transfer.uploadAssemble(
this.transport,
this.gate,
session,
uploadId,
segments,
opts,
);
if (NextcloudFilesTransferService.ok(result)) return;
if (result.ok && result.status === 412) {
// Das Ziel ist inzwischen vergeben (gemessen: der Upload-Ordner BLEIBT) -> aufraeumen.
const existing = await this.existingOf(session, segments);
await transfer.uploadAbort(this.transport, this.gate, session, uploadId).catch(() => {});
return this.fail(tenantId, userId, result, existing);
}
return this.fail(tenantId, userId, result);
}
uploadState(tenantId: string, userId: string, uploadId: string): UploadStateView {
NextcloudFilesTransferService.uploadId(uploadId);
this.prune();
const rec = this.assemblies.get(this.key(tenantId, userId, uploadId));
if (!rec) throw ncErrorDefault('notFound');
if (rec.state === 'failed') {
return {
state: 'failed',
code: rec.code ?? 'nextcloudError',
message: rec.message ?? ncErrorDefault('nextcloudError').message,
};
}
return { state: rec.state };
}
async abortUpload(
tenantId: string,
userId: string,
uploadId: string,
): Promise<{ aborted: boolean }> {
NextcloudFilesTransferService.uploadId(uploadId);
const session = await this.session(tenantId, userId);
const rec = this.assemblies.get(this.key(tenantId, userId, uploadId));
// Mitten im Zusammenbau wird nichts geloescht; Nextcloud raeumt selbst auf.
if (rec?.state === 'assembling') return { aborted: false };
const result = await transfer.uploadAbort(this.transport, this.gate, session, uploadId);
// 404: der Ordner ist schon weg (Zusammenbau fertig oder abgelaufen) — auch gut.
if (!NextcloudFilesTransferService.ok(result) && !(result.ok && result.status === 404)) {
return this.fail(tenantId, userId, result);
}
this.assemblies.delete(this.key(tenantId, userId, uploadId));
return { aborted: true };
}
// --- Herunterladen ----------------------------------------------------------------------------
/** Datei (oder mit `zip` ein Ordner als ZIP) als Strom an den Browser; `range` geht mit. */
async download(
res: Response,
tenantId: string,
userId: string,
rawPath: string,
opts: { zip?: boolean; range?: string } = {},
): Promise<void> {
const segments = parseUserPath(rawPath);
if (segments.length === 0 && !opts.zip) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
const abort = this.abortOnResponseClose(res);
const upstream = opts.zip
? await transfer.downloadFolderZip(this.transport, this.gate, session, segments, abort)
: await transfer.download(this.transport, this.gate, session, segments, {
range: opts.range,
signal: abort,
});
const name = opts.zip
? segments.length > 0
? `${segments[segments.length - 1]}.zip`
: 'Dateien.zip'
: segments[segments.length - 1];
await this.send(res, upstream, name, tenantId, userId);
}
/** Nur die gewaehlten Eintraege eines Ordners als ZIP. */
async downloadZip(
res: Response,
tenantId: string,
userId: string,
rawDir: string | undefined,
names: readonly string[],
): Promise<void> {
const dir = parseUserPath(rawDir);
// Alle Namen vor dem ersten Aufruf pruefen (`..` -> 400 invalidPath).
for (const name of names) validateSegment(name);
if (names.length === 0) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
const abort = this.abortOnResponseClose(res);
const upstream = await transfer.downloadSelectionZip(
this.transport,
this.gate,
session,
dir,
names,
abort,
);
const name = dir.length > 0 ? `${dir[dir.length - 1]}.zip` : 'Dateien.zip';
await this.send(res, upstream, name, tenantId, userId);
}
private abortOnResponseClose(res: Response): AbortSignal {
const abort = new AbortController();
res.on('close', () => {
if (!res.writableFinished) abort.abort();
});
return abort.signal;
}
private send(
res: Response,
upstream: NcResult,
fileName: string,
tenantId: string,
userId: string,
): Promise<void> {
return sendUpstreamStream(res, upstream, {
extraHeaders: {
'content-disposition': contentDispositionFor(fileName),
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
'cache-control': 'private, no-store',
},
onExpired: () => this.account.markExpired(tenantId, userId),
});
}
}
function viewOf(entry: NcEntry): ExistingView {
return { etag: entry.etag, size: entry.size, mtime: entry.mtime };
}
@@ -11,6 +11,11 @@ const req = (tenantId?: string) => ({ tenantId }) as any;
/** Verwalten (Administratoren und Freigabestufe Verwalten). Spaetere Aufgaben ergaenzen nichts hier. */
const MANAGE_HANDLERS = ['getSettings', 'saveSettings', 'testSettings'];
/** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */
const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle'];
const TRANSFER_PARAM = ['putChunk', 'completeUpload', 'uploadState', 'abortUpload'];
const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM];
/** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */
function routeHandlers(): string[] {
return Object.getOwnPropertyNames(NextcloudFilesController.prototype).filter(
@@ -81,6 +86,14 @@ describe('NextcloudFilesController — Metadaten', () => {
expect(route('createFolder')).toEqual([1, 'folders']);
expect(route('move')).toEqual([1, 'move']);
expect(route('preview')).toEqual([0, 'preview']);
expect(route('download')).toEqual([0, 'download']);
expect(route('downloadZip')).toEqual([0, 'download/zip']);
expect(route('startUpload')).toEqual([1, 'uploads']);
expect(route('putSingle')).toEqual([2, 'uploads/file']);
expect(route('putChunk')).toEqual([2, 'uploads/:uploadId/chunks/:n']);
expect(route('completeUpload')).toEqual([1, 'uploads/:uploadId/complete']);
expect(route('uploadState')).toEqual([0, 'uploads/:uploadId/state']);
expect(route('abortUpload')).toEqual([3, 'uploads/:uploadId']);
expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']);
expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']);
});
@@ -93,7 +106,13 @@ describe('NextcloudFilesController — Metadaten', () => {
});
it('die Datei-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => {
for (const name of ['list', 'remove', 'createFolder', 'move', 'preview']) {
for (const name of TRANSFER_HANDLERS.concat([
'list',
'remove',
'createFolder',
'move',
'preview',
])) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
}
@@ -109,6 +128,29 @@ describe('NextcloudFilesController — Metadaten', () => {
}
});
it('die statischen Transfer-Handler stehen vor dem Parameterblock, die mit :uploadId danach', () => {
const names = routeHandlers();
const staticLast = Math.max(
...names
.filter((n) => !String(Reflect.getMetadata('path', proto[n])).includes(':'))
.map((n) => names.indexOf(n)),
);
for (const name of TRANSFER_STATIC) {
expect(names.indexOf(name), name).toBeLessThan(staticLast + 1);
expect(String(Reflect.getMetadata('path', proto[name])), name).not.toContain(':');
}
for (const name of TRANSFER_PARAM) {
expect(names.indexOf(name), name).toBeGreaterThan(staticLast);
}
// uploads/:uploadId/... steht ganz am Ende, nach den Anmelde-Parameterrouten
expect(names.indexOf('putChunk')).toBeGreaterThan(names.indexOf('cancelFlow'));
});
it('der Zusammenbau antwortet 200 (oder 202 bei laufendem Zusammenbau), Start bleibt bei 201', () => {
expect(Reflect.getMetadata('__httpCode__', proto.completeUpload)).toBe(200);
expect(Reflect.getMetadata('__httpCode__', proto.startUpload)).toBeUndefined();
});
it('verschieben antwortet 200, Ordner anlegen bleibt bei 201', () => {
expect(Reflect.getMetadata('__httpCode__', proto.move)).toBe(200);
expect(Reflect.getMetadata('__httpCode__', proto.createFolder)).toBeUndefined();
@@ -165,6 +207,19 @@ describe('NextcloudFilesController — Delegation', () => {
};
}
function makeTransfer() {
return {
download: vi.fn(async (..._a: unknown[]) => undefined),
downloadZip: vi.fn(async (..._a: unknown[]) => undefined),
startUpload: vi.fn(async (..._a: unknown[]) => ({ mode: 'single' })),
putSingle: vi.fn(async (..._a: unknown[]) => ({ path: '/a', size: 1 })),
putChunk: vi.fn(async (..._a: unknown[]) => ({ n: 1, received: 1 })),
completeUpload: vi.fn(async (..._a: unknown[]) => ({ state: 'done' })),
uploadState: vi.fn((..._a: unknown[]) => ({ state: 'done' })),
abortUpload: vi.fn(async (..._a: unknown[]) => ({ aborted: true })),
};
}
function makeFiles() {
return {
list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })),
@@ -181,6 +236,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeSettings() as any,
makeAccount() as any,
files as any,
makeTransfer() as any,
);
const r = userReq('t1', 'u1');
const res = { end: vi.fn() } as any;
@@ -198,12 +254,86 @@ describe('NextcloudFilesController — Delegation', () => {
expect(files.preview).toHaveBeenCalledWith(res, 't1', 'u1', '42', '"e"');
});
it('Transfer-Handler: Mandant und Benutzer aus dem Token, Pfade aus Query und Body, 202 bei laufendem Zusammenbau', async () => {
const transfer = makeTransfer();
const controller = new NextcloudFilesController(
makeSettings() as any,
makeAccount() as any,
makeFiles() as any,
transfer as any,
);
const r = userReq('t1', 'u1');
(r as any).headers = { range: 'bytes=0-99' };
const res = { status: vi.fn() } as any;
const raw = { headers: {} } as any;
const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c';
await controller.download(r, res, { path: '/a.txt' } as any);
await controller.download(r, res, { path: '/Ordner', zip: '1' } as any);
await controller.downloadZip(r, res, { dir: '/Ordner', name: ['a', 'b'] } as any);
await controller.startUpload(r, { path: '/x', size: 5, userId: 'fremd' } as any);
const rawReq = Object.assign(raw, r);
await controller.putSingle(rawReq, { path: '/x', size: 5 } as any);
await controller.putChunk(rawReq, UP, '2', { path: '/x', size: 30 } as any);
expect(await controller.completeUpload(r, res, UP, { path: '/x', size: 30 } as any)).toEqual({
state: 'done',
});
expect(res.status).not.toHaveBeenCalled();
transfer.completeUpload.mockResolvedValueOnce({ state: 'assembling' });
await controller.completeUpload(r, res, UP, { path: '/x', size: 30 } as any);
expect(res.status).toHaveBeenCalledWith(202);
await controller.uploadState(r, UP);
await controller.abortUpload(r, UP);
expect(transfer.download).toHaveBeenNthCalledWith(1, res, 't1', 'u1', '/a.txt', {
zip: false,
range: 'bytes=0-99',
});
expect(transfer.download).toHaveBeenNthCalledWith(2, res, 't1', 'u1', '/Ordner', {
zip: true,
range: 'bytes=0-99',
});
expect(transfer.downloadZip).toHaveBeenCalledWith(res, 't1', 'u1', '/Ordner', ['a', 'b']);
expect(transfer.startUpload).toHaveBeenCalledWith('t1', 'u1', {
path: '/x',
size: 5,
userId: 'fremd',
});
expect(transfer.putChunk).toHaveBeenCalledWith('t1', 'u1', rawReq, UP, '2', {
path: '/x',
size: 30,
});
expect(transfer.uploadState).toHaveBeenCalledWith('t1', 'u1', UP);
expect(transfer.abortUpload).toHaveBeenCalledWith('t1', 'u1', UP);
});
it('Transfer-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
const transfer = makeTransfer();
const controller = new NextcloudFilesController(
makeSettings() as any,
makeAccount() as any,
makeFiles() as any,
transfer as any,
);
const r = userReq('t1', undefined);
const res = {} as any;
await expect(controller.download(r, res, { path: '/a' } as any)).rejects.toBeInstanceOf(
ForbiddenException,
);
await expect(controller.startUpload(r, { path: '/a', size: 1 } as any)).rejects.toBeInstanceOf(
ForbiddenException,
);
await expect(controller.uploadState(r, 'x')).rejects.toBeInstanceOf(ForbiddenException);
expect(transfer.download).not.toHaveBeenCalled();
expect(transfer.startUpload).not.toHaveBeenCalled();
expect(transfer.uploadState).not.toHaveBeenCalled();
});
it('Datei-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
const files = makeFiles();
const controller = new NextcloudFilesController(
makeSettings() as any,
makeAccount() as any,
files as any,
makeTransfer() as any,
);
const r = userReq('t1', undefined);
await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
@@ -217,7 +347,12 @@ describe('NextcloudFilesController — Delegation', () => {
it('reicht Mandant und Benutzer aus dem Token weiter, nie aus dem Body', async () => {
const settings = makeSettings();
const account = makeAccount();
const controller = new NextcloudFilesController(settings as any, account as any, makeFiles() as any);
const controller = new NextcloudFilesController(
settings as any,
account as any,
makeFiles() as any,
makeTransfer() as any,
);
await controller.getStatus(userReq('t1', 'u1'));
await controller.getSettings(req('t1'));
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any);
@@ -243,7 +378,12 @@ describe('NextcloudFilesController — Delegation', () => {
});
it('ohne Mandantenkontext: ForbiddenException', async () => {
const controller = new NextcloudFilesController(makeSettings() as any, makeAccount() as any, makeFiles() as any);
const controller = new NextcloudFilesController(
makeSettings() as any,
makeAccount() as any,
makeFiles() as any,
makeTransfer() as any,
);
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
ForbiddenException,
);
@@ -255,7 +395,12 @@ describe('NextcloudFilesController — Delegation', () => {
it('ohne Benutzer im Token: ForbiddenException, kein Aufruf des Kontodienstes', async () => {
const account = makeAccount();
const controller = new NextcloudFilesController(makeSettings() as any, account as any, makeFiles() as any);
const controller = new NextcloudFilesController(
makeSettings() as any,
account as any,
makeFiles() as any,
makeTransfer() as any,
);
await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf(
ForbiddenException,
);
@@ -27,9 +27,20 @@ import {
SaveNextcloudFilesSettingsDto,
TestNextcloudFilesSettingsDto,
} from './dto/nextcloud-files-settings.dto';
import {
CompleteUploadDto,
DownloadQueryDto,
StartUploadDto,
UploadQueryDto,
ZipQueryDto,
} from './dto/nextcloud-files-transfer.dto';
import { NextcloudFilesService } from './nextcloud-files.service';
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
import {
NextcloudFilesTransferService,
type RawUploadRequest,
} from './nextcloud-files-transfer.service';
/**
* `@UseModule('nextcloud-files')` auf Klassenebene — Aktivierung UND Freigabe.
@@ -62,6 +73,10 @@ import { NextcloudFilesSettingsService } from './nextcloud-files-settings.servic
* Tessera-Recht), sondern mit `{ code, message }` und einem anderen Status
* (siehe `nextcloud-files.types.ts`).
*/
function headerOf(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
@Controller('modules/nextcloud-files')
@UseModule('nextcloud-files')
export class NextcloudFilesController {
@@ -69,6 +84,7 @@ export class NextcloudFilesController {
private readonly settings: NextcloudFilesSettingsService,
private readonly account: NextcloudFilesAccountService,
private readonly files: NextcloudFilesService,
private readonly transfer: NextcloudFilesTransferService,
) {}
private requireTenantId(req: AuthenticatedRequest): string {
@@ -176,6 +192,59 @@ export class NextcloudFilesController {
);
}
// --- Herunterladen und Hochladen (statisch; Benutzen) ----------------------------------
/** Datei (oder mit `zip=1` ein Ordner als ZIP) als Datenstrom; `Range` wird durchgereicht. */
@Get('download')
async download(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
@Query() query: DownloadQueryDto,
): Promise<void> {
await this.transfer.download(
res,
this.requireTenantId(req),
this.requireUserId(req),
query.path,
{
zip: query.zip === '1',
range: headerOf(req.headers.range),
},
);
}
/** Nur die gewaehlten Eintraege eines Ordners als ZIP (`name` darf mehrfach vorkommen). */
@Get('download/zip')
async downloadZip(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
@Query() query: ZipQueryDto,
): Promise<void> {
await this.transfer.downloadZip(
res,
this.requireTenantId(req),
this.requireUserId(req),
query.dir,
query.name,
);
}
@Post('uploads')
async startUpload(@Req() req: AuthenticatedRequest, @Body() dto: StartUploadDto) {
return this.transfer.startUpload(this.requireTenantId(req), this.requireUserId(req), dto);
}
/** Ganze Datei (bis ein Stueck gross) als roher Datenstrom — kein Interceptor, nichts gepuffert. */
@Put('uploads/file')
async putSingle(@Req() req: AuthenticatedRequest, @Query() query: UploadQueryDto) {
return this.transfer.putSingle(
this.requireTenantId(req),
this.requireUserId(req),
req as unknown as RawUploadRequest,
query,
);
}
// --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) ---------------
@Get('connect/flow/:flowId')
@@ -193,4 +262,50 @@ export class NextcloudFilesController {
) {
return this.account.cancelFlow(this.requireTenantId(req), this.requireUserId(req), flowId);
}
@Put('uploads/:uploadId/chunks/:n')
async putChunk(
@Req() req: AuthenticatedRequest,
@Param('uploadId') uploadId: string,
@Param('n') n: string,
@Query() query: UploadQueryDto,
) {
return this.transfer.putChunk(
this.requireTenantId(req),
this.requireUserId(req),
req as unknown as RawUploadRequest,
uploadId,
n,
query,
);
}
/** 200 `done`, oder 202 `assembling`, wenn der Zusammenbau laenger als 20 s dauert. */
@Post('uploads/:uploadId/complete')
@HttpCode(200)
async completeUpload(
@Req() req: AuthenticatedRequest,
@Res({ passthrough: true }) res: Response,
@Param('uploadId') uploadId: string,
@Body() dto: CompleteUploadDto,
) {
const out = await this.transfer.completeUpload(
this.requireTenantId(req),
this.requireUserId(req),
uploadId,
dto,
);
if (out.state === 'assembling') res.status(202);
return out;
}
@Get('uploads/:uploadId/state')
async uploadState(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) {
return this.transfer.uploadState(this.requireTenantId(req), this.requireUserId(req), uploadId);
}
@Delete('uploads/:uploadId')
async abortUpload(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) {
return this.transfer.abortUpload(this.requireTenantId(req), this.requireUserId(req), uploadId);
}
}
@@ -11,6 +11,7 @@ import {
NEXTCLOUD_STATUS_FETCHER,
NextcloudFilesSettingsService,
} from './nextcloud-files-settings.service';
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
@@ -28,6 +29,7 @@ import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
NextcloudFilesSettingsService,
NextcloudFilesAccountService,
NextcloudFilesService,
NextcloudFilesTransferService,
NextcloudLoginGuard,
LoginFlowStore,
NextcloudCallGate,