@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
var (
|
||||
errUserNotFound = errors.New("user not found")
|
||||
errUserAlreadyActive = errors.New("user is already active")
|
||||
)
|
||||
|
||||
type userView struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Activated bool `json:"activated"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type gardenMemberView struct {
|
||||
GardenID int `json:"garden_id"`
|
||||
UserID int `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
}
|
||||
|
||||
type createUserInput struct {
|
||||
Name string
|
||||
Email string
|
||||
PasswordHash []byte
|
||||
Activated bool
|
||||
Role string
|
||||
Invite bool
|
||||
TokenTTL time.Duration
|
||||
}
|
||||
|
||||
type createUserResult struct {
|
||||
User userView
|
||||
Token *auth.Token
|
||||
}
|
||||
|
||||
type adminStore interface {
|
||||
Ping(context.Context) error
|
||||
CreateUser(context.Context, createUserInput) (createUserResult, error)
|
||||
ListUsers(context.Context) ([]userView, error)
|
||||
GetUser(context.Context, string) (userView, error)
|
||||
SetActivated(context.Context, string, bool) (userView, error)
|
||||
IssueInvitation(context.Context, string, time.Duration) (userView, auth.Token, error)
|
||||
ResetPassword(context.Context, string, []byte) (userView, error)
|
||||
SetUserRole(context.Context, string, string) (userView, error)
|
||||
AddGardenMember(context.Context, int, string, string) (gardenMemberView, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type postgresStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func openPostgresStore(dsn string) (adminStore, error) {
|
||||
if dsn == "" {
|
||||
return nil, errors.New("database DSN is missing; set GARDOMATIC_DB_DSN or use --dsn")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(5)
|
||||
db.SetMaxIdleConns(2)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
return &postgresStore{db: db}, nil
|
||||
}
|
||||
|
||||
// Close releases the CLI database pool.
|
||||
func (s *postgresStore) Close() error { return s.db.Close() }
|
||||
|
||||
// Ping verifies that the CLI can reach PostgreSQL within a bounded timeout.
|
||||
func (s *postgresStore) Ping(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
return s.db.PingContext(ctx)
|
||||
}
|
||||
|
||||
// CreateUser creates an account and optionally an invitation token atomically.
|
||||
func (s *postgresStore) CreateUser(ctx context.Context, input createUserInput) (createUserResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return createUserResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var result createUserResult
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users (name, email, password_hash, activated, application_role)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, email, activated, application_role, created_at, updated_at`,
|
||||
input.Name, input.Email, input.PasswordHash, input.Activated, input.Role,
|
||||
).Scan(
|
||||
&result.User.ID,
|
||||
&result.User.Name,
|
||||
&result.User.Email,
|
||||
&result.User.Activated,
|
||||
&result.User.Role,
|
||||
&result.User.CreatedAt,
|
||||
&result.User.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return createUserResult{}, mapPostgresError(err)
|
||||
}
|
||||
|
||||
if input.Invite {
|
||||
token := auth.NewToken(result.User.ID, input.TokenTTL, auth.ScopeActivation)
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO tokens (hash, user_id, expiry, scope)
|
||||
VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
|
||||
return createUserResult{}, err
|
||||
}
|
||||
result.Token = &token
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return createUserResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListUsers returns all non-deleted accounts for CLI administration.
|
||||
func (s *postgresStore) ListUsers(ctx context.Context) ([]userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, email, activated, application_role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]userView, 0)
|
||||
for rows.Next() {
|
||||
var user userView
|
||||
if err = rows.Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// GetUser returns a non-deleted account by email.
|
||||
func (s *postgresStore) GetUser(ctx context.Context, email string) (userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
return getUser(ctx, s.db, email)
|
||||
}
|
||||
|
||||
// SetActivated changes whether an account may authenticate.
|
||||
func (s *postgresStore) SetActivated(ctx context.Context, email string, activated bool) (userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var user userView
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE users
|
||||
SET activated = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE email = $1 AND deleted_at IS NULL
|
||||
RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, activated,
|
||||
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return userView{}, errUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
if activated {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// IssueInvitation replaces an account's activation token.
|
||||
func (s *postgresStore) IssueInvitation(ctx context.Context, email string, ttl time.Duration) (userView, auth.Token, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
user, err := getUser(ctx, tx, email)
|
||||
if err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
if user.Activated {
|
||||
return userView{}, auth.Token{}, errUserAlreadyActive
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
token := auth.NewToken(user.ID, ttl, auth.ScopeActivation)
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO tokens (hash, user_id, expiry, scope)
|
||||
VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
return user, token, nil
|
||||
}
|
||||
|
||||
// ResetPassword replaces an account password hash and revokes authentication tokens.
|
||||
func (s *postgresStore) ResetPassword(ctx context.Context, email string, passwordHash []byte) (userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var user userView
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE email = $1 AND deleted_at IS NULL
|
||||
RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, passwordHash,
|
||||
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return userView{}, errUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
DELETE FROM tokens
|
||||
WHERE user_id = $1 AND scope = ANY($2)`, user.ID, pq.Array([]string{auth.ScopeAuthentication, auth.ScopePasswordReset})); err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// SetUserRole changes an account's application role.
|
||||
func (s *postgresStore) SetUserRole(ctx context.Context, email, role string) (userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var user userView
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
UPDATE users SET application_role=$2, updated_at=CURRENT_TIMESTAMP, version=version+1
|
||||
WHERE email=$1 AND deleted_at IS NULL
|
||||
RETURNING id,name,email,activated,application_role,created_at,updated_at`, email, role,
|
||||
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return userView{}, errUserNotFound
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
// AddGardenMember adds or updates a user's membership in a garden.
|
||||
func (s *postgresStore) AddGardenMember(ctx context.Context, gardenID int, email, role string) (gardenMemberView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var member gardenMemberView
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_members(garden_id,user_id,role)
|
||||
SELECT $1,u.id,$3 FROM users u
|
||||
WHERE u.email=$2 AND u.deleted_at IS NULL
|
||||
AND EXISTS (SELECT 1 FROM gardens WHERE id=$1)
|
||||
AND EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
RETURNING garden_id,user_id,$2,role,joined_at`, gardenID, email, role,
|
||||
).Scan(&member.GardenID, &member.UserID, &member.Email, &member.Role, &member.JoinedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return gardenMemberView{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return member, mapPostgresError(err)
|
||||
}
|
||||
|
||||
type dbQuerier interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
func getUser(ctx context.Context, db dbQuerier, email string) (userView, error) {
|
||||
var user userView
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id, name, email, activated, application_role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = $1 AND deleted_at IS NULL`, email,
|
||||
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return userView{}, errUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func mapPostgresError(err error) error {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key" {
|
||||
return storage.ErrDuplicateEmail
|
||||
}
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user