krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: internal/store/users.go · raw
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "strings"
8)
9
10type User struct {
11 ID int64
12 Username string
13 IsAdmin bool
14 Pending bool // self-registered, email not yet verified
15}
16
17type SSHKey struct {
18 ID int64
19 UserID int64
20 Fingerprint string
21 Algo string
22 Blob []byte
23 Scope string
24}
25
26// ErrDuplicateKey carries the exact user-facing message from the spec. It
27// deliberately does not name the owning account (enumeration oracle).
28var ErrDuplicateKey = errors.New("that key is already registered to another account; remove it there first or use a different key")
29
30var ErrNotFound = errors.New("not found")
31
32func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) {
33 if taken, err := ownerNameTaken(s.DB, username); err != nil {
34 return 0, err
35 } else if taken {
36 return 0, fmt.Errorf("username %q is taken", username)
37 }
38 res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin))
39 if err != nil {
40 if isUniqueErr(err) {
41 return 0, fmt.Errorf("username %q is taken", username)
42 }
43 return 0, err
44 }
45 return res.LastInsertId()
46}
47
48func (s *Store) UserByUsername(name string) (User, error) {
49 var u User
50 var admin, pending int
51 err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE username = ?", name).
52 Scan(&u.ID, &u.Username, &admin, &pending)
53 if errors.Is(err, sql.ErrNoRows) {
54 return u, ErrNotFound
55 }
56 u.IsAdmin = admin != 0
57 u.Pending = pending != 0
58 return u, err
59}
60
61func (s *Store) UserByID(id int64) (User, error) {
62 var u User
63 var admin, pending int
64 err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE id = ?", id).
65 Scan(&u.ID, &u.Username, &admin, &pending)
66 if errors.Is(err, sql.ErrNoRows) {
67 return u, ErrNotFound
68 }
69 u.IsAdmin = admin != 0
70 u.Pending = pending != 0
71 return u, err
72}
73
74// AddSSHKey registers a key and bumps the key epoch in one transaction.
75func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope string) error {
76 tx, err := s.DB.Begin()
77 if err != nil {
78 return err
79 }
80 defer tx.Rollback()
81 if _, err := tx.Exec(
82 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, ?)",
83 userID, fingerprint, algo, blob, scope); err != nil {
84 if isUniqueErr(err) {
85 return ErrDuplicateKey
86 }
87 return err
88 }
89 if err := bumpKeyEpoch(tx); err != nil {
90 return err
91 }
92 return tx.Commit()
93}
94
95// RemoveSSHKey removes a key owned by userID and bumps the key epoch.
96func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
97 tx, err := s.DB.Begin()
98 if err != nil {
99 return err
100 }
101 defer tx.Rollback()
102 res, err := tx.Exec("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ?", userID, fingerprint)
103 if err != nil {
104 return err
105 }
106 if n, _ := res.RowsAffected(); n == 0 {
107 return ErrNotFound
108 }
109 if err := bumpKeyEpoch(tx); err != nil {
110 return err
111 }
112 return tx.Commit()
113}
114
115func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
116 var k SSHKey
117 err := s.DB.QueryRow(
118 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE fingerprint = ?",
119 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope)
120 if errors.Is(err, sql.ErrNoRows) {
121 return k, ErrNotFound
122 }
123 return k, err
124}
125
126func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
127 rows, err := s.DB.Query(
128 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE user_id = ? ORDER BY id",
129 userID)
130 if err != nil {
131 return nil, err
132 }
133 defer rows.Close()
134 var keys []SSHKey
135 for rows.Next() {
136 var k SSHKey
137 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope); err != nil {
138 return nil, err
139 }
140 keys = append(keys, k)
141 }
142 return keys, rows.Err()
143}
144
145// TouchSSHKey records key use; best-effort, callers ignore the error.
146func (s *Store) TouchSSHKey(id int64) error {
147 _, err := s.DB.Exec(
148 "UPDATE ssh_keys SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id)
149 return err
150}
151
152// AddEmail adds an address; verifiedBy is "" (unverified), "smtp", or "admin".
153// Adding an already-verified address bumps the key epoch: it is a trust input
154// for signature states.
155func (s *Store) AddEmail(userID int64, address, verifiedBy string, primary bool) error {
156 tx, err := s.DB.Begin()
157 if err != nil {
158 return err
159 }
160 defer tx.Rollback()
161 var vAt, vBy any
162 if verifiedBy != "" {
163 vAt = "now"
164 vBy = verifiedBy
165 }
166 _, err = tx.Exec(
167 `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary)
168 VALUES (?, ?, CASE WHEN ? IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%fZ','now') END, ?, ?)`,
169 userID, address, vAt, vBy, boolInt(primary))
170 if isUniqueErr(err) {
171 return fmt.Errorf("address %q is already in use", address)
172 }
173 if err != nil {
174 return err
175 }
176 if verifiedBy != "" {
177 if err := bumpKeyEpoch(tx); err != nil {
178 return err
179 }
180 }
181 return tx.Commit()
182}
183
184func (s *Store) KeyEpoch() (int64, error) {
185 var v int64
186 err := s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&v)
187 return v, err
188}
189
190type execer interface {
191 Exec(query string, args ...any) (sql.Result, error)
192}
193
194func bumpKeyEpoch(tx execer) error {
195 _, err := tx.Exec("UPDATE settings SET value = value + 1 WHERE key = 'key_epoch'")
196 return err
197}
198
199func boolInt(b bool) int {
200 if b {
201 return 1
202 }
203 return 0
204}
205
206func isUniqueErr(err error) bool {
207 return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
208}
209
210func (s *Store) SSHKeyByID(id int64) (SSHKey, error) {
211 var k SSHKey
212 err := s.DB.QueryRow(
213 "SELECT id, user_id, fingerprint, algo, blob, scope FROM ssh_keys WHERE id = ?",
214 id).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope)
215 if errors.Is(err, sql.ErrNoRows) {
216 return k, ErrNotFound
217 }
218 return k, err
219}