package sqlstore import ( "context" "database/sql" "errors" "fmt" "strings" "github.com/dariuszjaniak/warp/server/internal/storage" ) // The admin store's sequence table and its one counter. const ( adminSequences = "admin_sequences" seqAudit = "audit" ) // Admin is the shared implementation of storage.AdminStore. type Admin struct { db *sql.DB rebind func(string) string isUnique func(error) bool } // NewAdmin builds an admin store. func NewAdmin(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Admin { if rebind == nil { rebind = func(query string) string { return query } } if isUnique == nil { isUnique = func(error) bool { return false } } return &Admin{db: db, rebind: rebind, isUnique: isUnique} } func (s *Admin) q(query string) string { return s.rebind(query) } const ( registrationTokenColumns = `token, uses_allowed, completed, expires_at_ms, created_at_ms` insertRegistrationTokenSQL = `INSERT INTO admin_registration_tokens (` + registrationTokenColumns + `) VALUES (?, ?, ?, ?, ?)` selectRegistrationTokenSQL = `SELECT ` + registrationTokenColumns + ` FROM admin_registration_tokens WHERE token = ?` selectRegistrationTokensSQL = `SELECT ` + registrationTokenColumns + ` FROM admin_registration_tokens ORDER BY token` updateRegistrationTokenSQL = `UPDATE admin_registration_tokens SET uses_allowed = ?, expires_at_ms = ? WHERE token = ?` deleteRegistrationTokenSQL = `DELETE FROM admin_registration_tokens WHERE token = ?` // The usability test is in the WHERE clause, so that checking and // spending are one statement and two registrations cannot both take // the last use. useRegistrationTokenSQL = `UPDATE admin_registration_tokens SET completed = completed + 1 WHERE token = ? AND (uses_allowed IS NULL OR completed < uses_allowed) AND (expires_at_ms = 0 OR expires_at_ms > ?)` releaseRegistrationTokenSQL = `UPDATE admin_registration_tokens SET completed = completed - 1 WHERE token = ? AND completed > 0` ) // CreateRegistrationToken implements storage.AdminStore. func (s *Admin) CreateRegistrationToken(ctx context.Context, t storage.RegistrationToken) error { _, err := s.db.ExecContext(ctx, s.q(insertRegistrationTokenSQL), t.Token, nullableInt(t.UsesAllowed), t.Completed, t.ExpiresAtMS, t.CreatedAtMS) switch { case err != nil && s.isUnique(err): return storage.ErrRegistrationTokenExists case err != nil: return fmt.Errorf("create a registration token: %w", err) } return nil } // RegistrationToken implements storage.AdminStore. func (s *Admin) RegistrationToken(ctx context.Context, token string) (storage.RegistrationToken, error) { t, err := scanRegistrationToken(s.db.QueryRowContext(ctx, s.q(selectRegistrationTokenSQL), token).Scan) switch { case errors.Is(err, sql.ErrNoRows): return storage.RegistrationToken{}, storage.ErrNoSuchRegistrationToken case err != nil: return storage.RegistrationToken{}, fmt.Errorf("read a registration token: %w", err) } return t, nil } // RegistrationTokens implements storage.AdminStore. func (s *Admin) RegistrationTokens(ctx context.Context) ([]storage.RegistrationToken, error) { rows, err := s.db.QueryContext(ctx, s.q(selectRegistrationTokensSQL)) if err != nil { return nil, fmt.Errorf("list registration tokens: %w", err) } defer func() { _ = rows.Close() }() out := []storage.RegistrationToken{} for rows.Next() { t, err := scanRegistrationToken(rows.Scan) if err != nil { return nil, fmt.Errorf("list registration tokens: %w", err) } out = append(out, t) } return out, rows.Err() } // UpdateRegistrationToken implements storage.AdminStore. func (s *Admin) UpdateRegistrationToken(ctx context.Context, token string, usesAllowed *int, expiresAtMS int64) error { res, err := s.db.ExecContext(ctx, s.q(updateRegistrationTokenSQL), nullableInt(usesAllowed), expiresAtMS, token) if err != nil { return fmt.Errorf("update a registration token: %w", err) } return mustHaveAffected(res, storage.ErrNoSuchRegistrationToken) } // DeleteRegistrationToken implements storage.AdminStore. func (s *Admin) DeleteRegistrationToken(ctx context.Context, token string) error { res, err := s.db.ExecContext(ctx, s.q(deleteRegistrationTokenSQL), token) if err != nil { return fmt.Errorf("delete a registration token: %w", err) } return mustHaveAffected(res, storage.ErrNoSuchRegistrationToken) } // UseRegistrationToken implements storage.AdminStore. func (s *Admin) UseRegistrationToken(ctx context.Context, token string, nowMS int64) error { res, err := s.db.ExecContext(ctx, s.q(useRegistrationTokenSQL), token, nowMS) if err != nil { return fmt.Errorf("spend a registration token: %w", err) } return mustHaveAffected(res, storage.ErrNoSuchRegistrationToken) } // ReleaseRegistrationToken implements storage.AdminStore. A token deleted // in the meantime has nothing to give back to, which is not an error. func (s *Admin) ReleaseRegistrationToken(ctx context.Context, token string) error { if _, err := s.db.ExecContext(ctx, s.q(releaseRegistrationTokenSQL), token); err != nil { return fmt.Errorf("release a registration token: %w", err) } return nil } func scanRegistrationToken(scan func(...any) error) (storage.RegistrationToken, error) { var ( t storage.RegistrationToken uses sql.NullInt64 ) if err := scan(&t.Token, &uses, &t.Completed, &t.ExpiresAtMS, &t.CreatedAtMS); err != nil { return storage.RegistrationToken{}, err } if uses.Valid { n := int(uses.Int64) t.UsesAllowed = &n } return t, nil } // nullableInt binds an optional count as NULL when it is absent. func nullableInt(n *int) any { if n == nil { return nil } return int64(*n) } // mustHaveAffected turns an UPDATE or DELETE that matched nothing into // notFound. func mustHaveAffected(res sql.Result, notFound error) error { n, err := res.RowsAffected() if err != nil { return err } if n == 0 { return notFound } return nil } const ( insertAuditSQL = `INSERT INTO admin_audit_log (id, ts_ms, actor, action, target, details_json) VALUES (?, ?, ?, ?, ?, ?)` auditColumns = `id, ts_ms, actor, action, target, details_json` // defaultAuditPage and maxAuditPage bound a page of the audit log. defaultAuditPage = 50 maxAuditPage = 1000 ) // AppendAudit implements storage.AdminStore. func (s *Admin) AppendAudit(ctx context.Context, e storage.AuditEntry) (int64, error) { details := string(e.Details) if details == "" { details = "{}" } var id int64 err := inTx(ctx, s.db, func(tx *sql.Tx) error { var err error if id, err = nextSequence(ctx, tx, s.q, adminSequences, seqAudit); err != nil { return err } _, err = tx.ExecContext(ctx, s.q(insertAuditSQL), id, e.TSMS, e.Actor, e.Action, e.Target, details) return err }) if err != nil { return 0, fmt.Errorf("append to the audit log: %w", err) } return id, nil } // AuditLog implements storage.AdminStore. func (s *Admin) AuditLog(ctx context.Context, f storage.AuditFilter) ([]storage.AuditEntry, error) { var ( conditions []string args []any ) if f.Before > 0 { conditions = append(conditions, `id < ?`) args = append(args, f.Before) } if f.Actor != "" { conditions = append(conditions, `actor = ?`) args = append(args, f.Actor) } if f.Action != "" { conditions = append(conditions, `action = ?`) args = append(args, f.Action) } limit := f.Limit switch { case limit <= 0: limit = defaultAuditPage case limit > maxAuditPage: limit = maxAuditPage } args = append(args, limit) query := `SELECT ` + auditColumns + ` FROM admin_audit_log` if len(conditions) > 0 { query += ` WHERE ` + strings.Join(conditions, " AND ") } query += ` ORDER BY id DESC LIMIT ?` rows, err := s.db.QueryContext(ctx, s.q(query), args...) if err != nil { return nil, fmt.Errorf("read the audit log: %w", err) } defer func() { _ = rows.Close() }() out := []storage.AuditEntry{} for rows.Next() { var ( e storage.AuditEntry details string ) if err := rows.Scan(&e.ID, &e.TSMS, &e.Actor, &e.Action, &e.Target, &details); err != nil { return nil, fmt.Errorf("read the audit log: %w", err) } e.Details = []byte(details) out = append(out, e) } return out, rows.Err() } const ( selectServerNoticeRoomSQL = `SELECT room_id FROM admin_server_notice_rooms WHERE localpart = ?` upsertServerNoticeRoomSQL = ` INSERT INTO admin_server_notice_rooms (localpart, room_id) VALUES (?, ?) ON CONFLICT (localpart) DO UPDATE SET room_id = excluded.room_id` ) // ServerNoticeRoom implements storage.AdminStore. func (s *Admin) ServerNoticeRoom(ctx context.Context, localpart string) (string, error) { var roomID string err := s.db.QueryRowContext(ctx, s.q(selectServerNoticeRoomSQL), localpart).Scan(&roomID) switch { case errors.Is(err, sql.ErrNoRows): return "", storage.ErrNoServerNoticeRoom case err != nil: return "", fmt.Errorf("read the server notice room of %q: %w", localpart, err) } return roomID, nil } // SetServerNoticeRoom implements storage.AdminStore. func (s *Admin) SetServerNoticeRoom(ctx context.Context, localpart, roomID string) error { if _, err := s.db.ExecContext(ctx, s.q(upsertServerNoticeRoomSQL), localpart, roomID); err != nil { return fmt.Errorf("record the server notice room of %q: %w", localpart, err) } return nil } // inTx runs fn in a transaction on db, rolling back on any error. func inTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin transaction: %w", err) } defer func() { _ = tx.Rollback() }() if err := fn(tx); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit transaction: %w", err) } return nil } // Compile-time proof that the shared implementation satisfies the interface. var _ storage.AdminStore = (*Admin)(nil)