98 lines
4.0 KiB
Go
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) }
|