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:
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user