Files
brain/internal/db/users.go
T
Andrew S Erwin b9721ff13f
release gate / check (push) Failing after 8m35s
initial commit on Federted servers
2026-10-19 16:23:55 -05:00

98 lines
4.0 KiB
Go

package db
import (
"context"
"database/sql"
"encoding/json"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/models"
)
type UserRepo struct{ db *DB }
func NewUserRepo(d *DB) *UserRepo { return &UserRepo{d} }
func (r *UserRepo) find(ctx context.Context, field, value string) (*models.User, error) {
o, err := One(ctx, r.db, `SELECT to_jsonb(u) FROM "User" u WHERE `+Quote(field)+`=$1`, value)
if err != nil {
return nil, err
}
raw, _ := json.Marshal(o)
var row struct {
ID string `json:"id"`
Name *string `json:"name"`
Email *string `json:"email"`
EmailVerified *string `json:"emailVerified"`
Image *string `json:"image"`
Password *string `json:"password"`
Role string `json:"role"`
TokenVersion int `json:"tokenVersion"`
}
if err = json.Unmarshal(raw, &row); err != nil {
return nil, err
}
return &models.User{ID: row.ID, Name: row.Name, Email: row.Email, EmailVerified: row.EmailVerified, Image: row.Image, Password: row.Password, Role: row.Role, TokenVersion: row.TokenVersion}, nil
}
func (r *UserRepo) FindByEmail(ctx context.Context, e string) (*models.User, error) {
return r.find(ctx, "email", e)
}
func (r *UserRepo) FindByID(ctx context.Context, id string) (*models.User, error) {
return r.find(ctx, "id", id)
}
func (r *UserRepo) SetRole(ctx context.Context, id, role string) error {
_, err := r.db.ExecContext(ctx, `UPDATE "User" SET role=$1,"updatedAt"=now() WHERE id=$2`, role, id)
return err
}
// BumpTokenVersion invalidates every cookie JWT issued for the user so far.
func (r *UserRepo) BumpTokenVersion(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `UPDATE "User" SET "tokenVersion"="tokenVersion"+1,"updatedAt"=now() WHERE id=$1`, id)
return err
}
type NewUserInput struct{ ID, Name, Email, Password, Role string }
// A transaction-scoped lock serializes first-admin selection with signup.
//
// Review AUTH-06: initial provisioning is a one-shot lifecycle event,
// distinct from administrator recovery. Once bootstrap completion is
// recorded in AppSetting, the mere absence of a qualifying administrator —
// e.g. after demoting or removing the last one — never re-opens
// administrator assignment through signup. Recovery is operator-driven
// (ADMIN_EMAILS + make seed, or promotion by an existing admin).
func (r *UserRepo) Create(ctx context.Context, u *NewUserInput) (string, error) {
err := Transaction(ctx, r.db, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(927438)`); err != nil {
return err
}
var bootstrapDone bool
if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT value->>'completed' FROM "AppSetting" WHERE key='adminBootstrap'),'false')::boolean`).Scan(&bootstrapDone); err != nil {
return err
}
var admins int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); err != nil {
return err
}
role := u.Role
if role == "" {
role = "USER"
}
promote := config.AllowBootstrapSignup() && !bootstrapDone && admins == 0 && len(config.AdminEmails()) == 0 && !isTestEmail(u.Email) && role == "USER"
if promote {
role = "ADMIN"
}
// Record bootstrap completion as soon as the installation has (or was
// just given) a real administrator. A test-address signup with no
// admins present never claims the bootstrap.
if !bootstrapDone && (role == "ADMIN" || admins > 0) {
if _, err := tx.ExecContext(ctx, `INSERT INTO "AppSetting"(key,value) VALUES('adminBootstrap',jsonb_build_object('completed',true)) ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value,"updatedAt"=now()`); err != nil {
return err
}
}
_, err := Insert(ctx, tx, "User", Object{"id": u.ID, "name": u.Name, "email": u.Email, "password": u.Password, "role": role})
return err
})
return u.ID, err
}
func isTestEmail(e string) bool { return len(e) >= 12 && e[len(e)-12:] == "@example.com" }
func IsAdminEmail(email string) bool { return config.IsAdminEmail(email) }