From 511213b40deee2dabe54f0634901cf323de1d7f3 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 8 Sep 2026 23:38:26 +0200 Subject: [PATCH] feat(uploads): laufenden Upload abbrechen; fix(pdf): Zoomgeste entlasten Zoom ---- Jede Zoomstufe warf die Leinwaende weg und liess PDF.js neu malen -- eine Zusammenziehgeste hat leicht zwanzig davon, bei A4 und doppelter Pixeldichte je rund acht Megabyte. Noetig war das nie: Die Leinwand liegt mit width:100% im Seitenkasten, der Browser streckt sie also von selbst. Gezeichnet wird jetzt 120 ms nach der letzten Stufe. Die Textebene wandert korrekt mit (PDF.js setzt sie in Prozent), nur die Trefferkaesten sitzen in Pixeln -- die werden waehrend der Geste geraeumt und danach neu gemalt. Dabei ein aelterer Verlust: Jedes Oeffnen zeichnete einmal umsonst, weil der Zeichner lief, bevor die Einpassung gemessen war. Jetzt wartet er. Upload abbrechen ---------------- Das Kreuz an der Fortschrittsmeldung laesst einen laufenden Upload fallen; eine noch wartende Datei faengt gar nicht erst an. Ein Abbruch ist kein Fehler und verschwindet wie eine fertige Meldung. Auf dem Server bleibt nichts liegen -- das ist die Bauweise (Temp-Datei, Umbenennen erst am Ende), aber eine Bauweise ist keine Pruefung. Deshalb scripts/upload-abbruch-selbsttest.py: kappt eine echte Verbindung auf einem rohen Socket, 13 Pruefungen. Der teuerste Fall ist abgedeckt -- beim Ueberschreiben mit Versionierung schiebt der Server die alte Fassung kurzzeitig zur Seite; sie bleibt in Groesse, Pruefsumme und Inhalt heil. Downloads haben bewusst kein Kreuz: Sie gehen als Anker bzw. Formular an den Browser, dessen Download-Liste sie besitzt. Co-Authored-By: Claude Opus 5 --- TESTING.md | 27 +++ frontend/src/api/client.ts | 27 ++- frontend/src/components/PdfViewer.tsx | 77 +++++++- frontend/src/components/UploadQueue.tsx | 28 ++- frontend/src/hooks/useUploads.ts | 50 ++++- frontend/src/pages/FileBrowser.tsx | 2 +- scripts/upload-abbruch-selbsttest.py | 244 ++++++++++++++++++++++++ 7 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 scripts/upload-abbruch-selbsttest.py diff --git a/TESTING.md b/TESTING.md index 2d6d4cc..97d2e48 100644 --- a/TESTING.md +++ b/TESTING.md @@ -175,6 +175,33 @@ Zwei Fallen, die beim Bauen auffielen und die ein Test hier abdecken muss: je Datei, aber ein Eintrag ohne sie ist ein Eintrag, dessen Beschädigung beim Herunterladen niemand bemerkt. +## Abgebrochener Upload + +Am Kreuz der Fortschrittsmeldung lässt sich ein laufender Upload fallen +lassen. Die Zusage dahinter ist die eigentliche Frage: **Auf dem Server bleibt +dabei nichts zurück.** + + python3 scripts/upload-abbruch-selbsttest.py + +Der Test kappt eine echte Verbindung mitten im Leib – auf einem rohen Socket, +nicht über eine Bibliothek. Ein sauber gesendeter, aber zu kurzer Leib wäre +etwas anderes: Den wiese der Server als Formatfehler ab, und über den Abbruch +bewiese das nichts. + +13 Prüfungen: **neue Datei** (kein Eintrag, nichts im Papierkorb), +**Überschreiben mit Versionierung** – der gefährlichste Fall, weil der Server +die alte Fassung dabei kurzzeitig zur Seite schiebt: Sie muss danach in Größe, +Prüfsumme und Inhalt unversehrt sein –, **Verbrauch** und ein **Rescan**, der +nachsieht, ob herrenlose Bytes liegengeblieben sind. + +### Eine Falle für jeden, der die Schnittstelle skriptet + +`GET /api/v1/auth/me` **erneuert das CSRF-Token** und gibt das neue zurück. +Wer den Wert von der Anmeldung festhält, bekommt beim nächsten schreibenden +Ruf ein `403` und sucht die Ursache bei den Rechten. Die Weboberfläche zieht +den neuen Wert an allen drei Stellen nach (`hooks/useAuth.ts`); ein Skript +muss es auch. + ## Notfall: Migration hängt fest ("Dirty database version") Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 880396a..396b288 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -185,12 +185,25 @@ async function request( } // XHR-based upload with real progress reporting. +/** + * Ein vom Nutzer abgebrochener Upload – kein Fehler, sondern eine Absicht. + * + * Eigene Klasse, damit die Warteschlange die beiden unterscheiden kann: Ein + * Fehler bleibt als Meldung stehen, weil er eine Auskunft trägt. „Ich wollte + * das nicht" trägt keine und verschwindet von selbst. + */ +export class UploadAborted extends Error { + constructor() { super('Upload aborted') } +} + export function uploadWithProgress( file: File, folderId: string | undefined, - onProgress: (p: UploadProgress) => void + onProgress: (p: UploadProgress) => void, + signal?: AbortSignal, ): Promise { return new Promise((resolve, reject) => { + if (signal?.aborted) { reject(new UploadAborted()); return } const form = new FormData() // folder_id MUST come before the file so the server can stream the file // straight to storage without buffering the whole body first. @@ -229,7 +242,17 @@ export function uploadWithProgress( }) xhr.addEventListener('error', () => reject(new APIError(0, 'Network error'))) - xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted'))) + xhr.addEventListener('abort', () => reject(new UploadAborted())) + + /** + * Abbrechen heißt hier: die Verbindung fallen lassen. + * + * Der Server merkt das mitten im Lesen. Er schreibt in eine Temp-Datei und + * benennt sie erst am Ende um – ein abgebrochener Upload hinterlässt + * deshalb weder Bytes noch eine Zeile in der Datenbank. Es braucht also + * keinen zweiten Ruf zum Aufräumen; das Aufräumen ist die Bauweise. + */ + signal?.addEventListener('abort', () => xhr.abort(), { once: true }) xhr.send(form) }) diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index 96c7bb4..bd4a8ff 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -66,6 +66,27 @@ const sizeBatch = 24 */ const searchDelay = 160 +/** + * Wie lange nach dem letzten Zoomschritt gewartet wird, bevor neu gezeichnet + * wird. + * + * **Der Grund, warum eine Geste am Telefon ruckelte.** Jede Stufe warf die + * Leinwände weg und ließ PDF.js neu malen; eine Zusammenziehgeste hat leicht + * zwanzig davon. Zwanzig Neuzeichnungen eines A4-Blattes bei doppelter + * Pixeldichte sind zwanzig Bitmaps zu je rund acht Megabyte – der Abbruch der + * vorigen half nur gegen die Rechenzeit, nicht gegen das Belegen und Freigeben. + * + * Nötig war das nie: Die Leinwand liegt mit `width: 100%` im Seitenkasten, und + * der Kasten wächst sofort mit. Der Browser streckt das vorhandene Bild also + * von selbst – kostenlos und ohne Bild auszulassen. Es sieht währenddessen + * weich aus, mehr nicht. Scharf wird es, sobald die Finger stillstehen. + * + * Die Textebene wandert korrekt mit: PDF.js setzt ihre Stücke in **Prozent** + * des Kastens, und die Schriftgröße hängt an `--total-scale-factor`. Beides + * folgt dem Layout, nicht der Leinwand. + */ +const zoomSettle = 120 + interface Props { /** Adresse der Datei – dieselbe, die auch das