Files
Aether/backend/internal/avatar/avatar.go
T
marcandClaude Opus 5 9f44215698 feat(oidc): Profilbild vom Identitätsanbieter übernehmen
Schickt der Anbieter ein `picture`, wird es bei der Anmeldung geholt,
mittig auf 256x256 zugeschnitten und oben rechts angezeigt. Fehlt es
oder scheitert das Holen, bleibt es beim Buchstabenkreis - die
Anmeldung selbst hängt nicht davon ab.

Die Bildadresse kommt aus einem Token, also von außen. Erlaubt ist
deshalb ausschließlich der Ursprung aus OIDC_ISSUER, Weiterleitungen
eingeschlossen; dazu 4 MB und 5 Sekunden als Deckel. Ohne das wäre es
eine Aufforderung an den Server, für einen Fremden beliebige Adressen
im internen Netz anzufragen.

Neu: Migration 000019 (avatar_key/-source/-updated_at), Paket
internal/avatar, Route GET /api/v1/users/{id}/avatar. Der GC durchsucht
"avatars/" mit und kennt die Schlüssel - sonst räumte er die Bilder
nach einem Tag weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:43:22 +02:00

160 lines
5.2 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/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)
}