Files
Aether/backend/internal/avatar/avatar.go
T
marcandClaude Opus 5 4bd9a6ca56 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>
2026-08-25 14:08:10 +02:00

173 lines
5.8 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 avatar hält das Profilbild eines Kontos: aufbereiten, ablegen,
// ausliefern.
//
// Aufbereiten heißt hier immer: quadratisch, 256×256, PNG. Ein Profilbild wird
// als Kreis angezeigt, deshalb wird mittig zugeschnitten statt gerahmt – anders
// als beim App-Symbol, wo ein Zuschnitt ein Logo anschneiden würde.
package avatar
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"image"
"image/png"
"io"
"net/http"
"strconv"
// Dekodierer für die verbreiteten Formate. Ohne diese Zeilen kennt
// image.Decode nur das, was sonst noch importiert ist.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"aether/internal/auth"
"aether/internal/imageutil"
"aether/internal/storage"
"aether/pkg/response"
)
// Size ist die Kantenlänge des abgelegten Bildes. 256 reicht für jede Stelle,
// an der es vorkommt (26 px in der Leiste, 64 px in den Einstellungen), auch
// auf Bildschirmen mit doppelter Auflösung.
const Size = 256
// MaxBytes begrenzt, was überhaupt entgegengenommen wird – sowohl beim
// Herunterladen vom Identitätsanbieter als auch bei einem späteren Upload.
const MaxBytes = 4 << 20
// Process liest ein Bild und gibt ein quadratisches PNG zurück.
func Process(r io.Reader) ([]byte, error) {
// 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")
}
// Mittiges Quadrat aus der Vorlage – die längere Seite wird beschnitten.
side := b.Dx()
if b.Dy() < side {
side = b.Dy()
}
crop := image.Rect(0, 0, side, side).
Add(image.Pt(b.Min.X+(b.Dx()-side)/2, b.Min.Y+(b.Dy()-side)/2))
dst := image.NewRGBA(image.Rect(0, 0, Size, Size))
// CatmullRom statt des BiLinear aus internal/thumbnails: Ein Gesicht auf
// 26 px verkleinert braucht die schärfere Rechnung, und es passiert
// einmal je Anmeldung, nicht je Vorschaubild.
draw.CatmullRom.Scale(dst, dst.Bounds(), src, crop, draw.Src, nil)
var out bytes.Buffer
if err := png.Encode(&out, dst); err != nil {
return nil, fmt.Errorf("bild nicht schreibbar: %w", err)
}
return out.Bytes(), nil
}
// Key erzeugt einen neuen Speicherschlüssel für ein Konto. Der Zufallsanteil
// ist der Cache-Brecher: Unter gleichem Namen bliebe das alte Bild im Browser
// hängen, obwohl längst ein anderes hinterlegt ist.
func Key(userID uuid.UUID) (string, error) {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "avatars/" + userID.String() + "/" + hex.EncodeToString(b) + ".png", nil
}
// Store legt ein aufbereitetes Bild ab und trägt es beim Konto ein. Das
// vorherige Bild wird erst danach gelöscht.
func Store(ctx context.Context, st storage.Storage, users *auth.Store, userID uuid.UUID, data []byte, source string) error {
key, err := Key(userID)
if err != nil {
return err
}
if _, _, err := st.Put(ctx, key, bytes.NewReader(data)); err != nil {
return fmt.Errorf("avatar ablegen: %w", err)
}
oldKey, err := users.SetAvatar(ctx, userID, key, source)
if err != nil {
// Die Zeile steht nicht – dann darf auch der Blob nicht bleiben,
// sonst räumt ihn erst der GC nach einem Tag weg.
_ = st.Delete(ctx, key)
return fmt.Errorf("avatar eintragen: %w", err)
}
if oldKey != "" && oldKey != key {
_ = st.Delete(ctx, oldKey)
}
return nil
}
// ── Ausliefern ────────────────────────────────────────────────────────────────
type Handler struct {
users *auth.Store
storage storage.Storage
}
func NewHandler(users *auth.Store, st storage.Storage) *Handler {
return &Handler{users: users, storage: st}
}
// Serve beantwortet GET /api/v1/users/{id}/avatar.
//
// Jedes angemeldete Konto darf jedes Profilbild sehen – es steht ohnehin an
// jeder Freigabe und in jeder Nutzerliste. Ohne Anmeldung dagegen nicht: Der
// Endpunkt hängt hinter der geschützten Route.
func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
key, _, _, err := h.users.AvatarOf(r.Context(), id)
if err != nil || key == "" {
// Auch ein unbekanntes Konto endet hier mit 404 – nicht 403. Sonst
// verriete der Unterschied, welche Kennungen es gibt.
response.Error(w, http.StatusNotFound, "not found")
return
}
rc, err := h.storage.Get(r.Context(), key)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
defer rc.Close()
w.Header().Set("Content-Type", "image/png")
w.Header().Set("X-Content-Type-Options", "nosniff")
// Lange zwischenspeichern ist gefahrlos, weil die Adresse den Zeitstempel
// trägt: Ein neues Bild ist eine neue Adresse. "private", weil es zu einem
// Konto gehört und in keinem gemeinsamen Zwischenspeicher liegen soll.
if r.URL.Query().Get("v") != "" {
w.Header().Set("Cache-Control", "private, max-age="+strconv.Itoa(7*24*60*60)+", immutable")
} else {
w.Header().Set("Cache-Control", "private, no-cache")
}
_, _ = io.Copy(w, rc)
}