Kameras speichern die Bildpunkte fast immer quer und legen die Drehung als EXIF-Notiz daneben. Browser befolgen sie von selbst - Go nicht. Deshalb stand dasselbe Foto in der grossen Ansicht aufrecht und lag im Vorschaubild auf der Seite. Neues Paket internal/imageutil liest genau ein Feld (0x0112) aus dem Anfang der Datei und dreht danach. Von Hand statt mit einer Bibliothek: Der Weg dorthin ist kurz und liegt fest, und gebraucht wird ein Feld. Gelesen werden nur die ersten 64 KB, damit ein 40-MB-Foto nicht zweimal im Speicher liegt. Gedreht wird vor dem Verkleinern - andersherum bekaeme ein hochkantes Foto den Zuschnitt eines Querformats. Bestehende Vorschaubilder aendern sich davon nicht. Dafuer neu: POST /admin/thumbnails/rebuild samt Knopf im Admin-Bereich. Auch Profilbilder aus OIDC laufen jetzt durch dieselbe Auswertung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
5.8 KiB
Go
173 lines
5.8 KiB
Go
// 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/imageutil"
|
||
"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) {
|
||
// Dasselbe wie beim Vorschaubild: Der Anfang trägt die EXIF-Drehung, und
|
||
// ein Profilbild kommt oft direkt aus einer Telefonkamera – also quer
|
||
// gespeichert mit einer Notiz daneben.
|
||
head := make([]byte, imageutil.HeadBytes)
|
||
n, herr := io.ReadFull(io.LimitReader(r, MaxBytes), head)
|
||
if herr != nil && herr != io.EOF && herr != io.ErrUnexpectedEOF {
|
||
return nil, fmt.Errorf("bild nicht lesbar: %w", herr)
|
||
}
|
||
head = head[:n]
|
||
|
||
src, _, err := image.Decode(io.MultiReader(
|
||
bytes.NewReader(head), io.LimitReader(r, MaxBytes-int64(n))))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("bild nicht lesbar: %w", err)
|
||
}
|
||
src = imageutil.Apply(src, imageutil.Orientation(head))
|
||
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)
|
||
}
|