db.go
1package user
2
3import (
4 "database/sql"
5 "embed"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "log/slog"
10 "regexp"
11 "slices"
12 "strings"
13
14 "git.kilimanjaro.io/rtw/pkg/log"
15 "github.com/alexedwards/argon2id"
16 "github.com/pressly/goose/v3"
17)
18
19//go:embed schema/*.sql
20var migrations embed.FS
21
22var (
23 ErrEmailNotFound = errors.New("email not found")
24 ErrHandleNotFound = errors.New("handle not found")
25 ErrPasswordMismatch = errors.New("password mismatch")
26 ErrInvalidHandle = errors.New("invalid handle")
27 ErrHandleExists = errors.New("handle exists")
28 ErrInvalidEmail = errors.New("account exists for email")
29 ErrAccountExists = errors.New("account exists")
30)
31
32// Migrate sets up the database
33func Migrate(db *sql.DB) error {
34 goose.SetBaseFS(migrations)
35 goose.SetLogger(log.GooseLogger(slog.String("migrations", "pkg/user/schema")))
36
37 if err := goose.SetDialect("sqlite3"); err != nil {
38 return fmt.Errorf("error setting user database type: %w", err)
39 }
40
41 if err := goose.Up(db, "schema", goose.WithNoVersioning()); err != nil {
42 return fmt.Errorf("error migrating user tables: %w", err)
43 }
44
45 return nil
46}
47
48type userDetails struct {
49 email *string
50 handle *string
51 passwordHash *string
52 metadata []byte
53}
54
55// Option sets fields for a user
56type Option func(*userDetails) error
57
58// Email for a user
59func Email(email string) Option {
60 return func(c *userDetails) error {
61 c.email = &email
62 return nil
63 }
64}
65
66// Handle for a user
67func Handle(handle string) Option {
68 return func(c *userDetails) error {
69 if !isHandleValid(handle) {
70 return ErrInvalidHandle
71 }
72 c.handle = &handle
73 return nil
74 }
75}
76
77// Password for a user. Password is hashed using Argon2ID with default parameters for 8/2025
78func Password(password string) Option {
79 return func(c *userDetails) error {
80 hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
81 if err != nil {
82 return err
83 }
84 c.passwordHash = &hash
85 return nil
86 }
87}
88
89// Metadata for a user. Any value that can be marshaled to JSON.
90func Metadata(md any) Option {
91 return func(c *userDetails) error {
92 b, err := json.Marshal(md)
93 if err != nil {
94 return fmt.Errorf("error marshaling user metadata: %w", err)
95 }
96 c.metadata = b
97 return nil
98 }
99}
100
101// Set details for a user. This is an upsert operation if the id already exists.
102func Set[T ~int64](db *sql.DB, id T, options ...Option) error {
103 var c userDetails
104 for _, opt := range options {
105 if err := opt(&c); err != nil {
106 return err
107 }
108 }
109
110 if _, err := db.Exec(`INSERT INTO users
111 (id, handle, email, password_hash, metadata)
112 VALUES
113 (?, ?, ?, ?, ?)
114 ON CONFLICT (id) DO UPDATE SET
115 handle=COALESCE(excluded.handle, handle),
116 email=COALESCE(excluded.email, email),
117 password_hash=COALESCE(excluded.password_hash, password_hash),
118 metadata=COALESCE(excluded.metadata, metadata)`,
119 int64(id), c.handle, c.email, c.passwordHash, c.metadata); err != nil {
120 return fmt.Errorf("error inserting user record: %w", err)
121 }
122 return nil
123}
124
125// Login checks the email or handle with the password and returns the user ID or an error on password mismatch or non-existent account identifier.
126func Login[T ~int64](db *sql.DB, emailOrHandle string, password string) (T, error) {
127 // try email first then handle if email not found
128 id, err := loginEmail[T](db, emailOrHandle, password)
129 if err != nil && errors.Is(err, ErrEmailNotFound) {
130 return loginHandle[T](db, emailOrHandle, password)
131 }
132 return id, err
133}
134
135func loginEmail[T ~int64](db *sql.DB, email string, password string) (T, error) {
136 var id int64
137 var hash string
138 if err := db.QueryRow("SELECT id, password_hash FROM users_normalized WHERE email=lower(?)", email).Scan(&id, &hash); err != nil {
139 return T(-1), ErrEmailNotFound
140 }
141 match, err := argon2id.ComparePasswordAndHash(password, hash)
142 if match && err == nil {
143 return T(id), nil
144 }
145 return T(-1), ErrPasswordMismatch
146}
147
148func loginHandle[T ~int64](db *sql.DB, handle string, password string) (T, error) {
149 var id int64
150 var hash string
151 if err := db.QueryRow("SELECT id, password_hash FROM users_normalized WHERE handle=lower(?)", handle).Scan(&id, &hash); err != nil {
152 return T(-1), ErrHandleNotFound
153 }
154 match, err := argon2id.ComparePasswordAndHash(password, hash)
155 if match && err == nil {
156 return T(id), nil
157 }
158 return T(-1), ErrPasswordMismatch
159}
160
161// isHandleValid checks whether the handle meets the requirements and is not in the blocklist
162func isHandleValid(handle string) bool {
163 blocklist := []string{
164 "moderator",
165 "founder",
166 "rtw.run",
167 "ewan",
168 "charley",
169 "[deleted]",
170 "scooter",
171 "bt",
172 }
173 r := regexp.MustCompile(`^(([a-zA-Z0-9]+|(\.|_|\-|\/|@|\[|\]|!)+))+$`)
174 if len(handle) < 2 || r.FindString(handle) != handle || slices.Contains(blocklist, strings.ToLower(handle)) {
175 return false
176 }
177 return true
178}
179
180// CheckEmail returns an ErrAccountExists if the email is already in the database
181func CheckEmail(db *sql.DB, email string) error {
182 if !strings.Contains(email, "@") || email == "" {
183 return ErrInvalidEmail
184 }
185 var exists bool
186 if err := db.QueryRow("SELECT COUNT(email) FROM users_normalized WHERE email=lower(?)", email).Scan(&exists); err != nil {
187 return fmt.Errorf("unexpected error checking email: %w", err)
188 }
189 if exists {
190 return ErrAccountExists
191 }
192 return nil
193}
194
195// CheckHandle returns ErrHandleExists if the handle has been previously taken or ErrInvalidHandle if the handle doesnt meet handle rules or in the blocklist
196func CheckHandle(db *sql.DB, handle string) error {
197 if !isHandleValid(handle) {
198 return ErrInvalidHandle
199 }
200 var exists bool
201 if err := db.QueryRow("SELECT COUNT(handle) FROM users_normalized WHERE handle=lower(?)", handle).Scan(&exists); err != nil {
202 return fmt.Errorf("unexpected error checking handle: %w", err)
203 }
204 if exists {
205 return ErrHandleExists
206 }
207 return nil
208}
209
210// HandleRules returns the rules for a valid handle
211func HandleRules() []string {
212 return []string{
213 "Must start with a letter or number",
214 "May contain punctuation: . / _ - @ [] !",
215 "Can not start or end with a punctuation character",
216 "Must be at least 2 characters long",
217 }
218}
219
220// GetEmailAndHandle gets the email and handle for a user ID
221func GetEmailAndHandle[T ~int64](db *sql.DB, id T) (email string, handle string, err error) {
222 var e, h sql.NullString
223 err = db.QueryRow("SELECT email, handle FROM users WHERE id=?", int64(id)).Scan(&e, &h)
224 if e.Valid {
225 email = e.String
226 }
227 if h.Valid {
228 handle = h.String
229 }
230 return
231}
232
233// GetMetadata returns the metadata, unmarshaling it from JSON into receiver
234func GetMetadata[T ~int64](db *sql.DB, id T, receiver any) error {
235 var b []byte
236 if err := db.QueryRow("SELECT metadata FROM users WHERE id=?", int64(id)).Scan(&b); err != nil {
237 return fmt.Errorf("error getting metadata for id=%d: %w", id, err)
238 }
239 if err := json.Unmarshal(b, receiver); err != nil {
240 return fmt.Errorf("error unmarshaling metadata: %w", err)
241 }
242 return nil
243}
244
245// IsRegistered returns true if the user ID is associated with an account that has either an email or handle
246func IsRegistered[T ~int64](db *sql.DB, id T) (bool, error) {
247 var ok bool
248 if err := db.QueryRow("SELECT COUNT(*) FROM users_registered WHERE id=?", int64(id)).Scan(&ok); err != nil {
249 return false, fmt.Errorf("error checking registered status for user=%d: %w", id, err)
250 }
251 return ok, nil
252}
253
254// IsAnonymous returns true if the user ID does not have an associated email or handle
255func IsAnonymous[T ~int64](db *sql.DB, id T) (bool, error) {
256 ok, err := IsRegistered(db, id)
257 return !ok, err
258}