package storage import ( "context" "database/sql" "errors" "fmt" ) type rowScanner interface { Scan(dest ...any) error } type execer interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } const userColumns = `id, username, display_name, password_hash, disabled, uid_number, created_at, last_login_at, credentials_epoch` func (s *Store) CreateUser(ctx context.Context, input CreateUserInput) (User, error) { const operation = "create user" if err := s.checkOpen(operation); err != nil { return User{}, err } user, err := s.prepareUser(operation, input) if err != nil { return User{}, err } tx, err := s.sql.BeginTx(ctx, nil) if err != nil { return User{}, classify("begin "+operation, err) } defer tx.Rollback() user, err = insertUser(ctx, tx, operation, user) if err != nil { return User{}, err } if err := tx.Commit(); err != nil { return User{}, classify("commit "+operation, err) } return user, nil } func (s *Store) prepareUser(operation string, input CreateUserInput) (User, error) { username, err := normalizeUsername(input.Username) if err != nil { return User{}, invalid(operation, err) } if err := validateDisplayName(input.DisplayName); err != nil { return User{}, invalid(operation, err) } if len(input.PasswordHash) == 0 { return User{}, invalid(operation, fmt.Errorf("password hash is empty")) } id, err := s.newID() if err != nil { return User{}, fmt.Errorf("%s: %w", operation, err) } return User{ ID: id, Username: username, DisplayName: input.DisplayName, PasswordHash: append([]byte(nil), input.PasswordHash...), Disabled: input.Disabled, CreatedAt: canonicalTime(s.now()), }, nil } func insertUser(ctx context.Context, tx *sql.Tx, operation string, user User) (User, error) { var uid int if err := tx.QueryRowContext(ctx, `SELECT next_uid_number FROM settings WHERE id = 1`).Scan(&uid); err != nil { return User{}, classify("read next uid", err) } if uid < firstUID || uid > lastUID { return User{}, fmt.Errorf("%s: %w", operation, ErrUIDExhausted) } if _, err := tx.ExecContext(ctx, `UPDATE settings SET next_uid_number = ? WHERE id = 1`, uid+1); err != nil { return User{}, classify("advance uid", err) } if _, err := tx.ExecContext(ctx, ` INSERT INTO users ( id, username, display_name, password_hash, disabled, uid_number, created_at, last_login_at ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)`, user.ID, user.Username, user.DisplayName, user.PasswordHash, boolToInt(user.Disabled), uid, timeToInt(user.CreatedAt)); err != nil { return User{}, classify(operation, err) } if err := clearBootstrap(ctx, tx, operation, user.CreatedAt); err != nil { return User{}, err } user.UIDNumber = uid return user, nil } func (s *Store) UserByID(ctx context.Context, id string) (User, error) { return s.userBy(ctx, "get user by id", `SELECT `+userColumns+` FROM users WHERE id = ?`, id) } func (s *Store) UserByUsername(ctx context.Context, username string) (User, error) { const operation = "get user by username" normalized, err := normalizeUsername(username) if err != nil { return User{}, invalid(operation, err) } return s.userBy(ctx, operation, `SELECT `+userColumns+` FROM users WHERE username = ?`, normalized) } func (s *Store) userBy(ctx context.Context, operation, query string, arg any) (User, error) { if err := s.checkOpen(operation); err != nil { return User{}, err } user, err := scanUser(s.sql.QueryRowContext(ctx, query, arg)) if errors.Is(err, sql.ErrNoRows) { return User{}, fmt.Errorf("%s: %w", operation, ErrNotFound) } if err != nil { return User{}, classify(operation, err) } return user, nil } func (s *Store) ListUsers(ctx context.Context) ([]User, error) { const operation = "list users" if err := s.checkOpen(operation); err != nil { return nil, err } rows, err := s.sql.QueryContext(ctx, `SELECT `+userColumns+` FROM users ORDER BY username, id`) if err != nil { return nil, classify(operation, err) } defer rows.Close() var users []User for rows.Next() { user, err := scanUser(rows) if err != nil { return nil, classify(operation, err) } users = append(users, user) } if err := rows.Err(); err != nil { return nil, classify(operation, err) } return users, nil } func (s *Store) UpdateUser(ctx context.Context, id string, input UpdateUserInput) (User, error) { const operation = "update user" if err := s.checkOpen(operation); err != nil { return User{}, err } if err := validateDisplayName(input.DisplayName); err != nil { return User{}, invalid(operation, err) } if len(input.PasswordHash) == 0 { return User{}, invalid(operation, fmt.Errorf("password hash is empty")) } var lastLogin any if input.LastLoginAt != nil { lastLogin = timeToInt(*input.LastLoginAt) } result, err := s.sql.ExecContext(ctx, ` UPDATE users SET display_name = ?, password_hash = ?, disabled = ?, last_login_at = ? WHERE id = ?`, input.DisplayName, append([]byte(nil), input.PasswordHash...), boolToInt(input.Disabled), lastLogin, id) if err != nil { return User{}, classify(operation, err) } if err := requireChanged(operation, result); err != nil { return User{}, err } return s.UserByID(ctx, id) } func (s *Store) DeleteUser(ctx context.Context, id string) error { return s.deleteByID(ctx, "delete user", "users", id) } func scanUser(row rowScanner) (User, error) { var user User var disabled int var createdAt int64 var lastLogin sql.NullInt64 if err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.PasswordHash, &disabled, &user.UIDNumber, &createdAt, &lastLogin, &user.CredentialsEpoch); err != nil { return User{}, err } user.Disabled = disabled != 0 user.CreatedAt = intToTime(createdAt) user.PasswordHash = append([]byte(nil), user.PasswordHash...) if lastLogin.Valid { value := intToTime(lastLogin.Int64) user.LastLoginAt = &value } return user, nil } func (s *Store) CreateGroup(ctx context.Context, input CreateGroupInput) (Group, error) { const operation = "create group" if err := s.checkOpen(operation); err != nil { return Group{}, err } group, err := s.prepareGroup(operation, input) if err != nil { return Group{}, err } tx, err := s.sql.BeginTx(ctx, nil) if err != nil { return Group{}, classify("begin "+operation, err) } defer tx.Rollback() group, err = insertGroup(ctx, tx, operation, group) if err != nil { return Group{}, err } if err := tx.Commit(); err != nil { return Group{}, classify("commit "+operation, err) } return group, nil } func (s *Store) prepareGroup(operation string, input CreateGroupInput) (Group, error) { name, err := normalizeGroupName(input.Name) if err != nil { return Group{}, invalid(operation, err) } if err := validateGroupDescription(input.Description); err != nil { return Group{}, invalid(operation, err) } id, err := s.newID() if err != nil { return Group{}, fmt.Errorf("%s: %w", operation, err) } return Group{ID: id, Name: name, Description: input.Description, CreatedAt: canonicalTime(s.now())}, nil } func insertGroup(ctx context.Context, tx *sql.Tx, operation string, group Group) (Group, error) { var gid int64 if err := tx.QueryRowContext(ctx, `SELECT next_gid_number FROM settings WHERE id = 1`).Scan(&gid); err != nil { return Group{}, classify("read next gid", err) } if gid < firstGID || gid > lastGID { return Group{}, fmt.Errorf("%s: %w", operation, ErrGIDExhausted) } if _, err := tx.ExecContext(ctx, `UPDATE settings SET next_gid_number = ? WHERE id = 1`, gid+1); err != nil { return Group{}, classify("advance gid", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO groups (id, name, description, gid_number, created_at) VALUES (?, ?, ?, ?, ?)`, group.ID, group.Name, group.Description, gid, timeToInt(group.CreatedAt)); err != nil { return Group{}, classify(operation, err) } group.GIDNumber = int(gid) return group, nil } func (s *Store) GroupByID(ctx context.Context, id string) (Group, error) { return s.groupBy(ctx, "get group by id", `SELECT id, name, description, gid_number, created_at FROM groups WHERE id = ?`, id) } func (s *Store) GroupByName(ctx context.Context, name string) (Group, error) { const operation = "get group by name" normalized, err := normalizeGroupName(name) if err != nil { return Group{}, invalid(operation, err) } return s.groupBy(ctx, operation, `SELECT id, name, description, gid_number, created_at FROM groups WHERE name = ?`, normalized) } func (s *Store) groupBy(ctx context.Context, operation, query string, arg any) (Group, error) { if err := s.checkOpen(operation); err != nil { return Group{}, err } group, err := scanGroup(s.sql.QueryRowContext(ctx, query, arg)) if errors.Is(err, sql.ErrNoRows) { return Group{}, fmt.Errorf("%s: %w", operation, ErrNotFound) } if err != nil { return Group{}, classify(operation, err) } return group, nil } func (s *Store) ListGroups(ctx context.Context) ([]Group, error) { const operation = "list groups" if err := s.checkOpen(operation); err != nil { return nil, err } rows, err := s.sql.QueryContext(ctx, `SELECT id, name, description, gid_number, created_at FROM groups ORDER BY name, id`) if err != nil { return nil, classify(operation, err) } defer rows.Close() var groups []Group for rows.Next() { group, err := scanGroup(rows) if err != nil { return nil, classify(operation, err) } groups = append(groups, group) } if err := rows.Err(); err != nil { return nil, classify(operation, err) } return groups, nil } func (s *Store) UpdateGroupDescription(ctx context.Context, id, description string) (Group, error) { const operation = "update group" if err := s.checkOpen(operation); err != nil { return Group{}, err } if err := validateGroupDescription(description); err != nil { return Group{}, invalid(operation, err) } result, err := s.sql.ExecContext(ctx, `UPDATE groups SET description = ? WHERE id = ?`, description, id) if err != nil { return Group{}, classify(operation, err) } if err := requireChanged(operation, result); err != nil { return Group{}, err } return s.GroupByID(ctx, id) } func (s *Store) DeleteGroup(ctx context.Context, id string) error { return s.deleteByID(ctx, "delete group", "groups", id) } func scanGroup(row rowScanner) (Group, error) { var group Group var createdAt int64 if err := row.Scan(&group.ID, &group.Name, &group.Description, &group.GIDNumber, &createdAt); err != nil { return Group{}, err } group.CreatedAt = intToTime(createdAt) return group, nil } func (s *Store) deleteByID(ctx context.Context, operation, table, id string) error { if err := s.checkOpen(operation); err != nil { return err } query := `DELETE FROM ` + table + ` WHERE id = ?` result, err := s.sql.ExecContext(ctx, query, id) if err != nil { return classify(operation, err) } return requireChanged(operation, result) } func requireChanged(operation string, result sql.Result) error { count, err := result.RowsAffected() if err != nil { return classify(operation+" rows affected", err) } if count == 0 { return fmt.Errorf("%s: %w", operation, ErrNotFound) } return nil }