rin
Публичный Open-source lightweight git hosting.
user.go · 478 строк · 13.7 KB
Raw
1 package user
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "net/mail"
9 "regexp"
10 "strings"
11 "time"
12
13 "polar/internal/auth"
14 "polar/internal/db"
15 )
16
17 var (
18 ErrUserNotFound = errors.New("user not found")
19 ErrUserAlreadyExists = errors.New("username or email already taken")
20 ErrRegistrationDisabled = errors.New("registration is disabled")
21 ErrPendingApproval = errors.New("account is awaiting administrator approval")
22 ErrLastAdmin = errors.New("the last administrator account cannot be removed")
23 ErrInvalidUsername = errors.New("username must be 2–32 alphanumeric characters or single hyphens/underscores")
24 ErrReservedUsername = errors.New("this username is reserved by the system")
25 ErrInvalidEmail = errors.New("invalid email address")
26 ErrWeakPassword = errors.New("password must be at least 8 characters")
27 )
28
29 var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9]|[-_][a-zA-Z0-9]){1,31}$`)
30
31 var reservedUsernames = map[string]struct{}{
32 "login": {}, "register": {}, "logout": {}, "settings": {}, "admin": {},
33 "new": {}, "explore": {}, "repos": {}, "api": {}, "static": {},
34 "assets": {}, "healthz": {}, "_ui": {}, "git": {}, "raw": {},
35 "tree": {}, "blob": {}, "commits": {}, "commit": {}, "branches": {},
36 "tags": {}, "user": {}, "users": {}, "org": {}, "orgs": {},
37 "help": {}, "about": {}, "pricing": {}, "security": {}, "terms": {},
38 "privacy": {}, "favicon.ico": {}, "robots.txt": {}, "search": {},
39 "avatars": {}, "issues": {}, "archive": {},
40 }
41
42 type User struct {
43 ID int64
44 Username string
45 Email string
46 PasswordHash string
47 FullName string
48 Bio string
49 AvatarURL string
50 BackgroundURL string
51 Location string
52 Website string
53 IsAdmin bool
54 IsApproved bool
55 RepoCount int
56 CreatedAt time.Time
57 }
58
59 type Service struct {
60 db *db.DB
61 }
62
63 func NewService(database *db.DB) *Service {
64 return &Service{db: database}
65 }
66
67 func ValidateUsername(username string) error {
68 username = strings.TrimSpace(username)
69 if !usernameRegex.MatchString(username) {
70 return ErrInvalidUsername
71 }
72 lower := strings.ToLower(username)
73 if _, reserved := reservedUsernames[lower]; reserved {
74 return ErrReservedUsername
75 }
76 if strings.HasSuffix(lower, ".git") || strings.Contains(lower, "..") {
77 return ErrInvalidUsername
78 }
79 return nil
80 }
81
82 func ValidateEmail(email string) error {
83 email = strings.TrimSpace(email)
84 if len(email) < 3 || len(email) > 254 {
85 return ErrInvalidEmail
86 }
87 addr, err := mail.ParseAddress(email)
88 if err != nil || addr.Address != email || !strings.Contains(email, "@") {
89 return ErrInvalidEmail
90 }
91 return nil
92 }
93
94 func (s *Service) Count(ctx context.Context) (int, error) {
95 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
96 defer cancel()
97 var count int
98 if err := s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
99 return 0, fmt.Errorf("count users: %w", err)
100 }
101 return count, nil
102 }
103
104 func (s *Service) Register(ctx context.Context, username, email, password string, regEnabled bool) (*User, error) {
105 mode := db.RegistrationModeClosed
106 if regEnabled {
107 mode = db.RegistrationModePublic
108 }
109 return s.RegisterWithMode(ctx, username, email, password, mode)
110 }
111
112 // RegisterWithMode creates an account according to the server registration
113 // policy. "public" accounts are usable immediately, while "confirm" accounts
114 // stay pending until an administrator approves them. The first account is
115 // always an approved administrator, regardless of the policy.
116 func (s *Service) RegisterWithMode(ctx context.Context, username, email, password, mode string) (*User, error) {
117 username = strings.TrimSpace(username)
118 email = strings.ToLower(strings.TrimSpace(email))
119
120 if !db.ValidRegistrationMode(mode) {
121 return nil, fmt.Errorf("invalid registration mode %q", mode)
122 }
123 if err := ValidateUsername(username); err != nil {
124 return nil, err
125 }
126 if err := ValidateEmail(email); err != nil {
127 return nil, err
128 }
129 if len(password) < 8 {
130 return nil, ErrWeakPassword
131 }
132
133 hash, err := auth.HashPassword(password)
134 if err != nil {
135 return nil, fmt.Errorf("hash password: %w", err)
136 }
137
138 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
139 defer cancel()
140
141 tx, err := s.db.BeginTx(cctx, nil)
142 if err != nil {
143 return nil, fmt.Errorf("begin register tx: %w", err)
144 }
145 defer func() { _ = tx.Rollback() }()
146
147 var userCount int
148 if err := tx.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
149 return nil, fmt.Errorf("count existing users: %w", err)
150 }
151
152 if userCount > 0 && mode == db.RegistrationModeClosed {
153 return nil, ErrRegistrationDisabled
154 }
155
156 var existing int
157 err = tx.QueryRowContext(cctx, `
158 SELECT 1 FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)
159 `, username, email).Scan(&existing)
160 if err == nil {
161 return nil, ErrUserAlreadyExists
162 } else if !errors.Is(err, sql.ErrNoRows) {
163 return nil, fmt.Errorf("check existing user: %w", err)
164 }
165
166 isAdmin := userCount == 0
167 isAdminInt := 0
168 if isAdmin {
169 isAdminInt = 1
170 }
171 isApproved := isAdmin || mode == db.RegistrationModePublic
172 isApprovedInt := 0
173 if isApproved {
174 isApprovedInt = 1
175 }
176 now := time.Now().UTC()
177
178 res, err := tx.ExecContext(cctx, `
179 INSERT INTO users (username, email, password_hash, bio, is_admin, is_approved, created_at)
180 VALUES (?, ?, ?, '', ?, ?, ?)
181 `, username, email, hash, isAdminInt, isApprovedInt, now)
182 if err != nil {
183 if strings.Contains(strings.ToLower(err.Error()), "unique") {
184 return nil, ErrUserAlreadyExists
185 }
186 return nil, fmt.Errorf("insert user: %w", err)
187 }
188
189 id, _ := res.LastInsertId()
190 if err := tx.Commit(); err != nil {
191 return nil, fmt.Errorf("commit register tx: %w", err)
192 }
193
194 return &User{
195 ID: id,
196 Username: username,
197 Email: email,
198 PasswordHash: hash,
199 IsAdmin: isAdmin,
200 IsApproved: isApproved,
201 CreatedAt: now,
202 }, nil
203 }
204
205 func (s *Service) Authenticate(ctx context.Context, login, password string) (*User, error) {
206 login = strings.TrimSpace(login)
207 if login == "" || password == "" {
208 return nil, auth.ErrInvalidCredentials
209 }
210
211 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
212 defer cancel()
213
214 u, err := s.scanUser(s.db.QueryRowContext(cctx, `
215 SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at
216 FROM users
217 WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)
218 `, login, login))
219 if err != nil {
220 if errors.Is(err, ErrUserNotFound) {
221 return nil, auth.ErrInvalidCredentials
222 }
223 return nil, err
224 }
225
226 ok, err := auth.VerifyPassword(password, u.PasswordHash)
227 if err != nil || !ok {
228 return nil, auth.ErrInvalidCredentials
229 }
230 if !u.IsApproved {
231 return nil, ErrPendingApproval
232 }
233 return u, nil
234 }
235
236 func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) {
237 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
238 defer cancel()
239
240 return s.scanUser(s.db.QueryRowContext(cctx, `
241 SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at
242 FROM users WHERE id = ?
243 `, id))
244 }
245
246 func (s *Service) GetByUsername(ctx context.Context, username string) (*User, error) {
247 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
248 defer cancel()
249
250 return s.scanUser(s.db.QueryRowContext(cctx, `
251 SELECT id, username, email, password_hash, full_name, bio, avatar_url, background_url, location, website, is_admin, is_approved, created_at
252 FROM users WHERE LOWER(username) = LOWER(?)
253 `, strings.TrimSpace(username)))
254 }
255
256 func (s *Service) UpdateProfile(ctx context.Context, userID int64, email, bio string) error {
257 u, err := s.GetByID(ctx, userID)
258 if err != nil {
259 return err
260 }
261 return s.UpdateProfileDetails(ctx, userID, email, u.FullName, bio, u.Location, u.Website)
262 }
263
264 func (s *Service) UpdateProfileDetails(ctx context.Context, userID int64, email, fullName, bio, location, website string) error {
265 email = strings.ToLower(strings.TrimSpace(email))
266 fullName = strings.TrimSpace(fullName)
267 bio = strings.TrimSpace(bio)
268 location = strings.TrimSpace(location)
269 website = strings.TrimSpace(website)
270
271 if len(fullName) > 80 {
272 fullName = fullName[:80]
273 }
274 if len(bio) > 280 {
275 bio = bio[:280]
276 }
277 if len(location) > 80 {
278 location = location[:80]
279 }
280 if len(website) > 200 {
281 website = website[:200]
282 }
283 if website != "" && !strings.HasPrefix(website, "http://") && !strings.HasPrefix(website, "https://") {
284 website = "https://" + website
285 }
286 if err := ValidateEmail(email); err != nil {
287 return err
288 }
289
290 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
291 defer cancel()
292
293 _, err := s.db.ExecContext(cctx, `
294 UPDATE users SET email = ?, full_name = ?, bio = ?, location = ?, website = ? WHERE id = ?
295 `, email, fullName, bio, location, website, userID)
296 if err != nil {
297 if strings.Contains(strings.ToLower(err.Error()), "unique") {
298 return ErrUserAlreadyExists
299 }
300 return fmt.Errorf("update profile: %w", err)
301 }
302 return nil
303 }
304
305 func (s *Service) UpdateAvatar(ctx context.Context, userID int64, avatarURL string) error {
306 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
307 defer cancel()
308
309 _, err := s.db.ExecContext(cctx, `
310 UPDATE users SET avatar_url = ? WHERE id = ?
311 `, strings.TrimSpace(avatarURL), userID)
312 if err != nil {
313 return fmt.Errorf("update avatar: %w", err)
314 }
315 return nil
316 }
317
318 func (s *Service) UpdateBackground(ctx context.Context, userID int64, backgroundURL string) error {
319 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
320 defer cancel()
321
322 _, err := s.db.ExecContext(cctx, `
323 UPDATE users SET background_url = ? WHERE id = ?
324 `, strings.TrimSpace(backgroundURL), userID)
325 if err != nil {
326 return fmt.Errorf("update profile background: %w", err)
327 }
328 return nil
329 }
330
331 func (s *Service) UpdatePassword(ctx context.Context, userID int64, currentPassword, newPassword string) error {
332 if len(newPassword) < 8 {
333 return ErrWeakPassword
334 }
335 u, err := s.GetByID(ctx, userID)
336 if err != nil {
337 return err
338 }
339 ok, err := auth.VerifyPassword(currentPassword, u.PasswordHash)
340 if err != nil || !ok {
341 return auth.ErrInvalidCredentials
342 }
343 newHash, err := auth.HashPassword(newPassword)
344 if err != nil {
345 return fmt.Errorf("hash new password: %w", err)
346 }
347
348 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
349 defer cancel()
350
351 _, err = s.db.ExecContext(cctx, `UPDATE users SET password_hash = ? WHERE id = ?`, newHash, userID)
352 if err != nil {
353 return fmt.Errorf("update password: %w", err)
354 }
355 return nil
356 }
357
358 func (s *Service) ListAll(ctx context.Context) ([]User, error) {
359 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
360 defer cancel()
361
362 rows, err := s.db.QueryContext(cctx, `
363 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,
364 COUNT(r.id) AS repo_count
365 FROM users u
366 LEFT JOIN repositories r ON r.owner_id = u.id
367 GROUP BY u.id
368 ORDER BY u.created_at ASC
369 `)
370 if err != nil {
371 return nil, fmt.Errorf("list users: %w", err)
372 }
373 defer rows.Close()
374
375 var users []User
376 for rows.Next() {
377 var u User
378 var isAdmin, isApproved int
379 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 {
380 return nil, fmt.Errorf("scan user: %w", err)
381 }
382 u.IsAdmin = isAdmin == 1
383 u.IsApproved = isApproved == 1
384 users = append(users, u)
385 }
386 return users, rows.Err()
387 }
388
389 func (s *Service) CountAdmins(ctx context.Context) (int, error) {
390 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
391 defer cancel()
392 var count int
393 if err := s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM users WHERE is_admin = 1`).Scan(&count); err != nil {
394 return 0, fmt.Errorf("count administrators: %w", err)
395 }
396 return count, nil
397 }
398
399 func (s *Service) SetAdmin(ctx context.Context, userID int64, isAdmin bool) error {
400 if !isAdmin {
401 u, err := s.GetByID(ctx, userID)
402 if err != nil {
403 return err
404 }
405 if u.IsAdmin {
406 count, err := s.CountAdmins(ctx)
407 if err != nil {
408 return err
409 }
410 if count <= 1 {
411 return ErrLastAdmin
412 }
413 }
414 }
415
416 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
417 defer cancel()
418 val := 0
419 if isAdmin {
420 val = 1
421 }
422 _, err := s.db.ExecContext(cctx, `UPDATE users SET is_admin = ? WHERE id = ?`, val, userID)
423 return err
424 }
425
426 func (s *Service) SetApproved(ctx context.Context, userID int64, approved bool) error {
427 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
428 defer cancel()
429 val := 0
430 if approved {
431 val = 1
432 }
433 _, err := s.db.ExecContext(cctx, `UPDATE users SET is_approved = ? WHERE id = ?`, val, userID)
434 if err != nil {
435 return fmt.Errorf("update account approval: %w", err)
436 }
437 return nil
438 }
439
440 // Delete removes the user record. Callers must remove the user's repositories
441 // and their on-disk data first. The last administrator is protected.
442 func (s *Service) Delete(ctx context.Context, userID int64) error {
443 u, err := s.GetByID(ctx, userID)
444 if err != nil {
445 return err
446 }
447 if u.IsAdmin {
448 count, err := s.CountAdmins(ctx)
449 if err != nil {
450 return err
451 }
452 if count <= 1 {
453 return ErrLastAdmin
454 }
455 }
456
457 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
458 defer cancel()
459 if _, err := s.db.ExecContext(cctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
460 return fmt.Errorf("delete user: %w", err)
461 }
462 return nil
463 }
464
465 func (s *Service) scanUser(row *sql.Row) (*User, error) {
466 var u User
467 var isAdmin, isApproved int
468 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)
469 if err != nil {
470 if errors.Is(err, sql.ErrNoRows) {
471 return nil, ErrUserNotFound
472 }
473 return nil, fmt.Errorf("scan user: %w", err)
474 }
475 u.IsAdmin = isAdmin == 1
476 u.IsApproved = isApproved == 1
477 return &u, nil
478 }
479