spread

https://git.tonybtw.com/spread.git git://git.tonybtw.com/spread.git
2,512 bytes raw
1
package app
2
3
import (
4
	"context"
5
	"errors"
6
	"time"
7
8
	"github.com/jackc/pgx/v5"
9
	"golang.org/x/crypto/bcrypt"
10
)
11
12
const (
13
	inviteTTL = 7 * 24 * time.Hour
14
	resetTTL  = 24 * time.Hour
15
)
16
17
var errBadToken = errors.New("this link is invalid or has expired")
18
19
// issueToken creates a one-time token for a user and returns the raw value to
20
// embed in a link. Any prior unused token of the same purpose is dropped.
21
func (a *App) issueToken(ctx context.Context, userID int64, purpose string, ttl time.Duration) (string, error) {
22
	raw := randomToken()
23
	_, err := a.pool.Exec(ctx,
24
		`DELETE FROM user_tokens WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`,
25
		userID, purpose)
26
	if err != nil {
27
		return "", err
28
	}
29
	_, err = a.pool.Exec(ctx,
30
		`INSERT INTO user_tokens (token_hash, user_id, purpose, expires_at) VALUES ($1, $2, $3, $4)`,
31
		hashToken(raw), userID, purpose, time.Now().Add(ttl))
32
	if err != nil {
33
		return "", err
34
	}
35
	return raw, nil
36
}
37
38
// peekToken validates a token without consuming it (for rendering the form).
39
func (a *App) peekToken(ctx context.Context, raw string) error {
40
	if raw == "" {
41
		return errBadToken
42
	}
43
	var n int
44
	err := a.pool.QueryRow(ctx,
45
		`SELECT 1 FROM user_tokens WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`,
46
		hashToken(raw)).Scan(&n)
47
	if errors.Is(err, pgx.ErrNoRows) {
48
		return errBadToken
49
	}
50
	return err
51
}
52
53
// consumeToken validates a token, marks it used, and returns the user id.
54
func (a *App) consumeToken(ctx context.Context, raw string) (int64, error) {
55
	if raw == "" {
56
		return 0, errBadToken
57
	}
58
	var userID int64
59
	err := a.pool.QueryRow(ctx,
60
		`UPDATE user_tokens SET used_at = now()
61
		  WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
62
		  RETURNING user_id`,
63
		hashToken(raw)).Scan(&userID)
64
	if errors.Is(err, pgx.ErrNoRows) {
65
		return 0, errBadToken
66
	}
67
	return userID, err
68
}
69
70
// setPassword updates a user's password hash.
71
func (a *App) setPassword(ctx context.Context, userID int64, password string) error {
72
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
73
	if err != nil {
74
		return err
75
	}
76
	_, err = a.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, string(hash), userID)
77
	return err
78
}
79
80
func (a *App) subscriberForUser(ctx context.Context, userID int64) (Subscriber, error) {
81
	var subID int64
82
	if err := a.pool.QueryRow(ctx, `SELECT subscriber_id FROM users WHERE id = $1`, userID).Scan(&subID); err != nil {
83
		return Subscriber{}, err
84
	}
85
	return a.subscriber(ctx, subID)
86
}