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>
This commit is contained in:
1 parent
6fab2388ca
commit
662c0ebdc1
8 files changed
+391
-20
No files matched your search
+13
-2
@@ -9,7 +9,8 @@
|
|||||||
> | Punkte 1, 2, 2b, 3 (teilweise) | ✅ erledigt |
|
> | Punkte 1, 2, 2b, 3 (teilweise) | ✅ erledigt |
|
||||||
> | Go-Tests `documents`, `tags` | ✅ 12 Testfunktionen |
|
> | Go-Tests `documents`, `tags` | ✅ 12 Testfunktionen |
|
||||||
> | Go-Tests `sharing` | ✅ 6 Testfunktionen (25.09.) |
|
> | Go-Tests `sharing` | ✅ 6 Testfunktionen (25.09.) |
|
||||||
> | Go-Tests `rescan`, `audit`, `thumbnails` | **offen** |
|
> | 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 |
|
> | Frontend-DOM-Tests | ✅ jsdom seit 18.09., 46 Tests |
|
||||||
>
|
>
|
||||||
> Dazwischen lag eine Woche Oberflächenarbeit, die hier nicht auftaucht, weil
|
> Dazwischen lag eine Woche Oberflächenarbeit, die hier nicht auftaucht, weil
|
||||||
@@ -182,7 +183,7 @@ Zwei Tests in `pkg/middleware/logger_test.go` halten es fest.
|
|||||||
| `internal/tags` | 1338 | **4** ✅ | keine |
|
| `internal/tags` | 1338 | **4** ✅ | keine |
|
||||||
| `internal/documents` (OCR, Volltext) | 1243 | **8** ✅ | keine |
|
| `internal/documents` (OCR, Volltext) | 1243 | **8** ✅ | keine |
|
||||||
| `internal/sharing` | 347 | **6** ✅ | Python-Selbsttest, 31 Prüfungen |
|
| `internal/sharing` | 347 | **6** ✅ | Python-Selbsttest, 31 Prüfungen |
|
||||||
| `internal/rescan` | 435 | **0** | Python-Selbsttest, 20 Prüfungen |
|
| `internal/rescan` | 435 | **5** ✅ | Python-Selbsttest, 20 Prüfungen |
|
||||||
| `internal/audit` | 180 | **0** | keine |
|
| `internal/audit` | 180 | **0** | keine |
|
||||||
| `internal/thumbnails` | 95 | **0** | keine |
|
| `internal/thumbnails` | 95 | **0** | keine |
|
||||||
|
|
||||||
@@ -236,6 +237,16 @@ 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
|
## 4 · Was in Ordnung ist
|
||||||
|
|
||||||
Der Vollständigkeit halber, weil eine Liste nur aus Mängeln ein falsches Bild
|
Der Vollständigkeit halber, weil eine Liste nur aus Mängeln ein falsches Bild
|
||||||
|
|||||||
+63
@@ -1562,6 +1562,69 @@ bleibt `node` (schnell); wer ein DOM braucht, schreibt
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Der Abgleich bekommt eine Notbremse (26.09.2026)
|
||||||
|
|
||||||
|
`internal/rescan` ist das einzige Stueck Aether, das **hart** loescht:
|
||||||
|
`DELETE FROM files`, kein Papierkorb. Entfernt wird, was beim Durchgang ueber
|
||||||
|
die Platte nicht gesehen wurde.
|
||||||
|
|
||||||
|
Der Ernstfall ist nicht ausgedacht ([localfs.go:199](../backend/internal/storage/localfs.go)):
|
||||||
|
|
||||||
|
if _, err := os.Stat(base); os.IsNotExist(err) {
|
||||||
|
return nil // kein Fehler, keine Eintraege
|
||||||
|
}
|
||||||
|
|
||||||
|
Existiert das Datenverzeichnis nicht, meldet `Walk` **keinen Fehler** – es
|
||||||
|
meldet gar nichts. 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** Aufruf mit `?anwenden=true` loescht den
|
||||||
|
gesamten Bestand aus der Datenbank. Die Bytes laegen noch da, aber niemand
|
||||||
|
wuesste mehr, dass es sie gibt – und der GC raeumte sie 24 Stunden spaeter 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 faelschlich mit "leer". Der GC loescht dann Blobs – die
|
||||||
|
Datenbank weiss hinterher noch, was fehlt. Hier faellt die Zeile selbst. **Die
|
||||||
|
gefaehrlichere Seite war die ungesicherte.**
|
||||||
|
|
||||||
|
### Zwei Bremsen, eine davon nicht uebersteuerbar
|
||||||
|
|
||||||
|
| Bremse | wann | bestaetigbar |
|
||||||
|
|---|---|---|
|
||||||
|
| **Notbremse** | nichts auf der Platte gefunden, Datenbank kennt Dateien | **nein** |
|
||||||
|
| **Mengenbremse** | mehr als die Haelfte wuerde fallen | ja, mit `&bestaetigt=true` |
|
||||||
|
|
||||||
|
Die leere Installation (nichts hier, nichts dort) ist ausgenommen – da gibt es
|
||||||
|
nichts zu verwechseln.
|
||||||
|
|
||||||
|
Dazu umgebaut: erst sammeln, dann entscheiden, dann loeschen. Vorher wurde
|
||||||
|
mitten in der Schleife geloescht; die Gesamtzahl kennt man dort erst, wenn die
|
||||||
|
Haelfte schon weg ist, und eine Bremse kaeme zu spaet.
|
||||||
|
|
||||||
|
- [x] Fuenf Tests in `bremse_test.go`. Unter Mutation nachgewiesen: Wird die
|
||||||
|
Notbremse entfernt, werden `TestNotbremseBeiLeeremSpeicher` **und** der
|
||||||
|
Ernstfalltest rot.
|
||||||
|
- [x] Ernstfall am echten Geraet: fuenf Zeilen in der Datenbank, ein
|
||||||
|
Wurzelverzeichnis ohne `users/`. Nach `Apply` stehen alle fuenf noch –
|
||||||
|
auch mit ausdruecklicher Bestaetigung.
|
||||||
|
- [ ] Im Admin-Bereich: "Rescan storage" -> Check -> Apply bei heilem Bestand
|
||||||
|
arbeitet wie vorher 🔎
|
||||||
|
- [ ] Ein abgebrochener Lauf steht rot in der Karte, mit Grund, und die Karte
|
||||||
|
bleibt im Zustand "geprueft" (der Anwenden-Knopf verschwindet nicht)
|
||||||
|
- [ ] Ein abgebrochener Lauf steht im Audit-Log – gerade der: Er ist der
|
||||||
|
Hinweis darauf, dass mit dem Datenordner etwas nicht stimmt
|
||||||
|
|
||||||
|
### Nebenbefund: der Teilen-Selbsttest leckte App-Passwoerter
|
||||||
|
|
||||||
|
`kalender-teilen-selbsttest.py` legte je Lauf zwei App-Passwoerter an und
|
||||||
|
raeumte nur das des Empfaengers weg (das faellt mit dessen Konto). Das des
|
||||||
|
Besitzers blieb liegen. Nach **achtzehn** Laeufen war
|
||||||
|
`auth.maxAppPasswords = 20` erreicht, und caldav wie kalender-teilen
|
||||||
|
scheiterten mit `409` – ohne dass am Code etwas kaputt gewesen waere.
|
||||||
|
|
||||||
|
Aufgeraeumt und behoben; zweimal hintereinander laufen lassen, danach stehen
|
||||||
|
wieder nur die beiden echten (`CalDAV`, `DAV`).
|
||||||
|
|
||||||
## Freigabelinks: Prüfsumme, Gruppenordner, Grenzen (25.09.2026)
|
## Freigabelinks: Prüfsumme, Gruppenordner, Grenzen (25.09.2026)
|
||||||
|
|
||||||
Beim Lesen von `internal/sharing` fiel eine ungleiche Zusage auf: Der
|
Beim Lesen von `internal/sharing` fiel eine ungleiche Zusage auf: Der
|
||||||
|
|||||||
@@ -142,12 +142,16 @@ func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
anwenden := r.URL.Query().Get("anwenden") == "true"
|
anwenden := r.URL.Query().Get("anwenden") == "true"
|
||||||
|
// Zweiter Schlüssel für den Fall, dass wirklich mehr als die Hälfte fallen
|
||||||
|
// soll. Bewusst ein eigener Parameter: Wer „anwenden" tippt, meint
|
||||||
|
// aufräumen; wer „bestaetigt" dazutippt, hat den Bericht gelesen.
|
||||||
|
bestaetigt := r.URL.Query().Get("bestaetigt") == "true"
|
||||||
var (
|
var (
|
||||||
bef *rescan.Report
|
bef *rescan.Report
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if anwenden {
|
if anwenden {
|
||||||
bef, err = h.rescan.Apply(r.Context())
|
bef, err = h.rescan.Apply(r.Context(), bestaetigt)
|
||||||
} else {
|
} else {
|
||||||
bef, err = h.rescan.Check(r.Context())
|
bef, err = h.rescan.Check(r.Context())
|
||||||
}
|
}
|
||||||
@@ -157,9 +161,17 @@ func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if anwenden {
|
if anwenden {
|
||||||
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
// Ein abgebrochener Lauf gehört genauso ins Protokoll wie ein
|
||||||
fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors",
|
// ausgeführter – **gerade der**: Er ist der Hinweis darauf, dass mit
|
||||||
bef.Adopted, bef.Corrected, bef.Removed, bef.Errors))
|
// dem Datenordner etwas nicht stimmt.
|
||||||
|
if bef.Aborted {
|
||||||
|
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||||
|
"rescan aborted, nothing removed: "+bef.AbortReason)
|
||||||
|
} else {
|
||||||
|
h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "",
|
||||||
|
fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors",
|
||||||
|
bef.Adopted, bef.Corrected, bef.Removed, bef.Errors))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
response.JSON(w, http.StatusOK, bef)
|
response.JSON(w, http.StatusOK, bef)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package rescan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"aether/internal/files"
|
||||||
|
"aether/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Der Abgleich 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.
|
||||||
|
// Ohne Bremse gälte dann jede Datei als verwaist, und ein einziger Aufruf mit
|
||||||
|
// `?anwenden=true` löschte den ganzen Bestand aus der Datenbank.
|
||||||
|
//
|
||||||
|
// Der GC hat diese Bremse seit dem 12.09.2026. Hier fehlte sie – an der
|
||||||
|
// gefährlicheren von beiden Stellen.
|
||||||
|
|
||||||
|
func TestNotbremseBeiLeeremSpeicher(t *testing.T) {
|
||||||
|
// Nichts gesehen, obwohl die Datenbank Dateien kennt: nicht verhandelbar.
|
||||||
|
grund, uebersteuerbar := pruefeBremse(0, 1200, 1200, false)
|
||||||
|
if grund == "" {
|
||||||
|
t.Fatal("leerer Durchgang bei gefuellter Datenbank muss bremsen")
|
||||||
|
}
|
||||||
|
if uebersteuerbar {
|
||||||
|
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||||
|
}
|
||||||
|
// Auch mit ausdruecklicher Bestaetigung nicht.
|
||||||
|
if grund, _ := pruefeBremse(0, 1200, 1200, true); grund == "" {
|
||||||
|
t.Fatal("auch mit Bestaetigung muss die Notbremse halten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeereInstallationDarfDurch(t *testing.T) {
|
||||||
|
// Nichts auf der Platte, nichts in der Datenbank – da gibt es nichts zu
|
||||||
|
// verwechseln. Würde hier gebremst, liesse sich eine frische Anlage nie
|
||||||
|
// abgleichen.
|
||||||
|
if grund, _ := pruefeBremse(0, 0, 0, false); grund != "" {
|
||||||
|
t.Fatalf("leere Installation darf nicht bremsen: %s", grund)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMengenbremseLaesstSichBestaetigen(t *testing.T) {
|
||||||
|
// Mehr als die Hälfte auf einmal: erst nachfragen.
|
||||||
|
grund, uebersteuerbar := pruefeBremse(500, 1000, 501, false)
|
||||||
|
if grund == "" {
|
||||||
|
t.Fatal("mehr als die Haelfte muss nachfragen")
|
||||||
|
}
|
||||||
|
if !uebersteuerbar {
|
||||||
|
t.Fatal("die Mengenbremse muss sich bestaetigen lassen")
|
||||||
|
}
|
||||||
|
if grund, _ := pruefeBremse(500, 1000, 501, true); grund != "" {
|
||||||
|
t.Fatalf("mit Bestaetigung muss es durchgehen: %s", grund)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGewoehnlichesAufraeumenGehtDurch(t *testing.T) {
|
||||||
|
// Der Normalfall: ein paar Leichen unter vielen. Bremste es hier, wäre die
|
||||||
|
// Funktion unbrauchbar – und jemand baute die Bremse wieder aus.
|
||||||
|
for _, f := range []struct {
|
||||||
|
gescannt, lebende, fallen int
|
||||||
|
}{
|
||||||
|
{1000, 1000, 1},
|
||||||
|
{1000, 1000, 17},
|
||||||
|
{1000, 1000, 500}, // genau die Hälfte: noch erlaubt
|
||||||
|
{1, 1, 0},
|
||||||
|
} {
|
||||||
|
if grund, _ := pruefeBremse(f.gescannt, f.lebende, f.fallen, false); grund != "" {
|
||||||
|
t.Fatalf("%d von %d duerfen fallen, wurde gebremst: %s", f.fallen, f.lebende, grund)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Und jetzt der Ernstfall am echten Gerät: Dateien in der Datenbank, ein
|
||||||
|
// Datenordner, den es nicht gibt. Nach dem Anwenden muss **jede** Zeile noch
|
||||||
|
// da sein.
|
||||||
|
//
|
||||||
|
// Läuft nur mit TEST_DSN: ./scripts/db-tests.sh ./internal/rescan/
|
||||||
|
func TestAnwendenLoeschtNichtsWennDerOrdnerFehlt(t *testing.T) {
|
||||||
|
dsn := os.Getenv("TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_DSN nicht gesetzt")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verbinden: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
var nutzer uuid.UUID
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO users (username, display_name, password, is_admin)
|
||||||
|
VALUES ($1, $1, 'x', false) RETURNING id`, "rescan-"+uuid.NewString()[:8],
|
||||||
|
).Scan(&nutzer); err != nil {
|
||||||
|
t.Fatalf("konto: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, nutzer)
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO files (owner_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||||
|
VALUES ($1, NULL, $2, 'application/pdf', 3, 'x', $3)`,
|
||||||
|
nutzer, "Rechnung.pdf", "users/wer/home/"+uuid.NewString(),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("datei: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Wurzelverzeichnis, das es gibt – aber ohne "users/" darin. Genau das
|
||||||
|
// sieht der Server, wenn der Bind-Mount nicht hochkam.
|
||||||
|
wurzel := t.TempDir()
|
||||||
|
st, err := storage.NewLocalFS(wurzel, filepath.Join(wurzel, "tmp"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("speicher: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := New(pool, files.NewStore(pool), st)
|
||||||
|
bericht, err := scanner.Apply(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("anwenden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bericht.Aborted {
|
||||||
|
t.Fatal("der Lauf haette abbrechen muessen")
|
||||||
|
}
|
||||||
|
if bericht.Removed != 0 {
|
||||||
|
t.Fatalf("es durfte nichts entfernt werden, entfernt wurden %d", bericht.Removed)
|
||||||
|
}
|
||||||
|
if bericht.Overridable {
|
||||||
|
t.Fatal("die Notbremse darf sich nicht bestaetigen lassen")
|
||||||
|
}
|
||||||
|
|
||||||
|
var uebrig int
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||||
|
t.Fatalf("zaehlen: %v", err)
|
||||||
|
}
|
||||||
|
if uebrig != 5 {
|
||||||
|
t.Fatalf("alle fuenf Zeilen muessen stehen bleiben, es sind %d", uebrig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gegenprobe mit Bestaetigung: Auch dann bleibt alles stehen.
|
||||||
|
if _, err := scanner.Apply(ctx, true); err != nil {
|
||||||
|
t.Fatalf("anwenden mit Bestaetigung: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM files WHERE owner_id = $1`, nutzer).Scan(&uebrig); err != nil {
|
||||||
|
t.Fatalf("zaehlen: %v", err)
|
||||||
|
}
|
||||||
|
if uebrig != 5 {
|
||||||
|
t.Fatalf("auch mit Bestaetigung muessen alle fuenf stehen, es sind %d", uebrig)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"aether/internal/files"
|
"aether/internal/files"
|
||||||
"aether/internal/storage"
|
"aether/internal/storage"
|
||||||
@@ -66,6 +67,13 @@ type Report struct {
|
|||||||
Scanned int `json:"scanned"`
|
Scanned int `json:"scanned"`
|
||||||
Samples []Entry `json:"samples"`
|
Samples []Entry `json:"samples"`
|
||||||
Aborted bool `json:"aborted,omitempty"`
|
Aborted bool `json:"aborted,omitempty"`
|
||||||
|
// AbortReason sagt, **warum** nichts geändert wurde. Ein „aborted: true"
|
||||||
|
// ohne Grund lässt genau die Frage offen, die man dann hat.
|
||||||
|
AbortReason string `json:"abort_reason,omitempty"`
|
||||||
|
// Overridable unterscheidet die beiden Bremsen. Die Mengenbremse lässt
|
||||||
|
// sich bestätigen, die Notbremse nicht – und die Oberfläche soll keinen
|
||||||
|
// Knopf anbieten, der nichts bewirkt.
|
||||||
|
Overridable bool `json:"overridable,omitempty"`
|
||||||
|
|
||||||
// Nur bei Apply gefüllt.
|
// Nur bei Apply gefüllt.
|
||||||
Adopted int `json:"adopted,omitempty"`
|
Adopted int `json:"adopted,omitempty"`
|
||||||
@@ -137,13 +145,16 @@ func splitKey(key string) (root, owner, folderPath, name string, ok bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check liest nur und ändert nichts – jederzeit gefahrlos.
|
// Check liest nur und ändert nichts – jederzeit gefahrlos.
|
||||||
func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false) }
|
func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false, false) }
|
||||||
|
|
||||||
// Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste
|
// Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste
|
||||||
// Einträge.
|
// Einträge.
|
||||||
func (s *Scanner) Apply(ctx context.Context) (*Report, error) { return s.run(ctx, true) }
|
// trotzdem hebt die Mengenbremse auf – nicht die Notbremse. Siehe pruefeBremse.
|
||||||
|
func (s *Scanner) Apply(ctx context.Context, trotzdem bool) (*Report, error) {
|
||||||
|
return s.run(ctx, true, trotzdem)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) {
|
func (s *Scanner) run(ctx context.Context, apply, trotzdem bool) (*Report, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, maxDuration)
|
ctx, cancel := context.WithTimeout(ctx, maxDuration)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -217,7 +228,19 @@ func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) {
|
|||||||
// Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen
|
// Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen
|
||||||
// außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie
|
// außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie
|
||||||
// pauschal für verwaist zu erklären.
|
// pauschal für verwaist zu erklären.
|
||||||
|
// **Erst sammeln, dann entscheiden, dann löschen.** Vorher wurde mitten in
|
||||||
|
// der Schleife gelöscht – dann kennt man die Gesamtzahl erst, wenn die
|
||||||
|
// Hälfte schon weg ist, und eine Bremse käme zu spät.
|
||||||
|
type waise struct {
|
||||||
|
key string
|
||||||
|
id uuid.UUID
|
||||||
|
}
|
||||||
|
var waisen []waise
|
||||||
|
var lebende int
|
||||||
for key, row := range known {
|
for key, row := range known {
|
||||||
|
if !row.deleted {
|
||||||
|
lebende++
|
||||||
|
}
|
||||||
if row.seen || row.deleted {
|
if row.seen || row.deleted {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -226,10 +249,34 @@ func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rep.Orphaned++
|
waisen = append(waisen, waise{key: key, id: row.id})
|
||||||
e := Entry{Kind: KindOrphaned, StorageKey: key, Path: key}
|
}
|
||||||
|
rep.Orphaned = len(waisen)
|
||||||
|
|
||||||
|
if apply {
|
||||||
|
if grund, uebersteuerbar := pruefeBremse(rep.Scanned, lebende, len(waisen), trotzdem); grund != "" {
|
||||||
|
rep.Aborted = true
|
||||||
|
rep.AbortReason = grund
|
||||||
|
rep.Overridable = uebersteuerbar
|
||||||
|
log.Ctx(ctx).Error().
|
||||||
|
Int("scanned", rep.Scanned).
|
||||||
|
Int("lebende_eintraege", lebende).
|
||||||
|
Int("wuerden_fallen", len(waisen)).
|
||||||
|
Str("grund", grund).
|
||||||
|
Msg("rescan: Anwenden abgebrochen - das sieht nicht nach Aufraeumen aus")
|
||||||
|
// Die Waisen trotzdem in die Stichprobe, damit man im Bericht
|
||||||
|
// sieht, worum es gegangen wäre.
|
||||||
|
for _, o := range waisen {
|
||||||
|
sample(Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key})
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, o := range waisen {
|
||||||
|
e := Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key}
|
||||||
if apply {
|
if apply {
|
||||||
if err := s.remove(ctx, row.id); err != nil {
|
if err := s.remove(ctx, o.id); err != nil {
|
||||||
e.Reason = err.Error()
|
e.Reason = err.Error()
|
||||||
rep.Errors++
|
rep.Errors++
|
||||||
} else {
|
} else {
|
||||||
@@ -241,3 +288,47 @@ func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) {
|
|||||||
|
|
||||||
return rep, nil
|
return rep, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Die Bremsen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// mengenAnteil: Ab welchem Anteil der lebenden Einträge ein Lauf nicht mehr
|
||||||
|
// nach Aufräumen aussieht, sondern nach einem Unfall. Die Hälfte ist
|
||||||
|
// großzügig – wer wirklich einmal halb Aether von Hand aus dem Datenordner
|
||||||
|
// entfernt hat, bestätigt einmal und kommt durch.
|
||||||
|
const mengenAnteil = 2
|
||||||
|
|
||||||
|
// pruefeBremse entscheidet, ob wirklich gelöscht werden darf. Leerer Rückgabe-
|
||||||
|
// wert heißt: ja.
|
||||||
|
//
|
||||||
|
// **Warum es das hier braucht, obwohl der GC es schon hat.** 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 weiß hinterher noch, was fehlt. Hier fällt
|
||||||
|
// `DELETE FROM files`, und damit das Einzige, was noch wusste, dass es die
|
||||||
|
// Datei gab. Die gefährlichere Seite war die ungesicherte.
|
||||||
|
//
|
||||||
|
// Der Auslöser ist keine Theorie: `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. Ohne diese Prüfung gälte dann jede
|
||||||
|
// Datei als verwaist.
|
||||||
|
func pruefeBremse(gescannt, lebende, wuerdenFallen int, trotzdem bool) (grund string, uebersteuerbar bool) {
|
||||||
|
// **Notbremse – nicht übersteuerbar.** Nichts auf der Platte gesehen,
|
||||||
|
// obwohl die Datenbank Dateien kennt: Das ist kein Bestand, der
|
||||||
|
// verschwunden ist, das ist ein Speicher, der nicht da war.
|
||||||
|
if gescannt == 0 && lebende > 0 {
|
||||||
|
return "auf der Platte wurde keine einzige Datei gefunden, " +
|
||||||
|
"obwohl die Datenbank welche kennt - vermutlich ist der Datenordner " +
|
||||||
|
"nicht eingehaengt. Es wurde nichts geloescht.", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Mengenbremse – mit Bestaetigung übersteuerbar.** Dass mehr als die
|
||||||
|
// Hälfte auf einmal fällt, kommt bei echtem Aufräumen nicht vor.
|
||||||
|
if !trotzdem && wuerdenFallen > 0 && wuerdenFallen*mengenAnteil > lebende {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%d von %d Eintraegen wuerden entfernt - das ist mehr als die Haelfte. "+
|
||||||
|
"Wenn das stimmt, noch einmal mit ausdruecklicher Bestaetigung aufrufen.",
|
||||||
|
wuerdenFallen, lebende), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -122,6 +122,10 @@ export interface RescanReport {
|
|||||||
scanned: number
|
scanned: number
|
||||||
samples: RescanEntry[]
|
samples: RescanEntry[]
|
||||||
aborted?: boolean
|
aborted?: boolean
|
||||||
|
/** Warum nichts geändert wurde – siehe rescan.pruefeBremse. */
|
||||||
|
abort_reason?: string
|
||||||
|
/** Mengenbremse (bestätigbar) oder Notbremse (nicht)? */
|
||||||
|
overridable?: boolean
|
||||||
/** Nur nach dem Anwenden gefüllt. */
|
/** Nur nach dem Anwenden gefüllt. */
|
||||||
adopted?: number
|
adopted?: number
|
||||||
removed?: number
|
removed?: number
|
||||||
@@ -909,8 +913,10 @@ export const adminApi = {
|
|||||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
||||||
|
|
||||||
/** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */
|
/** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */
|
||||||
rescan: (apply = false) =>
|
/** `bestaetigt` hebt die Mengenbremse auf (mehr als die Hälfte würde
|
||||||
request<RescanReport>('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}`),
|
* fallen). Die Notbremse – nichts auf der Platte gefunden – bleibt. */
|
||||||
|
rescan: (apply = false, bestaetigt = false) =>
|
||||||
|
request<RescanReport>('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}${apply && bestaetigt ? '&bestaetigt=true' : ''}`),
|
||||||
|
|
||||||
runCleanup: () => request<{
|
runCleanup: () => request<{
|
||||||
scanned: number; orphans: number; deleted: number
|
scanned: number; orphans: number; deleted: number
|
||||||
|
|||||||
@@ -36,13 +36,16 @@ export default function RescanCard() {
|
|||||||
|
|
||||||
const pending = report ? report.new + report.orphaned + report.changed : 0
|
const pending = report ? report.new + report.orphaned + report.changed : 0
|
||||||
|
|
||||||
async function run(apply: boolean) {
|
async function run(apply: boolean, bestaetigt = false) {
|
||||||
setBusy(apply ? 'apply' : 'check')
|
setBusy(apply ? 'apply' : 'check')
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const r = await adminApi.rescan(apply)
|
const r = await adminApi.rescan(apply, bestaetigt)
|
||||||
setReport(r)
|
setReport(r)
|
||||||
setApplied(apply)
|
// Ein abgebrochener Lauf hat **nichts** angewendet – also bleibt die
|
||||||
|
// Karte im Zustand „geprüft", sonst verschwände der Anwenden-Knopf und
|
||||||
|
// es sähe aus, als wäre etwas geschehen.
|
||||||
|
setApplied(apply && !r.aborted)
|
||||||
} catch {
|
} catch {
|
||||||
setError(apply ? 'Applying failed' : 'Check failed')
|
setError(apply ? 'Applying failed' : 'Check failed')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,7 +84,7 @@ export default function RescanCard() {
|
|||||||
</button>
|
</button>
|
||||||
{report && !applied && pending > 0 && (
|
{report && !applied && pending > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => run(true)}
|
onClick={() => run(true, report.aborted && report.overridable)}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
style={{
|
style={{
|
||||||
padding: '7px 16px', borderRadius: 8, flexShrink: 0, border: 'none',
|
padding: '7px 16px', borderRadius: 8, flexShrink: 0, border: 'none',
|
||||||
@@ -89,11 +92,24 @@ export default function RescanCard() {
|
|||||||
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
|
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{busy === 'apply' ? 'Applying…' : `Apply (${pending})`}
|
{busy === 'apply' ? 'Applying…'
|
||||||
|
: report.aborted && report.overridable ? `Apply anyway (${pending})`
|
||||||
|
: `Apply (${pending})`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{report?.aborted && report.abort_reason && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 10, padding: '9px 11px', borderRadius: 8,
|
||||||
|
border: '1px solid var(--color-danger)',
|
||||||
|
background: 'rgba(var(--color-danger-rgb, 239 68 68), 0.10)',
|
||||||
|
color: 'var(--color-danger)', fontSize: 11.5, lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
<strong>Nothing was removed.</strong> {report.abort_reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{report && report.samples.length > 0 && (
|
{report && report.samples.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: 10, maxHeight: 260, overflowY: 'auto',
|
marginTop: 10, maxHeight: 260, overflowY: 'auto',
|
||||||
|
|||||||
@@ -122,10 +122,12 @@ besitzer, BES_CSRF = anmelden(USER, PASSWORT)
|
|||||||
marke = uuid.uuid4().hex[:6]
|
marke = uuid.uuid4().hex[:6]
|
||||||
empf_name = f"teilen-empf-{marke}"
|
empf_name = f"teilen-empf-{marke}"
|
||||||
empf_pw = "Empf!" + uuid.uuid4().hex[:12]
|
empf_pw = "Empf!" + uuid.uuid4().hex[:12]
|
||||||
angelegt = {"user": None, "group": None, "cal": None, "acl": []}
|
angelegt = {"user": None, "group": None, "cal": None, "acl": [], "apppw": []}
|
||||||
|
|
||||||
|
|
||||||
def aufraeumen():
|
def aufraeumen():
|
||||||
|
for pid in angelegt["apppw"]:
|
||||||
|
ruf(besitzer, "DELETE", f"/api/v1/app-passwords/{pid}", None, BES_CSRF)
|
||||||
for eid in angelegt["acl"]:
|
for eid in angelegt["acl"]:
|
||||||
ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF)
|
ruf(besitzer, "DELETE", f"/api/v1/acl/{eid}", None, BES_CSRF)
|
||||||
if angelegt["cal"]:
|
if angelegt["cal"]:
|
||||||
@@ -151,6 +153,12 @@ try:
|
|||||||
st, d = ruf(opener, "POST", "/api/v1/app-passwords",
|
st, d = ruf(opener, "POST", "/api/v1/app-passwords",
|
||||||
{"name": "teilen-test-" + marke}, csrf)
|
{"name": "teilen-test-" + marke}, csrf)
|
||||||
assert st in (200, 201), f"App-Passwort fehlgeschlagen: {st}"
|
assert st in (200, 201), f"App-Passwort fehlgeschlagen: {st}"
|
||||||
|
# **Merken, sonst leckt jeder Lauf eines.** Das des Empfaengers geht
|
||||||
|
# mit dessen Konto weg; das des Besitzers bleibt liegen. Nach 20 ist
|
||||||
|
# die Obergrenze erreicht (auth.maxAppPasswords) und der naechste Lauf
|
||||||
|
# scheitert mit 409 – genau das ist am 26.09.2026 passiert, nach
|
||||||
|
# achtzehn Laeufen.
|
||||||
|
angelegt["apppw"].append(d["id"])
|
||||||
return d["password"]
|
return d["password"]
|
||||||
|
|
||||||
BES_DAV = "Basic " + base64.b64encode(f"{USER}:{app_passwort(besitzer, BES_CSRF)}".encode()).decode()
|
BES_DAV = "Basic " + base64.b64encode(f"{USER}:{app_passwort(besitzer, BES_CSRF)}".encode()).decode()
|
||||||
|
|||||||
Reference in new issue
Block a user