fix(thumbnails): EXIF-Drehung auswerten, hochkant blieb quer

Kameras speichern die Bildpunkte fast immer quer und legen die Drehung
als EXIF-Notiz daneben. Browser befolgen sie von selbst - Go nicht.
Deshalb stand dasselbe Foto in der grossen Ansicht aufrecht und lag im
Vorschaubild auf der Seite.

Neues Paket internal/imageutil liest genau ein Feld (0x0112) aus dem
Anfang der Datei und dreht danach. Von Hand statt mit einer Bibliothek:
Der Weg dorthin ist kurz und liegt fest, und gebraucht wird ein Feld.
Gelesen werden nur die ersten 64 KB, damit ein 40-MB-Foto nicht zweimal
im Speicher liegt.

Gedreht wird vor dem Verkleinern - andersherum bekaeme ein hochkantes
Foto den Zuschnitt eines Querformats.

Bestehende Vorschaubilder aendern sich davon nicht. Dafuer neu:
POST /admin/thumbnails/rebuild samt Knopf im Admin-Bereich.

Auch Profilbilder aus OIDC laufen jetzt durch dieselbe Auswertung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-25 14:08:10 +02:00
1 parent edefce3572
commit 4bd9a6ca56
10 files changed
+514 -2

No files matched your search

+28
View File
@@ -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
+3
View File
@@ -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.
@@ -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,
})
}
+14 -1
View File
@@ -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")
+26
View File
@@ -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()
}
+144
View File
@@ -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
}
@@ -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")
}
}
+19 -1
View File
@@ -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
+6
View File
@@ -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<OcrStatus>('GET', '/admin/ocr'),
+55
View File
@@ -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<string | null>(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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Rebuild image previews
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{result ?? 'Regenerates every stored preview. Needed once: photos taken in portrait used to end up sideways, because the rotation flag was ignored.'}
</div>
</div>
<button
onClick={run}
disabled={running}
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: running ? 'default' : 'pointer', opacity: running ? 0.6 : 1,
}}
>
{running ? 'Rebuilding…' : 'Rebuild'}
</button>
</div>
)
}
export default function AuditTab() {
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
@@ -221,6 +275,7 @@ export default function AuditTab() {
<div>
<IndexingStatus />
<StorageCleanup />
<RebuildThumbnails />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<select