From 07474bd162b0ff08fb72f55471a07214e1f776bc Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 8 Oct 2026 22:39:05 +0200 Subject: [PATCH] fix(nextcloud-files): WR-03 hoechstens vier gleichzeitige Nextcloud-Aufrufe je Zugang - die Aufrufsperre vergibt je Zugangsschluessel vier Plaetze bis zur Antwort (Kopfzeilen); weitere Aufrufe warten der Reihe nach und pruefen danach, ob der Zugang inzwischen tot oder der Ursprung gesperrt ist; ein widerrufener Zugang erzeugt so hoechstens vier 401 - Hochladen und Zusammenbau (Antwort erst nach dem ganzen Koerper) belegen keinen Platz - Weboberflaeche: Vorschaubilder laden weiter lazy; schlaegt eines fehl, prueft die Ansicht hoechstens alle 10 s die Verbindung und kehrt bei connectionExpired zur Anmeldung zurueck Co-Authored-By: Claude Opus 5.5 (1M context) --- .../nextcloud-files/nextcloud-call-gate.ts | 71 ++++++++++++++ .../nextcloud-files/nextcloud-dav-transfer.ts | 6 +- apps/api/src/nextcloud-files/nextcloud-dav.ts | 3 + .../nextcloud-files/nextcloud-http.spec.ts | 96 +++++++++++++++++++ .../api/src/nextcloud-files/nextcloud-http.ts | 42 ++++++++ .../components/FileBrowser.test.tsx | 24 +++++ .../components/FileBrowser.tsx | 11 +++ .../nextcloud-files/components/FileGrid.tsx | 10 +- .../nextcloud-files/components/FileList.tsx | 8 +- .../nextcloud-files/components/TypeTile.tsx | 8 +- 10 files changed, 273 insertions(+), 6 deletions(-) diff --git a/apps/api/src/nextcloud-files/nextcloud-call-gate.ts b/apps/api/src/nextcloud-files/nextcloud-call-gate.ts index bbedccc..d693ccf 100644 --- a/apps/api/src/nextcloud-files/nextcloud-call-gate.ts +++ b/apps/api/src/nextcloud-files/nextcloud-call-gate.ts @@ -8,6 +8,8 @@ export const MAX_PAUSE_SECONDS = 60 * 60; export const DEAD_KEY_TTL_MS = 24 * 60 * 60 * 1000; /** Hoechstzahl gemerkter Zugangsschluessel (aelteste fliegen zuerst raus). */ export const MAX_DEAD_KEYS = 10_000; +/** Hoechstens so viele Aufrufe je Zugangsschluessel warten gleichzeitig auf ihre Antwort (WR-03). */ +export const MAX_CONCURRENT_PER_KEY = 4; /** * Aufrufsperre (quick-261008-mzu, D-O) — ein prozessweites Objekt, das jeder @@ -26,6 +28,12 @@ export const MAX_DEAD_KEYS = 10_000; * tot: spaetere Aufrufe gehen gar nicht erst raus, und laufende Aufrufe * desselben Schluessels werden abgebrochen. * + * (c) Hoechstens 4 gleichzeitige Aufrufe je Zugangsschluessel (WR-03): Die + * Sperre (b) greift erst, wenn das erste 401 zurueck ist. Ohne Grenze waeren + * bis dahin z. B. Dutzende Vorschaubilder unterwegs, und jedes davon zaehlte + * bei Nextcloud als Fehlanmeldung der gemeinsamen Server-Adresse. Ein Platz + * gilt bis zur Antwort (Kopfzeilen), nicht fuer den ganzen Datenstrom. + * * Der Zustand liegt im Arbeitsspeicher; ein Neustart der API hebt ihn auf (die * Nextcloud-Sperre selbst bleibt dort bestehen und wird beim naechsten 429 * wieder erkannt). @@ -38,6 +46,7 @@ export class NextcloudCallGate { private readonly pausedUntil = new Map(); private readonly dead = new Map(); private readonly controllers = new Map(); + private readonly slots = new Map void> }>(); // --- (a) Sperre je Ursprung -------------------------------------------------- @@ -119,4 +128,66 @@ export class NextcloudCallGate { } return controller.signal; } + + // --- (c) Gleichzeitige Aufrufe je Zugangsschluessel --------------------------- + + /** + * Belegt einen der 4 Plaetze des Schluessels; sind alle belegt, wird in der + * Reihenfolge der Anfragen gewartet. Liefert die Freigabe (mehrfacher Aufruf ist + * wirkungslos). Bricht `signal` waehrend des Wartens ab, wird mit dessen Grund + * abgelehnt. + */ + async acquireSlot(key: string, signal?: AbortSignal): Promise<() => void> { + let slot = this.slots.get(key); + if (!slot) { + slot = { active: 0, waiting: [] }; + this.slots.set(key, slot); + } + if (slot.active < MAX_CONCURRENT_PER_KEY) { + slot.active += 1; + return this.releaser(key, slot); + } + const queue = slot; + await new Promise((resolve, reject) => { + const onAbort = () => { + const i = queue.waiting.indexOf(grant); + if (i >= 0) queue.waiting.splice(i, 1); + reject(signal?.reason ?? new Error('aborted')); + }; + const grant = () => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }; + queue.waiting.push(grant); + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + } + }); + return this.releaser(key, queue); + } + + /** Belegte und wartende Aufrufe eines Schluessels (fuer Tests). */ + slotUsage(key: string): { active: number; waiting: number } { + const slot = this.slots.get(key); + return { active: slot?.active ?? 0, waiting: slot?.waiting.length ?? 0 }; + } + + private releaser(key: string, slot: { active: number; waiting: Array<() => void> }) { + let released = false; + return () => { + if (released) return; + released = true; + const next = slot.waiting.shift(); + // Der Platz geht direkt an den naechsten Wartenden (die Zahl der belegten bleibt gleich). + if (next) { + next(); + return; + } + slot.active -= 1; + if (slot.active <= 0 && slot.waiting.length === 0 && this.slots.get(key) === slot) { + this.slots.delete(key); + } + }; + } } diff --git a/apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts b/apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts index e1dcd62..2fe79bf 100644 --- a/apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts +++ b/apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts @@ -75,6 +75,8 @@ export async function putFile( headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS, bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS, signal: opts.signal, + // Die Antwort kommt erst nach dem ganzen Koerper; Uploads laufen je Datei nacheinander. + unthrottled: true, }), ); } @@ -131,6 +133,7 @@ export async function uploadChunk( headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS, bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS, signal: opts.signal, + unthrottled: true, }, ), ); @@ -166,7 +169,8 @@ export async function uploadAssemble( 'MOVE', '/remote.php/dav/uploads/', [uploadId, '.file'], - { headers, headersTimeoutMs: DAV_ASSEMBLE_HEADERS_TIMEOUT_MS }, + // Der Zusammenbau kann Minuten dauern und darf keinen Platz blockieren (WR-03). + { headers, headersTimeoutMs: DAV_ASSEMBLE_HEADERS_TIMEOUT_MS, unthrottled: true }, ), ); } diff --git a/apps/api/src/nextcloud-files/nextcloud-dav.ts b/apps/api/src/nextcloud-files/nextcloud-dav.ts index 0e11156..62b8ca5 100644 --- a/apps/api/src/nextcloud-files/nextcloud-dav.ts +++ b/apps/api/src/nextcloud-files/nextcloud-dav.ts @@ -48,6 +48,8 @@ export interface DavExtra { headersTimeoutMs?: number; bodyTimeoutMs?: number; signal?: AbortSignal; + /** Siehe `NcRequestOptions.unthrottled` (nur Hochladen und Zusammenbau). */ + unthrottled?: boolean; } /** Allgemeiner WebDAV-Aufruf im Benutzerbereich; `segments` sind OHNE die Benutzerkennung. */ @@ -74,6 +76,7 @@ export function davRequest( headersTimeoutMs: extra.headersTimeoutMs ?? DAV_SMALL_TIMEOUT_MS, bodyTimeoutMs: extra.bodyTimeoutMs ?? DAV_SMALL_TIMEOUT_MS, signal: extra.signal, + unthrottled: extra.unthrottled, }); } diff --git a/apps/api/src/nextcloud-files/nextcloud-http.spec.ts b/apps/api/src/nextcloud-files/nextcloud-http.spec.ts index a2740dd..3608e20 100644 --- a/apps/api/src/nextcloud-files/nextcloud-http.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-http.spec.ts @@ -367,6 +367,102 @@ describe('ncRequest — Aufrufsperre', () => { }); }); +describe('ncRequest — gleichzeitige Aufrufe je Zugangsschluessel (WR-03)', () => { + it('zwanzig gleichzeitige Vorschauen mit widerrufenem Zugang: hoechstens vier erreichen Nextcloud', async () => { + const gate = new NextcloudCallGate(); + let release!: () => void; + const hold = new Promise((r) => { + release = r; + }); + const { transport, calls } = fakeTransport(async () => { + await hold; + return reply(401); + }); + const all = Promise.all( + Array.from({ length: 20 }, () => + ncRequest(transport, gate, { + baseUrl: BASE, + prefix: '/index.php/core/preview', + query: { fileId: '1' }, + method: 'GET', + authorization: 'Basic eA==', + credentialKey: 'key-1', + }), + ), + ); + await new Promise((r) => setTimeout(r, 5)); + expect(calls).toHaveLength(4); + expect(gate.slotUsage('key-1')).toEqual({ active: 4, waiting: 16 }); + release(); + const results = await all; + expect(calls.length).toBeLessThanOrEqual(4); + expect(results.every((r) => !r.ok && r.kind === 'credential-dead')).toBe(true); + expect(gate.slotUsage('key-1')).toEqual({ active: 0, waiting: 0 }); + }); + + it('nach den Antworten werden Plaetze frei: alle Aufrufe eines lebenden Zugangs kommen durch', async () => { + const gate = new NextcloudCallGate(); + let inFlight = 0; + let peak = 0; + const { transport, calls } = fakeTransport(async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 2)); + inFlight -= 1; + return reply(200, 'ok'); + }); + const results = await Promise.all( + Array.from({ length: 12 }, () => + ncRequest(transport, gate, { + baseUrl: BASE, + prefix: '/status.php', + method: 'GET', + credentialKey: 'key-2', + }), + ), + ); + expect(calls).toHaveLength(12); + expect(peak).toBeLessThanOrEqual(4); + expect(results.every((r) => r.ok)).toBe(true); + }); + + it('ein Abbruch waehrend des Wartens gibt sofort aborted zurueck, ohne Aufruf', async () => { + const gate = new NextcloudCallGate(); + const holders = await Promise.all([0, 1, 2, 3].map(() => gate.acquireSlot('key-3'))); + const { transport, calls } = fakeTransport(async () => reply(200)); + const controller = new AbortController(); + const pending = ncRequest(transport, gate, { + baseUrl: BASE, + prefix: '/status.php', + method: 'GET', + credentialKey: 'key-3', + signal: controller.signal, + }); + controller.abort(); + expect(await pending).toEqual({ ok: false, kind: 'aborted' }); + expect(calls).toHaveLength(0); + expect(gate.slotUsage('key-3')).toEqual({ active: 4, waiting: 0 }); + for (const free of holders) free(); + expect(gate.slotUsage('key-3')).toEqual({ active: 0, waiting: 0 }); + }); + + it('Uploads (unthrottled) belegen keinen Platz', async () => { + const gate = new NextcloudCallGate(); + await Promise.all([0, 1, 2, 3].map(() => gate.acquireSlot('key-4'))); + const { transport, calls } = fakeTransport(async () => reply(201)); + const res = await ncRequest(transport, gate, { + baseUrl: BASE, + prefix: '/remote.php/dav/uploads/', + segments: ['anna', 'tessera-x', '00001'], + method: 'PUT', + credentialKey: 'key-4', + unthrottled: true, + }); + expect(res.ok).toBe(true); + expect(calls).toHaveLength(1); + }); +}); + describe('readCappedText', () => { it('liest bis zur Grenze', async () => { expect( diff --git a/apps/api/src/nextcloud-files/nextcloud-http.ts b/apps/api/src/nextcloud-files/nextcloud-http.ts index 285dc26..a452f61 100644 --- a/apps/api/src/nextcloud-files/nextcloud-http.ts +++ b/apps/api/src/nextcloud-files/nextcloud-http.ts @@ -212,6 +212,12 @@ export interface NcRequestOptions { authorization?: string; /** Gesetzt bei Aufrufen mit gespeichertem App-Passwort (siehe Aufrufsperre). */ credentialKey?: string; + /** + * Nicht unter die Grenze gleichzeitiger Aufrufe je Schluessel (WR-03) fallen: nur fuer + * Uebertragungen, deren Antwort erst nach dem ganzen Koerper kommt (PUT eines Stuecks, + * Zusammenbau). Sie laufen ohnehin nacheinander je Datei. + */ + unthrottled?: boolean; /** OCS-Aufruf: setzt `OCS-APIRequest` und `Accept: application/json`. */ ocs?: boolean; headersTimeoutMs?: number; @@ -319,6 +325,42 @@ export async function ncRequest( return { ok: false, kind: 'paused', retryAfterSeconds: pause.retryAfterSeconds }; } + // WR-03: hoechstens 4 gleichzeitige Aufrufe je Zugangsschluessel bis zur Antwort. Ein + // widerrufener Zugang erzeugt so hoechstens ein paar 401, bevor er als tot gilt. + let releaseSlot: (() => void) | undefined; + if (opts.credentialKey && !opts.unthrottled) { + try { + releaseSlot = await gate.acquireSlot(opts.credentialKey, opts.signal); + } catch { + return { ok: false, kind: 'aborted' }; + } + // Waehrend des Wartens kann der Schluessel gestorben oder der Ursprung gesperrt worden sein. + if (gate.isDead(opts.credentialKey)) { + releaseSlot(); + return { ok: false, kind: 'credential-dead' }; + } + const later = gate.isPaused(origin); + if (later.paused) { + releaseSlot(); + return { ok: false, kind: 'paused', retryAfterSeconds: later.retryAfterSeconds }; + } + } + try { + return await sendNcRequest(transport, gate, opts, url, origin); + } finally { + // Erst NACH der Auswertung (401 -> tot, 429 -> Sperre) freigeben: der naechste Wartende + // sieht den neuen Zustand, bevor er sendet. + releaseSlot?.(); + } +} + +async function sendNcRequest( + transport: NextcloudTransport, + gate: NextcloudCallGate, + opts: NcRequestOptions, + url: string, + origin: string, +): Promise { const headers: Record = { 'user-agent': NC_USER_AGENT }; if (opts.ocs) { headers['ocs-apirequest'] = 'true'; 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 a5f898d..df78bc9 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 @@ -520,3 +520,27 @@ describe('FileBrowser — Ziehen und Ablegen', () => { } }); }); + +describe('FileBrowser — Vorschaubilder (WR-03)', () => { + it('ein fehlgeschlagenes Vorschaubild prueft die Verbindung; ist sie abgelaufen, geht es zum Anmeldebildschirm', async () => { + const photos = [ + folder('/Projekte'), + entry('/Foto.jpg', { mime: 'image/jpeg', hasPreview: true, fileId: '501' }), + entry('/Bild.png', { mime: 'image/png', hasPreview: true, fileId: '502' }), + ]; + mockList.mockImplementation(async (p: string) => listing(p, p === '/' ? photos : [])); + const { container } = await mount(); + const imgs = container.querySelectorAll('img'); + expect(imgs.length).toBe(2); + expect(Array.from(imgs).every((i) => i.getAttribute('loading') === 'lazy')).toBe(true); + const callsBefore = mockList.mock.calls.length; + mockList.mockRejectedValue( + new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'), + ); + fireEvent.error(imgs[0]); + fireEvent.error(imgs[1]); + await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1)); + // Zwei Fehler kurz hintereinander loesen nur EINE Pruefung aus. + expect(mockList.mock.calls.length - callsBefore).toBe(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 c1a709e..125627e 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 @@ -736,6 +736,16 @@ export function FileBrowser({ } }; + // Ein Vorschaubild schlaegt fehl: hoechstens alle 10 s den Ordner still neu laden. Ist die + // Verbindung abgelaufen, fuehrt das zum Anmeldebildschirm, und keine weiteren Bilder laden (WR-03). + const previewCheckAt = useRef(0); + const onPreviewError = useCallback(() => { + const at = Date.now(); + if (at - previewCheckAt.current < 10_000) return; + previewCheckAt.current = at; + void load(pathRef.current, { quiet: true }); + }, [load]); + // --- Darstellung ------------------------------------------------------------------------------ const viewProps = { @@ -750,6 +760,7 @@ export function FileBrowser({ onToggle, onMenu: openMenu, onFocusItem: setFocusIndex, + onPreviewError, }; const targetName = diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx index e8f3381..dbb0199 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx @@ -10,7 +10,7 @@ import { CheckIcon, MoreIcon } from './icons'; import { TypeTile } from './TypeTile'; /** Vorschauflaeche: echtes Bild, wenn die Nextcloud eines liefert, sonst die grosse Typkachel. */ -function PreviewSurface({ entry }: { entry: NcEntry }) { +function PreviewSurface({ entry, onError }: { entry: NcEntry; onError?: () => void }) { const [failed, setFailed] = useState(false); // Echte Vorschauen fuer Bilder und Videos; Textvorschauen waeren nur ein fast leeres Blatt. const family = fileTypeOf(entry).family; @@ -27,7 +27,10 @@ function PreviewSurface({ entry }: { entry: NcEntry }) { loading="lazy" decoding="async" className="h-full w-full object-cover" - onError={() => setFailed(true)} + onError={() => { + setFailed(true); + onError?.(); + }} /> ) : ( @@ -51,6 +54,7 @@ export function FileGrid({ onToggle, onMenu, onFocusItem, + onPreviewError, }: EntryViewProps) { const t = useTranslations('nextcloudFiles.browser'); const locale = useLocale(); @@ -101,7 +105,7 @@ export function FileGrid({ : 'hover:bg-accent/80' }`} > - + void; onMenu: (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => void; onFocusItem: (index: number) => void; + /** + * Ein Vorschaubild liess sich nicht laden (WR-03). Die Ansicht prueft dann einmal, ob die + * Verbindung abgelaufen ist, und kehrt gegebenenfalls zum Anmeldebildschirm zurueck. + */ + onPreviewError?: () => void; } /** @@ -50,6 +55,7 @@ export function FileList({ onToggle, onMenu, onFocusItem, + onPreviewError, sort, onSortChange, onSelectAll, @@ -184,7 +190,7 @@ export function FileList({ - + {isFolder ? (