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
@@ -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<typeof import('@/lib/nextcloud-files-api')>();
@@ -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<string, string> }[] = [];
let onExpired: ReturnType<typeof vi.fn>;
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<string, string> = {};
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');
});
@@ -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<string, string>): void {
let frame = document.querySelector<HTMLIFrameElement>(`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: <DownloadIcon />,
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: <DownloadIcon />,
onSelect: () => triggerDownload(downloadUrl(entry.path)),
onSelect: () => void startDownload({ kind: 'file', path: entry.path }),
});
}
actions.push(
@@ -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}
>
@@ -20,6 +20,7 @@ const KNOWN = new Set([
'invalidName',
'invalidPath',
'nextcloudUnavailable',
'selectionTooLarge',
]);
export interface ErrorLike {
+43 -5
View File
@@ -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,
});
});
});
+43 -5
View File
@@ -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<string, string> {
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 },
});
}
+5 -2
View File
@@ -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."
}
}
}
+5 -2
View File
@@ -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."
}
}
}