package auth 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 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, 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") type Store struct { db *pgxpool.Pool } func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} } func (s *Store) GetByUsername(ctx context.Context, username string) (*User, string, error) { var u User var passwordHash string err := s.db.QueryRow(ctx, `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.AvatarVer, &u.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, "", ErrNotFound } if err != nil { return nil, "", fmt.Errorf("query user: %w", err) } return &u, passwordHash, nil } func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (*User, error) { var u User err := s.db.QueryRow(ctx, `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.AvatarVer, &u.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("query user: %w", err) } return &u, nil } func (s *Store) Create(ctx context.Context, username, passwordHash string, isAdmin bool) (*User, error) { var u User err := s.db.QueryRow(ctx, `INSERT INTO users (username, password, is_admin) VALUES ($1, $2, $3) 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.AvatarVer, &u.CreatedAt) if err != nil { if isUniqueViolation(err) { return nil, ErrUsernameTaken } return nil, fmt.Errorf("create user: %w", err) } return &u, nil } // GetOrCreateByOIDC findet den Nutzer über sein OIDC-Subject – oder legt ihn an. // // linkByUsername entscheidet über den heiklen Fall: Es gibt bereits ein Konto // mit diesem Benutzernamen, aber ohne Bindung an eine OIDC-Identität. // // - **aus (Voreinstellung):** Es entsteht ein *zweites* Konto. Da der Name // schon vergeben ist, heißt es „name2" – leer, ohne Adminrechte, ohne die // bisherigen Dateien. Wer seine Cloud bisher mit Passwort benutzt hat und // dann OIDC einschaltet, landet also in einer fremden, leeren Cloud. // - **an:** Das bestehende Konto wird mit der Identität verbunden. Dateien, // Adminrecht und Freigaben bleiben, künftig meldet man sich darüber an. // // **Warum das nicht die Voreinstellung ist:** Verbinden heißt, dem // Identitätsanbieter zu glauben, dass „marc" bei ihm derselbe „marc" ist wie // hier. Bei einem eigenen Anbieter, dessen Konten man selbst anlegt, stimmt // das. Bei einem Anbieter, bei dem sich Fremde registrieren und ihren // Benutzernamen frei wählen können, wäre es die Übernahme fremder Konten. // Deshalb eine bewusste Entscheidung in der .env statt einer stillen Annahme. // // Verbunden wird nur, was **noch keine** Bindung hat: Ein Konto, das bereits zu // einer anderen Identität gehört, wird nie überschrieben. func (s *Store) GetOrCreateByOIDC(ctx context.Context, sub, username, displayName string, linkByUsername bool) (*User, error) { // First: try to find by oidc_sub. var u User err := s.db.QueryRow(ctx, `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.AvatarVer, &u.CreatedAt) if err == nil { return &u, nil } // Bestehendes Konto übernehmen, falls gewünscht und noch ungebunden. if linkByUsername { err = s.db.QueryRow(ctx, `UPDATE users SET oidc_sub = $1 WHERE username = $2 AND (oidc_sub IS NULL OR oidc_sub = '') RETURNING `+userCols, sub, username, ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.IsAdmin, &u.QuotaBytes, &u.PrimaryColor, &u.AccentColor, &u.UseCustomTheme, &u.IsOIDC, &u.AvatarVer, &u.CreatedAt) if err == nil { return &u, nil } // Kein Treffer heißt: kein solches Konto oder schon anderweitig // gebunden. Beides führt weiter unten zum Anlegen eines neuen. } // Not found: create a new user. Ensure username uniqueness. candidate := username for i := 2; i <= 99; i++ { err = s.db.QueryRow(ctx, `INSERT INTO users (username, display_name, password, oidc_sub) VALUES ($1, $2, '', $3) 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.AvatarVer, &u.CreatedAt) if err == nil { return &u, nil } if isUniqueViolation(err) { candidate = fmt.Sprintf("%s%d", username, i) continue } return nil, fmt.Errorf("create oidc user: %w", err) } return nil, fmt.Errorf("could not find unique username for %q", username) } func (s *Store) UpdateDisplayName(ctx context.Context, id uuid.UUID, displayName string) error { _, err := s.db.Exec(ctx, `UPDATE users SET display_name=$2 WHERE id=$1`, id, displayName) return err } // SetUseCustomTheme legt um, ob die persönliche Palette angewendet wird. // Die gespeicherten Farben bleiben dabei unangetastet. func (s *Store) SetUseCustomTheme(ctx context.Context, id uuid.UUID, enabled bool) error { _, err := s.db.Exec(ctx, `UPDATE users SET use_custom_theme=$2 WHERE id=$1`, id, enabled) return err } // UpdateColors setzt die persönliche Palette. Leere Werte werden als NULL // gespeichert – dann gilt wieder die globale Einstellung des Administrators. func (s *Store) UpdateColors(ctx context.Context, id uuid.UUID, primary, accent string) error { var p, a *string if primary != "" { p = &primary } if accent != "" { a = &accent } _, err := s.db.Exec(ctx, `UPDATE users SET primary_color=$2, accent_color=$3 WHERE id=$1`, id, p, a) return err } func (s *Store) UpdatePassword(ctx context.Context, id uuid.UUID, newHash string) error { _, err := s.db.Exec(ctx, `UPDATE users SET password=$2 WHERE id=$1`, id, newHash) return err } func (s *Store) AdminExists(ctx context.Context) (bool, error) { var count int err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE is_admin = true`).Scan(&count) return count > 0, err } func isUniqueViolation(err error) bool { return err != nil && (containsCode(err, "23505")) } func containsCode(err error, code string) bool { return err != nil && len(err.Error()) > 0 && (func() bool { type pgErr interface{ SQLState() string } var pe pgErr if errors.As(err, &pe) { return pe.SQLState() == code } 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 }