From 8679668cacda83541d754bc2dc0b5599fabc8d0f Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 8 Oct 2026 22:50:24 +0200 Subject: [PATCH] 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) --- .../e2e/e2e-transfer.sh | 38 +++++-- .../dto/nextcloud-files-transfer.dto.ts | 40 ++++++- .../nextcloud-files-transfer.service.spec.ts | 85 +++++++++++++++ .../nextcloud-files-transfer.service.ts | 52 ++++++++- .../nextcloud-files.controller.spec.ts | 28 ++++- .../nextcloud-files.controller.ts | 20 ++-- .../nextcloud-files/nextcloud-files.types.ts | 6 ++ .../components/FileBrowser.test.tsx | 66 +++++++++++- .../components/FileBrowser.tsx | 100 +++++++++++++++--- .../nextcloud-files/components/FileList.tsx | 7 +- .../components/nextcloud-files/error-text.ts | 1 + apps/web/src/lib/nextcloud-files-api.test.ts | 48 ++++++++- apps/web/src/lib/nextcloud-files-api.ts | 48 ++++++++- apps/web/src/messages/de.json | 7 +- apps/web/src/messages/en.json | 7 +- docs/anleitung-anwender.md | 2 +- 16 files changed, 499 insertions(+), 56 deletions(-) diff --git a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh index 9ba01b0..be2bf29 100644 --- a/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh +++ b/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh @@ -176,19 +176,45 @@ e2e_expect 200 "$code" "Ordner als ZIP" grep -qi "^content-disposition: attachment; filename=\"$FNAME.zip\"" "$E2E_TMP/zip.hdr" || e2e_fail "ZIP-Name: $(grep -i content-disposition "$E2E_TMP/zip.hdr")" unzip -l "$E2E_TMP/folder.zip" | grep -q 'gross.bin' || e2e_fail "Ordner-ZIP enthaelt gross.bin nicht" -code=$(curl -s -D "$E2E_TMP/zip2.hdr" -o "$E2E_TMP/sel.zip" -w '%{http_code}' -b "$ADMIN" -G --data-urlencode "dir=$F" --data-urlencode "name=$UMLAUT_NAME" "$FILES/download/zip") -e2e_expect 200 "$code" "Auswahl als ZIP" +# Auswahl-ZIP per POST (WR-08): die Namen im Koerper, als JSON ... +jlist() { python3 -I -c 'import json,sys;print(json.dumps({"dir":sys.argv[1],"names":sys.argv[2:]}))' "$@"; } +code=$(curl -s -D "$E2E_TMP/zip2.hdr" -o "$E2E_TMP/sel.zip" -w '%{http_code}' -b "$ADMIN" -X POST -H 'Content-Type: application/json' \ + --data-binary "$(jlist "$F" "$UMLAUT_NAME")" "$FILES/download/zip") +e2e_expect 200 "$code" "Auswahl als ZIP (JSON)" [ "$(head -c 2 "$E2E_TMP/sel.zip")" = "PK" ] || e2e_fail "Auswahl-ZIP beginnt nicht mit PK" +grep -qi "^content-disposition: attachment; filename=\"$FNAME.zip\"" "$E2E_TMP/zip2.hdr" || e2e_fail "Auswahl-ZIP-Name: $(grep -i content-disposition "$E2E_TMP/zip2.hdr")" unzip -Z1 "$E2E_TMP/sel.zip" > "$E2E_TMP/sel.list" [ "$(wc -l < "$E2E_TMP/sel.list")" = "1" ] || e2e_fail "Auswahl-ZIP enthaelt nicht genau einen Eintrag: $(cat "$E2E_TMP/sel.list")" [ "$(cat "$E2E_TMP/sel.list")" = "$UMLAUT_NAME" ] || e2e_fail "Auswahl-ZIP: falscher Eintrag: $(cat "$E2E_TMP/sel.list")" -# zwei Namen (wiederholter Schluessel) -code=$(curl -s -o "$E2E_TMP/sel2.zip" -w '%{http_code}' -b "$ADMIN" -G --data-urlencode "dir=$F" --data-urlencode "name=$UMLAUT_NAME" --data-urlencode "name=gross.bin" "$FILES/download/zip") -e2e_expect 200 "$code" "Auswahl mit zwei Namen" +# ... und wie das unsichtbare Formular der Weboberflaeche (urlencoded, names als JSON-Text), ueber /api-proxy +NAMES_JSON=$(python3 -I -c 'import json,sys;print(json.dumps(sys.argv[1:]))' "$UMLAUT_NAME" gross.bin) +code=$(curl -s -o "$E2E_TMP/sel2.zip" -w '%{http_code}' -b "$ADMIN" -X POST --data-urlencode "dir=$F" --data-urlencode "names=$NAMES_JSON" "$VIA_WEB/download/zip") +e2e_expect 200 "$code" "Auswahl mit zwei Namen (Formular, via Web)" [ "$(unzip -Z1 "$E2E_TMP/sel2.zip" | wc -l)" = "2" ] || e2e_fail "Auswahl-ZIP mit zwei Namen hat nicht zwei Eintraege" -code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -G --data-urlencode "dir=$F" --data-urlencode "name=.." "$FILES/download/zip") +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X POST -H 'Content-Type: application/json' \ + --data-binary "$(jlist "$F" "..")" "$FILES/download/zip") e2e_expect 400 "$code" "ZIP mit Name .." e2e_contains "$E2E_TMP/body.out" '"code":"invalidPath"' "ZIP-Name ..: invalidPath" +# 300 lange Namen kommen im Koerper durch Proxy und API (frueher 431/414 an Tessera). Nextcloud nimmt +# die Auswahl aber nur in ihrer Adresse an (Apache: hoechstens 8190 Zeichen) -> klare 413 selectionTooLarge +# von Tessera, bevor Nextcloud gefragt wird. +MANY=$(python3 -I -c 'import json,sys;print(json.dumps({"dir":sys.argv[1],"names":[sys.argv[2]]+["fehlt-%03d-%s"%(i,"x"*60) for i in range(299)]}))' "$F" "$UMLAUT_NAME") +code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -X POST -H 'Content-Type: application/json' --data-binary "$MANY" "$VIA_WEB/download/zip") +e2e_expect 413 "$code" "300 lange Namen" +e2e_contains "$E2E_TMP/body.out" '"code":"selectionTooLarge"' "300 lange Namen: selectionTooLarge" +# Die alte Adressform gibt es nicht mehr +code=$(curl -s -o /dev/null -w '%{http_code}' -b "$ADMIN" -G --data-urlencode "dir=$F" --data-urlencode "name=gross.bin" "$FILES/download/zip") +e2e_expect 404 "$code" "GET download/zip gibt es nicht mehr" + +# Vorabpruefung (IN-06): { ok: true } oder der Fehler, ohne Datenstrom +code=$(e2e_status "$ADMIN" GET "$FILES/download?path=$(enc "$F/gross.bin")&check=1") +e2e_expect 200 "$code" "Vorabpruefung Datei" +e2e_contains "$E2E_TMP/body.out" '"ok":true' "Vorabpruefung Datei ok" +code=$(e2e_status "$ADMIN" GET "$FILES/download?path=$(enc "$F/gibt-es-nicht.bin")&check=1") +e2e_expect 404 "$code" "Vorabpruefung fehlende Datei" +code=$(e2e_status "$ADMIN" POST "$FILES/download/zip" "$(jlist "$F" gross.bin | python3 -I -c 'import json,sys;d=json.load(sys.stdin);d["check"]=True;print(json.dumps(d))')") +e2e_expect 200 "$code" "Vorabpruefung Auswahl-ZIP" +e2e_contains "$E2E_TMP/body.out" '"ok":true' "Vorabpruefung Auswahl-ZIP ok" # --- 7. Abbrechen raeumt auf ----------------------------------------------------------------------------------------- code=$(e2e_status "$ADMIN" POST "$FILES/uploads" "{\"path\":\"$F/abbruch.bin\",\"size\":$BIG_SIZE}") diff --git a/apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts b/apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts index 35ce2c8..a8a33c4 100644 --- a/apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts +++ b/apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts @@ -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; } diff --git a/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.spec.ts index 36b2b42..e8ef54e 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.spec.ts @@ -67,6 +67,12 @@ class FakeRes extends Writable { code = 200; headers: Record = {}; 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, diff --git a/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts b/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts index 627c447..a050fa1 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts @@ -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=`), 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 { 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 { 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 { + 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', () => { diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts index bedce60..96bff79 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts @@ -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, diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts index ec46f97..542eaa9 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts @@ -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 { 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' }, ); } diff --git a/apps/api/src/nextcloud-files/nextcloud-files.types.ts b/apps/api/src/nextcloud-files/nextcloud-files.types.ts index a4dd8cf..3024e77 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.types.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.types.ts @@ -37,6 +37,7 @@ export type NcErrorCode = | 'lengthRequired' | 'chunkTooLarge' | 'fileTooLarge' + | 'selectionTooLarge' | 'flowExpired' | 'tooManyFlows' | 'invalidUrl' @@ -155,6 +156,11 @@ export const NC_ERROR_DEFAULTS: Record = { 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.', diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx index df78bc9..1e4ec17 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx @@ -24,6 +24,8 @@ const mockCreate = vi.fn(); const mockMove = vi.fn(); const mockDelete = vi.fn(); const mockUpload = vi.fn(); +const mockCheckDownload = vi.fn(); +const mockCheckZip = vi.fn(); vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { const actual = await importOriginal(); @@ -33,6 +35,8 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { createFolder: (...a: unknown[]) => mockCreate(...a), moveEntry: (...a: unknown[]) => mockMove(...a), deleteEntry: (...a: unknown[]) => mockDelete(...a), + checkDownload: (...a: unknown[]) => mockCheckDownload(...a), + checkZip: (...a: unknown[]) => mockCheckZip(...a), }; }); vi.mock('@/lib/nextcloud-files-upload', () => ({ @@ -98,6 +102,7 @@ function render(ui: ReactElement) { } let clicked: string[] = []; +let submitted: { action: string; target: string; fields: Record }[] = []; let onExpired: ReturnType; beforeEach(() => { @@ -109,13 +114,24 @@ beforeEach(() => { mockMove.mockReset().mockResolvedValue({ from: '', to: '' }); mockDelete.mockReset().mockResolvedValue({ deleted: true }); mockUpload.mockReset().mockResolvedValue({ path: '/x', size: 1 }); + mockCheckDownload.mockReset().mockResolvedValue({ ok: true }); + mockCheckZip.mockReset().mockResolvedValue({ ok: true }); onExpired = vi.fn(); clicked = []; + submitted = []; vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( this: HTMLAnchorElement, ) { clicked.push(this.href); }); + vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(function ( + this: HTMLFormElement, + ) { + const fields: Record = {}; + for (const input of Array.from(this.querySelectorAll('input'))) + fields[input.name] = input.value; + submitted.push({ action: this.action, target: this.target, fields }); + }); }); afterEach(() => { @@ -188,7 +204,8 @@ describe('FileBrowser — Liste', () => { const file = screen.getByRole('row', { name: 'Bericht.pdf' }); fireEvent.focus(file); fireEvent.keyDown(file, { key: 'Enter' }); - expect(clicked).toHaveLength(1); + await waitFor(() => expect(clicked).toHaveLength(1)); + expect(mockCheckDownload).toHaveBeenCalledWith('/Bericht.pdf', { zip: false }); expect(clicked[0]).toContain('/modules/nextcloud-files/download?path=%2FBericht.pdf'); const dir = screen.getByRole('row', { name: 'Projekte' }); fireEvent.focus(dir); @@ -215,12 +232,54 @@ describe('FileBrowser — Liste', () => { expect(screen.getByRole('button', { name: 'Hochladen' })).toBeTruthy(); }); - it('Herunterladen mehrerer Eintraege nutzt das Auswahl-ZIP', async () => { + it('Herunterladen mehrerer Eintraege nutzt das Auswahl-ZIP per POST mit den Namen im Koerper (WR-08)', async () => { await mount(); fireEvent.click(screen.getByRole('row', { name: 'Bericht.pdf' })); fireEvent.click(screen.getByRole('row', { name: 'Projekte' }), { ctrlKey: true }); fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' })); - expect(clicked[0]).toContain('/download/zip?dir=%2F&name=Projekte&name=Bericht.pdf'); + await waitFor(() => expect(submitted).toHaveLength(1)); + expect(mockCheckZip).toHaveBeenCalledWith('/', ['Projekte', 'Bericht.pdf']); + expect(submitted[0].action).toMatch(/\/modules\/nextcloud-files\/download\/zip$/); + expect(submitted[0].target).toBe('nc-files-download'); + expect(submitted[0].fields).toEqual({ dir: '/', names: '["Projekte","Bericht.pdf"]' }); + expect(clicked).toHaveLength(0); + }); + + it('alles ausgewaehlt: der ganze Ordner als ZIP statt einer Namensliste', async () => { + await mount(); + fireEvent.keyDown(screen.getByRole('row', { name: 'Bericht.pdf' }), { + key: 'a', + ctrlKey: true, + }); + fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' })); + await waitFor(() => expect(clicked).toHaveLength(1)); + expect(clicked[0]).toContain('download?path=%2F&zip=1'); + expect(submitted).toHaveLength(0); + }); + + it('scheitert die Vorabpruefung, steht der Grund in der Statuszeile und nichts wird geladen (IN-06)', async () => { + mockCheckDownload.mockRejectedValueOnce( + new NextcloudFilesRequestError(404, 'notFound', 'Der Eintrag wurde nicht gefunden.'), + ); + await mount(); + const file = screen.getByRole('row', { name: 'Bericht.pdf' }); + fireEvent.focus(file); + fireEvent.keyDown(file, { key: 'Enter' }); + expect( + await screen.findByText('Herunterladen nicht möglich: Der Eintrag existiert nicht mehr.'), + ).toBeTruthy(); + expect(clicked).toHaveLength(0); + }); + + it('abgelaufene Verbindung beim Herunterladen: zurueck zum Anmeldebildschirm (IN-06)', async () => { + mockCheckDownload.mockRejectedValueOnce( + new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'), + ); + await mount(); + fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Als ZIP herunterladen' })); + await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1)); + expect(clicked).toHaveLength(0); }); it('Entf oeffnet die Rueckfrage mit Anzahl und Papierkorb; Bestaetigen loescht je Pfad und laedt neu', async () => { @@ -342,6 +401,7 @@ describe('FileBrowser — Liste', () => { await mount(); fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' })); fireEvent.click(screen.getByRole('menuitem', { name: 'Als ZIP herunterladen' })); + await waitFor(() => expect(clicked).toHaveLength(1)); expect(clicked[0]).toContain('download?path=%2FProjekte&zip=1'); }); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx index 3c69e9c..960874a 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx @@ -8,6 +8,8 @@ import { basename, freeName, join, parent, ROOT } from '@/components/nextcloud-f import { EMPTY_SELECTION, selectionReducer } from '@/components/nextcloud-files/selection'; import { isActive, useTransfers } from '@/components/nextcloud-files/use-transfers'; import { + checkDownload, + checkZip, createFolder, deleteEntry, downloadUrl, @@ -16,7 +18,9 @@ import { type NcEntry, type NcListing, type NcQuota, - zipUrl, + zipFormFields, + zipPostUrl, + zipSelectionFits, } from '@/lib/nextcloud-files-api'; import { useAuthStore } from '@/lib/stores/auth-store'; import { DeleteDialog } from './DeleteDialog'; @@ -82,6 +86,45 @@ export function triggerDownload(url: string): void { a.remove(); } +const DOWNLOAD_FRAME = 'nc-files-download'; + +/** + * Laedt per POST-Formular herunter (Auswahl-ZIP, WR-08): der Browser speichert den Strom + * direkt (nichts im Arbeitsspeicher der Seite). Ziel ist ein unsichtbarer Rahmen, damit + * eine unerwartete Fehlerantwort nie die Seite ersetzt. + */ +export function submitDownloadForm(action: string, fields: Record): void { + let frame = document.querySelector(`iframe[name="${DOWNLOAD_FRAME}"]`); + if (!frame) { + frame = document.createElement('iframe'); + frame.name = DOWNLOAD_FRAME; + frame.title = DOWNLOAD_FRAME; + frame.hidden = true; + frame.tabIndex = -1; + frame.setAttribute('aria-hidden', 'true'); + document.body.appendChild(frame); + } + const form = document.createElement('form'); + form.method = 'POST'; + form.action = action; + form.target = DOWNLOAD_FRAME; + form.style.display = 'none'; + for (const [name, value] of Object.entries(fields)) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = value; + form.appendChild(input); + } + document.body.appendChild(form); + form.submit(); + form.remove(); +} + +type DownloadJob = + | { kind: 'file' | 'folder'; path: string } + | { kind: 'selection'; dir: string; names: string[] }; + function isTypingTarget(el: EventTarget | null): boolean { if (!(el instanceof HTMLElement)) return false; return !!el.closest( @@ -427,27 +470,60 @@ export function FileBrowser({ // --- Aktionen --------------------------------------------------------------------------------- + /** + * Herunterladen mit Vorabpruefung (IN-06): erst fragt die Ansicht die API, ob Verbindung und + * Eintrag da sind, dann startet der eigentliche Download im Browser. Ein Fehler steht in der + * Statuszeile; eine abgelaufene Verbindung fuehrt zurueck zum Anmeldebildschirm. + */ + const startDownload = useCallback( + async (job: DownloadJob) => { + try { + if (job.kind === 'selection') await checkZip(job.dir, job.names); + else await checkDownload(job.path, { zip: job.kind === 'folder' }); + } catch (err) { + const e = toErrorLike(err); + if (e.code === 'connectionExpired') { + onExpiredRef.current(); + return; + } + say(t('status.downloadFailed', { reason: errorText(tCodes, e, locale) })); + return; + } + if (job.kind === 'selection') { + submitDownloadForm(zipPostUrl(), zipFormFields(job.dir, job.names)); + } else { + triggerDownload(downloadUrl(job.path, { zip: job.kind === 'folder' })); + } + }, + [say, t, tCodes, locale], + ); + const openEntry = useCallback( (entry: NcEntry) => { if (entry.type === 'folder') navigate(entry.path); - else triggerDownload(downloadUrl(entry.path)); + else void startDownload({ kind: 'file', path: entry.path }); }, - [navigate], + [navigate, startDownload], ); const downloadEntries = (list: NcEntry[]) => { if (list.length === 0) return; if (list.length === 1) { const [only] = list; - triggerDownload(downloadUrl(only.path, { zip: only.type === 'folder' })); + void startDownload({ kind: only.type === 'folder' ? 'folder' : 'file', path: only.path }); return; } - triggerDownload( - zipUrl( - path, - list.map((e) => e.name), - ), - ); + // Alles im Ordner gewaehlt: der ganze Ordner als ZIP (ohne Namensliste). + if (listing && !listing.truncated && list.length === entries.length) { + void startDownload({ kind: 'folder', path }); + return; + } + const names = list.map((e) => e.name); + if (!zipSelectionFits(path, names)) { + say(t('status.zipTooMany')); + return; + } + void startDownload({ kind: 'selection', dir: path, names }); }; /** Fuehrt eine Aktion je Eintrag aus und meldet Teilerfolge; laedt danach neu. */ @@ -555,14 +631,14 @@ export function FileBrowser({ id: 'downloadZip', label: t('menu.downloadZip'), icon: , - onSelect: () => triggerDownload(downloadUrl(entry.path, { zip: true })), + onSelect: () => void startDownload({ kind: 'folder', path: entry.path }), }); } else { actions.push({ id: 'download', label: t('menu.download'), icon: , - onSelect: () => triggerDownload(downloadUrl(entry.path)), + onSelect: () => void startDownload({ kind: 'file', path: entry.path }), }); } actions.push( diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx index 7c10946..2a874e4 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx @@ -209,7 +209,12 @@ export function FileList({ href={downloadUrl(entry.path)} tabIndex={-1} download - onClick={(e) => e.stopPropagation()} + onClick={(e) => { + // Ueber die Ansicht laden: sie prueft vorab und meldet Fehler (IN-06). + e.stopPropagation(); + e.preventDefault(); + onItemOpen(entry); + }} className="min-w-0 truncate text-foreground hover:underline hover:underline-offset-2" title={entry.name} > diff --git a/apps/web/src/components/nextcloud-files/error-text.ts b/apps/web/src/components/nextcloud-files/error-text.ts index 6bb1037..a6640d0 100644 --- a/apps/web/src/components/nextcloud-files/error-text.ts +++ b/apps/web/src/components/nextcloud-files/error-text.ts @@ -20,6 +20,7 @@ const KNOWN = new Set([ 'invalidName', 'invalidPath', 'nextcloudUnavailable', + 'selectionTooLarge', ]); export interface ErrorLike { diff --git a/apps/web/src/lib/nextcloud-files-api.test.ts b/apps/web/src/lib/nextcloud-files-api.test.ts index e0922c4..1e4d247 100644 --- a/apps/web/src/lib/nextcloud-files-api.test.ts +++ b/apps/web/src/lib/nextcloud-files-api.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + checkDownload, + checkZip, createFolder, deleteEntry, downloadUrl, @@ -7,7 +9,10 @@ import { moveEntry, NextcloudFilesRequestError, previewUrl, - zipUrl, + ZIP_MAX_NAMES, + zipFormFields, + zipPostUrl, + zipSelectionFits, } from './nextcloud-files-api'; const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; @@ -105,10 +110,43 @@ describe('nextcloud-files-api — Herunterladen', () => { ); }); - it('zipUrl: Ordner in dir, jeder Name als eigener wiederholter name-Schluessel, einzeln codiert', () => { - expect(zipUrl('/Projekte', ['a.txt', 'Ärger & Co', '100%'])).toBe( - `${API}/modules/nextcloud-files/download/zip?dir=%2FProjekte&name=a.txt&name=%C3%84rger%20%26%20Co&name=100%25`, + it('Auswahl-ZIP per POST: Namen im Koerper als JSON-Liste, nie in der Adresse (WR-08)', () => { + expect(zipPostUrl()).toBe(`${API}/modules/nextcloud-files/download/zip`); + expect(zipFormFields('/Projekte', ['a.txt', 'Ärger & Co', '100%'])).toEqual({ + dir: '/Projekte', + names: '["a.txt","Ärger & Co","100%"]', + }); + }); + + it('zipSelectionFits: Anzahl und Laenge der Adresse an Nextcloud (Apache: 8190 Zeichen)', () => { + const names = (n: number, len = 20) => + Array.from({ length: n }, (_, i) => `${String(i).padStart(4, '0')}${'x'.repeat(len)}`); + expect(zipSelectionFits('/', names(200))).toBe(true); + expect(zipSelectionFits('/', names(300))).toBe(false); + expect(zipSelectionFits('/', names(ZIP_MAX_NAMES + 1, 0))).toBe(false); + expect(zipSelectionFits('/', [])).toBe(false); + // Umlaute zaehlen codiert (je sechs Zeichen) + expect(zipSelectionFits('/', ['ä'.repeat(1200)])).toBe(false); + }); + + it('Vorabpruefungen (IN-06): GET download mit check=1, POST download/zip mit check: true', async () => { + const fn = mockFetch(200, { ok: true }); + await checkDownload('/Ordner/a b.txt'); + await checkDownload('/Ordner', { zip: true }); + await checkZip('/Ordner', ['a', 'b']); + expect(fn.mock.calls[0][0]).toBe( + `${API}/modules/nextcloud-files/download?path=%2FOrdner%2Fa%20b.txt&check=1`, ); - expect(zipUrl('/', ['x'])).toBe(`${API}/modules/nextcloud-files/download/zip?dir=%2F&name=x`); + expect(fn.mock.calls[1][0]).toBe( + `${API}/modules/nextcloud-files/download?path=%2FOrdner&zip=1&check=1`, + ); + expect(fn.mock.calls[2][0]).toBe(`${API}/modules/nextcloud-files/download/zip`); + const init = fn.mock.calls[2][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ + dir: '/Ordner', + names: ['a', 'b'], + check: true, + }); }); }); diff --git a/apps/web/src/lib/nextcloud-files-api.ts b/apps/web/src/lib/nextcloud-files-api.ts index 7d57050..b0cb7e9 100644 --- a/apps/web/src/lib/nextcloud-files-api.ts +++ b/apps/web/src/lib/nextcloud-files-api.ts @@ -269,11 +269,49 @@ export function downloadUrl(path: string, opts: { zip?: boolean } = {}): string return `${API_URL}${BASE}/download?path=${encodeURIComponent(path)}${zip}`; } +/** Hoechstzahl gewaehlter Eintraege fuer ein Auswahl-ZIP (wie die API, WR-08). */ +export const ZIP_MAX_NAMES = 1000; /** - * Adresse zum Herunterladen mehrerer Eintraege eines Ordners als ein ZIP. Jeder Name - * steht als eigener `name`-Parameter (wiederholter Schluessel), einzeln codiert. + * Budget fuer Ordner und Namensliste, codiert wie in der Adresse an Nextcloud. Nextcloud nimmt + * die Auswahl nur in der Adresse an, und ein Apache davor lehnt Anfragezeilen ueber 8190 + * Zeichen ab; die API prueft verbindlich (413 `selectionTooLarge`), hier wird vorher und + * etwas vorsichtiger gerechnet, damit der Benutzer sofort eine klare Meldung bekommt. */ -export function zipUrl(dir: string, names: readonly string[]): string { - const list = names.map((n) => `name=${encodeURIComponent(n)}`).join('&'); - return `${API_URL}${BASE}/download/zip?dir=${encodeURIComponent(dir)}&${list}`; +export const ZIP_MAX_ENCODED_CHARS = 7000; + +/** + * Ziel des Auswahl-ZIPs (WR-08): POST mit den Namen im KOERPER, nicht in der Adresse — + * mehrere hundert Namen sprengten sonst die Grenzen von Node (431) oder des Proxys (414). + */ +export function zipPostUrl(): string { + return `${API_URL}${BASE}/download/zip`; +} + +/** Formularfelder des Auswahl-ZIPs: Ordner und die Namen als JSON-Liste. */ +export function zipFormFields(dir: string, names: readonly string[]): Record { + return { dir, names: JSON.stringify(names) }; +} + +/** Passt die Auswahl in ein Auswahl-ZIP (Anzahl und Laenge der Adresse an Nextcloud)? */ +export function zipSelectionFits(dir: string, names: readonly string[]): boolean { + if (names.length === 0 || names.length > ZIP_MAX_NAMES) return false; + const encoded = encodeURIComponent(dir).length + encodeURIComponent(JSON.stringify(names)).length; + return encoded <= ZIP_MAX_ENCODED_CHARS; +} + +/** + * Vorabpruefung eines Downloads (IN-06): Verbindung und Eintrag. Ein Download ueber einen Link + * zeigt Fehler im Browser nicht an; so kann die Ansicht sie melden. + */ +export function checkDownload(path: string, opts: { zip?: boolean } = {}): Promise<{ ok: true }> { + const zip = opts.zip ? '&zip=1' : ''; + return request<{ ok: true }>(`/download?path=${encodeURIComponent(path)}${zip}&check=1`); +} + +/** Vorabpruefung eines Auswahl-ZIPs (IN-06): Verbindung, Ordner und die Namen selbst. */ +export function checkZip(dir: string, names: readonly string[]): Promise<{ ok: true }> { + return request<{ ok: true }>('/download/zip', { + method: 'POST', + json: { dir, names, check: true }, + }); } diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index a1a58a5..d25d22a 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -2486,7 +2486,9 @@ "moved": "{count, plural, one {„{name}“ nach „{target}“ verschoben.} other {# Elemente nach „{target}“ verschoben.}}", "renamed": "Umbenannt in „{name}“.", "folderCreated": "Ordner „{name}“ angelegt.", - "failed": "{failed, plural, one {Ein Element ließ sich nicht verarbeiten} other {# Elemente ließen sich nicht verarbeiten}}: {reason}" + "failed": "{failed, plural, one {Ein Element ließ sich nicht verarbeiten} other {# Elemente ließen sich nicht verarbeiten}}: {reason}", + "downloadFailed": "Herunterladen nicht möglich: {reason}", + "zipTooMany": "Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter." } }, "dialogs": { @@ -2569,7 +2571,8 @@ "invalidName": "Dieser Name ist nicht erlaubt.", "invalidPath": "Dieser Name ist nicht erlaubt.", "nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.", - "generic": "Das hat nicht geklappt. Bitte versuchen Sie es erneut." + "generic": "Das hat nicht geklappt. Bitte versuchen Sie es erneut.", + "selectionTooLarge": "Zu viele Einträge für eine ZIP-Datei. Wählen Sie weniger Einträge aus oder laden Sie den ganzen Ordner herunter." } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index bb16a70..cfa445f 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -2486,7 +2486,9 @@ "moved": "{count, plural, one {“{name}” moved to “{target}”.} other {# items moved to “{target}”.}}", "renamed": "Renamed to “{name}”.", "folderCreated": "Folder “{name}” created.", - "failed": "{failed, plural, one {One item could not be processed} other {# items could not be processed}}: {reason}" + "failed": "{failed, plural, one {One item could not be processed} other {# items could not be processed}}: {reason}", + "downloadFailed": "Download not possible: {reason}", + "zipTooMany": "Too many entries for one ZIP file. Select fewer entries or download the whole folder." } }, "dialogs": { @@ -2569,7 +2571,8 @@ "invalidName": "This name is not allowed.", "invalidPath": "This name is not allowed.", "nextcloudUnavailable": "Nextcloud cannot be reached or does not respond in time.", - "generic": "That did not work. Please try again." + "generic": "That did not work. Please try again.", + "selectionTooLarge": "Too many entries for one ZIP file. Select fewer entries or download the whole folder." } } } diff --git a/docs/anleitung-anwender.md b/docs/anleitung-anwender.md index 1334bca..fb9bbc1 100644 --- a/docs/anleitung-anwender.md +++ b/docs/anleitung-anwender.md @@ -261,7 +261,7 @@ Das Modul zeigt Ihre Dateien aus der Firmen-Nextcloud direkt in Tessera: ansehen **Gibt es den Namen schon?** Tessera überschreibt nie stillschweigend. Es fragt in der Übertragungsleiste: „Ersetzen“ (die vorhandene Datei wird durch die neue ersetzt), „Beide behalten“ (die neue bekommt einen eigenen Namen) oder „Überspringen“. -**Herunterladen:** Eine einzelne Datei lädt Ihr Browser direkt herunter. Ordner und Mehrfachauswahl kommen als ZIP-Datei. +**Herunterladen:** Eine einzelne Datei lädt Ihr Browser direkt herunter. Ordner und Mehrfachauswahl kommen als ZIP-Datei; haben Sie alles in einem Ordner ausgewählt, kommt der ganze Ordner als ZIP. Für eine ZIP-Datei aus einer Auswahl nimmt die Nextcloud nur eine begrenzte Zahl von Einträgen an (je nach Länge der Namen etwa 100 bis 200); bei mehr meldet Tessera das sofort, wählen Sie dann weniger aus oder laden Sie den ganzen Ordner herunter. Klappt ein Herunterladen nicht (zum Beispiel, weil die Datei inzwischen gelöscht wurde), steht der Grund in der Zeile unter der Dateiliste; ist die Verbindung abgelaufen, sehen Sie wieder den Anmeldebildschirm. **Tastatur:**