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