users.go
⎇
Raw
1package db
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "time"
8)
9
10// AdminUsername is the single privileged account name.
11const AdminUsername = "admin"
12
13type User struct {
14 ID int64
15 Username string
16 PasswordHash *string
17 CreatedAt string
18 AvatarVersion int64
19 IsPending bool
20 RegisterApplication *string
21}
22
23const userColumns = `id, username, password_hash, created_at, avatar_version, is_pending, register_application`
24
25func scanUser(row *sql.Row) (*User, error) {
26 var u User
27 err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt,
28 &u.AvatarVersion, &u.IsPending, &u.RegisterApplication)
29 if errors.Is(err, sql.ErrNoRows) {
30 return nil, nil
31 }
32 if err != nil {
33 return nil, err
34 }
35 return &u, nil
36}
37
38func (d *DB) UserByName(ctx context.Context, name string) (*User, error) {
39 return scanUser(d.QueryRowContext(ctx,
40 `SELECT `+userColumns+` FROM users WHERE username = ?`, name))
41}
42
43func (d *DB) UserByID(ctx context.Context, id int64) (*User, error) {
44 return scanUser(d.QueryRowContext(ctx,
45 `SELECT `+userColumns+` FROM users WHERE id = ?`, id))
46}
47
48// ActivePasswordHash returns the hash of a non-pending user, used by git
49// smart-HTTP basic auth. The second result is false when there is no such user.
50func (d *DB) ActivePasswordHash(ctx context.Context, username string) (string, bool, error) {
51 var hash *string
52 err := d.QueryRowContext(ctx,
53 `SELECT password_hash FROM users WHERE username = ? AND is_pending = 0`,
54 username).Scan(&hash)
55 if errors.Is(err, sql.ErrNoRows) || (err == nil && hash == nil) {
56 return "", false, nil
57 }
58 if err != nil {
59 return "", false, err
60 }
61 return *hash, true, nil
62}
63
64func (d *DB) CreateUser(ctx context.Context, username string, passwordHash *string,
65 createdAt string, isPending bool, registerApplication *string,
66) (int64, error) {
67 res, err := d.ExecContext(ctx,
68 `INSERT INTO users (username, password_hash, created_at, is_pending, register_application)
69 VALUES (?, ?, ?, ?, ?)`,
70 username, passwordHash, createdAt, isPending, registerApplication)
71 if err != nil {
72 return 0, err
73 }
74 return res.LastInsertId()
75}
76
77// SetPasskeySetupStarted marks an account as waiting for its first passkey,
78// or clears the mark when at is empty. A passwordless signup that never
79// registers a credential leaves an account nobody can log in to.
80func (d *DB) SetPasskeySetupStarted(ctx context.Context, id int64, at string) error {
81 var value any
82 if at != "" {
83 value = at
84 }
85 _, err := d.ExecContext(ctx,
86 `UPDATE users SET passkey_setup_started_at = ? WHERE id = ?`, value, id)
87 return err
88}
89
90// DeleteStalePasskeySignups removes abandoned passwordless signups: marked
91// before cutoff, still without a passkey and without a password. Sessions go
92// with them through the foreign key.
93func (d *DB) DeleteStalePasskeySignups(ctx context.Context, cutoff string) error {
94 _, err := d.ExecContext(ctx,
95 `DELETE FROM users
96 WHERE passkey_setup_started_at IS NOT NULL AND passkey_setup_started_at < ?
97 AND password_hash IS NULL
98 AND NOT EXISTS (SELECT 1 FROM passkeys WHERE passkeys.user_id = users.id)`, cutoff)
99 return err
100}
101
102func (d *DB) DeleteUser(ctx context.Context, id int64) error {
103 _, err := d.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
104 return err
105}
106
107// SetPasswordHash sets or clears (nil) a user's password.
108func (d *DB) SetPasswordHash(ctx context.Context, id int64, hash *string) error {
109 _, err := d.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
110 return err
111}
112
113// ClearPasswordHash removes the password only while a passkey is left. The
114// check is part of the statement, so two concurrent removals cannot race an
115// account into having no login method at all. It reports false when it did
116// not run.
117func (d *DB) ClearPasswordHash(ctx context.Context, id int64) (bool, error) {
118 res, err := d.ExecContext(ctx,
119 `UPDATE users SET password_hash = NULL
120 WHERE id = ? AND EXISTS (SELECT 1 FROM passkeys WHERE user_id = ?)`, id, id)
121 if err != nil {
122 return false, err
123 }
124 n, err := res.RowsAffected()
125 return n > 0, err
126}
127
128func (d *DB) BumpAvatarVersion(ctx context.Context, id int64) error {
129 _, err := d.ExecContext(ctx, `UPDATE users SET avatar_version = avatar_version + 1 WHERE id = ?`, id)
130 return err
131}
132
133// PendingUser is one row of the admin approval queue.
134type PendingUser struct {
135 ID int64
136 Username string
137 RegisterApplication *string
138 CreatedAt string
139}
140
141func (d *DB) PendingUsers(ctx context.Context) ([]PendingUser, error) {
142 rows, err := d.QueryContext(ctx,
143 `SELECT id, username, register_application, created_at
144 FROM users WHERE is_pending = 1 ORDER BY created_at DESC`)
145 if err != nil {
146 return nil, err
147 }
148 defer rows.Close()
149 var out []PendingUser
150 for rows.Next() {
151 var p PendingUser
152 if err := rows.Scan(&p.ID, &p.Username, &p.RegisterApplication, &p.CreatedAt); err != nil {
153 return nil, err
154 }
155 out = append(out, p)
156 }
157 return out, rows.Err()
158}
159
160func (d *DB) ApprovePendingUser(ctx context.Context, id int64) error {
161 _, err := d.ExecContext(ctx, `UPDATE users SET is_pending = 0 WHERE id = ? AND is_pending = 1`, id)
162 return err
163}
164
165func (d *DB) RejectPendingUser(ctx context.Context, id int64) error {
166 _, err := d.ExecContext(ctx, `DELETE FROM users WHERE id = ? AND is_pending = 1`, id)
167 return err
168}
169
170func (d *DB) ApproveAllPendingUsers(ctx context.Context) error {
171 _, err := d.ExecContext(ctx, `UPDATE users SET is_pending = 0 WHERE is_pending = 1`)
172 return err
173}
174
175func (d *DB) RejectAllPendingUsers(ctx context.Context) error {
176 _, err := d.ExecContext(ctx, `DELETE FROM users WHERE is_pending = 1`)
177 return err
178}
179
180// NowISO formats the current time as UTC with milliseconds. Every timestamp
181// column in the database already uses this format.
182func NowISO() string {
183 return time.Now().UTC().Format(ISOLayout)
184}
185
186// ISOLayout is the layout NowISO uses. Callers that format a time other than
187// "now" use it directly.
188const ISOLayout = "2006-01-02T15:04:05.000Z"
189
190// InitAdmin creates the admin account when it does not exist yet. It reports
191// whether a new account was created.
192func (d *DB) InitAdmin(ctx context.Context, password string) (bool, error) {
193 existing, err := d.UserByName(ctx, AdminUsername)
194 if err != nil || existing != nil {
195 return false, err
196 }
197 hash, err := HashPassword(password)
198 if err != nil {
199 return false, err
200 }
201 if _, err := d.CreateUser(ctx, AdminUsername, &hash, NowISO(), false, nil); err != nil {
202 return false, err
203 }
204 return true, nil
205}
206