package user import ( "context" "database/sql" "errors" "fmt" "net/mail" "regexp" "strings" "time" "polar/internal/auth" "polar/internal/db" ) var ( ErrUserNotFound = errors.New("user not found") ErrUserAlreadyExists = errors.New("username or email already taken") ErrRegistrationDisabled = errors.New("registration is disabled") ErrPendingApproval = errors.New("account is awaiting administrator approval") ErrLastAdmin = errors.New("the last administrator account cannot be removed") ErrInvalidUsername = errors.New("username must be 2–32 alphanumeric characters or single hyphens/underscores") ErrReservedUsername = errors.New("this username is reserved by the system") ErrInvalidEmail = errors.New("invalid email address") ErrWeakPassword = errors.New("password must be at least 8 characters") ) var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9]|[-_][a-zA-Z0-9]){1,31}$`) var reservedUsernames = map[string]struct{}{ "login": {}, "register": {}, "logout": {}, "settings": {}, "admin": {}, "new": {}, "explore": {}, "repos": {}, "api": {}, "static": {}, "assets": {}, "healthz": {}, "_ui": {}, "git": {}, "raw": {}, "tree": {}, "blob": {}, "commits": {}, "commit": {}, "branches": {}, "tags": {}, "user": {}, "users": {}, "org": {}, "orgs": {}, "help": {}, "about": {}, "pricing": {}, "security": {}, "terms": {}, "privacy": {}, "favicon.ico": {}, "robots.txt": {}, "search": {}, "avatars": {}, "issues": {}, "archive": {}, } type User struct { ID int64 Username string Email string PasswordHash string FullName string Bio string AvatarURL string BackgroundURL string Location string Website string IsAdmin bool IsApproved bool RepoCount int CreatedAt time.Time } type Service struct { db *db.DB } func NewService(database *db.DB) *Service { return &Service{db: database} } func ValidateUsername(username string) error { username = strings.TrimSpace(username) if !usernameRegex.MatchString(username) { return ErrInvalidUsername } lower := strings.ToLower(username) if _, reserved := reservedUsernames[lower]; reserved { return ErrReservedUsername } if strings.HasSuffix(lower, ".git") || strings.Contains(lower, "..") { return ErrInvalidUsername } return nil } func ValidateEmail(email string) error { email = strings.TrimSpace(email) if len(email) < 3 || len(email) > 254 { return ErrInvalidEmail } addr, err := mail.ParseAddress(email) if err != nil || addr.Address != email || !strings.Contains(email, "@") { return ErrInvalidEmail } return nil } func (s *Service) Count(ctx context.Context) (int, error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var count int if err := s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { return 0, fmt.Errorf("count users: %w", err) } return count, nil } func (s *Service) Register(ctx context.Context, username, email, password string, regEnabled bool) (*User, error) { mode := db.RegistrationModeClosed if regEnabled { mode = db.RegistrationModePublic } return s.RegisterWithMode(ctx, username, email, password, mode) } // RegisterWithMode creates an account according to the server registration // policy. "public" accounts are usable immediately, while "confirm" accounts // stay pending until an administrator approves them. The first account is // always an approved administrator, regardless of the policy. func (s *Service) RegisterWithMode(ctx context.Context, username, email, password, mode string) (*User, error) { username = strings.TrimSpace(username) email = strings.ToLower(strings.TrimSpace(email)) if !db.ValidRegistrationMode(mode) { return nil, fmt.Errorf("invalid registration mode %q", mode) } if err := ValidateUsername(username); err != nil { return nil, err } if err := ValidateEmail(email); err != nil { return nil, err } if len(password) < 8 { return nil, ErrWeakPassword } hash, err := auth.HashPassword(password) if err != nil { return nil, fmt.Errorf("hash password: %w", err) } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() tx, err := s.db.BeginTx(cctx, nil) if err != nil { return nil, fmt.Errorf("begin register tx: %w", err) } defer func() { _ = tx.Rollback() }() var userCount int if err := tx.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { return nil, fmt.Errorf("count existing users: %w", err) } if userCount > 0 && mode == db.RegistrationModeClosed { return nil, ErrRegistrationDisabled } var existing int err = tx.QueryRowContext(cctx, ` SELECT 1 FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?) `, username, email).Scan(&existing) if err == nil { return nil, ErrUserAlreadyExists } else if !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("check existing user: %w", err) } isAdmin := userCount == 0 isAdminInt := 0 if isAdmin { isAdminInt = 1 } isApproved := isAdmin || mode == db.RegistrationModePublic isApprovedInt := 0 if isApproved { isApprovedInt = 1 } now := time.Now().UTC() res, err := tx.ExecContext(cctx, ` INSERT INTO users (username, email, password_hash, bio, is_admin, is_approved, created_at) VALUES (?, ?, ?, '', ?, ?, ?) `, username, email, hash, isAdminInt, isApprovedInt, now) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { return nil, ErrUserAlreadyExists } return nil, fmt.Errorf("insert user: %w", err) } id, _ := res.LastInsertId() if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit register tx: %w", err) } return &User{ ID: id, Username: username, Email: email, PasswordHash: hash, IsAdmin: isAdmin, IsApproved: isApproved, CreatedAt: now, }, nil } func (s *Service) Authenticate(ctx context.Context, login, password string) (*User, error) { login = strings.TrimSpace(login) if login == "" || password == "" { return nil, auth.ErrInvalidCredentials } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() u, err := s.scanUser(s.db.QueryRowContext(cctx, ` SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?) `, login, login)) if err != nil { if errors.Is(err, ErrUserNotFound) { return nil, auth.ErrInvalidCredentials } return nil, err } ok, err := auth.VerifyPassword(password, u.PasswordHash) if err != nil || !ok { return nil, auth.ErrInvalidCredentials } if !u.IsApproved { return nil, ErrPendingApproval } return u, nil } func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() return s.scanUser(s.db.QueryRowContext(cctx, ` SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at FROM users WHERE id = ? `, id)) } func (s *Service) GetByUsername(ctx context.Context, username string) (*User, error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() return s.scanUser(s.db.QueryRowContext(cctx, ` SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at FROM users WHERE LOWER(username) = LOWER(?) `, strings.TrimSpace(username))) } func (s *Service) UpdateProfile(ctx context.Context, userID int64, email, bio string) error { u, err := s.GetByID(ctx, userID) if err != nil { return err } return s.UpdateProfileDetails(ctx, userID, email, u.FullName, bio, u.Location, u.Website) } func (s *Service) UpdateProfileDetails(ctx context.Context, userID int64, email, fullName, bio, location, website string) error { email = strings.ToLower(strings.TrimSpace(email)) fullName = strings.TrimSpace(fullName) bio = strings.TrimSpace(bio) location = strings.TrimSpace(location) website = strings.TrimSpace(website) if len(fullName) > 80 { fullName = fullName[:80] } if len(bio) > 280 { bio = bio[:280] } if len(location) > 80 { location = location[:80] } if len(website) > 200 { website = website[:200] } if website != "" && !strings.HasPrefix(website, "http://") && !strings.HasPrefix(website, "https://") { website = "https://" + website } if err := ValidateEmail(email); err != nil { return err } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() _, err := s.db.ExecContext(cctx, ` UPDATE users SET email = ?, full_name = ?, bio = ?, location = ?, website = ? WHERE id = ? `, email, fullName, bio, location, website, userID) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "unique") { return ErrUserAlreadyExists } return fmt.Errorf("update profile: %w", err) } return nil } func (s *Service) UpdateAvatar(ctx context.Context, userID int64, avatarURL string) error { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() _, err := s.db.ExecContext(cctx, ` UPDATE users SET avatar_url = ? WHERE id = ? `, strings.TrimSpace(avatarURL), userID) if err != nil { return fmt.Errorf("update avatar: %w", err) } return nil } func (s *Service) UpdateBackground(ctx context.Context, userID int64, backgroundURL string) error { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() _, err := s.db.ExecContext(cctx, ` UPDATE users SET background_url = ? WHERE id = ? `, strings.TrimSpace(backgroundURL), userID) if err != nil { return fmt.Errorf("update profile background: %w", err) } return nil } func (s *Service) UpdatePassword(ctx context.Context, userID int64, currentPassword, newPassword string) error { if len(newPassword) < 8 { return ErrWeakPassword } u, err := s.GetByID(ctx, userID) if err != nil { return err } ok, err := auth.VerifyPassword(currentPassword, u.PasswordHash) if err != nil || !ok { return auth.ErrInvalidCredentials } newHash, err := auth.HashPassword(newPassword) if err != nil { return fmt.Errorf("hash new password: %w", err) } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() _, err = s.db.ExecContext(cctx, `UPDATE users SET password_hash = ? WHERE id = ?`, newHash, userID) if err != nil { return fmt.Errorf("update password: %w", err) } return nil } func (s *Service) ListAll(ctx context.Context) ([]User, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() rows, err := s.db.QueryContext(cctx, ` SELECT u.id, u.username, u.email, u.password_hash, u.full_name, u.bio, u.avatar_url, u.background_url, u.location, u.website, u.is_admin, u.is_approved, u.created_at, COUNT(r.id) AS repo_count FROM users u LEFT JOIN repositories r ON r.owner_id = u.id GROUP BY u.id ORDER BY u.created_at ASC `) if err != nil { return nil, fmt.Errorf("list users: %w", err) } defer rows.Close() var users []User for rows.Next() { var u User var isAdmin, isApproved int if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.BackgroundURL, &u.Location, &u.Website, &isAdmin, &isApproved, &u.CreatedAt, &u.RepoCount); err != nil { return nil, fmt.Errorf("scan user: %w", err) } u.IsAdmin = isAdmin == 1 u.IsApproved = isApproved == 1 users = append(users, u) } return users, rows.Err() } func (s *Service) CountAdmins(ctx context.Context) (int, error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var count int if err := s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM users WHERE is_admin = 1`).Scan(&count); err != nil { return 0, fmt.Errorf("count administrators: %w", err) } return count, nil } func (s *Service) SetAdmin(ctx context.Context, userID int64, isAdmin bool) error { if !isAdmin { u, err := s.GetByID(ctx, userID) if err != nil { return err } if u.IsAdmin { count, err := s.CountAdmins(ctx) if err != nil { return err } if count <= 1 { return ErrLastAdmin } } } cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() val := 0 if isAdmin { val = 1 } _, err := s.db.ExecContext(cctx, `UPDATE users SET is_admin = ? WHERE id = ?`, val, userID) return err } func (s *Service) SetApproved(ctx context.Context, userID int64, approved bool) error { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() val := 0 if approved { val = 1 } _, err := s.db.ExecContext(cctx, `UPDATE users SET is_approved = ? WHERE id = ?`, val, userID) if err != nil { return fmt.Errorf("update account approval: %w", err) } return nil } // Delete removes the user record. Callers must remove the user's repositories // and their on-disk data first. The last administrator is protected. func (s *Service) Delete(ctx context.Context, userID int64) error { u, err := s.GetByID(ctx, userID) if err != nil { return err } if u.IsAdmin { count, err := s.CountAdmins(ctx) if err != nil { return err } if count <= 1 { return ErrLastAdmin } } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if _, err := s.db.ExecContext(cctx, `DELETE FROM users WHERE id = ?`, userID); err != nil { return fmt.Errorf("delete user: %w", err) } return nil } func (s *Service) scanUser(row *sql.Row) (*User, error) { var u User var isAdmin, isApproved int err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.BackgroundURL, &u.Location, &u.Website, &isAdmin, &isApproved, &u.CreatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("scan user: %w", err) } u.IsAdmin = isAdmin == 1 u.IsApproved = isApproved == 1 return &u, nil }