diff --git a/TESTING.md b/TESTING.md index 2ac74ae..be3ea6b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1442,6 +1442,34 @@ könnte. - [ ] Am Trackpad gegenprüfen (Zwei-Finger-Zoom meldet sich als Strg + Rad) +## Hochkant-Fotos lagen im Vorschaubild quer (24.08.2026) + +Kameras speichern die Bildpunkte fast immer quer und legen die Drehung als +EXIF-Notiz daneben. Browser befolgen sie von selbst – Go nicht. Gemessen an +einem Testbild mit 400×200 Bildpunkten und Drehung 6: + +| | vorher | nachher | +|---|---|---| +| große Ansicht (Browser) | 200×400 hochkant ✅ | unverändert | +| Vorschaubild (Server) | **256×128 quer** ❌ | 128×256 hochkant ✅ | + +Alle acht Fälle, beide Byte-Reihenfolgen (II und MM): + +- Drehung 1 und 3 bleiben quer, 6 und 8 werden hochkant – Seitenverhältnis + allein genügt aber nicht als Beweis, deshalb ein gelber Balken als Marke: + bei 6 landet er **oben**, bei 8 **unten**. Beide Bilder angesehen. +- Leere Datei, Text statt JPEG, abgeschnittener EXIF-Block, Drehung 0 und 99 + ergeben alle 1 („nicht drehen") statt eines Fehlers. +- Bei Drehung 1 entsteht keine Kopie des Bildes. + +Bestehende Vorschaubilder ändern sich davon **nicht** – sie liegen fertig im +Speicher. Dafür neu: Admin → Audit → „Rebuild image previews". +Gemessen: 6 Bilder, 6 neu erzeugt, 0 Fehler; das quer liegende Testbild war +danach hochkant. + +- [ ] Auf der produktiven Instanz einmal „Rebuild" drücken und mit echten + Handy-Fotos gegenprüfen + ## Automatisiert geprüft (22.08.2026) Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index bbbc98a..a745a4b 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -529,6 +529,9 @@ func main() { r.Get("/admin/audit", adminHandler.ListAudit) r.Get("/admin/audit/actions", adminHandler.ListAuditActions) r.Post("/admin/gc", adminHandler.RunGC) + // Vorschaubilder neu erzeugen – nötig für alles, was vor der + // Auswertung der EXIF-Drehung hochgeladen wurde. + r.Post("/admin/thumbnails/rebuild", adminHandler.RebuildThumbnails) // Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann // mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert, // wäre für die Oberfläche nicht von einem Fehler zu unterscheiden. diff --git a/backend/internal/admin/thumbnails_handler.go b/backend/internal/admin/thumbnails_handler.go new file mode 100644 index 0000000..44ac0c5 --- /dev/null +++ b/backend/internal/admin/thumbnails_handler.go @@ -0,0 +1,71 @@ +package admin + +import ( + "fmt" + "net/http" + + "aether/internal/audit" + "aether/internal/thumbnails" + "aether/pkg/response" +) + +/* +POST /api/v1/admin/thumbnails/rebuild – Vorschaubilder neu erzeugen. + +Anlass: Bis heute wurde die EXIF-Drehung beim Erzeugen nicht ausgewertet. +Jedes hochkant aufgenommene Foto liegt im bestehenden Bestand deshalb quer, +während dasselbe Bild in der großen Ansicht aufrecht steht – der Browser +befolgt die Notiz von selbst, der Server tat es nicht. + +Beim Hochladen entstehen Vorschaubilder von allein; für das, was schon da ist, +braucht es diesen einen Anstoß. Er läuft im Vordergrund und meldet Zahlen: +Ein Vorgang, der den halben Bestand neu schreibt, soll nicht stumm im +Hintergrund verschwinden. +*/ +func (h *Handler) RebuildThumbnails(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(r); !ok { + response.Error(w, http.StatusForbidden, "admin required") + return + } + + list, err := h.files.ImageFiles(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "could not list images") + return + } + + var rebuilt, failed int + for _, f := range list { + if !thumbnails.SupportedMIME(f.MimeType) { + continue + } + rc, err := h.storage.Get(r.Context(), f.StorageKey) + if err != nil { + failed++ + continue + } + err = thumbnails.Generate(r.Context(), h.storage, f.ID.String(), f.MimeType, rc) + rc.Close() + if err != nil { + failed++ + continue + } + // Auch setzen, wenn es schon stand: Dateien, bei denen die Erzeugung + // beim Hochladen fehlschlug, bekommen so nachträglich eines. + if err := h.files.UpdateThumbnail(r.Context(), f.ID, true); err != nil { + failed++ + continue + } + rebuilt++ + } + + h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "", + fmt.Sprintf("thumbnails rebuilt: %d of %d images, %d failed", + rebuilt, len(list), failed)) + + response.JSON(w, http.StatusOK, map[string]any{ + "images": len(list), + "rebuilt": rebuilt, + "failed": failed, + }) +} diff --git a/backend/internal/avatar/avatar.go b/backend/internal/avatar/avatar.go index 1a1c73a..50e8335 100644 --- a/backend/internal/avatar/avatar.go +++ b/backend/internal/avatar/avatar.go @@ -30,6 +30,7 @@ import ( _ "golang.org/x/image/webp" "aether/internal/auth" + "aether/internal/imageutil" "aether/internal/storage" "aether/pkg/response" ) @@ -45,10 +46,22 @@ const MaxBytes = 4 << 20 // Process liest ein Bild und gibt ein quadratisches PNG zurück. func Process(r io.Reader) ([]byte, error) { - src, _, err := image.Decode(io.LimitReader(r, MaxBytes)) + // Dasselbe wie beim Vorschaubild: Der Anfang trägt die EXIF-Drehung, und + // ein Profilbild kommt oft direkt aus einer Telefonkamera – also quer + // gespeichert mit einer Notiz daneben. + head := make([]byte, imageutil.HeadBytes) + n, herr := io.ReadFull(io.LimitReader(r, MaxBytes), head) + if herr != nil && herr != io.EOF && herr != io.ErrUnexpectedEOF { + return nil, fmt.Errorf("bild nicht lesbar: %w", herr) + } + head = head[:n] + + src, _, err := image.Decode(io.MultiReader( + bytes.NewReader(head), io.LimitReader(r, MaxBytes-int64(n)))) if err != nil { return nil, fmt.Errorf("bild nicht lesbar: %w", err) } + src = imageutil.Apply(src, imageutil.Orientation(head)) b := src.Bounds() if b.Dx() <= 0 || b.Dy() <= 0 { return nil, fmt.Errorf("bild ist leer") diff --git a/backend/internal/files/store.go b/backend/internal/files/store.go index 5e6c5d3..c9a749c 100644 --- a/backend/internal/files/store.go +++ b/backend/internal/files/store.go @@ -533,3 +533,29 @@ func isUniqueViolation(err error) bool { } return false } + +// ImageFiles nennt alle nicht gelöschten Dateien, aus denen ein Vorschaubild +// entstehen kann – für das Neuerzeugen von Hand. +// +// Ohne Papierkorb: Für eine Datei, die man weggeworfen hat, lohnt keine Runde +// durch den Dekodierer. +func (s *Store) ImageFiles(ctx context.Context) ([]*File, error) { + rows, err := s.db.Query(ctx, + `SELECT `+fileCols+` + FROM files + WHERE is_deleted = false AND mime_type LIKE 'image/%' + ORDER BY created_at`) + if err != nil { + return nil, fmt.Errorf("query image files: %w", err) + } + defer rows.Close() + var out []*File + for rows.Next() { + var f File + if err := scanFile(rows, &f); err != nil { + return nil, fmt.Errorf("scan image file: %w", err) + } + out = append(out, &f) + } + return out, rows.Err() +} diff --git a/backend/internal/imageutil/orientation.go b/backend/internal/imageutil/orientation.go new file mode 100644 index 0000000..f73c7e3 --- /dev/null +++ b/backend/internal/imageutil/orientation.go @@ -0,0 +1,144 @@ +// Package imageutil enthält, was mehrere Bildpfade teilen: heute die +// EXIF-Drehung. +package imageutil + +import ( + "encoding/binary" + "image" +) + +// HeadBytes ist die Menge, die zum Finden der Drehung gelesen werden muss. +// Der EXIF-Block steht unmittelbar hinter dem Dateianfang; 64 KB decken auch +// Kameras ab, die ein Vorschaubild mit hineinlegen. +const HeadBytes = 64 << 10 + +/* +Orientation liest die EXIF-Drehung (1–8) aus dem Anfang einer JPEG-Datei. +Alles Unklare ergibt 1, also „nicht drehen" – ein unlesbarer Block ist kein +Grund, ein Bild zu verweigern. + +**Warum von Hand statt mit einer Bibliothek:** Gebraucht wird genau ein Feld. +Der Weg dorthin ist kurz und liegt fest: JPEG-Segmente durchgehen bis APP1, +dort "Exif\0\0", dahinter ein TIFF-Kopf, darin der Verzeichniseintrag mit der +Kennung 0x0112. + +**Warum es überhaupt gebraucht wird:** Kameras und Telefone speichern die +Bildpunkte fast immer quer und legen die Drehung als Notiz daneben. Browser +befolgen sie von selbst – Go nicht. Ohne diese Auswertung liegt jedes hochkant +aufgenommene Foto im Vorschaubild auf der Seite, während dasselbe Foto in der +großen Ansicht aufrecht steht. +*/ +func Orientation(head []byte) int { + if len(head) < 4 || head[0] != 0xFF || head[1] != 0xD8 { // SOI + return 1 + } + i := 2 + for i+4 <= len(head) { + if head[i] != 0xFF { + return 1 // kein Segmentanfang mehr – hier ist nichts zu holen + } + marker := head[i+1] + // Bildinhalt erreicht: ab hier kommt kein EXIF mehr. + if marker == 0xDA || marker == 0xD9 { + return 1 + } + size := int(binary.BigEndian.Uint16(head[i+2 : i+4])) + if size < 2 || i+2+size > len(head) { + return 1 + } + if marker == 0xE1 { + if o, ok := orientationInAPP1(head[i+4 : i+2+size]); ok { + return o + } + } + i += 2 + size + } + return 1 +} + +func orientationInAPP1(seg []byte) (int, bool) { + const hdr = "Exif\x00\x00" + if len(seg) < len(hdr)+8 || string(seg[:len(hdr)]) != hdr { + return 0, false + } + tiff := seg[len(hdr):] + + var bo binary.ByteOrder + switch { + case tiff[0] == 'I' && tiff[1] == 'I': + bo = binary.LittleEndian + case tiff[0] == 'M' && tiff[1] == 'M': + bo = binary.BigEndian + default: + return 0, false + } + if bo.Uint16(tiff[2:4]) != 42 { + return 0, false + } + off := int(bo.Uint32(tiff[4:8])) + if off < 8 || off+2 > len(tiff) { + return 0, false + } + count := int(bo.Uint16(tiff[off : off+2])) + entry := off + 2 + for n := 0; n < count; n++ { + if entry+12 > len(tiff) { + return 0, false + } + if bo.Uint16(tiff[entry:entry+2]) == 0x0112 { + // Der Wert ist ein SHORT und steht deshalb im Eintrag selbst, + // nicht anderswo in der Datei. + v := int(bo.Uint16(tiff[entry+8 : entry+10])) + if v >= 1 && v <= 8 { + return v, true + } + return 0, false + } + entry += 12 + } + return 0, false +} + +// Apply dreht und spiegelt ein Bild so, wie die EXIF-Notiz es vorsieht. +// Bei 1 (und allem Unbekannten) kommt das Bild unverändert zurück – dann +// entsteht auch keine Kopie. +func Apply(img image.Image, orientation int) image.Image { + if orientation <= 1 || orientation > 8 { + return img + } + b := img.Bounds() + w, h := b.Dx(), b.Dy() + + // Die vier Fälle ab 5 vertauschen Breite und Höhe. + outW, outH := w, h + if orientation >= 5 { + outW, outH = h, w + } + dst := image.NewRGBA(image.Rect(0, 0, outW, outH)) + + // Für jeden Zielpunkt den Quellpunkt bestimmen. Andersherum (Quelle → + // Ziel) bliebe bei ungeraden Größen gelegentlich eine Lücke. + for y := 0; y < outH; y++ { + for x := 0; x < outW; x++ { + var sx, sy int + switch orientation { + case 2: // waagerecht gespiegelt + sx, sy = w-1-x, y + case 3: // 180 Grad + sx, sy = w-1-x, h-1-y + case 4: // senkrecht gespiegelt + sx, sy = x, h-1-y + case 5: // an der Hauptdiagonalen gespiegelt + sx, sy = y, x + case 6: // 90 Grad im Uhrzeigersinn + sx, sy = y, h-1-x + case 7: // an der Nebendiagonalen gespiegelt + sx, sy = w-1-y, h-1-x + case 8: // 90 Grad gegen den Uhrzeigersinn + sx, sy = w-1-y, x + } + dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy)) + } + } + return dst +} diff --git a/backend/internal/imageutil/orientation_test.go b/backend/internal/imageutil/orientation_test.go new file mode 100644 index 0000000..24e26e5 --- /dev/null +++ b/backend/internal/imageutil/orientation_test.go @@ -0,0 +1,148 @@ +package imageutil + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/jpeg" + "testing" +) + +// jpegMitDrehung baut eine echte JPEG-Datei mit EXIF-Block. Von Hand, damit +// der Test keine Abhängigkeit braucht und genau das prüft, was Kameras +// schreiben: APP1 direkt hinter dem Dateianfang. +func jpegMitDrehung(t *testing.T, o uint16, big bool) []byte { + t.Helper() + var body bytes.Buffer + if err := jpeg.Encode(&body, image.NewGray(image.Rect(0, 0, 8, 8)), nil); err != nil { + t.Fatal(err) + } + raw := body.Bytes() // beginnt mit FFD8 + + var bo binary.AppendByteOrder = binary.LittleEndian + order := []byte{'I', 'I'} + if big { + bo = binary.BigEndian + order = []byte{'M', 'M'} + } + tif := make([]byte, 0, 26) + tif = append(tif, order...) + tif = bo.AppendUint16(tif, 42) + tif = bo.AppendUint32(tif, 8) // Verzeichnis beginnt direkt dahinter + tif = bo.AppendUint16(tif, 1) // ein Eintrag + tif = bo.AppendUint16(tif, 0x0112) + tif = bo.AppendUint16(tif, 3) // SHORT + tif = bo.AppendUint32(tif, 1) // ein Wert + tif = bo.AppendUint16(tif, o) + tif = append(tif, 0, 0) // Auffüllung auf vier Byte + tif = bo.AppendUint32(tif, 0) // kein weiteres Verzeichnis + + payload := append([]byte("Exif\x00\x00"), tif...) + seg := []byte{0xFF, 0xE1} + seg = binary.BigEndian.AppendUint16(seg, uint16(len(payload)+2)) + seg = append(seg, payload...) + + out := append([]byte{}, raw[:2]...) // SOI + out = append(out, seg...) + return append(out, raw[2:]...) +} + +func TestOrientationLiestAlleFaelle(t *testing.T) { + for o := uint16(1); o <= 8; o++ { + for _, big := range []bool{false, true} { + got := Orientation(jpegMitDrehung(t, o, big)) + if got != int(o) { + name := "II" + if big { + name = "MM" + } + t.Errorf("%s Drehung %d gelesen als %d", name, o, got) + } + } + } +} + +// Was keine Drehung trägt oder kaputt ist, muss 1 ergeben – niemals einen +// Fehler und niemals einen Zufallswert. +func TestOrientationBleibtBeiEinsWennUnklar(t *testing.T) { + var ohne bytes.Buffer + if err := jpeg.Encode(&ohne, image.NewGray(image.Rect(0, 0, 4, 4)), nil); err != nil { + t.Fatal(err) + } + mitDrehung := jpegMitDrehung(t, 6, false) + + faelle := map[string][]byte{ + "leer": {}, + "kein JPEG": []byte("das hier ist ein Text"), + "nur SOI": {0xFF, 0xD8}, + "ohne EXIF": ohne.Bytes(), + "abgeschnitten": mitDrehung[:12], + "Laenge zu gross": append([]byte{0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF}, []byte("Exif\x00\x00")...), + "Drehung 0": jpegMitDrehung(t, 0, false), + "Drehung 99": jpegMitDrehung(t, 99, false), + } + for name, data := range faelle { + if got := Orientation(data); got != 1 { + t.Errorf("%s ergab %d statt 1", name, got) + } + } +} + +// Ein Bild mit einer eindeutigen Ecke: Nur so lässt sich prüfen, dass nicht +// bloß Breite und Höhe getauscht wurden, sondern richtig herum gedreht. +func markiert(w, h int) image.Image { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{0, 0, 0, 255}) + } + } + img.Set(0, 0, color.RGBA{255, 0, 0, 255}) // oben links rot + return img +} + +func TestApplyDrehtRichtigHerum(t *testing.T) { + const w, h = 4, 2 + src := markiert(w, h) + + // Wo landet der rote Punkt von oben links? + erwartet := map[int][2]int{ + 1: {0, 0}, + 2: {w - 1, 0}, // waagerecht gespiegelt + 3: {w - 1, h - 1}, // 180 Grad + 4: {0, h - 1}, // senkrecht gespiegelt + 5: {0, 0}, // Hauptdiagonale + 6: {h - 1, 0}, // 90 Grad im Uhrzeigersinn: links oben → rechts oben + 7: {h - 1, w - 1}, + 8: {0, w - 1}, // 90 Grad dagegen: links oben → links unten + } + for o, want := range erwartet { + out := Apply(src, o) + b := out.Bounds() + wantW, wantH := w, h + if o >= 5 { + wantW, wantH = h, w + } + if b.Dx() != wantW || b.Dy() != wantH { + t.Errorf("Drehung %d: %dx%d, erwartet %dx%d", o, b.Dx(), b.Dy(), wantW, wantH) + continue + } + r, _, _, _ := out.At(want[0], want[1]).RGBA() + if r < 0x8000 { + t.Errorf("Drehung %d: an %v steht kein roter Punkt", o, want) + } + } +} + +// Bei 1 darf kein neues Bild entstehen – das spart bei jedem Vorschaubild +// eine vollständige Kopie. +func TestApplyLaesstEinsUnberuehrt(t *testing.T) { + src := markiert(3, 3) + if Apply(src, 1) != src { + t.Error("Drehung 1 hat kopiert") + } + if Apply(src, 0) != src || Apply(src, 9) != src { + t.Error("unbekannte Drehung hat kopiert") + } +} diff --git a/backend/internal/thumbnails/thumbnails.go b/backend/internal/thumbnails/thumbnails.go index 0d72bf8..c425320 100644 --- a/backend/internal/thumbnails/thumbnails.go +++ b/backend/internal/thumbnails/thumbnails.go @@ -15,6 +15,7 @@ import ( "golang.org/x/image/draw" + "aether/internal/imageutil" "aether/internal/storage" ) @@ -44,11 +45,28 @@ func Generate(ctx context.Context, st storage.Storage, fileID, mimeType string, return fmt.Errorf("unsupported MIME type: %s", mimeType) } - img, _, err := image.Decode(src) + // Den Anfang zwischenspeichern: Dort steht die EXIF-Drehung, und danach + // muss derselbe Strom noch dekodiert werden. Nur der Anfang, nicht die + // ganze Datei – ein 40-MB-Foto soll nicht zweimal im Speicher liegen. + head := make([]byte, imageutil.HeadBytes) + n, err := io.ReadFull(src, head) + if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { + return fmt.Errorf("read image head: %w", err) + } + head = head[:n] + orientation := imageutil.Orientation(head) + + img, _, err := image.Decode(io.MultiReader(bytes.NewReader(head), src)) if err != nil { return fmt.Errorf("decode image: %w", err) } + // Vor dem Verkleinern drehen. Andersherum stimmte das Seitenverhältnis + // nicht: Ein hochkantes Foto würde als 256×128 eingepasst und danach + // gedreht – aus dem Bild wären 128×256 geworden, aber mit dem Zuschnitt + // eines Querformats. + img = imageutil.Apply(img, orientation) + thumb := resize(img, maxWidth, maxHeight) var buf bytes.Buffer diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 34b64be..e69001b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -830,6 +830,12 @@ export const adminApi = { freed_bytes: number; skipped_young: number }>('POST', '/admin/gc'), + /** Vorschaubilder neu erzeugen – für alles, was vor der Auswertung der + * EXIF-Drehung hochgeladen wurde und deshalb quer liegt. */ + rebuildThumbnails: () => request<{ + images: number; rebuilt: number; failed: number + }>('POST', '/admin/thumbnails/rebuild'), + /** Stand der Texterkennung. `enabled: false` heißt: abgeschaltet oder die * Programme fehlen im Image – kein Fehler, sondern eine Auskunft. */ ocrStatus: () => request('GET', '/admin/ocr'), diff --git a/frontend/src/pages/admin/AuditTab.tsx b/frontend/src/pages/admin/AuditTab.tsx index 120ed8e..57c58f6 100644 --- a/frontend/src/pages/admin/AuditTab.tsx +++ b/frontend/src/pages/admin/AuditTab.tsx @@ -195,6 +195,60 @@ function StorageCleanup() { ) } +/** Vorschaubilder entstehen beim Hochladen von selbst. Dieser Knopf ist für + * den Bestand: Bis heute wurde die EXIF-Drehung nicht ausgewertet, hochkant + * aufgenommene Fotos liegen als Vorschaubild deshalb quer. */ +function RebuildThumbnails() { + const [running, setRunning] = useState(false) + const [result, setResult] = useState(null) + + async function run() { + setRunning(true) + setResult(null) + try { + const r = await adminApi.rebuildThumbnails() + setResult( + r.images === 0 + ? 'No images stored yet' + : `${r.rebuilt} of ${r.images} previews rebuilt` + (r.failed ? `, ${r.failed} failed` : ''), + ) + } catch { + setResult('Rebuild failed') + } finally { + setRunning(false) + } + } + + return ( +
+
+
+ Rebuild image previews +
+
+ {result ?? 'Regenerates every stored preview. Needed once: photos taken in portrait used to end up sideways, because the rotation flag was ignored.'} +
+
+ +
+ ) +} + export default function AuditTab() { const [entries, setEntries] = useState([]) const [total, setTotal] = useState(0) @@ -221,6 +275,7 @@ export default function AuditTab() {
+