internal/store/adminusers.go

v1.43.0
gitbay/internal/store/adminusers.go history · blame · raw

217 lines · 6613 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"time"
  9)
 10
 11// AdminUser is one account as the instance admin sees it. LastSeen is the
 12// most recent authentication by any of the account's SSH keys or API
 13// tokens, "" when there has been none.
 14type AdminUser struct {
 15	Username  string
 16	IsAdmin   bool
 17	Pending   bool
 18	Disabled  bool
 19	CreatedAt string
 20	LastSeen  string
 21}
 22
 23const adminUserSelect = `SELECT u.username, u.is_admin, u.pending, u.disabled, u.created_at,
 24	COALESCE((SELECT MAX(t) FROM (
 25		SELECT last_used_at t FROM ssh_keys WHERE user_id = u.id
 26		UNION ALL SELECT last_used_at FROM api_tokens WHERE user_id = u.id)), '')
 27	FROM users u`
 28
 29func scanAdminUser(row interface{ Scan(...any) error }) (AdminUser, error) {
 30	var u AdminUser
 31	var admin, pending, disabled int
 32	err := row.Scan(&u.Username, &admin, &pending, &disabled, &u.CreatedAt, &u.LastSeen)
 33	u.IsAdmin = admin != 0
 34	u.Pending = pending != 0
 35	u.Disabled = disabled != 0
 36	return u, err
 37}
 38
 39// ListUsers returns accounts by username. state narrows the set: "" for
 40// every account, active (neither pending nor disabled), pending, disabled,
 41// or admin. after is the keyset cursor: usernames strictly greater than
 42// it, "" from the start. limit 0 means no cap.
 43func (s *Store) ListUsers(state string, limit int, after string) ([]AdminUser, error) {
 44	where := "WHERE u.username > ?"
 45	switch state {
 46	case "":
 47	case "active":
 48		where += " AND u.pending = 0 AND u.disabled = 0"
 49	case "pending":
 50		where += " AND u.pending = 1"
 51	case "disabled":
 52		where += " AND u.disabled = 1"
 53	case "admin":
 54		where += " AND u.is_admin = 1"
 55	default:
 56		return nil, fmt.Errorf("unknown state %q", state)
 57	}
 58	q := adminUserSelect + " " + where + " ORDER BY u.username"
 59	args := []any{after}
 60	if limit > 0 {
 61		q += " LIMIT ?"
 62		args = append(args, limit)
 63	}
 64	rows, err := s.DB.Query(q, args...)
 65	if err != nil {
 66		return nil, err
 67	}
 68	defer rows.Close()
 69	var out []AdminUser
 70	for rows.Next() {
 71		u, err := scanAdminUser(rows)
 72		if err != nil {
 73			return nil, err
 74		}
 75		out = append(out, u)
 76	}
 77	return out, rows.Err()
 78}
 79
 80// AdminUserByName is the ListUsers row for one account.
 81func (s *Store) AdminUserByName(name string) (AdminUser, error) {
 82	u, err := scanAdminUser(s.DB.QueryRow(adminUserSelect+" WHERE u.username = ?", name))
 83	if errors.Is(err, sql.ErrNoRows) {
 84		return u, ErrNotFound
 85	}
 86	return u, err
 87}
 88
 89// AdminMailAddresses returns where to reach the instance's admins: the
 90// verified primary address of every active admin who has activity mail
 91// on. An admin with no verified primary, or with mail off, is skipped
 92// rather than reported, the same rule ActivityMailAddress applies to
 93// anyone else (#234).
 94func (s *Store) AdminMailAddresses() ([]string, error) {
 95	rows, err := s.DB.Query(`SELECT e.address FROM users u
 96		JOIN emails e ON e.user_id = u.id AND e.is_primary = 1 AND e.verified_at IS NOT NULL
 97		WHERE u.is_admin = 1 AND u.pending = 0 AND u.disabled = 0 AND u.notify_mail != 0
 98		ORDER BY e.address`)
 99	if err != nil {
100		return nil, err
101	}
102	defer rows.Close()
103	var out []string
104	for rows.Next() {
105		var a string
106		if err := rows.Scan(&a); err != nil {
107			return nil, err
108		}
109		out = append(out, a)
110	}
111	return out, rows.Err()
112}
113
114// OwnedRepoCount counts repositories a user or an org owns directly.
115func (s *Store) OwnedRepoCount(kind string, id int64) (int64, error) {
116	var n int64
117	err := s.DB.QueryRow("SELECT COUNT(*) FROM repos WHERE owner_kind = ? AND owner_id = ?", kind, id).Scan(&n)
118	return n, err
119}
120
121// WebSessionCount counts the user's unexpired browser sessions.
122func (s *Store) WebSessionCount(userID int64) (int64, error) {
123	var n int64
124	err := s.DB.QueryRow("SELECT COUNT(*) FROM web_sessions WHERE user_id = ? AND expires_at > ?",
125		userID, fmtTime(time.Now())).Scan(&n)
126	return n, err
127}
128
129// ErrLastAdmin refuses the demotion that would leave the instance with no
130// admin at all.
131var ErrLastAdmin = errors.New("that is the only instance admin; promote someone else first")
132
133// SetUserAdmin grants or removes instance admin. Removing it from the last
134// admin is refused inside the same transaction that counts them.
135func (s *Store) SetUserAdmin(userID int64, admin bool) error {
136	tx, err := s.DB.Begin()
137	if err != nil {
138		return err
139	}
140	defer tx.Rollback()
141	if !admin {
142		var others int
143		if err := tx.QueryRow("SELECT COUNT(*) FROM users WHERE is_admin = 1 AND id != ?", userID).Scan(&others); err != nil {
144			return err
145		}
146		if others == 0 {
147			return ErrLastAdmin
148		}
149	}
150	res, err := tx.Exec("UPDATE users SET is_admin = ? WHERE id = ?", boolInt(admin), userID)
151	if err != nil {
152		return err
153	}
154	if n, _ := res.RowsAffected(); n == 0 {
155		return ErrNotFound
156	}
157	return tx.Commit()
158}
159
160// AdminRepo is one repository as the instance admin lists it. LastPush is
161// the newest push event, "" when nothing has been pushed.
162type AdminRepo struct {
163	Path       string // owner/name, the keyset cursor
164	OwnerName  string
165	Name       string
166	Visibility string
167	Archived   bool
168	CreatedAt  string
169	LastPush   string
170}
171
172// ListReposAdmin lists repositories across every owner, by path. owner and
173// visibility narrow the set when non-empty; after is the path keyset
174// cursor; limit 0 means no cap.
175func (s *Store) ListReposAdmin(owner, visibility string, limit int, after string) ([]AdminRepo, error) {
176	q := `SELECT COALESCE(u.username, o.name) || '/' || r.name, COALESCE(u.username, o.name), r.name,
177		r.visibility, r.settings_json, r.created_at,
178		COALESCE((SELECT MAX(created_at) FROM events WHERE repo_id = r.id AND kind = 'push'), '')
179		FROM repos r
180		LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
181		LEFT JOIN orgs o  ON r.owner_kind = 'org'  AND o.id = r.owner_id
182		WHERE COALESCE(u.username, o.name) || '/' || r.name > ?`
183	args := []any{after}
184	if owner != "" {
185		q += " AND COALESCE(u.username, o.name) = ?"
186		args = append(args, owner)
187	}
188	if visibility != "" {
189		q += " AND r.visibility = ?"
190		args = append(args, visibility)
191	}
192	q += " ORDER BY 1"
193	if limit > 0 {
194		q += " LIMIT ?"
195		args = append(args, limit)
196	}
197	rows, err := s.DB.Query(q, args...)
198	if err != nil {
199		return nil, err
200	}
201	defer rows.Close()
202	var out []AdminRepo
203	for rows.Next() {
204		var r AdminRepo
205		var settingsJSON string
206		if err := rows.Scan(&r.Path, &r.OwnerName, &r.Name, &r.Visibility, &settingsJSON, &r.CreatedAt, &r.LastPush); err != nil {
207			return nil, err
208		}
209		var st RepoSettings
210		if err := json.Unmarshal([]byte(settingsJSON), &st); err != nil {
211			return nil, fmt.Errorf("repo %s settings: %w", r.Path, err)
212		}
213		r.Archived = st.Archived
214		out = append(out, r)
215	}
216	return out, rows.Err()
217}