feat(admin): Abgleich zwischen Platte und Datenbank
Aether fuehrt zwei Buecher: die Bytes auf der Platte und die Eintraege in
der Datenbank. Wer Dateien von aussen hineinlegt -- per SFTP oder aus
einer Sicherung zurueckgeholt --, erzeugt nur das eine. Die Datei liegt
da, Aether weiss nichts von ihr, und der GC haelt sie nach 24 Stunden
fuer einen Rest und loescht sie.
Neu deshalb internal/rescan, die Gegenrichtung zum GC, und ein Knopf
unter Admin -> Audit. Drei Faelle:
Datei da, kein Eintrag Eintrag anlegen, samt Ordnerkette,
Pruefsumme und Dateityp
Eintrag da, Datei fort Eintrag entfernen, dazu Vorschaubild und
fruehere Fassungen
von aussen ersetzt Groesse und Pruefsumme uebernehmen
Zwei Schritte, nie einer: Check liest nur, Apply aendert. Ein Knopf, der
beides in einem taete, gaebe niemandem die Gelegenheit, vorher
hinzusehen -- und was hier geloescht wird, ist nicht der Papierkorb.
Nicht angefasst werden .trash/ und .versions/. Beide liegen im selben
Baum, gehoeren aber dem System: Wuerden sie uebernommen, tauchten
geloeschte Dateien wieder auf und jede fruehere Fassung wuerde zu einer
eigenen Datei.
Zwei Fallen, die erst der Praxistest zeigte:
- Der Dateityp muss aus Endung *oder Inhalt* kommen. Beim Hochladen
schickt der Browser ihn mit; eine per SFTP abgelegte Datei bringt
niemanden mit, der ihn kennt. Dazu hat das Alpine-Abbild keine
/etc/mime.types, und Gos eingebaute Liste kennt nicht einmal .txt --
jede Textdatei waere als application/octet-stream gelandet.
- COALESCE($1, $2) ohne Cast: Postgres haelt die Parameter dann fuer
Text und bricht mit "operator does not exist: uuid = text" ab. Die
Ordnerkette wurde nie angelegt.
Die Pruefsumme wird trotz des vollstaendigen Lesevorgangs berechnet. Ein
Eintrag ohne sie waere ein Eintrag, dessen Beschaedigung beim
Herunterladen niemand bemerkt -- genau die Zusage, die Aether sonst
ueberall einhaelt.
scripts/rescan-selbsttest.py mit 20 Pruefungen in fuenf Gruppen, darunter
"Pruefen aendert nichts" und "Papierkorb bleibt unangetastet". Nebenbei
raeumt der Freigabe-Selbsttest jetzt auch seinen Papierkorb und nimmt
einen eindeutigen Kontonamen je Lauf -- ein abgebrochener Lauf blockierte
sonst jeden weiteren mit 409.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fce8053d09
commit
55dcf517f2
12 files changed
+1029
-4
No files matched your search
@@ -507,6 +507,46 @@ hunderte pro Minute.
|
||||
|
||||
---
|
||||
|
||||
## Dateien von außen: Abgleich mit der Datenbank
|
||||
|
||||
Aether führt zwei Bücher: die Bytes auf der Platte und die Einträge in der
|
||||
Datenbank. Normalerweise entstehen beide zusammen. Wer Dateien **von außen**
|
||||
hineinlegt – per SFTP oder aus einer Sicherung zurückgeholt –, erzeugt nur
|
||||
das eine: Die Datei liegt da, aber Aether weiß nichts von ihr, zeigt sie
|
||||
nicht an, und die Speicherbereinigung hält sie nach 24 Stunden für einen Rest
|
||||
und löscht sie.
|
||||
|
||||
Dagegen gibt es unter **Admin → Audit** den Knopf **Rescan storage**:
|
||||
|
||||
| Fall | Was der Abgleich tut |
|
||||
|---|---|
|
||||
| Datei da, kein Eintrag | legt den Eintrag an – samt Ordnerkette, Prüfsumme und Dateityp |
|
||||
| Eintrag da, Datei fort | entfernt den Eintrag, dazu Vorschaubild und frühere Fassungen |
|
||||
| Datei von außen ersetzt | übernimmt neue Größe und Prüfsumme |
|
||||
|
||||
**Zwei Schritte, nie einer.** *Check* liest nur und zeigt, was passieren
|
||||
würde; erst *Apply* ändert etwas. Was hier gelöscht wird, ist nicht der
|
||||
Papierkorb – es ist endgültig.
|
||||
|
||||
Was der Abgleich **nicht** anfasst: `.trash/` und `.versions/`. Beide liegen
|
||||
im selben Verzeichnisbaum, gehören aber dem System. Würden sie übernommen,
|
||||
tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu einer
|
||||
eigenen Datei.
|
||||
|
||||
### Für Sicherungen heißt das
|
||||
|
||||
Die Dateien liegen im Klartext unter `users/<konto>/home/<ordner>/<datei>` –
|
||||
eine Wiederherstellung einzelner Dateien aus einem Datei-Backup (etwa dem
|
||||
File-Restore des Proxmox Backup Servers) ist damit möglich, ohne die Datenbank
|
||||
mit zurückzuspielen. Der Weg ist: **Datei zurücklegen → Rescan → Check →
|
||||
Apply.** Ohne den Abgleich bliebe sie unsichtbar.
|
||||
|
||||
Für den umgekehrten Fall – ein Eintrag, dessen Datei aus der Sicherung
|
||||
*nicht* zurückkam – zeigt derselbe Lauf die verwaisten Einträge und räumt sie
|
||||
auf Wunsch weg.
|
||||
|
||||
---
|
||||
|
||||
## Kalender (CalDAV)
|
||||
|
||||
Adresse: `https://<deine-domain>/caldav/` – oder gar nichts eintippen und die
|
||||
|
||||
+40
@@ -135,6 +135,46 @@ diesem Bereich sucht:
|
||||
Freigabepfad lag außerhalb jeder Ratenbegrenzung; jeder Versuch rechnet
|
||||
Argon2, war also zugleich ein billiger Weg, den Server zu beschäftigen.
|
||||
|
||||
## Abgleich Platte ↔ Datenbank: Selbsttest per Skript
|
||||
|
||||
Der Abgleich (Admin → Audit → **Rescan storage**) übernimmt Dateien, die von
|
||||
außen dazugekommen sind – aus einer Sicherung zurückgeholt oder per SFTP
|
||||
abgelegt – und **entfernt Einträge**, deren Datei fort ist. Das zweite ist der
|
||||
Grund für diesen Test: Ein Fehler darin löscht Daten, die niemand vermisst,
|
||||
bis er sie braucht.
|
||||
|
||||
python3 scripts/rescan-selbsttest.py [BASIS-URL] [DATENPFAD]
|
||||
|
||||
Der Test schreibt selbst Dateien ins Datenverzeichnis – er muss also auf dem
|
||||
Rechner laufen, auf dem die Daten liegen (Vorgabe `~/aether-data`).
|
||||
|
||||
20 Prüfungen in fünf Gruppen: **Prüfen ändert nichts** (zweimal geprüft
|
||||
liefert dasselbe, nichts wird sichtbar), **Übernehmen** (Ordnerkette angelegt,
|
||||
Prüfsumme und Größe stimmen, Dateityp erkannt, Herunterladen liefert die
|
||||
Datei), **von außen ersetzt** (Größe und Prüfsumme werden berichtigt),
|
||||
**Datei ist fort** (verwaister Eintrag wird gefunden, bleibt beim Prüfen
|
||||
stehen, verschwindet erst beim Anwenden), **nicht anfassen** (Papierkorb und
|
||||
frühere Fassungen liegen im selben Baum und dürfen nicht übernommen werden –
|
||||
sonst tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu
|
||||
einer eigenen Datei), und **nur für Verwalter**.
|
||||
|
||||
### Warum es das überhaupt gibt
|
||||
|
||||
Der GC räumt Dateien weg, zu denen es keinen Eintrag gibt. Für Reste
|
||||
gelöschter Dateien ist das richtig – für eine per SFTP abgelegte Datei ist es
|
||||
falsch: Sie ist in Aether unsichtbar und nach 24 Stunden weg. Der Abgleich ist
|
||||
die Gegenrichtung und macht aus solchen Dateien richtige Einträge.
|
||||
|
||||
Zwei Fallen, die beim Bauen auffielen und die ein Test hier abdecken muss:
|
||||
|
||||
- **Der Dateityp muss aus Endung oder Inhalt kommen.** Beim Hochladen schickt
|
||||
der Browser ihn mit; eine per SFTP abgelegte Datei bringt niemanden mit, der
|
||||
ihn kennt. Dazu kommt: Das Alpine-Abbild hat keine `/etc/mime.types`, und
|
||||
Gos eingebaute Liste kennt nicht einmal `.txt`.
|
||||
- **Prüfsumme nicht überspringen.** Sie kostet einen vollständigen Lesevorgang
|
||||
je Datei, aber ein Eintrag ohne sie ist ein Eintrag, dessen Beschädigung
|
||||
beim Herunterladen niemand bemerkt.
|
||||
|
||||
## Notfall: Migration hängt fest ("Dirty database version")
|
||||
|
||||
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"aether/internal/gc"
|
||||
"aether/internal/office"
|
||||
appoidc "aether/internal/oidc"
|
||||
"aether/internal/rescan"
|
||||
"aether/internal/sharing"
|
||||
"aether/internal/storage"
|
||||
"aether/internal/tags"
|
||||
@@ -222,7 +223,10 @@ func main() {
|
||||
collector := gc.New(pool, blobStore)
|
||||
|
||||
adminStore := admin.NewStore(pool)
|
||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore)
|
||||
// Der Abgleich braucht dieselben Bausteine wie der GC, geht aber in die
|
||||
// andere Richtung: von der Platte in die Datenbank statt umgekehrt.
|
||||
rescanner := rescan.New(pool, fileStore, blobStore)
|
||||
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore, rescanner)
|
||||
|
||||
// docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann
|
||||
// schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go
|
||||
@@ -585,6 +589,7 @@ func main() {
|
||||
r.Get("/admin/audit", adminHandler.ListAudit)
|
||||
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
|
||||
r.Post("/admin/gc", adminHandler.RunGC)
|
||||
r.Post("/admin/rescan", adminHandler.Rescan)
|
||||
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
|
||||
// Platz, der in keinem Kontingent auftaucht.
|
||||
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/audit"
|
||||
"aether/internal/rescan"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
@@ -118,3 +119,47 @@ func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
response.JSON(w, http.StatusOK, usage)
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/rescan[?anwenden=true] – Platte mit der Datenbank
|
||||
// abgleichen.
|
||||
//
|
||||
// Die Gegenrichtung zum GC: Der räumt Dateien weg, zu denen es keinen Eintrag
|
||||
// gibt. Hier geht es um Dateien, die von außen dazugekommen sind – aus einer
|
||||
// Sicherung zurückgeholt oder per SFTP abgelegt. Ohne Abgleich sind sie in
|
||||
// Aether unsichtbar, und der GC würde sie nach 24 Stunden für Reste halten.
|
||||
//
|
||||
// Ohne Parameter wird **nur gelesen**. Erst `anwenden=true` ändert etwas.
|
||||
// Zwei Schritte, weil es um Dateien geht: Wer nicht vorher sehen kann, was
|
||||
// passieren wird, kann es auch nicht verhindern.
|
||||
func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireAdmin(r); !ok {
|
||||
response.Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if h.rescan == nil {
|
||||
response.Error(w, http.StatusServiceUnavailable, "rescan not available")
|
||||
return
|
||||
}
|
||||
|
||||
anwenden := r.URL.Query().Get("anwenden") == "true"
|
||||
var (
|
||||
bef *rescan.Report
|
||||
err error
|
||||
)
|
||||
if anwenden {
|
||||
bef, err = h.rescan.Apply(r.Context())
|
||||
} else {
|
||||
bef, err = h.rescan.Check(r.Context())
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "rescan failed")
|
||||
return
|
||||
}
|
||||
|
||||
if anwenden {
|
||||
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)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/gc"
|
||||
"aether/internal/rescan"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
@@ -30,10 +31,11 @@ type Handler struct {
|
||||
auditor *audit.Logger
|
||||
gc *gc.Collector
|
||||
files *files.Store
|
||||
rescan *rescan.Scanner
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler {
|
||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore}
|
||||
func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store, scanner *rescan.Scanner) *Handler {
|
||||
return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore, rescan: scanner}
|
||||
}
|
||||
|
||||
// logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// checksumOf liest die Datei einmal ganz und liefert Prüfsum und Größe.
|
||||
//
|
||||
// Ohne sie ginge es schneller – aber eine Datei ohne Prüfsum in der
|
||||
// Datenbank ist eine Datei, deren Beschädigung beim Herunterladen niemand
|
||||
// bemerkt. Das ist der Punkt, an dem Aether sonst gerade nicht spart.
|
||||
// Der Anfang der Datei genügt zur Typerkennung – http.DetectContentType
|
||||
// sieht sich ohnehin nur die ersten 512 Bytes an.
|
||||
const sniffLen = 512
|
||||
|
||||
func (s *Scanner) checksumOf(ctx context.Context, key string) (sum string, size int64, head []byte, err error) {
|
||||
rc, err := s.storage.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
h := sha256.New()
|
||||
// Die Datei wird für die Prüfsumme ohnehin einmal ganz gelesen; den
|
||||
// Anfang dabei mitzunehmen kostet nichts und erspart einen zweiten Weg
|
||||
// über die Platte.
|
||||
kopf := &bytes.Buffer{}
|
||||
n, err := io.Copy(h, io.TeeReader(rc, &begrenzterSchreiber{buf: kopf, rest: sniffLen}))
|
||||
if err != nil {
|
||||
return "", 0, nil, fmt.Errorf("lesen: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, kopf.Bytes(), nil
|
||||
}
|
||||
|
||||
// begrenzterSchreiber nimmt nur die ersten rest Bytes an und wirft den Rest
|
||||
// weg – ohne das läge bei einer 5-GB-Datei alles im Arbeitsspeicher.
|
||||
type begrenzterSchreiber struct {
|
||||
buf *bytes.Buffer
|
||||
rest int
|
||||
}
|
||||
|
||||
func (w *begrenzterSchreiber) Write(p []byte) (int, error) {
|
||||
if w.rest > 0 {
|
||||
n := min(w.rest, len(p))
|
||||
w.buf.Write(p[:n])
|
||||
w.rest -= n
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// mimeOf bestimmt den Dateityp – erst über die Endung, sonst über den
|
||||
// Inhalt.
|
||||
//
|
||||
// Der Umweg über den Inhalt ist nötig, nicht schmückend: Das Alpine-Abbild
|
||||
// bringt keine /etc/mime.types mit, und Gos eingebaute Liste kennt gerade
|
||||
// einmal ein Dutzend Endungen – .txt gehört nicht dazu. Ohne den zweiten
|
||||
// Anlauf bekäme jede Textdatei "application/octet-stream" und würde in der
|
||||
// Vorschau nicht angezeigt.
|
||||
//
|
||||
// Beim Hochladen fällt das nie auf, weil der Browser den Typ mitschickt.
|
||||
// Eine per SFTP abgelegte Datei bringt niemanden mit, der ihn kennt.
|
||||
func mimeOf(name string, head []byte) string {
|
||||
if t := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); t != "" {
|
||||
return ohneParameter(t)
|
||||
}
|
||||
if len(head) > 0 {
|
||||
return ohneParameter(http.DetectContentType(head))
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// "text/plain; charset=utf-8" → "text/plain", so wie es der Upload-Weg auch
|
||||
// speichert.
|
||||
func ohneParameter(t string) string {
|
||||
if i := strings.Index(t, ";"); i > 0 {
|
||||
return strings.TrimSpace(t[:i])
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// resolveOwner findet heraus, wem eine gefundene Datei gehört: das Konto
|
||||
// mit diesem Namen, oder die Gruppe mit dieser Kennung.
|
||||
func (s *Scanner) resolveOwner(ctx context.Context, root, owner string) (ownerID, groupID *uuid.UUID, err error) {
|
||||
if root == "groups" {
|
||||
id, err := uuid.Parse(owner)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("keine Gruppenkennung: %q", owner)
|
||||
}
|
||||
var exists bool
|
||||
if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM groups WHERE id = $1)`, id).Scan(&exists); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil, fmt.Errorf("Gruppe %s gibt es nicht", owner)
|
||||
}
|
||||
return nil, &id, nil
|
||||
}
|
||||
|
||||
var id uuid.UUID
|
||||
err = s.db.QueryRow(ctx, `SELECT id FROM users WHERE username = $1`, owner).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, fmt.Errorf("Konto %q gibt es nicht", owner)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &id, nil, nil
|
||||
}
|
||||
|
||||
// ensureFolders legt die Ordnerkette an, in der die Datei liegt, und liefert
|
||||
// den innersten Ordner. Ein Ordner, den es schon gibt, wird benutzt.
|
||||
func (s *Scanner) ensureFolders(ctx context.Context, ownerID, groupID *uuid.UUID, folderPath string) (*uuid.UUID, error) {
|
||||
if folderPath == "" {
|
||||
return nil, nil // liegt direkt im Wurzelverzeichnis
|
||||
}
|
||||
var parent *uuid.UUID
|
||||
for _, name := range strings.Split(folderPath, "/") {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var id uuid.UUID
|
||||
// COALESCE statt zweier Abfragen: Ein Ordner hängt entweder an einem
|
||||
// Konto oder an einer Gruppe, nie an beidem.
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id FROM folders
|
||||
WHERE COALESCE(owner_id, group_id) = COALESCE($1::uuid, $2::uuid)
|
||||
AND name = $3
|
||||
AND parent_id IS NOT DISTINCT FROM $4`,
|
||||
ownerID, groupID, name, parent,
|
||||
).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`INSERT INTO folders (owner_id, group_id, parent_id, name)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
ownerID, groupID, parent, name,
|
||||
).Scan(&id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Ordner %q: %w", name, err)
|
||||
}
|
||||
copy := id
|
||||
parent = ©
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
// adopt macht aus einer Datei auf der Platte einen Entry in Aether.
|
||||
func (s *Scanner) adopt(ctx context.Context, root, owner, folder, name, key string) error {
|
||||
ownerID, groupID, err := s.resolveOwner(ctx, root, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderID, err := s.ensureFolders(ctx, ownerID, groupID, folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, size, head, err := s.checksumOf(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(ctx,
|
||||
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
ownerID, groupID, folderID, name, mimeOf(name, head), size, sum, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Entry anlegen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// correct übernimmt Größe und Prüfsum einer von außen ersetzten Datei.
|
||||
func (s *Scanner) correct(ctx context.Context, id uuid.UUID, key string) error {
|
||||
sum, size, _, err := s.checksumOf(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(ctx,
|
||||
`UPDATE files SET size = $2, checksum = $3, updated_at = now() WHERE id = $1`,
|
||||
id, size, sum)
|
||||
return err
|
||||
}
|
||||
|
||||
// remove räumt einen Entry weg, dessen Datei nicht mehr exists ist – und mit
|
||||
// ihm alles, was nur wegen dieser Datei existierte: frühere Fassungen,
|
||||
// Vorschaubild, umgewandelte Ansicht.
|
||||
//
|
||||
// Die Reihenfolge ist Absicht: erst die Schlüssel der früheren Fassungen
|
||||
// sample, dann die Zeile löschen (die Kaskade nimmt die Versionszeilen mit),
|
||||
// dann die Bytes. Andersherum bliebe bei einem Abbruch eine Zeile stehen, die
|
||||
// auf nichts mehr zeigt – also genau das, was hier aufgeräumt werden soll.
|
||||
func (s *Scanner) remove(ctx context.Context, id uuid.UUID) error {
|
||||
rows, err := s.db.Query(ctx, `SELECT storage_key FROM file_versions WHERE file_id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("frühere Fassungen lesen: %w", err)
|
||||
}
|
||||
var versionKeys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
versionKeys = append(versionKeys, k)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(ctx, `DELETE FROM files WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("Entry remove: %w", err)
|
||||
}
|
||||
|
||||
// Ab hier ist der Entry weg; was jetzt noch scheitert, ist ein Rest auf
|
||||
// der Platte, den der GC beim nächsten Lauf ohnehin einsammelt.
|
||||
for _, k := range versionKeys {
|
||||
_ = s.storage.Delete(ctx, k)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, "thumbs/"+id.String()+".jpg")
|
||||
_ = s.storage.Delete(ctx, "previews/"+id.String()+".pdf")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Package rescan gleicht die Platte mit der Datenbank ab – in der Richtung,
|
||||
// die der GC nicht abdeckt.
|
||||
//
|
||||
// Der GC (internal/gc) räumt Dateien weg, zu denen es keinen Eintrag mehr
|
||||
// gibt. Das ist richtig für Reste gelöschter Dateien, aber falsch für
|
||||
// Dateien, die von außen dazugekommen sind: per SFTP abgelegt oder aus einer
|
||||
// Sicherung zurückgeholt. Ohne diesen Abgleich sind sie in Aether unsichtbar
|
||||
// – und der GC hält sie nach 24 Stunden für Reste.
|
||||
//
|
||||
// Deshalb zwei Schritte, nie einer: Check liest nur, Apply ändert. Ein Knopf,
|
||||
// der beides in einem tut, gibt niemandem die Gelegenheit, vorher hinzusehen.
|
||||
// Hier geht es um Dateien.
|
||||
package rescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
)
|
||||
|
||||
// Grenzen, damit ein Abgleich auf großem Bestand nicht endlos läuft und die
|
||||
// Antwort nicht ins Uferlose wächst.
|
||||
const (
|
||||
maxSamples = 200
|
||||
maxDuration = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
// KindNew: Datei liegt auf der Platte, kein Eintrag in der Datenbank.
|
||||
// Apply macht daraus einen Eintrag – sie gehört dem Konto, in dessen
|
||||
// Verzeichnis sie liegt.
|
||||
KindNew Kind = "new"
|
||||
// KindOrphaned: Eintrag da, Datei fort. Apply entfernt den Eintrag samt
|
||||
// Vorschaubild und früheren Fassungen.
|
||||
KindOrphaned Kind = "orphaned"
|
||||
// KindChanged: beides da, aber die Größe weicht ab – jemand hat die Datei
|
||||
// von außen ersetzt. Apply übernimmt Größe und Prüfsumme.
|
||||
KindChanged Kind = "changed"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Kind Kind `json:"kind"`
|
||||
StorageKey string `json:"storage_key"`
|
||||
// Path ist der Pfad, wie er in Aether erscheint – nicht der auf der Platte.
|
||||
Path string `json:"path"`
|
||||
Owner string `json:"owner"`
|
||||
Size int64 `json:"size"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
New int `json:"new"`
|
||||
Orphaned int `json:"orphaned"`
|
||||
Changed int `json:"changed"`
|
||||
Scanned int `json:"scanned"`
|
||||
Samples []Entry `json:"samples"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
|
||||
// Nur bei Apply gefüllt.
|
||||
Adopted int `json:"adopted,omitempty"`
|
||||
Removed int `json:"removed,omitempty"`
|
||||
Corrected int `json:"corrected,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type Scanner struct {
|
||||
db *pgxpool.Pool
|
||||
store *files.Store
|
||||
storage storage.Storage
|
||||
}
|
||||
|
||||
func New(db *pgxpool.Pool, store *files.Store, st storage.Storage) *Scanner {
|
||||
return &Scanner{db: db, store: store, storage: st}
|
||||
}
|
||||
|
||||
// known hält einen Datenbankeintrag so, wie der Abgleich ihn braucht: über
|
||||
// den Ablagepfad auffindbar.
|
||||
type known struct {
|
||||
id uuid.UUID
|
||||
size int64
|
||||
deleted bool
|
||||
seen bool
|
||||
}
|
||||
|
||||
func (s *Scanner) knownFiles(ctx context.Context) (map[string]*known, error) {
|
||||
rows, err := s.db.Query(ctx, `SELECT id, storage_key, size, is_deleted FROM files`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read known files: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]*known, 1024)
|
||||
for rows.Next() {
|
||||
var k known
|
||||
var key string
|
||||
if err := rows.Scan(&k.id, &key, &k.size, &k.deleted); err != nil {
|
||||
return nil, fmt.Errorf("scan file row: %w", err)
|
||||
}
|
||||
row := k
|
||||
out[key] = &row
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// splitKey liest aus einem Ablagepfad, wohin die Datei gehört:
|
||||
//
|
||||
// users/{name}/home/{ordner...}/{datei}
|
||||
// groups/{id}/home/{ordner...}/{datei}
|
||||
//
|
||||
// Alles andere (.trash, .versions, thumbs, previews, avatars, branding, tmp)
|
||||
// ist kein Nutzerinhalt und wird nicht angefasst.
|
||||
func splitKey(key string) (root, owner, folderPath, name string, ok bool) {
|
||||
parts := strings.Split(key, "/")
|
||||
if len(parts) < 4 {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
if parts[0] != "users" && parts[0] != "groups" {
|
||||
return "", "", "", "", false
|
||||
}
|
||||
if parts[2] != "home" {
|
||||
return "", "", "", "", false // .trash und .versions gehören dem System
|
||||
}
|
||||
name = parts[len(parts)-1]
|
||||
folderPath = strings.Join(parts[3:len(parts)-1], "/")
|
||||
return parts[0], parts[1], folderPath, name, name != ""
|
||||
}
|
||||
|
||||
// Check liest nur und ändert nichts – jederzeit gefahrlos.
|
||||
func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false) }
|
||||
|
||||
// Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste
|
||||
// Einträge.
|
||||
func (s *Scanner) Apply(ctx context.Context) (*Report, error) { return s.run(ctx, true) }
|
||||
|
||||
func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, maxDuration)
|
||||
defer cancel()
|
||||
|
||||
known, err := s.knownFiles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bewusst eine leere Liste statt nil: Als JSON wird daraus [] statt null,
|
||||
// und kein Aufrufer braucht dafür einen Sonderfall.
|
||||
rep := &Report{Samples: []Entry{}}
|
||||
sample := func(e Entry) {
|
||||
if len(rep.Samples) < maxSamples {
|
||||
rep.Samples = append(rep.Samples, e)
|
||||
}
|
||||
}
|
||||
|
||||
for _, prefix := range []string{"users/", "groups/"} {
|
||||
err := s.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
if ctx.Err() != nil {
|
||||
rep.Aborted = true
|
||||
return ctx.Err()
|
||||
}
|
||||
root, owner, folder, name, ok := splitKey(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rep.Scanned++
|
||||
|
||||
if row, exists := known[key]; exists {
|
||||
row.seen = true
|
||||
if row.deleted {
|
||||
return nil // liegt im Papierkorb – kein Widerspruch
|
||||
}
|
||||
if row.size != info.Size {
|
||||
rep.Changed++
|
||||
e := Entry{Kind: KindChanged, StorageKey: key,
|
||||
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||
if apply {
|
||||
if err := s.correct(ctx, row.id, key); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Corrected++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rep.New++
|
||||
e := Entry{Kind: KindNew, StorageKey: key,
|
||||
Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size}
|
||||
if apply {
|
||||
if err := s.adopt(ctx, root, owner, folder, name, key); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Adopted++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, fmt.Errorf("walk %s: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen
|
||||
// außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie
|
||||
// pauschal für verwaist zu erklären.
|
||||
for key, row := range known {
|
||||
if row.seen || row.deleted {
|
||||
continue
|
||||
}
|
||||
if _, _, _, _, ok := splitKey(key); !ok {
|
||||
if _, err := s.storage.Stat(ctx, key); err == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
rep.Orphaned++
|
||||
e := Entry{Kind: KindOrphaned, StorageKey: key, Path: key}
|
||||
if apply {
|
||||
if err := s.remove(ctx, row.id); err != nil {
|
||||
e.Reason = err.Error()
|
||||
rep.Errors++
|
||||
} else {
|
||||
rep.Removed++
|
||||
}
|
||||
}
|
||||
sample(e)
|
||||
}
|
||||
|
||||
return rep, nil
|
||||
}
|
||||
@@ -103,6 +103,32 @@ export interface PublicShareInfo {
|
||||
mime_type: string
|
||||
}
|
||||
|
||||
/** Ein einzelner Befund des Abgleichs zwischen Platte und Datenbank. */
|
||||
export interface RescanEntry {
|
||||
kind: 'new' | 'orphaned' | 'changed'
|
||||
storage_key: string
|
||||
/** Pfad, wie er in Aether erscheint – nicht der auf der Platte. */
|
||||
path: string
|
||||
owner: string
|
||||
size: number
|
||||
/** Gesetzt, wenn das Anwenden an diesem Eintrag gescheitert ist. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface RescanReport {
|
||||
new: number
|
||||
orphaned: number
|
||||
changed: number
|
||||
scanned: number
|
||||
samples: RescanEntry[]
|
||||
aborted?: boolean
|
||||
/** Nur nach dem Anwenden gefüllt. */
|
||||
adopted?: number
|
||||
removed?: number
|
||||
corrected?: number
|
||||
errors?: number
|
||||
}
|
||||
|
||||
export interface FileVersion {
|
||||
id: string
|
||||
file_id: string
|
||||
@@ -851,6 +877,10 @@ export const adminApi = {
|
||||
derivedStorage: () =>
|
||||
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
|
||||
|
||||
/** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */
|
||||
rescan: (apply = false) =>
|
||||
request<RescanReport>('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}`),
|
||||
|
||||
runCleanup: () => request<{
|
||||
scanned: number; orphans: number; deleted: number
|
||||
freed_bytes: number; skipped_young: number
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import RescanCard from './RescanCard'
|
||||
import { adminApi, type AuditEntry, type OcrStatus } from '../../api/client'
|
||||
import { formatBytes } from './ui'
|
||||
|
||||
@@ -176,6 +177,7 @@ function StorageCleanup() {
|
||||
return (
|
||||
<>
|
||||
<DerivedStorage items={derived} />
|
||||
<RescanCard />
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
|
||||
padding: '12px 14px', borderRadius: 10,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react'
|
||||
import { adminApi, type RescanReport, type RescanEntry } from '../../api/client'
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
||||
return `${(n / 1024 ** 3).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const kindLabel: Record<RescanEntry['kind'], string> = {
|
||||
new: 'New on disk',
|
||||
orphaned: 'Record without file',
|
||||
changed: 'Replaced on disk',
|
||||
}
|
||||
|
||||
const kindColor: Record<RescanEntry['kind'], string> = {
|
||||
new: 'var(--color-primary)',
|
||||
orphaned: 'var(--color-danger)',
|
||||
changed: 'var(--color-accent)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgleich zwischen Platte und Datenbank – die Gegenrichtung zur
|
||||
* Speicherbereinigung daneben.
|
||||
*
|
||||
* Bewusst in zwei Schritten: „Check" liest nur, „Apply" ändert. Ein einzelner
|
||||
* Knopf, der sofort loslegt, gäbe niemandem die Gelegenheit, vorher
|
||||
* hinzusehen – und hier werden Einträge angelegt und gelöscht.
|
||||
*/
|
||||
export default function RescanCard() {
|
||||
const [busy, setBusy] = useState<'check' | 'apply' | null>(null)
|
||||
const [report, setReport] = useState<RescanReport | null>(null)
|
||||
const [applied, setApplied] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const pending = report ? report.new + report.orphaned + report.changed : 0
|
||||
|
||||
async function run(apply: boolean) {
|
||||
setBusy(apply ? 'apply' : 'check')
|
||||
setError('')
|
||||
try {
|
||||
const r = await adminApi.rescan(apply)
|
||||
setReport(r)
|
||||
setApplied(apply)
|
||||
} catch {
|
||||
setError(apply ? 'Applying failed' : 'Check failed')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '12px 14px', borderRadius: 10,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Rescan storage
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2, lineHeight: 1.45 }}>
|
||||
{error ? <span style={{ color: 'var(--color-danger)' }}>{error}</span>
|
||||
: report ? summary(report, applied)
|
||||
: 'Finds files placed on disk from outside — restored from a backup or copied in over SFTP — '
|
||||
+ 'and records whose file is gone. Checking changes nothing.'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => run(false)}
|
||||
disabled={busy !== null}
|
||||
style={{
|
||||
padding: '7px 16px', borderRadius: 8, flexShrink: 0,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface)',
|
||||
color: 'var(--color-text)', fontSize: 12, fontWeight: 600,
|
||||
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{busy === 'check' ? 'Checking…' : 'Check'}
|
||||
</button>
|
||||
{report && !applied && pending > 0 && (
|
||||
<button
|
||||
onClick={() => run(true)}
|
||||
disabled={busy !== null}
|
||||
style={{
|
||||
padding: '7px 16px', borderRadius: 8, flexShrink: 0, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff', fontSize: 12, fontWeight: 600,
|
||||
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{busy === 'apply' ? 'Applying…' : `Apply (${pending})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{report && report.samples.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 10, maxHeight: 260, overflowY: 'auto',
|
||||
border: '1px solid var(--color-border)', borderRadius: 8,
|
||||
background: 'var(--color-surface)',
|
||||
}}>
|
||||
{report.samples.map(e => (
|
||||
<div key={e.storage_key} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '6px 10px',
|
||||
borderBottom: '1px solid var(--color-border)', fontSize: 11.5,
|
||||
}}>
|
||||
<span style={{
|
||||
flexShrink: 0, width: 128, color: kindColor[e.kind], fontWeight: 600,
|
||||
}}>
|
||||
{kindLabel[e.kind]}
|
||||
</span>
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={e.storage_key}>
|
||||
{e.path}
|
||||
</span>
|
||||
{e.reason
|
||||
? <span style={{ color: 'var(--color-danger)', flexShrink: 0 }}>{e.reason}</span>
|
||||
: <span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||
{e.kind === 'orphaned' ? '' : formatBytes(e.size)}
|
||||
</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function summary(r: RescanReport, applied: boolean): string {
|
||||
const pending = r.new + r.orphaned + r.changed
|
||||
if (applied) {
|
||||
const teile = [
|
||||
r.adopted ? `${r.adopted} adopted` : '',
|
||||
r.corrected ? `${r.corrected} corrected` : '',
|
||||
r.removed ? `${r.removed} records removed` : '',
|
||||
r.errors ? `${r.errors} failed` : '',
|
||||
].filter(Boolean)
|
||||
return teile.length ? teile.join(', ') : 'Nothing changed'
|
||||
}
|
||||
if (pending === 0) {
|
||||
return `Everything matches — ${r.scanned} stored files checked`
|
||||
}
|
||||
const teile = [
|
||||
r.new ? `${r.new} new on disk` : '',
|
||||
r.changed ? `${r.changed} replaced` : '',
|
||||
r.orphaned ? `${r.orphaned} records without a file` : '',
|
||||
].filter(Boolean)
|
||||
return `${teile.join(', ')} (${r.scanned} checked)`
|
||||
+ (r.aborted ? ' — stopped early, run again' : '')
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Selbsttest des Abgleichs zwischen Platte und Datenbank.
|
||||
|
||||
python3 scripts/rescan-selbsttest.py [BASIS-URL] [DATENPFAD] [BENUTZER] [PASSWORT]
|
||||
|
||||
Der Abgleich (Admin → Speicher → „Rescan storage") übernimmt Dateien, die von
|
||||
außen dazugekommen sind, und **entfernt Einträge**, deren Datei fort ist. Das
|
||||
zweite ist der Grund für diesen Test: Ein Fehler darin löscht Daten, die
|
||||
niemand vermisst, bis er sie braucht.
|
||||
|
||||
Der Test schreibt selbst Dateien in das Datenverzeichnis – er muss also auf
|
||||
dem Rechner laufen, auf dem die Daten liegen, und räumt am Ende auf.
|
||||
"""
|
||||
import hashlib
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/")
|
||||
DATEN = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser("~/aether-data")
|
||||
USER = sys.argv[3] if len(sys.argv) > 3 else "admin"
|
||||
PASSWORT = sys.argv[4] if len(sys.argv) > 4 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}")
|
||||
|
||||
|
||||
jar = http.cookiejar.CookieJar()
|
||||
OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
|
||||
|
||||
def api(method, path, body=None, headers=None, opener=None, roh=False):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
hdrs = dict(headers or {})
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
|
||||
try:
|
||||
with (opener or OPENER).open(req) as r:
|
||||
payload = r.read()
|
||||
if roh:
|
||||
return r.status, payload
|
||||
text = payload.decode()
|
||||
return r.status, (json.loads(text).get("data") if text else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, None
|
||||
|
||||
|
||||
st, login = api("POST", "/api/v1/auth/login", {"username": USER, "password": PASSWORT})
|
||||
assert st == 200, f"Anmeldung fehlgeschlagen: {st}"
|
||||
CSRF = {"X-CSRF-Token": login["csrf_token"]}
|
||||
|
||||
HOME = os.path.join(DATEN, "users", USER, "home")
|
||||
assert os.path.isdir(HOME), f"Datenverzeichnis nicht gefunden: {HOME}"
|
||||
|
||||
MARKE = "selbsttest-" + uuid.uuid4().hex[:8]
|
||||
ORDNER = os.path.join(HOME, MARKE)
|
||||
angelegt = []
|
||||
|
||||
|
||||
def aufraeumen():
|
||||
shutil.rmtree(ORDNER, ignore_errors=True)
|
||||
# Was der Abgleich angelegt hat, wieder wegräumen – über die
|
||||
# Schnittstelle, damit auch die Einträge verschwinden.
|
||||
_, lst = api("GET", "/api/v1/files")
|
||||
for f in (lst or {}).get("folders", []):
|
||||
if f["name"] == MARKE:
|
||||
api("DELETE", f"/api/v1/folders/{f['id']}", None, CSRF)
|
||||
api("POST", "/api/v1/admin/rescan?anwenden=true", {}, CSRF)
|
||||
|
||||
|
||||
def schreiben(name, inhalt: bytes):
|
||||
os.makedirs(ORDNER, exist_ok=True)
|
||||
pfad = os.path.join(ORDNER, name)
|
||||
with open(pfad, "wb") as f:
|
||||
f.write(inhalt)
|
||||
return pfad
|
||||
|
||||
|
||||
def rescan(anwenden=False):
|
||||
st, r = api("POST", f"/api/v1/admin/rescan{'?anwenden=true' if anwenden else ''}", {}, CSRF)
|
||||
assert st == 200, f"Abgleich fehlgeschlagen: {st}"
|
||||
return r
|
||||
|
||||
|
||||
def eigene(r):
|
||||
return [e for e in (r.get("samples") or [])
|
||||
if MARKE in e["path"] or MARKE in e["storage_key"]]
|
||||
|
||||
|
||||
print(f"Abgleich-Selbsttest gegen {BASE}\nDaten: {DATEN}\n")
|
||||
|
||||
try:
|
||||
print("── Prüfen ändert nichts ──")
|
||||
INHALT = b"aus der Sicherung zurueckgeholt\n"
|
||||
schreiben("beleg.txt", INHALT)
|
||||
|
||||
vorher = rescan()
|
||||
meine = eigene(vorher)
|
||||
check("neue Datei wird gefunden",
|
||||
len(meine) == 1 and meine[0]["kind"] == "new", meine)
|
||||
|
||||
_, lst = api("GET", "/api/v1/files")
|
||||
check("aber noch nicht sichtbar",
|
||||
all(f["name"] != MARKE for f in lst["folders"]), lst["folders"])
|
||||
|
||||
nochmal = rescan()
|
||||
check("zweimal prüfen liefert dasselbe",
|
||||
len(eigene(nochmal)) == 1, eigene(nochmal))
|
||||
|
||||
print("\n── Übernehmen ──")
|
||||
r = rescan(anwenden=True)
|
||||
check("meldet eine Übernahme", r["adopted"] >= 1 and r.get("errors", 0) == 0,
|
||||
{k: v for k, v in r.items() if k != "samples"})
|
||||
|
||||
_, lst = api("GET", "/api/v1/files")
|
||||
ordner = {f["name"]: f for f in lst["folders"]}
|
||||
check("Ordner ist angelegt", MARKE in ordner, list(ordner))
|
||||
_, drin = api("GET", f"/api/v1/files?folder_id={ordner[MARKE]['id']}")
|
||||
datei = next((f for f in drin["files"] if f["name"] == "beleg.txt"), None)
|
||||
check("Datei ist sichtbar", datei is not None, drin["files"])
|
||||
# Ohne richtige Prüfsumme merkt niemand, wenn die Datei später kippt –
|
||||
# genau die Zusage, die Aether sonst überall einhält.
|
||||
check("Prüfsumme stimmt",
|
||||
datei and datei["checksum"] == hashlib.sha256(INHALT).hexdigest(), datei)
|
||||
check("Größe stimmt", datei and datei["size"] == len(INHALT), datei)
|
||||
# Beim Hochladen schickt der Browser den Typ mit; hier gibt es niemanden,
|
||||
# der ihn kennt – er muss aus Endung oder Inhalt kommen.
|
||||
check("Dateityp erkannt", datei and datei["mime_type"] == "text/plain",
|
||||
datei["mime_type"] if datei else None)
|
||||
|
||||
st, inhalt = api("GET", f"/api/v1/files/{datei['id']}/download", None, None, roh=True)
|
||||
check("Herunterladen liefert die Datei", st == 200 and inhalt == INHALT, inhalt[:40])
|
||||
|
||||
check("danach ist nichts mehr offen", len(eigene(rescan())) == 0, eigene(rescan()))
|
||||
|
||||
print("\n── Von außen ersetzt ──")
|
||||
NEU = b"jemand hat sie per SFTP ueberschrieben\n"
|
||||
schreiben("beleg.txt", NEU)
|
||||
r = rescan()
|
||||
meine = eigene(r)
|
||||
check("Änderung wird bemerkt", len(meine) == 1 and meine[0]["kind"] == "changed", meine)
|
||||
|
||||
rescan(anwenden=True)
|
||||
_, drin = api("GET", f"/api/v1/files?folder_id={ordner[MARKE]['id']}")
|
||||
datei = next(f for f in drin["files"] if f["name"] == "beleg.txt")
|
||||
check("Größe berichtigt", datei["size"] == len(NEU), datei["size"])
|
||||
check("Prüfsumme berichtigt",
|
||||
datei["checksum"] == hashlib.sha256(NEU).hexdigest(), datei["checksum"])
|
||||
|
||||
print("\n── Datei ist fort ──")
|
||||
os.remove(os.path.join(ORDNER, "beleg.txt"))
|
||||
r = rescan()
|
||||
meine = eigene(r)
|
||||
check("verwaister Eintrag wird gefunden",
|
||||
len(meine) == 1 and meine[0]["kind"] == "orphaned", meine)
|
||||
|
||||
_, drin = api("GET", f"/api/v1/files?folder_id={ordner[MARKE]['id']}")
|
||||
check("solange nur geprüft wird, bleibt der Eintrag",
|
||||
any(f["name"] == "beleg.txt" for f in drin["files"]), drin["files"])
|
||||
|
||||
rescan(anwenden=True)
|
||||
_, drin = api("GET", f"/api/v1/files?folder_id={ordner[MARKE]['id']}")
|
||||
check("nach dem Anwenden ist er weg",
|
||||
all(f["name"] != "beleg.txt" for f in drin["files"]), drin["files"])
|
||||
|
||||
print("\n── Was der Abgleich **nicht** anfassen darf ──")
|
||||
# Papierkorb und frühere Fassungen liegen im selben Baum. Würden sie
|
||||
# übernommen, tauchten gelöschte Dateien wieder auf und jede frühere
|
||||
# Fassung würde zu einer eigenen Datei.
|
||||
trash = os.path.join(DATEN, "users", USER, ".trash")
|
||||
versions = os.path.join(DATEN, "users", USER, ".versions")
|
||||
os.makedirs(trash, exist_ok=True)
|
||||
os.makedirs(versions, exist_ok=True)
|
||||
t1 = os.path.join(trash, MARKE + "-papierkorb.txt")
|
||||
v1 = os.path.join(versions, MARKE + "-fassung")
|
||||
open(t1, "wb").write(b"im Papierkorb\n")
|
||||
open(v1, "wb").write(b"fruehere Fassung\n")
|
||||
r = rescan()
|
||||
check("Papierkorb bleibt unangetastet",
|
||||
not any("papierkorb" in e["storage_key"] for e in r["samples"]), r["samples"][:3])
|
||||
check("frühere Fassungen bleiben unangetastet",
|
||||
not any(".versions" in e["storage_key"] for e in r["samples"]), r["samples"][:3])
|
||||
os.remove(t1)
|
||||
os.remove(v1)
|
||||
|
||||
print("\n── Nur für Verwalter ──")
|
||||
fremd_name = "rescan-selbsttest-fremd"
|
||||
fremd_pw = "Fremd!" + uuid.uuid4().hex[:12]
|
||||
st, fremd = api("POST", "/api/v1/admin/users",
|
||||
{"username": fremd_name, "display_name": "Fremd",
|
||||
"password": fremd_pw, "is_admin": False, "quota_bytes": 0}, CSRF)
|
||||
if st in (200, 201):
|
||||
jar2 = http.cookiejar.CookieJar()
|
||||
op2 = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar2))
|
||||
_, l2 = api("POST", "/api/v1/auth/login",
|
||||
{"username": fremd_name, "password": fremd_pw}, None, op2)
|
||||
st, _ = api("POST", "/api/v1/admin/rescan", {},
|
||||
{"X-CSRF-Token": l2["csrf_token"]}, op2)
|
||||
check("gewöhnliches Konto darf nicht", st == 403, st)
|
||||
api("DELETE", f"/api/v1/admin/users/{fremd['id']}", None, CSRF)
|
||||
|
||||
finally:
|
||||
aufraeumen()
|
||||
|
||||
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
|
||||
if fail:
|
||||
print("Fehlgeschlagen: " + ", ".join(fail))
|
||||
sys.exit(1 if fail else 0)
|
||||
@@ -101,7 +101,7 @@ admin, ADMIN_CSRF = anmelden(USER, PASSWORT)
|
||||
|
||||
# ── Zweitnutzer: nur mit ihm lässt sich prüfen, dass fremde Dateien fremd
|
||||
# bleiben. Ein Test, der nur das eigene Konto kennt, kann das nicht.
|
||||
fremd_name = "selbsttest-fremd"
|
||||
fremd_name = "selbsttest-fremd-" + uuid.uuid4().hex[:6]
|
||||
fremd_pw = "Fremd!" + uuid.uuid4().hex[:12]
|
||||
st, fremd = ruf(admin, "POST", "/api/v1/admin/users",
|
||||
{"username": fremd_name, "display_name": "Selbsttest Fremd",
|
||||
@@ -119,6 +119,12 @@ def aufraeumen():
|
||||
ruf(admin, "DELETE", f"/api/v1/shares/{lid}", None, ADMIN_CSRF)
|
||||
if eigen:
|
||||
ruf(admin, "DELETE", f"/api/v1/files/{eigen['id']}", None, ADMIN_CSRF)
|
||||
# Löschen heißt Papierkorb – ein Test, der nur so weit aufräumt, hinterlässt
|
||||
# bei jedem Lauf eine Leiche mehr. Also auch dort hinterher.
|
||||
_, muell = ruf(admin, "GET", "/api/v1/trash")
|
||||
for f in muell or []:
|
||||
if f.get("name", "").startswith("selbsttest-"):
|
||||
ruf(admin, "DELETE", f"/api/v1/trash/{f['id']}", None, ADMIN_CSRF)
|
||||
ruf(admin, "DELETE", f"/api/v1/admin/users/{FREMD_ID}", None, ADMIN_CSRF)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user