Files
Aether/BEFUNDE.md
T
marcandClaude Opus 5 662c0ebdc1 security(rescan): Notbremse vor dem harten Löschen
internal/rescan ist das einzige Stück Aether, das hart löscht: DELETE FROM
files, kein Papierkorb. Entfernt wird, was beim Durchgang über die Platte nicht
gesehen wurde.

Der Ernstfall ist nicht ausgedacht. LocalFS.Walk gibt für ein nicht vorhandenes
Verzeichnis nil zurück – kein Fehler, keine Einträge. Genau der Fingerabdruck
eines Bind-Mounts, der nach einem Neustart nicht hochkam, oder eines
Tippfehlers in DATA_PATH. Dann gilt jede Datei als verwaist, und ein einziger
Aufruf mit ?anwenden=true löscht den gesamten Bestand aus der Datenbank. Die
Bytes lägen noch da, aber niemand wüsste mehr, dass es sie gibt – und der GC
räumte sie 24 Stunden später ab.

Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank ab,
nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall: Eine
Seite antwortet fälschlich mit „leer". Der GC löscht dann Blobs – die Datenbank
weiss hinterher noch, was fehlt. Hier fällt die Zeile selbst. Die gefährlichere
Seite war die ungesicherte.

Zwei Bremsen: Die Notbremse (nichts gefunden, Datenbank kennt Dateien) lässt
sich nicht übersteuern. Die Mengenbremse (mehr als die Hälfte) verlangt
&bestaetigt=true. Die leere Installation ist ausgenommen.

Dazu umgebaut: erst sammeln, dann entscheiden, dann löschen. Vorher wurde
mitten in der Schleife gelöscht; die Gesamtzahl kennt man dort erst, wenn die
Hälfte schon weg ist.

Der Bericht nennt den Grund und ob er bestätigbar ist – die Oberfläche soll
keinen Knopf anbieten, der nichts bewirkt. Ein abgebrochener Lauf steht rot in
der Karte und im Audit-Log; gerade der, denn er ist der Hinweis darauf, dass
mit dem Datenordner etwas nicht stimmt.

Fünf Tests, unter Mutation nachgewiesen. Der Ernstfall läuft am echten Gerät:
fünf Zeilen in der Datenbank, ein Wurzelverzeichnis ohne users/ – nach Apply
stehen alle fünf noch, auch mit Bestätigung.

audit und thumbnails bekommen bewusst keine Go-Tests; die Begründung steht in
BEFUNDE.md statt als ewig offener Punkt.

Nebenbefund behoben: kalender-teilen-selbsttest.py leckte je Lauf ein
App-Passwort des Besitzers. Nach achtzehn Läufen war maxAppPasswords = 20
erreicht, und zwei Suiten scheiterten mit 409, ohne dass am Code etwas kaputt
war.

Geprüft: go build/vet sauber, alle Go-Pakete grün, 243 Prüfungen der sieben
Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 15:26:17 +02:00

