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 }