Files
Aether/backend/internal/rescan/apply.go
T
marcandClaude Opus 5 944b2c63e2 feat(office): mobileEdit am Telefon, PDF-Umweg entfällt
Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices
Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war
falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und
OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe
Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche.
`EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit
in der Tabelle und wurde nie abgerufen.

Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales
Fenster auf dem Schreibtisch ist immer noch eine Maus.

Mit dem Anlass fällt die Notlösung: preview.go (229 Zeilen), convert.go (271)
samt Test, files.preview_version (Migration 000026), die Sonderabfrage im GC,
die Route und dreizehn Fundstellen im Frontend. convert.go hatte keinen
anderen Aufrufer – die gesamte Umwandlung nach PDF existierte allein für
diese Ansicht. Wer ein PDF braucht, exportiert es im Editor. Die Dateien
unter previews/ sammelt der GC ein.

Zwei Fehler, die dabei sichtbar wurden:

fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim
Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose
ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte,
den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste
Schritte verschluckt genau diese popstate.

fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und
lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am
Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst`
standen nie drin; das war die Asymmetrie.

Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die
Ankündigung nennt 5) und die Android-App als Idee.

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

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

236 lines
7.3 KiB
Go
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.
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 = &copy
}
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")
return nil
}