rin
Публичный Open-source lightweight git hosting.
user.go · 370 строк · 10.4 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("public registration is disabled")
21 ErrInvalidUsername = errors.New("username must be 2–32 alphanumeric characters or single hyphens/underscores")
22 ErrReservedUsername = errors.New("this username is reserved by the system")
23 ErrInvalidEmail = errors.New("invalid email address")
24 ErrWeakPassword = errors.New("password must be at least 8 characters")
25 )
26
27 var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9]|[-_][a-zA-Z0-9]){1,31}$`)
28
29 var reservedUsernames = map[string]struct{}{
30 "login": {}, "register": {}, "logout": {}, "settings": {}, "admin": {},
31 "new": {}, "explore": {}, "repos": {}, "api": {}, "static": {},
32 "assets": {}, "healthz": {}, "_ui": {}, "git": {}, "raw": {},
33 "tree": {}, "blob": {}, "commits": {}, "commit": {}, "branches": {},
34 "tags": {}, "user": {}, "users": {}, "org": {}, "orgs": {},
35 "help": {}, "about": {}, "pricing": {}, "security": {}, "terms": {},
36 "privacy": {}, "favicon.ico": {}, "robots.txt": {}, "search": {},
37 "avatars": {}, "issues": {}, "archive": {},
38 }
39
40 type User struct {
41 ID int64
42 Username string
43 Email string
44 PasswordHash string
45 FullName string
46 Bio string
47 AvatarURL string
48 Location string
49 Website string
50 IsAdmin bool
51 RepoCount int
52 CreatedAt time.Time
53 }
54
55 type Service struct {
56 db *db.DB
57 }
58
59 func NewService(database *db.DB) *Service {
60 return &Service{db: database}
61 }
62
63 func ValidateUsername(username string) error {
64 username = strings.TrimSpace(username)
65 if !usernameRegex.MatchString(username) {
66 return ErrInvalidUsername
67 }
68 lower := strings.ToLower(username)
69 if _, reserved := reservedUsernames[lower]; reserved {
70 return ErrReservedUsername
71 }
72 if strings.HasSuffix(lower, ".git") || strings.Contains(lower, "..") {
73 return ErrInvalidUsername
74 }
75 return nil
76 }
77
78 func ValidateEmail(email string) error {
79 email = strings.TrimSpace(email)
80 if len(email) < 3 || len(email) > 254 {
81 return ErrInvalidEmail
82 }
83 addr, err := mail.ParseAddress(email)
84 if err != nil || addr.Address != email || !strings.Contains(email, "@") {
85 return ErrInvalidEmail
86 }
87 return nil
88 }
89
90 func (s *Service) Count(ctx context.Context) (int, error) {
91 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
92 defer cancel()
93 var count int
94 if err := s.db.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
95 return 0, fmt.Errorf("count users: %w", err)
96 }
97 return count, nil
98 }
99
100 func (s *Service) Register(ctx context.Context, username, email, password string, regEnabled bool) (*User, error) {
101 username = strings.TrimSpace(username)
102 email = strings.ToLower(strings.TrimSpace(email))
103
104 if err := ValidateUsername(username); err != nil {
105 return nil, err
106 }
107 if err := ValidateEmail(email); err != nil {
108 return nil, err
109 }
110 if len(password) < 8 {
111 return nil, ErrWeakPassword
112 }
113
114 hash, err := auth.HashPassword(password)
115 if err != nil {
116 return nil, fmt.Errorf("hash password: %w", err)
117 }
118
119 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
120 defer cancel()
121
122 tx, err := s.db.BeginTx(cctx, nil)
123 if err != nil {
124 return nil, fmt.Errorf("begin register tx: %w", err)
125 }
126 defer func() { _ = tx.Rollback() }()
127
128 var userCount int
129 if err := tx.QueryRowContext(cctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
130 return nil, fmt.Errorf("count existing users: %w", err)
131 }
132
133 if userCount > 0 && !regEnabled {
134 return nil, ErrRegistrationDisabled
135 }
136
137 var existing int
138 err = tx.QueryRowContext(cctx, `
139 SELECT 1 FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)
140 `, username, email).Scan(&existing)
141 if err == nil {
142 return nil, ErrUserAlreadyExists
143 } else if !errors.Is(err, sql.ErrNoRows) {
144 return nil, fmt.Errorf("check existing user: %w", err)
145 }
146
147 isAdmin := userCount == 0
148 isAdminInt := 0
149 if isAdmin {
150 isAdminInt = 1
151 }
152 now := time.Now().UTC()
153
154 res, err := tx.ExecContext(cctx, `
155 INSERT INTO users (username, email, password_hash, bio, is_admin, created_at)
156 VALUES (?, ?, ?, '', ?, ?)
157 `, username, email, hash, isAdminInt, now)
158 if err != nil {
159 if strings.Contains(strings.ToLower(err.Error()), "unique") {
160 return nil, ErrUserAlreadyExists
161 }
162 return nil, fmt.Errorf("insert user: %w", err)
163 }
164
165 id, _ := res.LastInsertId()
166 if err := tx.Commit(); err != nil {
167 return nil, fmt.Errorf("commit register tx: %w", err)
168 }
169
170 return &User{
171 ID: id,
172 Username: username,
173 Email: email,
174 PasswordHash: hash,
175 IsAdmin: isAdmin,
176 CreatedAt: now,
177 }, nil
178 }
179
180 func (s *Service) Authenticate(ctx context.Context, login, password string) (*User, error) {
181 login = strings.TrimSpace(login)
182 if login == "" || password == "" {
183 return nil, auth.ErrInvalidCredentials
184 }
185
186 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
187 defer cancel()
188
189 u, err := s.scanUser(s.db.QueryRowContext(cctx, `
190 SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at
191 FROM users
192 WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)
193 `, login, login))
194 if err != nil {
195 if errors.Is(err, ErrUserNotFound) {
196 return nil, auth.ErrInvalidCredentials
197 }
198 return nil, err
199 }
200
201 ok, err := auth.VerifyPassword(password, u.PasswordHash)
202 if err != nil || !ok {
203 return nil, auth.ErrInvalidCredentials
204 }
205 return u, nil
206 }
207
208 func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) {
209 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
210 defer cancel()
211
212 return s.scanUser(s.db.QueryRowContext(cctx, `
213 SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at
214 FROM users WHERE id = ?
215 `, id))
216 }
217
218 func (s *Service) GetByUsername(ctx context.Context, username string) (*User, error) {
219 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
220 defer cancel()
221
222 return s.scanUser(s.db.QueryRowContext(cctx, `
223 SELECT id, username, email, password_hash, full_name, bio, avatar_url, location, website, is_admin, created_at
224 FROM users WHERE LOWER(username) = LOWER(?)
225 `, strings.TrimSpace(username)))
226 }
227
228 func (s *Service) UpdateProfile(ctx context.Context, userID int64, email, bio string) error {
229 u, err := s.GetByID(ctx, userID)
230 if err != nil {
231 return err
232 }
233 return s.UpdateProfileDetails(ctx, userID, email, u.FullName, bio, u.Location, u.Website)
234 }
235
236 func (s *Service) UpdateProfileDetails(ctx context.Context, userID int64, email, fullName, bio, location, website string) error {
237 email = strings.ToLower(strings.TrimSpace(email))
238 fullName = strings.TrimSpace(fullName)
239 bio = strings.TrimSpace(bio)
240 location = strings.TrimSpace(location)
241 website = strings.TrimSpace(website)
242
243 if len(fullName) > 80 {
244 fullName = fullName[:80]
245 }
246 if len(bio) > 280 {
247 bio = bio[:280]
248 }
249 if len(location) > 80 {
250 location = location[:80]
251 }
252 if len(website) > 200 {
253 website = website[:200]
254 }
255 if website != "" && !strings.HasPrefix(website, "http://") && !strings.HasPrefix(website, "https://") {
256 website = "https://" + website
257 }
258 if err := ValidateEmail(email); err != nil {
259 return err
260 }
261
262 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
263 defer cancel()
264
265 _, err := s.db.ExecContext(cctx, `
266 UPDATE users SET email = ?, full_name = ?, bio = ?, location = ?, website = ? WHERE id = ?
267 `, email, fullName, bio, location, website, userID)
268 if err != nil {
269 if strings.Contains(strings.ToLower(err.Error()), "unique") {
270 return ErrUserAlreadyExists
271 }
272 return fmt.Errorf("update profile: %w", err)
273 }
274 return nil
275 }
276
277 func (s *Service) UpdateAvatar(ctx context.Context, userID int64, avatarURL string) error {
278 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
279 defer cancel()
280
281 _, err := s.db.ExecContext(cctx, `
282 UPDATE users SET avatar_url = ? WHERE id = ?
283 `, strings.TrimSpace(avatarURL), userID)
284 if err != nil {
285 return fmt.Errorf("update avatar: %w", err)
286 }
287 return nil
288 }
289
290 func (s *Service) UpdatePassword(ctx context.Context, userID int64, currentPassword, newPassword string) error {
291 if len(newPassword) < 8 {
292 return ErrWeakPassword
293 }
294 u, err := s.GetByID(ctx, userID)
295 if err != nil {
296 return err
297 }
298 ok, err := auth.VerifyPassword(currentPassword, u.PasswordHash)
299 if err != nil || !ok {
300 return auth.ErrInvalidCredentials
301 }
302 newHash, err := auth.HashPassword(newPassword)
303 if err != nil {
304 return fmt.Errorf("hash new password: %w", err)
305 }
306
307 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
308 defer cancel()
309
310 _, err = s.db.ExecContext(cctx, `UPDATE users SET password_hash = ? WHERE id = ?`, newHash, userID)
311 if err != nil {
312 return fmt.Errorf("update password: %w", err)
313 }
314 return nil
315 }
316
317 func (s *Service) ListAll(ctx context.Context) ([]User, error) {
318 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
319 defer cancel()
320
321 rows, err := s.db.QueryContext(cctx, `
322 SELECT u.id, u.username, u.email, u.password_hash, u.full_name, u.bio, u.avatar_url, u.location, u.website, u.is_admin, u.created_at,
323 COUNT(r.id) AS repo_count
324 FROM users u
325 LEFT JOIN repositories r ON r.owner_id = u.id
326 GROUP BY u.id
327 ORDER BY u.created_at ASC
328 `)
329 if err != nil {
330 return nil, fmt.Errorf("list users: %w", err)
331 }
332 defer rows.Close()
333
334 var users []User
335 for rows.Next() {
336 var u User
337 var isAdmin int
338 if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.Location, &u.Website, &isAdmin, &u.CreatedAt, &u.RepoCount); err != nil {
339 return nil, fmt.Errorf("scan user: %w", err)
340 }
341 u.IsAdmin = isAdmin == 1
342 users = append(users, u)
343 }
344 return users, rows.Err()
345 }
346
347 func (s *Service) SetAdmin(ctx context.Context, userID int64, isAdmin bool) error {
348 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
349 defer cancel()
350 val := 0
351 if isAdmin {
352 val = 1
353 }
354 _, err := s.db.ExecContext(cctx, `UPDATE users SET is_admin = ? WHERE id = ?`, val, userID)
355 return err
356 }
357
358 func (s *Service) scanUser(row *sql.Row) (*User, error) {
359 var u User
360 var isAdmin int
361 err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.FullName, &u.Bio, &u.AvatarURL, &u.Location, &u.Website, &isAdmin, &u.CreatedAt)
362 if err != nil {
363 if errors.Is(err, sql.ErrNoRows) {
364 return nil, ErrUserNotFound
365 }
366 return nil, fmt.Errorf("scan user: %w", err)
367 }
368 u.IsAdmin = isAdmin == 1
369 return &u, nil
370 }
371