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

+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)
}
}
}