// 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/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) { src, _, err := image.Decode(io.LimitReader(r, MaxBytes)) if err != nil { return nil, fmt.Errorf("bild nicht lesbar: %w", err) } 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) }