301 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bestandsaufnahme, 12.09.2026
> **Stand 24.09.2026.** Dieses Dokument ist die Aufnahme vom 12.09. Was seither
> erledigt wurde, steht an Ort und Stelle durchgestrichen oder vermerkt; die
> Kurzfassung:
>
> | | |
> |---|---|
> | Punkte 1, 2, 2b, 3 (teilweise) | ✅ erledigt |
> | Go-Tests `documents`, `tags` | ✅ 12 Testfunktionen |
> | Go-Tests `sharing` | ✅ 6 Testfunktionen (25.09.) |
> | Go-Tests `rescan` | ✅ 5 Testfunktionen (26.09.) – dabei eine fehlende Notbremse gefunden |
> | Go-Tests `audit`, `thumbnails` | **bewusst nicht** – siehe unten |
> | Frontend-DOM-Tests | ✅ jsdom seit 18.09., 46 Tests |
>
> Dazwischen lag eine Woche Oberflächenarbeit, die hier nicht auftaucht, weil
> sie keine Befunde abarbeitete: Rückmeldungen mit Rückgängig, Platzhalter,
> Leerzustände, Sprache vereinheitlicht, `Intl`. Steht in
> [TESTING.md](TESTING.md).
Einmal durch die ganze Cloud: Code, Sicherheit, offene Enden. Geordnet nach
dem, was passieren kann — nicht nach Aufwand.
Was hier steht, ist **gemessen**, nicht geschätzt: Routen aus `main.go`
extrahiert und gegen die Rechteprüfungen in den Handlern abgeglichen,
`govulncheck` über den ganzen Baum, Zeilen und Testumfang je Paket gezählt.
---
## 1 · Ein Bild-Upload kann den Prozess töten
**Schwere: hoch. Jeder Kontoinhaber, normale Bedienung.**
`cmd/aether/main.go:702` erzeugt das Vorschaubild in einer **nackten
Goroutine**:
```go
go func() {
…
thumbnails.Generate(ctx, st, …) // dekodiert eine hochgeladene Datei
}()
```
Chis `Recoverer` hängt am Anfragebehandler. Eine Panik in einer *eigenen*
Goroutine fängt er nicht — sie nimmt den **ganzen Go-Prozess** mit. Docker
startet ihn neu (`restart: unless-stopped`), aber jeder gerade laufende Upload
stirbt mit, und wer die Datei noch einmal hochlädt, wiederholt das Spiel.
Die passende Lücke war offen: **GO-2026-5061**, Panik im WebP-Dekodierer bei
unstimmiger Alphakanal-Größe. Behoben am 12.09.2026 durch `x/image` 0.42 → 0.45
(`ef60497`).
**Damit ist die Hälfte erledigt, nicht das Problem.** Die Bauweise bleibt: Der
nächste Dekodierer-Fehler trifft genauso, und Bilddekodierer sind eine Gattung,
in der regelmäßig etwas gefunden wird. Vier weitere Goroutinen dieser Art gibt
es: `internal/sharing/handler.go:232` und `main.go:185` (beide nur Datenbank,
harmlos), `internal/gc/gc.go:257`, und der OCR-Arbeiter (`go worker.Run(ctx)`),
der Fremdprogramme aufruft und deren Ausgabe liest.
**✅ Erledigt am 12.09.2026.** `pkg/bg` mit zwei Funktionen:
* `bg.Go(name, fn)` – startet eine Goroutine, die eine Panik überlebt.
* `bg.Run(name, fn)` – dasselbe ohne eigene Goroutine, für Schleifen.
Der Unterschied ist keine Feinheit: Fängt man eine Panik um den **ganzen**
Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und von da
an wird still keine Datei mehr erkannt. Der OCR-Arbeiter sichert deshalb jeden
**Auftrag** einzeln ab; ein unverdauliches Dokument kostet dann genau sich
selbst.
Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zähler,
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit Begründung
im Code – die Signalbehandlung beim Herunterfahren; ein recover brächte dort
nichts, weil `main` ohnehin wartete, und sie fasst keine fremden Daten an.
Eine aufgefangene Panik wird mit Stapel und Herkunftsname protokolliert. Sie
ist ein Fehler und bleibt einer – sie reißt nur nichts mehr mit. Vier Tests in
`pkg/bg/bg_test.go`, darunter der, auf den es ankommt: Eine Schleife läuft nach
einer Panik im dritten Durchgang weiter.
> Der Unterschied ist wichtig: Die Version zu heben schloss **diese** Lücke.
> Der `recover` schließt die **Gattung**.
---
## 2 · Beschädigte Downloads werden erkannt und verschwiegen
**Schwere: mittel. Datenintegrität, still.**
`internal/files/handler.go:496` rechnet beim Ausliefern die Prüfsumme mit —
genau so, wie es die Projektregel verlangt („SHA-256: beim Upload berechnen,
beim Download verifizieren"). Nur passiert bei einer Abweichung nichts:
```go
actual := hex.EncodeToString(h256.Sum(nil))
if actual != f.Checksum {
// Log corruption; headers already sent, can't change status.
// TODO: alert / observability hook
_ = actual
}
```
Kein Logeintrag, kein Zähler, keine Meldung. Der Kommentar sagt, was fehlt.
Dass der Statuscode nicht mehr zu ändern ist, stimmt — die Kopfzeilen sind
längst draußen. Aber **still** muss es deshalb nicht sein. Ein `log.Error()`
mit Datei-ID, erwarteter und tatsächlicher Summe kostet nichts und ist der
Unterschied zwischen „ich habe eine kaputte Datei" und „ich habe eine kaputte
Datei und weiß es seit drei Monaten nicht".
Zweitens ließe sich die Verbindung abbrechen, statt sie sauber zu beenden: Der
Browser zeigt dann einen abgebrochenen Download statt einer heilen Datei mit
falschem Inhalt. Das ist die ehrlichere Ausgabe.
**✅ Erledigt am 12.09.2026.** Beides – und ein Drittes, das dabei herauskam.
Der Logeintrag nennt Datei-ID, Speicherschlüssel, erwartete und gelesene Summe.
Dazu wird die Verbindung fallen gelassen (`panic(http.ErrAbortHandler)`, den
chis Recoverer ausdrücklich durchreicht).
**Damit das überhaupt wirkt, mussten die letzten 32 KiB zurückgehalten
werden.** Sonst wäre die Datei beim Erkennen des Fehlers längst vollständig
beim Empfänger, und der Abbruch käme zu spät: Die Kopfzeilen sind draußen, die
Bytes auch. Ein `tailWriter` reicht deshalb alles durch außer dem Schluss; erst
wenn die Summe stimmt, darf der hinterher. Stimmt sie nicht, bekommt die
Gegenstelle weniger Bytes als im `Content-Length` angekündigt – und wertet das
als Fehler statt als Datei.
Geprüft in `scripts/pruefsumme-selbsttest.py` (14 Prüfungen): Der Test kippt
ein Bit **auf der Platte**, nicht im Programm, und zwar in der Mitte der Datei,
damit der Fehler nicht schon an der Länge auffällt. Zwei Größen – über und
unter den 32 KiB, denn bei einer kleinen Datei wird gar nichts herausgegeben.
Und er prüft zuerst, dass eine **heile** Datei vollständig durchkommt; ohne das
bewiese der Rest nichts.
---
## 2b · Und der Fund dahinter: 34 Logaufrufe schrieben ins Nichts
**Schwere: hoch, indirekt. Ein Fehler, der andere Fehler versteckt.**
Der neue Logeintrag blieb beim ersten Testlauf aus, obwohl der Abbruch
funktionierte. Ursache:
```go
func Ctx(ctx context.Context) *Logger {
if l, ok := ctx.Value(ctxKey{}).(*Logger); ok { return l }
else if l = DefaultContextLogger; l != nil { return l }
return disabledLogger // ← hier landete alles
}
```
`zerolog.Ctx` liefert einen **abgeschalteten** Logger, wenn im Kontext keiner
steckt und kein Standard gesetzt ist. Aethers `middleware.Logger` hängte nie
einen hinein, und `DefaultContextLogger` war nie gesetzt.
**Alle 34 `log.Ctx(...)`-Aufrufe im Baum schrieben damit ins Nichts.** Darunter:
* die Warnung, dass beim Zurückrollen einer Fassung ein Blob verwaist ist
(`files/handler.go`, `files/replace.go`, `handler_versions.go`)
* gescheiterte Suchen
* Fehler der Speicherbereinigung
* die neue Meldung über beschädigte Dateien
Das ist die unangenehmste Art Fehler: Der Aufruf sieht richtig aus, `go vet`
schweigt, und auffallen kann es nur, wenn man den Fehler herbeiführt, den die
Zeile melden soll.
**Behoben zweifach:** `zerolog.DefaultContextLogger` beim Start gesetzt (deckt
auch Hintergrundarbeit mit eigenem Kontext ab), und `middleware.Logger` hängt
zusätzlich einen Logger mit `request_id` in den Anfragekontext – damit bei zwei
gleichzeitigen Downloads erkennbar bleibt, welche Meldung zu welchem gehört.
Zwei Tests in `pkg/middleware/logger_test.go` halten es fest.
---
## 3 · Vier Pakete ohne einen einzigen Go-Test
**Schwere: mittel. Nicht was heute kaputt ist, sondern was morgen still kaputtgeht.**
| Paket | Zeilen | Test (24.09.) | Aussenpruefung |
|---|---:|---:|---|
| `internal/tags` | 1338 | **4** ✅ | keine |
| `internal/documents` (OCR, Volltext) | 1243 | **8** ✅ | keine |
| `internal/sharing` | 347 | **6** ✅ | Python-Selbsttest, 31 Prüfungen |
| `internal/rescan` | 435 | **5** ✅ | Python-Selbsttest, 20 Prüfungen |
| `internal/audit` | 180 | **0** | keine |
| `internal/thumbnails` | 95 | **0** | keine |
Bei `sharing` und `rescan` deckt wenigstens ein Selbsttest die Außenseite ab.
Bei **`documents` und `tags` gar nichts** — und `documents` ist das Stück, das
aus `scan_0042.pdf` ein auffindbares Dokument macht. Genau der Grund, aus dem
Aether das Rechnungsarchiv ist.
**Teilweise erledigt am 12.09.2026:** `internal/documents` hat jetzt acht
Tests — gezielt auf die drei Zustände, in denen die Warteschlange
hängenbleiben kann, ohne dass es auffällt (Details in
[TESTING.md](TESTING.md)). Dazu `scripts/db-tests.sh`, damit sie nicht wie
`vitest` zur Dekoration werden: Sie überspringen sich ohne `TEST_DSN` und
liefen sonst nie.
Gegengeprüft an absichtlich kaputtem Code — und **der zweite Versuch blieb
grün**: `maxAttempts` von 3 auf 9999 gesetzt, alle Tests weiter in Ordnung,
nur die Laufzeit stieg von 0,2 auf 27 Sekunden. Die Tests schleiften über die
Konstante und setzten die Zahl voraus, statt sie zu prüfen. Dagegen steht jetzt
ein eigener Test.
**`internal/tags` ebenfalls erledigt am 12.09.2026.** Fünf Tests, alle auf
dieselbe Frage: Bleibt ein fremdes Etikett fremd – in der Liste, beim
Umbenennen, beim Löschen, beim Anhängen an die eigene Datei, und vor allem beim
**Auflisten der Dateien darunter**. Der letzte Weg gibt Dateinamen, Ordner und
Größen heraus; wer dort eine fremde Etikett-ID errät, sähe einen Ausschnitt aus
fremden Unterlagen.
Auch hier gegen kaputten Code gegengeprüft: Eigentümerprüfung aus
`FilesWithTag` entfernt → *„Bert sieht 1 fremde Dateien: Gehaltsabrechnung.pdf"*.
Prüfung in `SetFieldsForFile` ausgehebelt → zwei Tests rot.
**Ein Verdacht erwies sich dabei als falsch, und das ist der nützlichere
Ausgang:** `List` bietet Gruppen-Etiketten an, `SetFieldsForFile` vergleicht
gegen den Prinzipal der *Datei* – das sah nach einem Widerspruch aus, bei dem
die Oberfläche etwas anbietet, was der Server ablehnt. Ist keiner: Die
Oberfläche filtert bereits (`TagDialog.tsx:69`). Und die Ablehnung ist nicht
Förmlichkeit, sondern die tragende Absicherung – `FilesWithTag` prüft, ob man
das *Etikett* sehen darf, **nicht** die *Datei*. Das ist nur deshalb harmlos,
weil ein Etikett stets denselben Prinzipal hat wie die Dateien darunter. Fiele
die Prüfung weg, hinge an einem Gruppen-Etikett eine private Datei, und jedes
Gruppenmitglied sähe ihren Namen. Der Test hält jetzt diesen Zusammenhang fest,
nicht bloß eine Fehlermeldung.
Nebenbei: `internal/tags` hat **zwei** Fehlermarken für dieselbe Aussage –
`ErrNotFound` und `ErrCorrespondentNotFound`. Kein Fehler, aber eine
Stolperstelle; jetzt wenigstens festgehalten.
**Offen bleiben** `internal/sharing`, `internal/rescan` (beide mit
Python-Selbsttest), `internal/audit` und `internal/thumbnails`.
---
### `audit` und `thumbnails`: bewusst ohne Go-Tests (26.09.2026)
Nicht vergessen, sondern entschieden. Ein Fehler in `thumbnails` (95 Zeilen)
erzeugt ein hässliches Vorschaubild; einer in `audit` (180 Zeilen) kostet einen
Protokolleintrag. Beide schreiben nichts kaputt und löschen nichts.
Tests dafür wären Häkchen, keine Absicherung – und sie kosten dieselbe
Aufmerksamkeit wie die Stellen, an denen wirklich etwas passieren kann. Wer sie
später doch will, hat damit wenigstens die Gegenrede.
## 4 · Was in Ordnung ist
Der Vollständigkeit halber, weil eine Liste nur aus Mängeln ein falsches Bild
gibt:
* **Alle 27 Admin-Routen prüfen serverseitig auf Adminrecht.** Zwei
Ausnahmen sind Absicht und dokumentiert: `GET /admin/settings` liest jeder
Angemeldete (Theming — und es liegen dort nur sechs Branding-Schlüssel,
nachgesehen), `GET /ocr` ist der eigene Erkennungsstand ohne Adminrecht.
* **Kein einziges zusammengesetztes SQL.** Alles parametrisiert.
* **Inline-Auslieferung nur über eine Positivliste** harmloser Typen, mit
`sandbox`, und `allow-scripts` ausschließlich für PDF.
* **Ratenbegrenzung an jeder unangemeldeten Tür**, die eines braucht: Anmeldung
(20/min), WebDAV, CalDAV, Freigabelinks (je 10 Fehlversuche / 15 Minuten).
Die übrigen offenen Wege (`/health`, `/branding`, `/manifest.webmanifest`,
`/api/v1/public/config`) geben nichts preis.
* **Kein `dangerouslySetInnerHTML`, kein `eval`** im Frontend.
* **Sechs Selbsttest-Suiten, 220 Prüfungen** gegen die laufende Instanz, dazu
37 Frontend-Tests und die Go-Tests.
---
## 5 · Kleinere offene Enden
* **`internal/groups` ist ein leerer Stub** (0 Zeilen). Gruppenordner liegen in
`files/groupfolder.go`. Entweder das Verzeichnis weg oder der Code dorthin —
ein leeres Paket im Baum ist eine Frage, die jeder einmal stellt.
* **`favicon_storage_key`** steht als leere Zeile in den Einstellungen; die
Funktion heißt seit dem App-Symbol anders. Schadet nichts, wird ignoriert.
* **Die Fehlversuchssperren liegen im Arbeitsspeicher.** Ein Neustart des
Containers hebt jede Sperre auf. Für den Hausgebrauch vertretbar; wer es
anders will, braucht eine Tabelle.
* ~~**Frontend ohne Komponententests.**~~ ✅ **18.09.2026:** `jsdom` ist in den
devDependencies, die Umgebung bleibt `node` (schnell); wer ein DOM braucht,
schreibt `// @vitest-environment jsdom` an den Anfang seiner Datei. 46 Tests,
davon vier gegen ein DOM. Der erste davon hat sofort einen Fehler gefunden –
in der Behebung vom Vortag (`useBackClose`).
**Was er nicht leistet, steht dabei:** Den ursprünglichen Fehler bildet jsdom
nicht ab; es verwirft einen Verlaufssprung, sobald `pushState` folgt. Dafür
bräuchte es einen echten Browser (Playwright). Nachgeprüft, nicht vermutet:
mit der alten, kaputten Fassung blieben alle Tests grün.
---
## Reihenfolge, die ich vorschlagen würde
1. ~~`recover` in die Hintergrund-Goroutinen~~ ✅ 12.09.2026
2. ~~Prüfsummen-Abweichung melden~~ ✅ 12.09.2026 – und dabei fiel auf, dass
**alle** kontextgebundenen Logzeilen ins Nichts gingen (2b).
3. ~~Tests für `documents`~~ ✅ und ~~`tags`~~ ✅ 12.09.2026.