fix(nextcloud-files): WR-08/IN-06 Auswahl-ZIP per POST, Downloads mit Vorabpruefung

- WR-08: POST download/zip mit den Namen im Koerper (JSON oder unsichtbares Formular mit
  names als JSON-Text) statt im Query-String; hoechstens 1000 Namen; die Weboberflaeche
  laedt per Formular in einen unsichtbaren Rahmen (Strom direkt auf die Platte, Cookie und
  Content-Disposition bleiben); alles ausgewaehlt -> ganzer Ordner als ZIP
- Nextcloud nimmt die Auswahl nur in ihrer Adresse an (gemessen: Apache lehnt ueber 8190
  Zeichen mit 414 ab); Tessera prueft die Laenge und antwortet vorher mit 413
  selectionTooLarge, die Weboberflaeche meldet es schon vor dem Absenden
- IN-06: Vorabpruefung (check=1 bzw. check: true, ein PROPFIND Depth 0) vor jedem Download;
  Fehler stehen in der Statuszeile, connectionExpired fuehrt zum Anmeldebildschirm; auch der
  Klick auf einen Dateinamen geht ueber die Pruefung
- e2e-transfer auf POST umgestellt (JSON, Formular ueber /api-proxy, 300 Namen, Pruefung),
  Anwenderhandbuch

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:50:24 +02:00
parent 486819f80b
commit 8679668cac
16 changed files with 499 additions and 56 deletions
@@ -112,21 +112,51 @@ export class DownloadQueryDto {
@IsOptional()
@IsIn(['1'])
zip?: string;
/**
* `1`: nur pruefen (Verbindung, Eintrag vorhanden), keinen Datenstrom liefern (IN-06). Die
* Weboberflaeche fragt so vor dem eigentlichen Herunterladen, weil sie Fehler eines
* Downloads im Browser sonst nicht sieht.
*/
@IsOptional()
@IsIn(['1'])
check?: string;
}
export class ZipQueryDto {
/** Hoechstzahl gewaehlter Eintraege fuer ein Auswahl-ZIP (WR-08). */
export const ZIP_MAX_NAMES = 1000;
/**
* Auswahl-ZIP (WR-08): die Namen kommen im KOERPER eines POST, nicht in der Adresse — mehrere
* hundert Namen sprengten sonst die Kopfzeilen-Grenze von Node (431) oder den Proxy (414).
* Als JSON (`names` ist eine Liste) oder aus einem unsichtbaren Formular der Weboberflaeche
* (`names` ist dieselbe Liste als JSON-Text; so laedt der Browser das ZIP als Strom direkt
* auf die Platte, statt es im Arbeitsspeicher zu sammeln).
*/
export class ZipBodyDto {
/** 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]))
@Transform(({ value }) => {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch {
return value;
}
})
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@ArrayMaxSize(ZIP_MAX_NAMES)
@IsString({ each: true })
@MaxLength(255, { each: true })
name!: string[];
names!: string[];
/** `true` (JSON) oder `1` (Formular): nur pruefen, kein ZIP (IN-06). */
@IsOptional()
@IsIn([true, '1'])
check?: boolean | string;
}
@@ -67,6 +67,12 @@ class FakeRes extends Writable {
code = 200;
headers: Record<string, string> = {};
written: Buffer[] = [];
jsonBody: unknown = undefined;
json(body: unknown) {
this.jsonBody = body;
this.end();
return this;
}
status(c: number) {
this.code = c;
return this;
@@ -687,6 +693,85 @@ describe('Eingaben: replaceEtag (IN-01)', () => {
});
});
describe('NextcloudFilesTransferService — Auswahl-ZIP im Koerper, Vorabpruefung (WR-08, IN-06)', () => {
it('ZipBodyDto: Namen als JSON-Liste oder als JSON-Text aus einem Formular; mehr als 1000 ist 400', async () => {
const { plainToInstance } = await import('class-transformer');
const { validate } = await import('class-validator');
const { ZipBodyDto } = await import('./dto/nextcloud-files-transfer.dto');
const asJson = plainToInstance(ZipBodyDto, { dir: '/A', names: ['a', 'ä b'] });
expect(await validate(asJson)).toHaveLength(0);
const asForm = plainToInstance(ZipBodyDto, { dir: '/A', names: '["a","ä b"]', check: '1' });
expect(await validate(asForm)).toHaveLength(0);
expect(asForm.names).toEqual(['a', 'ä b']);
const tooMany = plainToInstance(ZipBodyDto, {
names: Array.from({ length: 1001 }, (_, i) => `n${i}`),
});
expect((await validate(tooMany)).length).toBeGreaterThan(0);
const notJson = plainToInstance(ZipBodyDto, { names: 'a' });
expect((await validate(notJson)).length).toBeGreaterThan(0);
const thousand = plainToInstance(ZipBodyDto, {
names: Array.from({ length: 1000 }, (_, i) => `Datei mit einem längeren Namen ${i}.pdf`),
});
expect(await validate(thousand)).toHaveLength(0);
});
it('check: ein PROPFIND Depth 0 auf den Ordner, Antwort { ok: true }, kein ZIP', async () => {
const { service, calls } = setup(() => ({ status: 207, text: statXml('e1') }));
const res = new FakeRes();
await service.downloadZip(res as never, 't1', 'u1', '/Projekte', ['a.txt'], { check: true });
expect(res.jsonBody).toEqual({ ok: true });
expect(calls.map((c) => c.method)).toEqual(['PROPFIND']);
expect(calls[0].headers.depth).toBe('0');
});
it('check auf eine verschwundene Datei: notFound; mit totem Zugang: connectionExpired', async () => {
const gone = setup(() => ({ status: 404 }));
const e = parts(
await caught(
gone.service.download(new FakeRes() as never, 't1', 'u1', '/weg.txt', { check: true }),
),
);
expect(e.body.code).toBe('notFound');
const dead = setup(() => ({ status: 401 }));
const e2 = parts(
await caught(
dead.service.download(new FakeRes() as never, 't1', 'u1', '/a.txt', { check: true }),
),
);
expect(e2.body.code).toBe('connectionExpired');
});
it('Auswahl, deren Adresse an Nextcloud zu lang wuerde: 413 selectionTooLarge, auch bei der Pruefung', async () => {
const { service, calls } = setup(() => ({ status: 207, text: statXml('e1') }));
const names = Array.from({ length: 300 }, (_, i) => `Datei mit Namen ${i}.pdf`);
for (const check of [false, true]) {
const e = parts(
await caught(
service.downloadZip(new FakeRes() as never, 't1', 'u1', '/Projekte', names, { check }),
),
);
expect(e).toMatchObject({ status: 413, body: { code: 'selectionTooLarge' } });
}
expect(calls).toHaveLength(0);
// 100 solche Namen passen
const ok = new FakeRes();
await service.downloadZip(ok as never, 't1', 'u1', '/Projekte', names.slice(0, 100), {
check: true,
});
expect(ok.jsonBody).toEqual({ ok: true });
});
it('mehr als 1000 Namen: 400 ohne Aufruf', async () => {
const { service, calls } = setup();
const names = Array.from({ length: 1001 }, (_, i) => `n${i}`);
const e = parts(
await caught(service.downloadZip(new FakeRes() as never, 't1', 'u1', '/', names)),
);
expect(e.status).toBe(400);
expect(calls).toHaveLength(0);
});
});
describe('NextcloudFilesTransferService — Herunterladen', () => {
const FILE = {
status: 200,
@@ -3,12 +3,14 @@ import type { Readable } from 'node:stream';
import { HttpException, Inject, Injectable } from '@nestjs/common';
import { NEXTCLOUD_FILES_CHUNK_SIZE, NEXTCLOUD_FILES_MAX_CHUNKS } from '@tessera/shared';
import type { Response } from 'express';
import { ZIP_MAX_NAMES } from './dto/nextcloud-files-transfer.dto';
import { NextcloudCallGate } from './nextcloud-call-gate';
import * as dav from './nextcloud-dav';
import * as transfer from './nextcloud-dav-transfer';
import { type NcSession, ncError, ncErrorDefault } from './nextcloud-files.types';
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
import {
buildNcUrl,
type NcResult,
NEXTCLOUD_TRANSPORT,
type NextcloudTransport,
@@ -67,6 +69,12 @@ export const MAX_UPLOAD_BYTES = NEXTCLOUD_FILES_MAX_CHUNKS * CHUNK_SIZE;
export const ASSEMBLY_WAIT_MS = 20_000;
/** So lange bleibt der Ausgang eines Zusammenbaus abfragbar. */
export const ASSEMBLY_STATE_TTL_MS = 10 * 60_000;
/**
* Laengste Adresse fuer ein Auswahl-ZIP an Nextcloud (WR-08). Nextcloud nimmt die Auswahl nur
* in der Adresse an (`files=<JSON>`), und ein Apache davor lehnt Anfragezeilen ueber 8190
* Zeichen mit 414 ab (gemessen gegen nextcloud:stable: 6150 Zeichen gehen, 8200 nicht).
*/
export const ZIP_MAX_URL_CHARS = 8000;
/** Obergrenze fuer gleichzeitig gemerkte Zusammenbauten (Schutz des Arbeitsspeichers). */
const ASSEMBLY_STATE_MAX = 2000;
@@ -555,17 +563,23 @@ export class NextcloudFilesTransferService {
// --- Herunterladen ----------------------------------------------------------------------------
/** Datei (oder mit `zip` ein Ordner als ZIP) als Strom an den Browser; `range` geht mit. */
/**
* Datei (oder mit `zip` ein Ordner als ZIP) als Strom an den Browser; `range` geht mit. Mit
* `check` wird nur geprueft, ob Verbindung und Eintrag da sind (IN-06): Antwort `{ ok: true }`
* oder der uebliche Fehler — so kann die Weboberflaeche einen Fehler anzeigen, den sie bei
* einem Download im Browser nicht saehe.
*/
async download(
res: Response,
tenantId: string,
userId: string,
rawPath: string,
opts: { zip?: boolean; range?: string } = {},
opts: { zip?: boolean; range?: string; check?: boolean } = {},
): Promise<void> {
const segments = parseUserPath(rawPath);
if (segments.length === 0 && !opts.zip) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
if (opts.check) return this.sendCheck(res, tenantId, userId, session, segments);
const abort = this.abortOnResponseClose(res);
const upstream = opts.zip
@@ -582,19 +596,33 @@ export class NextcloudFilesTransferService {
await this.send(res, upstream, name, tenantId, userId);
}
/** Nur die gewaehlten Eintraege eines Ordners als ZIP. */
/**
* Nur die gewaehlten Eintraege eines Ordners als ZIP (WR-08): hoechstens 1000 Namen, und die
* Adresse an Nextcloud darf `ZIP_MAX_URL_CHARS` nicht ueberschreiten (sonst 413
* `selectionTooLarge`, auch schon bei der Vorabpruefung).
*/
async downloadZip(
res: Response,
tenantId: string,
userId: string,
rawDir: string | undefined,
names: readonly string[],
opts: { check?: boolean } = {},
): 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');
if (names.length === 0 || names.length > ZIP_MAX_NAMES) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
const upstreamUrl = buildNcUrl(
session.baseUrl,
'/remote.php/dav/files/',
[session.ncUserId, ...dir],
{ accept: 'zip', files: JSON.stringify(names) },
true,
);
if (upstreamUrl.length > ZIP_MAX_URL_CHARS) throw ncErrorDefault('selectionTooLarge');
if (opts.check) return this.sendCheck(res, tenantId, userId, session, dir);
const abort = this.abortOnResponseClose(res);
const upstream = await transfer.downloadSelectionZip(
this.transport,
@@ -608,6 +636,22 @@ export class NextcloudFilesTransferService {
await this.send(res, upstream, name, tenantId, userId);
}
/** Vorabpruefung eines Downloads (IN-06): ein PROPFIND Depth 0, Antwort `{ ok: true }`. */
private async sendCheck(
res: Response,
tenantId: string,
userId: string,
session: NcSession,
segments: readonly string[],
): Promise<void> {
const probe = await dav.stat(this.transport, this.gate, session, segments);
if (!probe.ok) return this.fail(tenantId, userId, probe);
if (!NextcloudFilesTransferService.ok(probe)) return this.fail(tenantId, userId, probe);
res.status(200);
res.setHeader('cache-control', 'private, no-store');
res.json({ ok: true });
}
private abortOnResponseClose(res: Response): AbortSignal {
const abort = new AbortController();
res.on('close', () => {
@@ -100,7 +100,7 @@ describe('NextcloudFilesController — Metadaten', () => {
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('downloadZip')).toEqual([1, 'download/zip']);
expect(route('startUpload')).toEqual([1, 'uploads']);
expect(route('putSingle')).toEqual([2, 'uploads/file']);
expect(route('putChunk')).toEqual([2, 'uploads/:uploadId/chunks/:n']);
@@ -297,7 +297,9 @@ describe('NextcloudFilesController — Delegation', () => {
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.downloadZip(r, res, { dir: '/Ordner', names: ['a', 'b'] } as any);
await controller.downloadZip(r, res, { dir: '/Ordner', names: ['a'], check: '1' } as any);
await controller.download(r, res, { path: '/a.txt', check: '1' } 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);
@@ -314,12 +316,32 @@ describe('NextcloudFilesController — Delegation', () => {
expect(transfer.download).toHaveBeenNthCalledWith(1, res, 't1', 'u1', '/a.txt', {
zip: false,
range: 'bytes=0-99',
check: false,
});
expect(transfer.download).toHaveBeenNthCalledWith(2, res, 't1', 'u1', '/Ordner', {
zip: true,
range: 'bytes=0-99',
check: false,
});
expect(transfer.download).toHaveBeenNthCalledWith(3, res, 't1', 'u1', '/a.txt', {
zip: false,
range: 'bytes=0-99',
check: true,
});
expect(transfer.downloadZip).toHaveBeenNthCalledWith(
1,
res,
't1',
'u1',
'/Ordner',
['a', 'b'],
{
check: false,
},
);
expect(transfer.downloadZip).toHaveBeenNthCalledWith(2, res, 't1', 'u1', '/Ordner', ['a'], {
check: true,
});
expect(transfer.downloadZip).toHaveBeenCalledWith(res, 't1', 'u1', '/Ordner', ['a', 'b']);
expect(transfer.startUpload).toHaveBeenCalledWith('t1', 'u1', {
path: '/x',
size: 5,
@@ -35,7 +35,7 @@ import {
DownloadQueryDto,
StartUploadDto,
UploadQueryDto,
ZipQueryDto,
ZipBodyDto,
} from './dto/nextcloud-files-transfer.dto';
import { NextcloudFilesService } from './nextcloud-files.service';
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
@@ -63,7 +63,7 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
* Benutzen (nur Klassen-`@UseModule`): alles andere — GET status, GET server,
* GET server/logo, POST connect/password, POST connect/flow,
* DELETE connect, GET files, DELETE files, POST folders, POST move,
* GET preview, GET download, GET download/zip, POST uploads, PUT
* GET preview, GET download, POST download/zip, POST uploads, PUT
* uploads/file; danach die Parameterrouten am ENDE: GET/DELETE
* connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
@@ -248,23 +248,29 @@ export class NextcloudFilesController {
{
zip: query.zip === '1',
range: headerOf(req.headers.range),
check: query.check === '1',
},
);
}
/** Nur die gewaehlten Eintraege eines Ordners als ZIP (`name` darf mehrfach vorkommen). */
@Get('download/zip')
/**
* Nur die gewaehlten Eintraege eines Ordners als ZIP. Die Namen stehen im Koerper (WR-08),
* als JSON oder aus einem Formular; `check` prueft nur, ohne ZIP (IN-06).
*/
@Post('download/zip')
@HttpCode(200)
async downloadZip(
@Req() req: AuthenticatedRequest,
@Res() res: Response,
@Query() query: ZipQueryDto,
@Body() body: ZipBodyDto,
): Promise<void> {
await this.transfer.downloadZip(
res,
this.requireTenantId(req),
this.requireUserId(req),
query.dir,
query.name,
body.dir,
body.names,
{ check: body.check === true || body.check === '1' },
);
}
@@ -37,6 +37,7 @@ export type NcErrorCode =
| 'lengthRequired'
| 'chunkTooLarge'
| 'fileTooLarge'
| 'selectionTooLarge'
| 'flowExpired'
| 'tooManyFlows'
| 'invalidUrl'
@@ -155,6 +156,11 @@ export const NC_ERROR_DEFAULTS: Record<NcErrorCode, ErrorDefault> = {
status: 413,
message: 'Die Datei ist zu groß für die Übertragung.',
},
selectionTooLarge: {
status: 413,
message:
'Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter.',
},
flowExpired: {
status: 410,
message: 'Die Anmeldung im Browser ist abgelaufen. Bitte starten Sie sie erneut.',