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