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) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:39:05 +02:00
parent ddae9400a3
commit 07474bd162
10 changed files with 273 additions and 6 deletions
@@ -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<string, number>();
private readonly dead = new Map<string, number>();
private readonly controllers = new Map<string, AbortController>();
private readonly slots = new Map<string, { active: number; waiting: Array<() => 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<void>((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);
}
};
}
}
@@ -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 },
),
);
}
@@ -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,
});
}
@@ -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<void>((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(
@@ -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<NcResult> {
const headers: Record<string, string> = { 'user-agent': NC_USER_AGENT };
if (opts.ocs) {
headers['ocs-apirequest'] = 'true';
@@ -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);
});
});
@@ -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 =
@@ -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?.();
}}
/>
) : (
<TypeTile entry={entry} size="lg" />
@@ -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'
}`}
>
<PreviewSurface entry={entry} />
<PreviewSurface entry={entry} onError={onPreviewError} />
<input
type="checkbox"
tabIndex={-1}
@@ -29,6 +29,11 @@ export interface EntryViewProps {
onToggle: (entry: NcEntry, index: number) => 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({
</td>
<td className="py-0 pl-1 pr-2">
<span className="flex min-w-0 items-center gap-3">
<TypeTile entry={entry} preview />
<TypeTile entry={entry} preview onPreviewError={onPreviewError} />
{isFolder ? (
<button
type="button"
@@ -40,12 +40,15 @@ export function TypeTile({
entry,
size = 'sm',
preview = false,
onPreviewError,
}: {
entry: Pick<NcEntry, 'name' | 'type' | 'mime'> &
Partial<Pick<NcEntry, 'hasPreview' | 'fileId' | 'etag'>>;
size?: 'sm' | 'lg';
/** Vorschaubild statt Kurzzeichen, wenn die Nextcloud eines liefert. */
preview?: boolean;
/** Das Vorschaubild liess sich nicht laden. */
onPreviewError?: () => void;
}) {
const [failed, setFailed] = useState(false);
const { family, code } = fileTypeOf(entry);
@@ -69,7 +72,10 @@ export function TypeTile({
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={() => setFailed(true)}
onError={() => {
setFailed(true);
onPreviewError?.();
}}
/>
</span>
);