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 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea90f43e77
commit
511213b40d
7 files changed
+437
-18
No files matched your search
+27
@@ -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
|
||||
|
||||
@@ -185,12 +185,25 @@ async function request<T>(
|
||||
}
|
||||
|
||||
// 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<FileItem> {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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 <iframe> bekam. */
|
||||
url: string
|
||||
@@ -84,6 +105,16 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
const [sizes, setSizes] = useState<PageSize[]>([])
|
||||
const [scale, setScale] = useState<number | 'fit'>('fit')
|
||||
const [fitScale, setFitScale] = useState(1)
|
||||
/**
|
||||
* Ist die Einpassung schon gemessen?
|
||||
*
|
||||
* Ohne diese Wache zeichnete jedes Öffnen **einmal umsonst**: Die Maße
|
||||
* kommen an, der Zeichner läuft los – und erst danach steht fest, dass das
|
||||
* Blatt nicht auf 100 %, sondern auf 65 % gehört. Die halbfertige Leinwand
|
||||
* wurde weggeworfen. Bei einem A4-Blatt in doppelter Pixeldichte sind das
|
||||
* acht Megabyte, die niemand je zu sehen bekam.
|
||||
*/
|
||||
const [fitReady, setFitReady] = useState(false)
|
||||
const [current, setCurrent] = useState(1)
|
||||
|
||||
const [query, setQuery] = useState(searchTerm ?? '')
|
||||
@@ -99,13 +130,27 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
/** Der Maßstab, in dem das Blatt **liegt** – Kastengröße, Textebene, Anzeige. */
|
||||
const effScale = scale === 'fit' ? fitScale : scale
|
||||
/**
|
||||
* Der Maßstab, in dem zuletzt **gemalt** wurde. Er hinkt während einer Geste
|
||||
* hinterher; solange streckt der Browser das vorhandene Bild. Siehe
|
||||
* `zoomSettle`.
|
||||
*/
|
||||
const [renderScale, setRenderScale] = useState(effScale)
|
||||
useEffect(() => {
|
||||
if (renderScale === effScale) return
|
||||
const t = setTimeout(() => setRenderScale(effScale), zoomSettle)
|
||||
return () => clearTimeout(t)
|
||||
}, [effScale, renderScale])
|
||||
/** Läuft gerade eine Geste – ist das Blatt also nur gestreckt? */
|
||||
const stretched = renderScale !== effScale
|
||||
|
||||
// ---------------------------------------------------------------- Laden
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setDoc(null); setSizes([]); setError(''); setCurrent(1)
|
||||
setDoc(null); setSizes([]); setError(''); setCurrent(1); setFitReady(false)
|
||||
// Die Verweise auf die Seitenkästen gehören zum alten Dokument. Ohne das
|
||||
// blieben bei einem kürzeren nächsten Dokument Einträge stehen, die auf
|
||||
// längst entfernte Elemente zeigen.
|
||||
@@ -190,6 +235,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
|
||||
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
|
||||
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
|
||||
setFitReady(true)
|
||||
}, [sizes])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -211,8 +257,16 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
|
||||
* ihre Leinwand frei.
|
||||
*/
|
||||
/**
|
||||
* Steht der Maßstab fest? Bewusst als **ein Wahrheitswert** in der
|
||||
* Abhängigkeitsliste und nicht als `scale`: Der Zeichner darf sich nicht bei
|
||||
* jedem Zoomschritt neu aufbauen, sonst wäre die Geste wieder so teuer wie
|
||||
* vorher. Dieser Wert kippt einmal von falsch auf wahr.
|
||||
*/
|
||||
const scaleKnown = scale !== 'fit' || fitReady
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc || sizes.length === 0) return
|
||||
if (!doc || sizes.length === 0 || !scaleKnown) return
|
||||
const root = scrollRef.current
|
||||
if (!root) return
|
||||
|
||||
@@ -226,7 +280,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
if (!doc || !host || drawn.has(n)) return
|
||||
drawn.add(n)
|
||||
const page = await doc.getPage(n)
|
||||
const viewport = page.getViewport({ scale: effScale })
|
||||
const viewport = page.getViewport({ scale: renderScale })
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -293,7 +347,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
io.disconnect()
|
||||
for (const t of running.values()) t.cancel()
|
||||
}
|
||||
}, [doc, sizes, effScale])
|
||||
}, [doc, sizes, renderScale, scaleKnown])
|
||||
|
||||
// --------------------------------------------------- Sichtbare Seitenzahl
|
||||
|
||||
@@ -518,6 +572,21 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
}
|
||||
}, [query, hits])
|
||||
|
||||
/**
|
||||
* Während einer Zoomgeste die Färbung wegnehmen.
|
||||
*
|
||||
* Die Kästen sind das einzige im Seitenkasten, das in **Pixeln** sitzt –
|
||||
* Leinwand und Textebene rechnen in Prozent und wandern von selbst mit. Ein
|
||||
* stehengebliebener Kasten läge nach zwei Stufen sichtbar neben dem Wort.
|
||||
* Nach dem Zeichnen kommt er von selbst zurück (`pdf-text-ready`).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!stretched) return
|
||||
for (const host of pageRefs.current) {
|
||||
host?.querySelector('.pdf-hitLayer')?.replaceChildren()
|
||||
}
|
||||
}, [stretched])
|
||||
|
||||
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
|
||||
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
|
||||
const target = hits[hitIndex]
|
||||
|
||||
@@ -4,13 +4,15 @@ interface UploadItem {
|
||||
id: string
|
||||
name: string
|
||||
progress: number // 0-100
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
status: 'uploading' | 'done' | 'error' | 'canceled'
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: UploadItem[]
|
||||
onDismiss: (id: string) => void
|
||||
/** Bricht einen laufenden oder noch wartenden Upload ab. */
|
||||
onCancel: (id: string) => void
|
||||
}
|
||||
|
||||
export type { UploadItem }
|
||||
@@ -31,7 +33,7 @@ const dismissAfter = 4000
|
||||
*/
|
||||
const maxVisible = 4
|
||||
|
||||
export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
export default function UploadQueue({ items, onDismiss, onCancel }: Props) {
|
||||
// Der Aufrufer gibt bei jedem Rendern eine neue Funktion herein. Über eine
|
||||
// Referenz gelesen, muss der Effekt darauf nicht erneut laufen – sonst
|
||||
// begännen die Fristen bei jedem Fortschrittsschritt von vorn.
|
||||
@@ -45,7 +47,9 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
// Fehler bleiben stehen. Sie sind das Einzige, was hier eine Auskunft
|
||||
// trägt, die man sonst nirgends wiederfindet – die verschwindet nicht
|
||||
// von selbst.
|
||||
if (item.status !== 'done') continue
|
||||
// Abgebrochenes verschwindet wie Fertiges: Die Entscheidung ist
|
||||
// getroffen, die Zeile hat nichts mehr zu sagen.
|
||||
if (item.status !== 'done' && item.status !== 'canceled') continue
|
||||
if (timers.current.has(item.id)) continue
|
||||
timers.current.set(item.id, setTimeout(() => {
|
||||
timers.current.delete(item.id)
|
||||
@@ -85,11 +89,16 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
<span className="truncate font-medium text-xs" style={{ color: 'var(--color-text)', maxWidth: 180 }}>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.status !== 'uploading' && (
|
||||
<button onClick={() => onDismiss(item.id)}
|
||||
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
|
||||
style={{ color: 'var(--color-text-muted)' }}>✕</button>
|
||||
)}
|
||||
{/* Dasselbe Kreuz, zwei Bedeutungen – und beide sind dieselbe
|
||||
Geste: „weg damit". Läuft der Upload noch, lässt es die
|
||||
Verbindung fallen; ist er vorbei, räumt es nur die Meldung ab.
|
||||
Auf dem Server bleibt in keinem Fall etwas liegen. */}
|
||||
<button
|
||||
onClick={() => item.status === 'uploading' ? onCancel(item.id) : onDismiss(item.id)}
|
||||
title={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
|
||||
aria-label={item.status === 'uploading' ? 'Cancel upload' : 'Dismiss'}
|
||||
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
|
||||
style={{ color: 'var(--color-text-muted)' }}>✕</button>
|
||||
</div>
|
||||
|
||||
{item.status === 'uploading' && (
|
||||
@@ -108,6 +117,9 @@ export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
{item.status === 'error' && (
|
||||
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
|
||||
)}
|
||||
{item.status === 'canceled' && (
|
||||
<p style={{ color: 'var(--color-text-muted)' }} className="text-xs">Cancelled — nothing was saved</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef, useState, type DragEvent } from 'react'
|
||||
import { foldersApi, uploadWithProgress, APIError } from '../api/client'
|
||||
import { foldersApi, uploadWithProgress, APIError, UploadAborted } from '../api/client'
|
||||
import type { UploadItem } from '../components/UploadQueue'
|
||||
import { useInternalDrag } from './useInternalDrag'
|
||||
|
||||
@@ -48,6 +48,23 @@ export function useUploads({ folderId, onFinished }: Options) {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const isInternalDrag = useInternalDrag()
|
||||
|
||||
/**
|
||||
* Der Abbruchgriff je laufender Datei.
|
||||
*
|
||||
* In einem Ref, nicht im Zustand: Er wird nur gerufen, nie angezeigt – und
|
||||
* ein Zustand mehr hieße ein Neuaufbau der Liste bei jedem Anfang und Ende
|
||||
* einer Datei.
|
||||
*/
|
||||
const running = useRef(new Map<string, AbortController>())
|
||||
/**
|
||||
* Wer abgebrochen wurde, **bevor** er an der Reihe war.
|
||||
*
|
||||
* Drei Dateien laufen gleichzeitig, der Rest wartet. Ein Klick auf das Kreuz
|
||||
* einer wartenden Datei hat noch keine Verbindung zum Fallenlassen; ohne
|
||||
* diesen Merkzettel finge sie später trotzdem an.
|
||||
*/
|
||||
const canceled = useRef(new Set<string>())
|
||||
|
||||
/**
|
||||
* Der Zielordner in einem Ref – **nicht** in der Abhängigkeitsliste.
|
||||
*
|
||||
@@ -67,14 +84,41 @@ export function useUploads({ folderId, onFinished }: Options) {
|
||||
const patch = useCallback((id: string, change: Partial<UploadItem>) => {
|
||||
setItems(u => u.map(x => (x.id === id ? { ...x, ...change } : x)))
|
||||
}, [])
|
||||
const patchRef = useRef(patch)
|
||||
patchRef.current = patch
|
||||
|
||||
/**
|
||||
* Den Vorgang einer Zeile abbrechen.
|
||||
*
|
||||
* Läuft er schon, wird die Verbindung fallen gelassen; wartet er noch, wird
|
||||
* er vorgemerkt und gar nicht erst begonnen. In beiden Fällen bleibt auf dem
|
||||
* Server nichts zurück – er schreibt in eine Temp-Datei und benennt sie erst
|
||||
* um, wenn die letzte Byte angekommen ist.
|
||||
*/
|
||||
const cancel = useCallback((id: string) => {
|
||||
canceled.current.add(id)
|
||||
running.current.get(id)?.abort()
|
||||
patchRef.current(id, { status: 'canceled', progress: 0 })
|
||||
}, [])
|
||||
|
||||
/** Eine Datei hochladen und ihre Zeile nachführen. Wirft nicht. */
|
||||
const send = useCallback(async (id: string, file: File, target: string | undefined) => {
|
||||
// Zwischen Einreihen und Anfangen kann geklickt worden sein.
|
||||
if (canceled.current.has(id)) return
|
||||
const controller = new AbortController()
|
||||
running.current.set(id, controller)
|
||||
try {
|
||||
await uploadWithProgress(file, target, ({ percent }) => patch(id, { progress: percent }))
|
||||
await uploadWithProgress(file, target,
|
||||
({ percent }) => patch(id, { progress: percent }), controller.signal)
|
||||
patch(id, { progress: 100, status: 'done' })
|
||||
} catch (err) {
|
||||
// Ein Abbruch ist kein Fehler: Die Zeile steht schon auf „abgebrochen",
|
||||
// und eine rote Meldung darüber wäre ein Vorwurf für eine Entscheidung,
|
||||
// die der Nutzer gerade selbst getroffen hat.
|
||||
if (err instanceof UploadAborted) return
|
||||
patch(id, { status: 'error', error: err instanceof APIError ? err.message : 'Upload failed' })
|
||||
} finally {
|
||||
running.current.delete(id)
|
||||
}
|
||||
}, [patch])
|
||||
|
||||
@@ -207,7 +251,7 @@ export function useUploads({ folderId, onFinished }: Options) {
|
||||
}, [])
|
||||
|
||||
return {
|
||||
items, dismiss, dragging, uploadFiles,
|
||||
items, dismiss, cancel, dragging, uploadFiles,
|
||||
/** Direkt an das äußerste Element der Seite hängen. */
|
||||
dropProps: { onDragOver, onDragLeave, onDrop },
|
||||
}
|
||||
|
||||
@@ -676,7 +676,7 @@ export default function FileBrowser({
|
||||
}}
|
||||
/>
|
||||
|
||||
<UploadQueue items={uploads.items} onDismiss={uploads.dismiss} />
|
||||
<UploadQueue items={uploads.items} onDismiss={uploads.dismiss} onCancel={uploads.cancel} />
|
||||
{ctxMenu && <ContextMenu {...ctxMenu} onClose={() => setCtxMenu(null)} />}
|
||||
|
||||
<FileDialogs
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Selbsttest: Was bleibt liegen, wenn ein Upload abgebrochen wird?
|
||||
|
||||
python3 scripts/upload-abbruch-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
|
||||
|
||||
Die Oberfläche hat jetzt ein Kreuz, das einen laufenden Upload fallen lässt.
|
||||
Die Behauptung dahinter lautet: **Auf dem Server bleibt dabei nichts zurück.**
|
||||
Sie stützt sich darauf, dass der Speicher in eine Temp-Datei schreibt und erst
|
||||
am Ende umbenennt – aber eine Bauweise ist keine Prüfung. Hier wird sie eine.
|
||||
|
||||
Geprüft wird gegen die **laufende Instanz**, mit einer echten, mitten im Strom
|
||||
abgeschnittenen Verbindung:
|
||||
|
||||
* kein Eintrag in der Datenbank
|
||||
* keine Bytes am Zielpfad
|
||||
* keine liegengebliebene Temp-Datei
|
||||
* und der gefährlichste Fall: Wird eine **vorhandene** Datei überschrieben
|
||||
und der Upload dabei abgebrochen, muss die alte Fassung unversehrt bleiben.
|
||||
Genau dort verschiebt der Server sie kurzzeitig zur Seite.
|
||||
|
||||
Alles Angelegte wird am Ende wieder entfernt.
|
||||
"""
|
||||
import hashlib
|
||||
import http.cookiejar
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/")
|
||||
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
|
||||
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
|
||||
|
||||
ok_count = 0
|
||||
fail = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
global ok_count
|
||||
if cond:
|
||||
ok_count += 1
|
||||
print(f" \033[32mok\033[0m {name}")
|
||||
else:
|
||||
fail.append(name)
|
||||
print(f" \033[31mFEHL\033[0m {name} {detail}")
|
||||
|
||||
|
||||
def sitzung():
|
||||
jar = http.cookiejar.CookieJar()
|
||||
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)), jar
|
||||
|
||||
|
||||
def ruf(opener, method, path, body=None, headers=None):
|
||||
hdrs = dict(headers or {})
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
|
||||
try:
|
||||
with opener.open(req) as r:
|
||||
text = r.read().decode()
|
||||
return r.status, (json.loads(text).get("data") if text else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, None
|
||||
|
||||
|
||||
def anmelden():
|
||||
o, jar = sitzung()
|
||||
st, d = ruf(o, "POST", "/api/v1/auth/login", {"username": USER, "password": PASSWORT})
|
||||
assert st == 200, f"Anmeldung fehlgeschlagen: {st}"
|
||||
return o, jar, {"X-CSRF-Token": d["csrf_token"]}
|
||||
|
||||
|
||||
def keksleiste(jar):
|
||||
return "; ".join(f"{c.name}={c.value}" for c in jar)
|
||||
|
||||
|
||||
def hochladen(opener, csrf, name, inhalt: bytes, folder_id=None):
|
||||
grenze = "----aether" + uuid.uuid4().hex
|
||||
teile = []
|
||||
if folder_id:
|
||||
teile.append(f'--{grenze}\r\nContent-Disposition: form-data; name="folder_id"\r\n\r\n{folder_id}\r\n')
|
||||
kopf = (f'--{grenze}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
||||
f'Content-Type: application/octet-stream\r\n\r\n')
|
||||
leib = b"".join(t.encode() for t in teile) + kopf.encode() + inhalt + f"\r\n--{grenze}--\r\n".encode()
|
||||
req = urllib.request.Request(BASE + "/api/v1/files", data=leib, method="POST",
|
||||
headers={"Content-Type": f"multipart/form-data; boundary={grenze}", **csrf})
|
||||
with opener.open(req) as r:
|
||||
return json.loads(r.read().decode())["data"]
|
||||
|
||||
|
||||
def abgebrochen_hochladen(jar, csrf, name, folder_id, gesamt, nach_bytes):
|
||||
"""Baut die Anfrage von Hand auf einem rohen Socket auf und **kappt** sie.
|
||||
|
||||
Nur so entsteht wirklich das, was ein Klick auf das Kreuz erzeugt: eine
|
||||
Verbindung, die mitten im Leib endet. Ein sauber gesendeter, aber zu kurzer
|
||||
Leib wäre etwas anderes – den würde der Server als Formatfehler abweisen,
|
||||
und der Test bewiese nichts über den Abbruch.
|
||||
"""
|
||||
u = urllib.parse.urlparse(BASE)
|
||||
grenze = "----aether" + uuid.uuid4().hex
|
||||
kopf = (f'--{grenze}\r\nContent-Disposition: form-data; name="folder_id"\r\n\r\n{folder_id}\r\n'
|
||||
f'--{grenze}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
||||
f'Content-Type: application/octet-stream\r\n\r\n').encode()
|
||||
schwanz = f"\r\n--{grenze}--\r\n".encode()
|
||||
laenge = len(kopf) + gesamt + len(schwanz)
|
||||
|
||||
s = socket.create_connection((u.hostname, u.port or 80), timeout=10)
|
||||
anfrage = (
|
||||
f"POST /api/v1/files HTTP/1.1\r\n"
|
||||
f"Host: {u.hostname}:{u.port or 80}\r\n"
|
||||
f"Content-Type: multipart/form-data; boundary={grenze}\r\n"
|
||||
f"Content-Length: {laenge}\r\n"
|
||||
f"Cookie: {keksleiste(jar)}\r\n"
|
||||
f"X-CSRF-Token: {csrf['X-CSRF-Token']}\r\n"
|
||||
f"Connection: close\r\n\r\n"
|
||||
).encode()
|
||||
s.sendall(anfrage + kopf)
|
||||
block = b"x" * 65536
|
||||
gesendet = 0
|
||||
while gesendet < nach_bytes:
|
||||
n = min(len(block), nach_bytes - gesendet)
|
||||
s.sendall(block[:n])
|
||||
gesendet += n
|
||||
# Hart schließen – kein sauberes Ende, genau wie ein fallengelassenes XHR.
|
||||
s.close()
|
||||
return gesendet
|
||||
|
||||
|
||||
print(f"Upload-Abbruch-Selbsttest gegen {BASE}\n")
|
||||
|
||||
sess, jar, CSRF = anmelden()
|
||||
|
||||
ordner_name = "abbruch-test-" + uuid.uuid4().hex[:6]
|
||||
st, ordner = ruf(sess, "POST", "/api/v1/folders", {"name": ordner_name}, CSRF)
|
||||
assert st in (200, 201), f"Ordner anlegen fehlgeschlagen: {st}"
|
||||
ORDNER = ordner["id"]
|
||||
|
||||
|
||||
def aufraeumen():
|
||||
ruf(sess, "DELETE", f"/api/v1/folders/{ORDNER}", None, CSRF)
|
||||
# Auch aus dem Papierkorb, damit der Lauf nichts hinterlässt.
|
||||
st, papier = ruf(sess, "GET", "/api/v1/trash", None, CSRF)
|
||||
for e in (papier or []):
|
||||
if e.get("name", "").startswith("abbruch-test-") or e.get("name", "") in ABGELEGT:
|
||||
ruf(sess, "DELETE", f"/api/v1/trash/{e['id']}", None, CSRF)
|
||||
|
||||
|
||||
ABGELEGT = set()
|
||||
|
||||
try:
|
||||
# ── 1. Neue Datei, mittendrin abgebrochen ────────────────────────────────
|
||||
print("Abgebrochener Upload einer neuen Datei")
|
||||
name = "abbruch-neu.bin"
|
||||
ABGELEGT.add(name)
|
||||
gesendet = abgebrochen_hochladen(jar, CSRF, name, ORDNER, 40 * 1024 * 1024, 4 * 1024 * 1024)
|
||||
check("Verbindung wurde mitten im Leib gekappt", gesendet == 4 * 1024 * 1024,
|
||||
f"gesendet={gesendet}")
|
||||
|
||||
# Dem Server einen Moment lassen, den Abbruch zu bemerken und aufzuräumen.
|
||||
time.sleep(1.5)
|
||||
|
||||
st, liste = ruf(sess, "GET", f"/api/v1/files?folder_id={ORDNER}", None, CSRF)
|
||||
namen = [f["name"] for f in (liste or {}).get("files", [])]
|
||||
check("keine Datei in der Datenbank", name not in namen, f"gefunden: {namen}")
|
||||
|
||||
st, papier = ruf(sess, "GET", "/api/v1/trash", None, CSRF)
|
||||
check("auch nichts im Papierkorb", all(e["name"] != name for e in (papier or [])))
|
||||
|
||||
# ── 2. Vorhandene Datei überschreiben und dabei abbrechen ────────────────
|
||||
print("\nAbbruch beim Überschreiben – die alte Fassung muss bleiben")
|
||||
inhalt = b"Die urspruengliche Fassung. " * 500
|
||||
pruefsum = hashlib.sha256(inhalt).hexdigest()
|
||||
name2 = "abbruch-vorhanden.bin"
|
||||
ABGELEGT.add(name2)
|
||||
datei = hochladen(sess, CSRF, name2, inhalt, ORDNER)
|
||||
check("Ausgangsdatei liegt", datei["size"] == len(inhalt), str(datei["size"]))
|
||||
|
||||
# Versionierung an: Das ist der Weg, auf dem der Server die alte Fassung
|
||||
# kurzzeitig zur Seite schiebt – dort wäre ein Verlust am teuersten.
|
||||
st, _ = ruf(sess, "PATCH", f"/api/v1/files/{datei['id']}/versioning",
|
||||
{"enabled": True}, CSRF)
|
||||
check("Versionierung eingeschaltet", st == 200, f"status={st}")
|
||||
|
||||
abgebrochen_hochladen(jar, CSRF, name2, ORDNER, 40 * 1024 * 1024, 4 * 1024 * 1024)
|
||||
time.sleep(1.5)
|
||||
|
||||
st, liste = ruf(sess, "GET", f"/api/v1/files?folder_id={ORDNER}", None, CSRF)
|
||||
treffer = [f for f in (liste or {}).get("files", []) if f["name"] == name2]
|
||||
check("die Datei gibt es noch genau einmal", len(treffer) == 1, str(len(treffer)))
|
||||
if treffer:
|
||||
check("ihre Größe ist unverändert", treffer[0]["size"] == len(inhalt),
|
||||
f"{treffer[0]['size']} statt {len(inhalt)}")
|
||||
check("ihre Prüfsumme ist unverändert", treffer[0].get("checksum") == pruefsum,
|
||||
str(treffer[0].get("checksum"))[:16])
|
||||
|
||||
# Und der Inhalt lässt sich wirklich noch holen – die Zeile in der
|
||||
# Datenbank nützt nichts, wenn die Bytes weggewandert sind.
|
||||
req = urllib.request.Request(BASE + f"/api/v1/files/{treffer[0]['id']}/download")
|
||||
with sess.open(req) as r:
|
||||
geladen = r.read()
|
||||
check("der Inhalt kommt unversehrt zurück", geladen == inhalt,
|
||||
f"{len(geladen)} statt {len(inhalt)} Bytes")
|
||||
|
||||
# ── 3. Der Zähler stimmt: kein Platz für Gespenster ──────────────────────
|
||||
print("\nVerbrauch")
|
||||
st, me = ruf(sess, "GET", "/api/v1/auth/me", None, CSRF)
|
||||
check("Verbrauch abrufbar", st == 200 and me is not None, f"status={st}")
|
||||
check("Verbrauch ist eine Zahl", isinstance((me or {}).get("user", {}).get("used_bytes"), int),
|
||||
str((me or {}).get("user", {}).get("used_bytes")))
|
||||
# **/auth/me erneuert das CSRF-Token.** Wer das übersieht, bekommt beim
|
||||
# nächsten schreibenden Ruf ein 403 und sucht es an der falschen Stelle –
|
||||
# genau darauf bin ich beim Schreiben dieses Tests hereingefallen. Die
|
||||
# Weboberfläche zieht den neuen Wert nach (hooks/useAuth.ts), ein Skript
|
||||
# muss es auch.
|
||||
if me and me.get("csrf_token"):
|
||||
CSRF["X-CSRF-Token"] = me["csrf_token"]
|
||||
|
||||
# ── 4. Verwaiste Reste? Der Rescan weiß es ───────────────────────────────
|
||||
print("\nRescan: liegen Bytes ohne Eintrag herum?")
|
||||
st, bericht = ruf(sess, "POST", "/api/v1/admin/rescan", {"apply": False}, CSRF)
|
||||
if st == 200 and bericht is not None:
|
||||
neu = bericht.get("new", 0)
|
||||
proben = [p.get("path", "") for p in (bericht.get("samples") or [])
|
||||
if p.get("kind") == "new"]
|
||||
uebrig = [p for p in proben if "abbruch-" in p]
|
||||
check("keine herrenlosen Bytes vom Abbruch", not uebrig, str(uebrig))
|
||||
check("Rescan lief durch", isinstance(neu, int), str(neu))
|
||||
else:
|
||||
check("Rescan erreichbar", False, f"status={st}")
|
||||
|
||||
finally:
|
||||
aufraeumen()
|
||||
|
||||
print()
|
||||
if fail:
|
||||
print(f"\033[31m{len(fail)} fehlgeschlagen\033[0m: " + ", ".join(fail))
|
||||
print(f"{ok_count} bestanden, {len(fail)} fehlgeschlagen")
|
||||
sys.exit(1 if fail else 0)
|
||||
Reference in new issue
Block a user