Files
Gardomatic/cmd/cli/store.go
T
2026-09-14 19:58:31 +02:00

356 lines
11 KiB
Go

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 demoResetInput struct {
Name string
Email string
PasswordHash []byte
}
type demoResetResult struct {
Email string `json:"email"`
Garden string `json:"garden"`
Images int `json:"images"`
Locations int `json:"locations"`
Species int `json:"species"`
Plants int `json:"plants"`
Tasks int `json:"tasks"`
Journal int `json:"journal_entries"`
Attachments int `json:"attachments"`
}
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)
ResetDemo(context.Context, demoResetInput) (demoResetResult, 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
}