rin
Публичный Open-source lightweight git hosting.
repo.go · 591 строка · 16.9 KB
Raw
1 package repo
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "time"
13
14 "polar/internal/db"
15 "polar/internal/git"
16 "polar/internal/user"
17 )
18
19 var (
20 ErrRepoNotFound = errors.New("repository not found")
21 ErrRepoAlreadyExists = errors.New("repository with this name already exists")
22 ErrInvalidRepoName = errors.New("repository name must be 1–64 characters (letters, digits, '.', '-', '_')")
23 ErrPathTraversal = errors.New("unsafe repository path")
24 )
25
26 var repoNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
27
28 var reservedRepoNames = map[string]struct{}{
29 "settings": {}, "commits": {}, "commit": {}, "tree": {}, "blob": {},
30 "raw": {}, "info": {}, "git-upload-pack": {}, "git-receive-pack": {},
31 "new": {}, "delete": {}, "rename": {}, "branches": {}, "tags": {},
32 "issues": {}, "archive": {}, "pulls": {}, "releases": {}, "activity": {},
33 }
34
35 type Repository struct {
36 ID int64
37 OwnerID int64
38 OwnerUsername string
39 OwnerAvatarURL string
40 Name string
41 Description string
42 IsPrivate bool
43 DefaultBranch string
44 EnableIssues bool
45 EnablePulls bool
46 EnableReleases bool
47 EnableActivity bool
48
49 ForkedFromRepoID *int64
50 ForkedFromOwner string
51 ForkedFromName string
52 ForkCount int
53
54 OpenIssueCount int
55 OpenPRCount int
56 ReleaseCount int
57 CreatedAt time.Time
58 UpdatedAt time.Time
59 }
60
61 type Service struct {
62 db *db.DB
63 reposDir string
64 }
65
66 func NewService(database *db.DB, reposDir string) *Service {
67 return &Service{
68 db: database,
69 reposDir: reposDir,
70 }
71 }
72
73 func ValidateRepoName(name string) error {
74 name = strings.TrimSpace(name)
75 if !repoNameRegex.MatchString(name) {
76 return ErrInvalidRepoName
77 }
78 lower := strings.ToLower(name)
79 if _, reserved := reservedRepoNames[lower]; reserved {
80 return ErrInvalidRepoName
81 }
82 if strings.Contains(lower, "..") ||
83 strings.HasSuffix(lower, ".git") ||
84 strings.HasSuffix(lower, ".lock") ||
85 strings.HasSuffix(lower, ".") {
86 return ErrInvalidRepoName
87 }
88 return nil
89 }
90
91 func (s *Service) DiskPath(ownerUsername, repoName string) (string, error) {
92 if err := user.ValidateUsername(ownerUsername); err != nil {
93 return "", err
94 }
95 if err := ValidateRepoName(repoName); err != nil {
96 return "", err
97 }
98
99 baseAbs, err := filepath.Abs(s.reposDir)
100 if err != nil {
101 return "", fmt.Errorf("resolve repos dir: %w", err)
102 }
103 target := filepath.Join(baseAbs, strings.ToLower(ownerUsername), strings.ToLower(repoName)+".git")
104 rel, err := filepath.Rel(baseAbs, target)
105 if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
106 return "", ErrPathTraversal
107 }
108 return target, nil
109 }
110
111 func (s *Service) Create(ctx context.Context, owner *user.User, name, description string, isPrivate bool) (*Repository, error) {
112 name = strings.TrimSpace(name)
113 description = strings.TrimSpace(description)
114 if len(description) > 500 {
115 description = description[:500]
116 }
117
118 if err := ValidateRepoName(name); err != nil {
119 return nil, err
120 }
121
122 diskPath, err := s.DiskPath(owner.Username, name)
123 if err != nil {
124 return nil, err
125 }
126
127 cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
128 defer cancel()
129
130 var exists int
131 err = s.db.QueryRowContext(cctx, `
132 SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?)
133 `, owner.ID, name).Scan(&exists)
134 if err == nil {
135 return nil, ErrRepoAlreadyExists
136 } else if !errors.Is(err, sql.ErrNoRows) {
137 return nil, fmt.Errorf("check repo exists: %w", err)
138 }
139
140 defaultBranch := "main"
141 if err := git.InitBare(cctx, diskPath, defaultBranch); err != nil {
142 return nil, fmt.Errorf("init bare git repo: %w", err)
143 }
144
145 privInt := 0
146 if isPrivate {
147 privInt = 1
148 }
149 now := time.Now().UTC()
150
151 res, err := s.db.ExecContext(cctx, `
152 INSERT INTO repositories (owner_id, name, description, is_private, default_branch, created_at, updated_at)
153 VALUES (?, ?, ?, ?, ?, ?, ?)
154 `, owner.ID, name, description, privInt, defaultBranch, now, now)
155 if err != nil {
156 _ = os.RemoveAll(diskPath)
157 if strings.Contains(strings.ToLower(err.Error()), "unique") {
158 return nil, ErrRepoAlreadyExists
159 }
160 return nil, fmt.Errorf("insert repository: %w", err)
161 }
162
163 id, _ := res.LastInsertId()
164 return &Repository{
165 ID: id,
166 OwnerID: owner.ID,
167 OwnerUsername: owner.Username,
168 OwnerAvatarURL: owner.AvatarURL,
169 Name: name,
170 Description: description,
171 IsPrivate: isPrivate,
172 DefaultBranch: defaultBranch,
173 EnableIssues: true,
174 EnablePulls: true,
175 EnableReleases: true,
176 EnableActivity: true,
177 CreatedAt: now,
178 UpdatedAt: now,
179 }, nil
180 }
181
182 const repoSelectFields = `
183 SELECT r.id, r.owner_id, u.username, u.avatar_url, r.name, r.description, r.is_private, r.default_branch,
184 r.enable_issues, r.enable_pulls, r.enable_releases, r.enable_activity,
185 r.forked_from_repo_id, fu.username AS forked_from_owner, fr.name AS forked_from_name,
186 (SELECT COUNT(*) FROM repositories f WHERE f.forked_from_repo_id = r.id) AS fork_count,
187 (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.is_closed = 0) AS open_issues,
188 (SELECT COUNT(*) FROM pull_requests pr WHERE pr.repo_id = r.id AND pr.status = 'open') AS open_prs,
189 (SELECT COUNT(*) FROM releases rel WHERE rel.repo_id = r.id AND rel.is_draft = 0) AS release_count,
190 r.created_at, r.updated_at
191 FROM repositories r
192 JOIN users u ON u.id = r.owner_id
193 LEFT JOIN repositories fr ON fr.id = r.forked_from_repo_id
194 LEFT JOIN users fu ON fu.id = fr.owner_id
195 `
196
197 func (s *Service) GetByOwnerAndName(ctx context.Context, ownerUsername, repoName string) (*Repository, error) {
198 ownerUsername = strings.TrimSpace(ownerUsername)
199 repoName = strings.TrimSuffix(strings.TrimSpace(repoName), ".git")
200
201 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
202 defer cancel()
203
204 row := s.db.QueryRowContext(cctx, repoSelectFields+`
205 WHERE LOWER(u.username) = LOWER(?) AND LOWER(r.name) = LOWER(?)
206 `, ownerUsername, repoName)
207
208 return scanRepo(row)
209 }
210
211 // GetByID loads a repository by primary key.
212 func (s *Service) GetByID(ctx context.Context, id int64) (*Repository, error) {
213 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
214 defer cancel()
215
216 row := s.db.QueryRowContext(cctx, repoSelectFields+` WHERE r.id = ? `, id)
217 return scanRepo(row)
218 }
219 func (s *Service) ListByOwner(ctx context.Context, ownerID int64, includePrivate bool) ([]Repository, error) {
220 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
221 defer cancel()
222
223 query := repoSelectFields + ` WHERE r.owner_id = ?`
224 if !includePrivate {
225 query += ` AND r.is_private = 0`
226 }
227 query += ` ORDER BY r.updated_at DESC, r.created_at DESC`
228
229 rows, err := s.db.QueryContext(cctx, query, ownerID)
230 if err != nil {
231 return nil, fmt.Errorf("list repos by owner: %w", err)
232 }
233 defer rows.Close()
234 return scanRepos(rows)
235 }
236
237 func (s *Service) ListPublic(ctx context.Context, limit int) ([]Repository, error) {
238 if limit <= 0 || limit > 200 {
239 limit = 50
240 }
241 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
242 defer cancel()
243
244 rows, err := s.db.QueryContext(cctx, repoSelectFields+`
245 WHERE r.is_private = 0
246 ORDER BY r.updated_at DESC, r.created_at DESC
247 LIMIT ?
248 `, limit)
249 if err != nil {
250 return nil, fmt.Errorf("list public repos: %w", err)
251 }
252 defer rows.Close()
253 return scanRepos(rows)
254 }
255
256 func (s *Service) ListAccessible(ctx context.Context, viewer *user.User, limit int) ([]Repository, error) {
257 if viewer == nil {
258 return s.ListPublic(ctx, limit)
259 }
260 if limit <= 0 || limit > 200 {
261 limit = 100
262 }
263 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
264 defer cancel()
265
266 var (
267 rows *sql.Rows
268 err error
269 )
270 if viewer.IsAdmin {
271 rows, err = s.db.QueryContext(cctx, repoSelectFields+`
272 ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC
273 LIMIT ?
274 `, viewer.ID, limit)
275 } else {
276 rows, err = s.db.QueryContext(cctx, repoSelectFields+`
277 WHERE r.owner_id = ? OR r.is_private = 0
278 ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC
279 LIMIT ?
280 `, viewer.ID, viewer.ID, limit)
281 }
282 if err != nil {
283 return nil, fmt.Errorf("list accessible repos: %w", err)
284 }
285 defer rows.Close()
286 return scanRepos(rows)
287 }
288
289 func (s *Service) Update(ctx context.Context, r *Repository, description string, isPrivate bool, defaultBranch string, enableIssues, enablePulls, enableReleases, enableActivity bool) error {
290 description = strings.TrimSpace(description)
291 if len(description) > 500 {
292 description = description[:500]
293 }
294 if defaultBranch == "" {
295 defaultBranch = r.DefaultBranch
296 }
297 if err := git.ValidateRef(defaultBranch); err != nil {
298 return err
299 }
300
301 privInt := 0
302 if isPrivate {
303 privInt = 1
304 }
305 b2i := func(b bool) int {
306 if b {
307 return 1
308 }
309 return 0
310 }
311 now := time.Now().UTC()
312
313 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
314 defer cancel()
315
316 _, err := s.db.ExecContext(cctx, `
317 UPDATE repositories
318 SET description = ?, is_private = ?, default_branch = ?,
319 enable_issues = ?, enable_pulls = ?, enable_releases = ?, enable_activity = ?,
320 updated_at = ?
321 WHERE id = ?
322 `, description, privInt, defaultBranch, b2i(enableIssues), b2i(enablePulls), b2i(enableReleases), b2i(enableActivity), now, r.ID)
323 if err != nil {
324 return fmt.Errorf("update repository: %w", err)
325 }
326 r.Description = description
327 r.IsPrivate = isPrivate
328 r.DefaultBranch = defaultBranch
329 r.EnableIssues = enableIssues
330 r.EnablePulls = enablePulls
331 r.EnableReleases = enableReleases
332 r.EnableActivity = enableActivity
333 r.UpdatedAt = now
334 return nil
335 }
336
337 func (s *Service) TouchUpdatedAt(ctx context.Context, repoID int64) {
338 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
339 defer cancel()
340 _, _ = s.db.ExecContext(cctx, `UPDATE repositories SET updated_at = ? WHERE id = ?`, time.Now().UTC(), repoID)
341 }
342
343 func (s *Service) Rename(ctx context.Context, r *Repository, newName string) error {
344 newName = strings.TrimSpace(newName)
345 if err := ValidateRepoName(newName); err != nil {
346 return err
347 }
348 if strings.EqualFold(r.Name, newName) && r.Name == newName {
349 return nil
350 }
351
352 oldPath, err := s.DiskPath(r.OwnerUsername, r.Name)
353 if err != nil {
354 return err
355 }
356 newPath, err := s.DiskPath(r.OwnerUsername, newName)
357 if err != nil {
358 return err
359 }
360
361 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
362 defer cancel()
363
364 if !strings.EqualFold(r.Name, newName) {
365 var exists int
366 err := s.db.QueryRowContext(cctx, `
367 SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?) AND id != ?
368 `, r.OwnerID, newName, r.ID).Scan(&exists)
369 if err == nil {
370 return ErrRepoAlreadyExists
371 }
372 }
373
374 if oldPath != newPath {
375 if err := os.Rename(oldPath, newPath); err != nil {
376 return fmt.Errorf("rename repository directory: %w", err)
377 }
378 }
379
380 now := time.Now().UTC()
381 _, err = s.db.ExecContext(cctx, `
382 UPDATE repositories SET name = ?, updated_at = ? WHERE id = ?
383 `, newName, now, r.ID)
384 if err != nil {
385 if oldPath != newPath {
386 _ = os.Rename(newPath, oldPath)
387 }
388 return fmt.Errorf("update repository name: %w", err)
389 }
390
391 r.Name = newName
392 r.UpdatedAt = now
393 return nil
394 }
395
396 func (s *Service) Delete(ctx context.Context, r *Repository) error {
397 diskPath, err := s.DiskPath(r.OwnerUsername, r.Name)
398 if err != nil {
399 return err
400 }
401
402 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
403 defer cancel()
404
405 if _, err := s.db.ExecContext(cctx, `DELETE FROM repositories WHERE id = ?`, r.ID); err != nil {
406 return fmt.Errorf("delete repository record: %w", err)
407 }
408 if err := os.RemoveAll(diskPath); err != nil {
409 return fmt.Errorf("remove repository directory: %w", err)
410 }
411 return nil
412 }
413
414 func boolInt(b bool) int {
415 if b {
416 return 1
417 }
418 return 0
419 }
420
421 // FindForkByOwner returns the owner's fork of the given repository, if any.
422 func (s *Service) FindForkByOwner(ctx context.Context, srcRepoID, ownerID int64) (*Repository, error) {
423 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
424 defer cancel()
425
426 row := s.db.QueryRowContext(cctx, repoSelectFields+`
427 WHERE r.forked_from_repo_id = ? AND r.owner_id = ?
428 ORDER BY r.created_at ASC
429 LIMIT 1
430 `, srcRepoID, ownerID)
431 r, err := scanRepo(row)
432 if errors.Is(err, ErrRepoNotFound) {
433 return nil, nil
434 }
435 return r, err
436 }
437
438 // Fork copies a repository into the owner's namespace.
439 func (s *Service) Fork(ctx context.Context, src *Repository, owner *user.User, name string) (*Repository, error) {
440 name = strings.TrimSpace(name)
441 if name == "" {
442 name = src.Name
443 }
444 if err := ValidateRepoName(name); err != nil {
445 return nil, err
446 }
447
448 srcPath, err := s.DiskPath(src.OwnerUsername, src.Name)
449 if err != nil {
450 return nil, err
451 }
452 dstPath, err := s.DiskPath(owner.Username, name)
453 if err != nil {
454 return nil, err
455 }
456
457 cctx, cancel := context.WithTimeout(ctx, 120*time.Second)
458 defer cancel()
459
460 var exists int
461 err = s.db.QueryRowContext(cctx, `
462 SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?)
463 `, owner.ID, name).Scan(&exists)
464 if err == nil {
465 return nil, ErrRepoAlreadyExists
466 } else if !errors.Is(err, sql.ErrNoRows) {
467 return nil, fmt.Errorf("check fork name: %w", err)
468 }
469
470 if err := git.CloneBare(cctx, srcPath, dstPath); err != nil {
471 return nil, fmt.Errorf("copy repository: %w", err)
472 }
473
474 now := time.Now().UTC()
475 srcID := src.ID
476 res, err := s.db.ExecContext(cctx, `
477 INSERT INTO repositories (owner_id, name, description, is_private, default_branch,
478 enable_issues, enable_pulls, enable_releases, enable_activity, forked_from_repo_id, created_at, updated_at)
479 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
480 `, owner.ID, name, src.Description, boolInt(src.IsPrivate), src.DefaultBranch,
481 boolInt(src.EnableIssues), boolInt(src.EnablePulls), boolInt(src.EnableReleases), boolInt(src.EnableActivity),
482 srcID, now, now)
483 if err != nil {
484 _ = os.RemoveAll(dstPath)
485 if strings.Contains(strings.ToLower(err.Error()), "unique") {
486 return nil, ErrRepoAlreadyExists
487 }
488 return nil, fmt.Errorf("insert fork: %w", err)
489 }
490
491 id, _ := res.LastInsertId()
492 return &Repository{
493 ID: id,
494 OwnerID: owner.ID,
495 OwnerUsername: owner.Username,
496 OwnerAvatarURL: owner.AvatarURL,
497 Name: name,
498 Description: src.Description,
499 IsPrivate: src.IsPrivate,
500 DefaultBranch: src.DefaultBranch,
501 EnableIssues: src.EnableIssues,
502 EnablePulls: src.EnablePulls,
503 EnableReleases: src.EnableReleases,
504 EnableActivity: src.EnableActivity,
505 ForkedFromRepoID: &srcID,
506 ForkedFromOwner: src.OwnerUsername,
507 ForkedFromName: src.Name,
508 CreatedAt: now,
509 UpdatedAt: now,
510 }, nil
511 }
512
513 // ListForks returns repositories forked from the given repository.
514 func (s *Service) ListForks(ctx context.Context, srcRepoID int64, limit int) ([]Repository, error) {
515 if limit <= 0 || limit > 100 {
516 limit = 20
517 }
518 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
519 defer cancel()
520
521 rows, err := s.db.QueryContext(cctx, repoSelectFields+`
522 WHERE r.forked_from_repo_id = ?
523 ORDER BY r.updated_at DESC
524 LIMIT ?
525 `, srcRepoID, limit)
526 if err != nil {
527 return nil, fmt.Errorf("list forks: %w", err)
528 }
529 defer rows.Close()
530 return scanRepos(rows)
531 }
532
533 func scanRepo(row *sql.Row) (*Repository, error) {
534 var r Repository
535 var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int
536 var forkedFromID sql.NullInt64
537 var forkedFromOwner, forkedFromName sql.NullString
538 err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
539 &enableIssues, &enablePulls, &enableReleases, &enableActivity,
540 &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount,
541 &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount,
542 &r.CreatedAt, &r.UpdatedAt)
543 if err != nil {
544 if errors.Is(err, sql.ErrNoRows) {
545 return nil, ErrRepoNotFound
546 }
547 return nil, fmt.Errorf("scan repository: %w", err)
548 }
549 r.IsPrivate = isPriv == 1
550 r.EnableIssues = enableIssues == 1
551 r.EnablePulls = enablePulls == 1
552 r.EnableReleases = enableReleases == 1
553 r.EnableActivity = enableActivity == 1
554 if forkedFromID.Valid {
555 id := forkedFromID.Int64
556 r.ForkedFromRepoID = &id
557 }
558 r.ForkedFromOwner = forkedFromOwner.String
559 r.ForkedFromName = forkedFromName.String
560 return &r, nil
561 }
562
563 func scanRepos(rows *sql.Rows) ([]Repository, error) {
564 var list []Repository
565 for rows.Next() {
566 var r Repository
567 var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int
568 var forkedFromID sql.NullInt64
569 var forkedFromOwner, forkedFromName sql.NullString
570 if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch,
571 &enableIssues, &enablePulls, &enableReleases, &enableActivity,
572 &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount,
573 &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount,
574 &r.CreatedAt, &r.UpdatedAt); err != nil {
575 return nil, fmt.Errorf("scan repository row: %w", err)
576 }
577 r.IsPrivate = isPriv == 1
578 r.EnableIssues = enableIssues == 1
579 r.EnablePulls = enablePulls == 1
580 r.EnableReleases = enableReleases == 1
581 r.EnableActivity = enableActivity == 1
582 if forkedFromID.Valid {
583 id := forkedFromID.Int64
584 r.ForkedFromRepoID = &id
585 }
586 r.ForkedFromOwner = forkedFromOwner.String
587 r.ForkedFromName = forkedFromName.String
588 list = append(list, r)
589 }
590 return list, rows.Err()
591 }
592