spread

https://git.tonybtw.com/spread.git git://git.tonybtw.com/spread.git
3,952 bytes raw
1
package app
2
3
import (
4
	"context"
5
	"errors"
6
	"fmt"
7
	"strings"
8
9
	"github.com/jackc/pgx/v5"
10
	"golang.org/x/crypto/bcrypt"
11
)
12
13
// User is an authenticated account. A user always belongs to one subscriber.
14
type User struct {
15
	ID           int64
16
	SubscriberID int64
17
	Email        string
18
	Name         string
19
}
20
21
// Subscriber is a tenant. The internal subscriber (is_internal=true) sees the
22
// admin/CMS view; every other subscriber sees the customer dashboard.
23
type Subscriber struct {
24
	ID         int64
25
	Name       string
26
	IsInternal bool
27
}
28
29
var errNoCredentials = errors.New("invalid email or password")
30
31
func normEmail(email string) string {
32
	return strings.ToLower(strings.TrimSpace(email))
33
}
34
35
// authenticate verifies an email/password pair and returns the user.
36
func (a *App) authenticate(ctx context.Context, email, password string) (User, error) {
37
	var u User
38
	var hash string
39
	err := a.pool.QueryRow(ctx,
40
		`SELECT id, subscriber_id, email, name, password_hash FROM users WHERE email = $1`,
41
		normEmail(email),
42
	).Scan(&u.ID, &u.SubscriberID, &u.Email, &u.Name, &hash)
43
	if errors.Is(err, pgx.ErrNoRows) {
44
		return User{}, errNoCredentials
45
	}
46
	if err != nil {
47
		return User{}, err
48
	}
49
	if hash == "" || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
50
		return User{}, errNoCredentials
51
	}
52
	return u, nil
53
}
54
55
func (a *App) subscriber(ctx context.Context, id int64) (Subscriber, error) {
56
	var s Subscriber
57
	err := a.pool.QueryRow(ctx,
58
		`SELECT id, name, is_internal FROM subscribers WHERE id = $1`, id,
59
	).Scan(&s.ID, &s.Name, &s.IsInternal)
60
	return s, err
61
}
62
63
// InitAdmin ensures the internal subscriber exists and creates or updates the
64
// first admin user under it. Used by the `spread init-admin` subcommand.
65
func (a *App) InitAdmin(ctx context.Context, email, password string) error {
66
	if len(password) < 8 {
67
		return fmt.Errorf("password must be at least 8 characters")
68
	}
69
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
70
	if err != nil {
71
		return err
72
	}
73
74
	tx, err := a.pool.Begin(ctx)
75
	if err != nil {
76
		return err
77
	}
78
	defer tx.Rollback(ctx)
79
80
	var subID int64
81
	err = tx.QueryRow(ctx, `SELECT id FROM subscribers WHERE is_internal = TRUE ORDER BY id LIMIT 1`).Scan(&subID)
82
	if errors.Is(err, pgx.ErrNoRows) {
83
		if err = tx.QueryRow(ctx,
84
			`INSERT INTO subscribers (name, is_internal) VALUES ('Spread (internal)', TRUE) RETURNING id`,
85
		).Scan(&subID); err != nil {
86
			return err
87
		}
88
	} else if err != nil {
89
		return err
90
	}
91
92
	_, err = tx.Exec(ctx,
93
		`INSERT INTO users (subscriber_id, email, password_hash, name)
94
		 VALUES ($1, $2, $3, 'Admin')
95
		 ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, subscriber_id = EXCLUDED.subscriber_id`,
96
		subID, normEmail(email), string(hash),
97
	)
98
	if err != nil {
99
		return err
100
	}
101
	return tx.Commit(ctx)
102
}
103
104
// OpenAndInitAdmin is a convenience for the init-admin subcommand.
105
func OpenAndInitAdmin(ctx context.Context, dsn, email, password string) error {
106
	a, err := Open(ctx, Config{DSN: dsn})
107
	if err != nil {
108
		return err
109
	}
110
	defer a.Close()
111
	return a.InitAdmin(ctx, email, password)
112
}
113
114
func (a *App) findOrCreateSubscriber(ctx context.Context, name string) (int64, error) {
115
	var id int64
116
	err := a.pool.QueryRow(ctx,
117
		`SELECT id FROM subscribers WHERE name = $1 AND is_internal = FALSE ORDER BY id LIMIT 1`, name,
118
	).Scan(&id)
119
	if errors.Is(err, pgx.ErrNoRows) {
120
		return a.createSubscriber(ctx, name)
121
	}
122
	return id, err
123
}
124
125
// OpenAndCreateCustomer creates a customer user, making the named subscriber if
126
// it doesn't exist yet. Convenience for the create-customer subcommand.
127
func OpenAndCreateCustomer(ctx context.Context, dsn, email, subscriberName, password string) error {
128
	a, err := Open(ctx, Config{DSN: dsn})
129
	if err != nil {
130
		return err
131
	}
132
	defer a.Close()
133
	subID, err := a.findOrCreateSubscriber(ctx, subscriberName)
134
	if err != nil {
135
		return err
136
	}
137
	_, err = a.createUser(ctx, subID, email, "", password)
138
	return err
139
}