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