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
@@ -4,18 +4,21 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// userCols ist die Spaltenliste jeder Nutzerabfrage – einmal, weil vier
|
||||
// userCols ist die Spaltenliste jeder Nutzerabfrage – einmal, weil fünf
|
||||
// Abfragen mit derselben Reihenfolge scannen und eine übersehene Ergänzung
|
||||
// sonst erst zur Laufzeit auffällt.
|
||||
const userCols = `id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme,
|
||||
oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`
|
||||
oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc,
|
||||
COALESCE(EXTRACT(EPOCH FROM avatar_updated_at)::BIGINT, 0) AS avatar_ver,
|
||||
created_at`
|
||||
|
||||
var ErrNotFound = errors.New("user not found")
|
||||
var ErrUsernameTaken = errors.New("username already taken")
|
||||
@@ -32,10 +35,12 @@ func (s *Store) GetByUsername(ctx context.Context, username string) (*User, stri
|
||||
var u User
|
||||
var passwordHash string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, username, display_name, password, is_admin, quota_bytes, created_at
|
||||
`SELECT id, username, display_name, password, is_admin, quota_bytes,
|
||||
COALESCE(EXTRACT(EPOCH FROM avatar_updated_at)::BIGINT, 0), created_at
|
||||
FROM users WHERE username = $1`,
|
||||
username,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &passwordHash, &u.IsAdmin, &u.QuotaBytes, &u.CreatedAt)
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &passwordHash, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.AvatarVer, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
@@ -48,12 +53,11 @@ func (s *Store) GetByUsername(ctx context.Context, username string) (*User, stri
|
||||
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at
|
||||
`SELECT `+userCols+`
|
||||
FROM users WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -68,11 +72,10 @@ func (s *Store) Create(ctx context.Context, username, passwordHash string, isAdm
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO users (username, password, is_admin)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`,
|
||||
RETURNING `+userCols+``,
|
||||
username, passwordHash, isAdmin,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrUsernameTaken
|
||||
@@ -107,12 +110,11 @@ func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayNam
|
||||
// First: try to find by oidc_sub.
|
||||
var u User
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at
|
||||
`SELECT `+userCols+`
|
||||
FROM users WHERE oidc_sub=$1`,
|
||||
sub,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt)
|
||||
if err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
@@ -125,7 +127,7 @@ func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayNam
|
||||
RETURNING `+userCols,
|
||||
sub, username,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt)
|
||||
if err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
@@ -139,11 +141,10 @@ func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayNam
|
||||
err = s.db.QueryRow(ctx,
|
||||
`INSERT INTO users (username, display_name, password, oidc_sub)
|
||||
VALUES ($1, $2, '', $3)
|
||||
RETURNING id, username, display_name, is_admin, quota_bytes,
|
||||
COALESCE(primary_color,''), COALESCE(accent_color,''), use_custom_theme, oidc_sub IS NOT NULL AND oidc_sub <> '' AS is_oidc, created_at`,
|
||||
RETURNING `+userCols+``,
|
||||
candidate, displayName, sub,
|
||||
).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes,
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.CreatedAt)
|
||||
&u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt)
|
||||
if err == nil {
|
||||
return &u, nil
|
||||
}
|
||||
@@ -209,3 +210,38 @@ func containsCode(err error, code string) bool {
|
||||
return false
|
||||
}())
|
||||
}
|
||||
|
||||
// ── Profilbild ────────────────────────────────────────────────────────────────
|
||||
|
||||
// AvatarOf liefert Speicherschlüssel und Herkunft des Profilbildes. Beides ist
|
||||
// leer, wenn keines hinterlegt ist.
|
||||
func (s *Store) AvatarOf(ctx context.Context, id uuid.UUID) (key, source string, updated time.Time, err error) {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`SELECT COALESCE(avatar_key,''), COALESCE(avatar_source,''),
|
||||
COALESCE(avatar_updated_at, 'epoch'::TIMESTAMPTZ)
|
||||
FROM users WHERE id = $1`, id,
|
||||
).Scan(&key, &source, &updated)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", time.Time{}, ErrNotFound
|
||||
}
|
||||
return key, source, updated, err
|
||||
}
|
||||
|
||||
// SetAvatar trägt ein neues Profilbild ein und gibt den Schlüssel des alten
|
||||
// zurück – der Aufrufer löscht dessen Blob, nachdem die Zeile steht.
|
||||
//
|
||||
// Reihenfolge wie beim Logo: erst schreiben, dann eintragen, dann das alte
|
||||
// wegräumen. Andersherum stünde bei einem Abbruch dazwischen ein Schlüssel in
|
||||
// der Datenbank, zu dem es keine Datei mehr gibt.
|
||||
func (s *Store) SetAvatar(ctx context.Context, id uuid.UUID, key, source string) (oldKey string, err error) {
|
||||
err = s.db.QueryRow(ctx,
|
||||
`UPDATE users u SET avatar_key = $2, avatar_source = $3, avatar_updated_at = NOW()
|
||||
FROM (SELECT id, avatar_key FROM users WHERE id = $1) old
|
||||
WHERE u.id = old.id
|
||||
RETURNING COALESCE(old.avatar_key,'')`, id, key, source,
|
||||
).Scan(&oldKey)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return oldKey, err
|
||||
}
|
||||
Reference in new issue
Block a user