Switch avatar resize to ApproxBiLinear, cap hunt/profile/admin list queries, drop redundant admin/profile lookups, dedupe CI on app PRs, and refresh stale todo.md notes.
202 lines
4.8 KiB
Go
202 lines
4.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"plumber/internal/store/sqlc"
|
|
)
|
|
|
|
// ErrLastAdmin is returned when demoting the only remaining admin.
|
|
var ErrLastAdmin = errors.New("cannot demote the last admin")
|
|
|
|
// Role is a user privilege level stored in users.role.
|
|
type Role string
|
|
|
|
const (
|
|
RoleUser Role = "user"
|
|
RoleAdmin Role = "admin"
|
|
)
|
|
|
|
// User is an account row. Methods run SQL against db via sqlc.
|
|
type User struct {
|
|
ID string
|
|
Username string
|
|
Name string
|
|
Role Role
|
|
AvatarURL string
|
|
State string
|
|
CreatedAt string
|
|
PasswordHash string
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewUser returns a User bound to db (not yet inserted).
|
|
func NewUser(db *sql.DB) *User {
|
|
return &User{db: db}
|
|
}
|
|
|
|
func (u *User) Admin() bool {
|
|
return u != nil && u.Role == RoleAdmin
|
|
}
|
|
|
|
func NormalizeUsername(s string) string {
|
|
return strings.ToLower(strings.TrimSpace(s))
|
|
}
|
|
|
|
func toUser(db *sql.DB, id, username, name, role, avatarURL, state, createdAt, passwordHash string) *User {
|
|
return &User{
|
|
ID: id,
|
|
Username: username,
|
|
Name: name,
|
|
Role: Role(role),
|
|
AvatarURL: avatarURL,
|
|
State: state,
|
|
CreatedAt: createdAt,
|
|
PasswordHash: passwordHash,
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// Create inserts the user. Sets ID, Name, and CreatedAt when empty.
|
|
func (u *User) Create(ctx context.Context) error {
|
|
if u == nil || u.db == nil {
|
|
return fmt.Errorf("user: no database")
|
|
}
|
|
if u.Role != RoleUser && u.Role != RoleAdmin {
|
|
return fmt.Errorf("invalid role")
|
|
}
|
|
u.Username = NormalizeUsername(u.Username)
|
|
if u.ID == "" {
|
|
u.ID = uuid.NewString()
|
|
}
|
|
if u.Name == "" {
|
|
u.Name = u.Username
|
|
}
|
|
if u.CreatedAt == "" {
|
|
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
return sqlc.New(u.db).CreateUser(ctx, sqlc.CreateUserParams{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
Name: u.Name,
|
|
PasswordHash: u.PasswordHash,
|
|
Role: string(u.Role),
|
|
CreatedAt: u.CreatedAt,
|
|
})
|
|
}
|
|
|
|
// adminRoleLockKey serializes SetRole so concurrent demotions cannot bypass the
|
|
// last-admin guard under READ COMMITTED.
|
|
const adminRoleLockKey int64 = 0x706c756d5f61646d // "plum_adm"
|
|
|
|
// SetRole updates this user's role (last-admin safe).
|
|
func (u *User) SetRole(ctx context.Context, role Role) error {
|
|
if u == nil || u.db == nil {
|
|
return fmt.Errorf("user: no database")
|
|
}
|
|
if role != RoleUser && role != RoleAdmin {
|
|
return fmt.Errorf("invalid role")
|
|
}
|
|
tx, err := u.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, adminRoleLockKey); err != nil {
|
|
return err
|
|
}
|
|
|
|
q := sqlc.New(tx)
|
|
current, err := q.GetUserRole(ctx, u.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if Role(current) == RoleAdmin && role == RoleUser {
|
|
n, err := q.CountAdmins(ctx, string(RoleAdmin))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n <= 1 {
|
|
return ErrLastAdmin
|
|
}
|
|
}
|
|
res, err := q.UpdateUserRole(ctx, sqlc.UpdateUserRoleParams{
|
|
Role: string(role),
|
|
ID: u.ID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
aff, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if aff == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
u.Role = role
|
|
return nil
|
|
}
|
|
|
|
// SaveProfile writes State and optionally AvatarURL.
|
|
func (u *User) SaveProfile(ctx context.Context) error {
|
|
if u == nil || u.db == nil {
|
|
return fmt.Errorf("user: no database")
|
|
}
|
|
u.State = strings.TrimSpace(u.State)
|
|
q := sqlc.New(u.db)
|
|
if u.AvatarURL == "" {
|
|
return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID})
|
|
}
|
|
return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{
|
|
State: u.State,
|
|
AvatarUrl: u.AvatarURL,
|
|
ID: u.ID,
|
|
})
|
|
}
|
|
|
|
func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
|
|
n, err := sqlc.New(db).CountAdmins(ctx, string(RoleAdmin))
|
|
return int(n), err
|
|
}
|
|
|
|
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
|
|
rows, err := sqlc.New(db).ListUsers(ctx, AdminUsersLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]User, 0, len(rows))
|
|
for _, r := range rows {
|
|
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "")
|
|
out = append(out, *u)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
|
|
r, err := sqlc.New(db).GetUserByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
|
|
}
|
|
|
|
func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) {
|
|
r, err := sqlc.New(db).GetUserByUsername(ctx, NormalizeUsername(username))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
|
|
}
|