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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-24 13:43:22 +02:00
1 parent 3019e84681
commit 9f44215698
18 files changed
+814 -26

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
// 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)
}
+102
View File
@@ -0,0 +1,102 @@
package avatar
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"strings"
"testing"
)
// Ein Bild, dessen linke Hälfte rot und rechte Hälfte blau ist – so lässt sich
// nachrechnen, welcher Ausschnitt genommen wurde.
func striped(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++ {
if x < w/2 {
img.Set(x, y, color.RGBA{255, 0, 0, 255})
} else {
img.Set(x, y, color.RGBA{0, 0, 255, 255})
}
}
}
return img
}
func encodePNG(t *testing.T, img image.Image) []byte {
t.Helper()
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func TestProcessMachtQuadrat(t *testing.T) {
cases := []struct {
name string
w, h int
}{
{"breit", 800, 200},
{"hoch", 200, 800},
{"quadratisch", 512, 512},
{"winzig", 16, 16},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
out, err := Process(bytes.NewReader(encodePNG(t, striped(c.w, c.h))))
if err != nil {
t.Fatalf("Process: %v", err)
}
img, _, err := image.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("Ergebnis nicht lesbar: %v", err)
}
b := img.Bounds()
if b.Dx() != Size || b.Dy() != Size {
t.Errorf("Größe %dx%d, erwartet %dx%d", b.Dx(), b.Dy(), Size, Size)
}
})
}
}
// Bei einem breiten Bild wird mittig geschnitten, nicht links angesetzt: Die
// linke Kante des Ergebnisses muss deshalb noch rot sein, die rechte blau.
func TestProcessSchneidetMittig(t *testing.T) {
out, err := Process(bytes.NewReader(encodePNG(t, striped(1000, 200))))
if err != nil {
t.Fatal(err)
}
img, _, _ := image.Decode(bytes.NewReader(out))
r, _, bl, _ := img.At(4, Size/2).RGBA()
if r < 0x8000 || bl > 0x4000 {
t.Errorf("linke Kante ist nicht rot – Ausschnitt sitzt falsch")
}
r, _, bl = func() (uint32, uint32, uint32) { a, b, c, _ := img.At(Size-5, Size/2).RGBA(); return a, b, c }()
if bl < 0x8000 || r > 0x4000 {
t.Errorf("rechte Kante ist nicht blau – Ausschnitt sitzt falsch")
}
}
func TestProcessNimmtJPEG(t *testing.T) {
var b bytes.Buffer
if err := jpeg.Encode(&b, striped(300, 300), nil); err != nil {
t.Fatal(err)
}
if _, err := Process(bytes.NewReader(b.Bytes())); err != nil {
t.Errorf("JPEG abgelehnt: %v", err)
}
}
// Was kein Bild ist, muss als Fehler zurückkommen – und nicht etwa als leere
// Datei im Speicher landen.
func TestProcessLehntUnsinnAb(t *testing.T) {
for _, s := range []string{"", "<svg/>", "nicht mal ansatzweise ein bild"} {
if _, err := Process(strings.NewReader(s)); err == nil {
t.Errorf("kein Fehler für %q", s)
}
}
}