This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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) }
|
||||
Reference in New Issue
Block a user