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:
1 parent
3019e84681
commit
9f44215698
18 files changed
+814
-26
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user