package repo import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "time" "polar/internal/db" "polar/internal/git" "polar/internal/user" ) var ( ErrRepoNotFound = errors.New("repository not found") ErrRepoAlreadyExists = errors.New("repository with this name already exists") ErrInvalidRepoName = errors.New("repository name must be 1–64 characters (letters, digits, '.', '-', '_')") ErrPathTraversal = errors.New("unsafe repository path") ) var repoNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) var reservedRepoNames = map[string]struct{}{ "settings": {}, "commits": {}, "commit": {}, "tree": {}, "blob": {}, "raw": {}, "info": {}, "git-upload-pack": {}, "git-receive-pack": {}, "new": {}, "delete": {}, "rename": {}, "branches": {}, "tags": {}, "issues": {}, "archive": {}, "pulls": {}, "releases": {}, "activity": {}, } type Repository struct { ID int64 OwnerID int64 OwnerUsername string OwnerAvatarURL string Name string Description string AvatarURL string IsPrivate bool DefaultBranch string EnableIssues bool EnablePulls bool EnableReleases bool EnableActivity bool ForkedFromRepoID *int64 ForkedFromOwner string ForkedFromName string ForkCount int OpenIssueCount int OpenPRCount int ReleaseCount int CreatedAt time.Time UpdatedAt time.Time } type Service struct { db *db.DB reposDir string } func NewService(database *db.DB, reposDir string) *Service { return &Service{ db: database, reposDir: reposDir, } } func ValidateRepoName(name string) error { name = strings.TrimSpace(name) if !repoNameRegex.MatchString(name) { return ErrInvalidRepoName } lower := strings.ToLower(name) if _, reserved := reservedRepoNames[lower]; reserved { return ErrInvalidRepoName } if strings.Contains(lower, "..") || strings.HasSuffix(lower, ".git") || strings.HasSuffix(lower, ".lock") || strings.HasSuffix(lower, ".") { return ErrInvalidRepoName } return nil } func (s *Service) DiskPath(ownerUsername, repoName string) (string, error) { if err := user.ValidateUsername(ownerUsername); err != nil { return "", err } if err := ValidateRepoName(repoName); err != nil { return "", err } baseAbs, err := filepath.Abs(s.reposDir) if err != nil { return "", fmt.Errorf("resolve repos dir: %w", err) } target := filepath.Join(baseAbs, strings.ToLower(ownerUsername), strings.ToLower(repoName)+".git") rel, err := filepath.Rel(baseAbs, target) if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { return "", ErrPathTraversal } return target, nil } func (s *Service) Create(ctx context.Context, owner *user.User, name, description string, isPrivate bool) (*Repository, error) { name = strings.TrimSpace(name) description = strings.TrimSpace(description) if len(description) > 500 { description = description[:500] } if err := ValidateRepoName(name); err != nil { return nil, err } diskPath, err := s.DiskPath(owner.Username, name) if err != nil { return nil, err } cctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() var exists int err = s.db.QueryRowContext(cctx, ` SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?) `, owner.ID, name).Scan(&exists) if err == nil { return nil, ErrRepoAlreadyExists } else if !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("check repo exists: %w", err) } defaultBranch := "main" if err := git.InitBare(cctx, diskPath, defaultBranch); err != nil { return nil, fmt.Errorf("init bare git repo: %w", err) } privInt := 0 if isPrivate { privInt = 1 } now := time.Now().UTC() res, err := s.db.ExecContext(cctx, ` INSERT INTO repositories (owner_id, name, description, is_private, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) `, owner.ID, name, description, privInt, defaultBranch, now, now) if err != nil { _ = os.RemoveAll(diskPath) if strings.Contains(strings.ToLower(err.Error()), "unique") { return nil, ErrRepoAlreadyExists } return nil, fmt.Errorf("insert repository: %w", err) } id, _ := res.LastInsertId() return &Repository{ ID: id, OwnerID: owner.ID, OwnerUsername: owner.Username, OwnerAvatarURL: owner.AvatarURL, Name: name, Description: description, IsPrivate: isPrivate, DefaultBranch: defaultBranch, EnableIssues: true, EnablePulls: true, EnableReleases: true, EnableActivity: true, CreatedAt: now, UpdatedAt: now, }, nil } const repoSelectFields = ` SELECT r.id, r.owner_id, u.username, u.avatar_url, r.avatar_url, r.name, r.description, r.is_private, r.default_branch, r.enable_issues, r.enable_pulls, r.enable_releases, r.enable_activity, r.forked_from_repo_id, fu.username AS forked_from_owner, fr.name AS forked_from_name, (SELECT COUNT(*) FROM repositories f WHERE f.forked_from_repo_id = r.id) AS fork_count, (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.is_closed = 0) AS open_issues, (SELECT COUNT(*) FROM pull_requests pr WHERE pr.repo_id = r.id AND pr.status = 'open') AS open_prs, (SELECT COUNT(*) FROM releases rel WHERE rel.repo_id = r.id AND rel.is_draft = 0) AS release_count, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_id LEFT JOIN repositories fr ON fr.id = r.forked_from_repo_id LEFT JOIN users fu ON fu.id = fr.owner_id ` func (s *Service) GetByOwnerAndName(ctx context.Context, ownerUsername, repoName string) (*Repository, error) { ownerUsername = strings.TrimSpace(ownerUsername) repoName = strings.TrimSuffix(strings.TrimSpace(repoName), ".git") cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() row := s.db.QueryRowContext(cctx, repoSelectFields+` WHERE LOWER(u.username) = LOWER(?) AND LOWER(r.name) = LOWER(?) `, ownerUsername, repoName) return scanRepo(row) } // GetByID loads a repository by primary key. func (s *Service) GetByID(ctx context.Context, id int64) (*Repository, error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() row := s.db.QueryRowContext(cctx, repoSelectFields+` WHERE r.id = ? `, id) return scanRepo(row) } func (s *Service) ListByOwner(ctx context.Context, ownerID int64, includePrivate bool) ([]Repository, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() query := repoSelectFields + ` WHERE r.owner_id = ?` if !includePrivate { query += ` AND r.is_private = 0` } query += ` ORDER BY r.updated_at DESC, r.created_at DESC` rows, err := s.db.QueryContext(cctx, query, ownerID) if err != nil { return nil, fmt.Errorf("list repos by owner: %w", err) } defer rows.Close() return scanRepos(rows) } func (s *Service) ListPublic(ctx context.Context, limit int) ([]Repository, error) { if limit <= 0 || limit > 200 { limit = 50 } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() rows, err := s.db.QueryContext(cctx, repoSelectFields+` WHERE r.is_private = 0 ORDER BY r.updated_at DESC, r.created_at DESC LIMIT ? `, limit) if err != nil { return nil, fmt.Errorf("list public repos: %w", err) } defer rows.Close() return scanRepos(rows) } func (s *Service) ListAccessible(ctx context.Context, viewer *user.User, limit int) ([]Repository, error) { if viewer == nil { return s.ListPublic(ctx, limit) } if limit <= 0 || limit > 200 { limit = 100 } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() var ( rows *sql.Rows err error ) if viewer.IsAdmin { rows, err = s.db.QueryContext(cctx, repoSelectFields+` ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC LIMIT ? `, viewer.ID, limit) } else { rows, err = s.db.QueryContext(cctx, repoSelectFields+` WHERE r.owner_id = ? OR r.is_private = 0 ORDER BY (r.owner_id = ?) DESC, r.updated_at DESC LIMIT ? `, viewer.ID, viewer.ID, limit) } if err != nil { return nil, fmt.Errorf("list accessible repos: %w", err) } defer rows.Close() return scanRepos(rows) } func (s *Service) Update(ctx context.Context, r *Repository, description string, isPrivate bool, defaultBranch string, enableIssues, enablePulls, enableReleases, enableActivity bool) error { description = strings.TrimSpace(description) if len(description) > 500 { description = description[:500] } if defaultBranch == "" { defaultBranch = r.DefaultBranch } if err := git.ValidateRef(defaultBranch); err != nil { return err } privInt := 0 if isPrivate { privInt = 1 } b2i := func(b bool) int { if b { return 1 } return 0 } now := time.Now().UTC() cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() _, err := s.db.ExecContext(cctx, ` UPDATE repositories SET description = ?, is_private = ?, default_branch = ?, enable_issues = ?, enable_pulls = ?, enable_releases = ?, enable_activity = ?, updated_at = ? WHERE id = ? `, description, privInt, defaultBranch, b2i(enableIssues), b2i(enablePulls), b2i(enableReleases), b2i(enableActivity), now, r.ID) if err != nil { return fmt.Errorf("update repository: %w", err) } r.Description = description r.IsPrivate = isPrivate r.DefaultBranch = defaultBranch r.EnableIssues = enableIssues r.EnablePulls = enablePulls r.EnableReleases = enableReleases r.EnableActivity = enableActivity r.UpdatedAt = now return nil } func (s *Service) UpdateAvatar(ctx context.Context, repoID int64, avatarURL string) error { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() _, err := s.db.ExecContext(cctx, ` UPDATE repositories SET avatar_url = ?, updated_at = ? WHERE id = ? `, strings.TrimSpace(avatarURL), time.Now().UTC(), repoID) if err != nil { return fmt.Errorf("update repository avatar: %w", err) } return nil } func (s *Service) TouchUpdatedAt(ctx context.Context, repoID int64) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _, _ = s.db.ExecContext(cctx, `UPDATE repositories SET updated_at = ? WHERE id = ?`, time.Now().UTC(), repoID) } func (s *Service) Rename(ctx context.Context, r *Repository, newName string) error { newName = strings.TrimSpace(newName) if err := ValidateRepoName(newName); err != nil { return err } if strings.EqualFold(r.Name, newName) && r.Name == newName { return nil } oldPath, err := s.DiskPath(r.OwnerUsername, r.Name) if err != nil { return err } newPath, err := s.DiskPath(r.OwnerUsername, newName) if err != nil { return err } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if !strings.EqualFold(r.Name, newName) { var exists int err := s.db.QueryRowContext(cctx, ` SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?) AND id != ? `, r.OwnerID, newName, r.ID).Scan(&exists) if err == nil { return ErrRepoAlreadyExists } } if oldPath != newPath { if err := os.Rename(oldPath, newPath); err != nil { return fmt.Errorf("rename repository directory: %w", err) } } now := time.Now().UTC() _, err = s.db.ExecContext(cctx, ` UPDATE repositories SET name = ?, updated_at = ? WHERE id = ? `, newName, now, r.ID) if err != nil { if oldPath != newPath { _ = os.Rename(newPath, oldPath) } return fmt.Errorf("update repository name: %w", err) } r.Name = newName r.UpdatedAt = now return nil } func (s *Service) Delete(ctx context.Context, r *Repository) error { diskPath, err := s.DiskPath(r.OwnerUsername, r.Name) if err != nil { return err } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if _, err := s.db.ExecContext(cctx, `DELETE FROM repositories WHERE id = ?`, r.ID); err != nil { return fmt.Errorf("delete repository record: %w", err) } if err := os.RemoveAll(diskPath); err != nil { return fmt.Errorf("remove repository directory: %w", err) } return nil } // DeleteAllByOwner removes every repository owned by the user, including bare // Git data and uploaded release assets, then removes the owner's empty storage // directory. It is used when an account is deleted. func (s *Service) DeleteAllByOwner(ctx context.Context, owner *user.User) error { if owner == nil { return ErrRepoNotFound } repos, err := s.ListByOwner(ctx, owner.ID, true) if err != nil { return err } var errs []error for i := range repos { if err := s.Delete(ctx, &repos[i]); err != nil { errs = append(errs, fmt.Errorf("delete %s/%s: %w", repos[i].OwnerUsername, repos[i].Name, err)) } } baseAbs, err := filepath.Abs(s.reposDir) if err != nil { return fmt.Errorf("resolve repos dir: %w", err) } ownerDir := filepath.Join(baseAbs, strings.ToLower(owner.Username)) if len(errs) == 0 { if err := os.RemoveAll(ownerDir); err != nil { errs = append(errs, fmt.Errorf("remove owner repository directory: %w", err)) } } return errors.Join(errs...) } func boolInt(b bool) int { if b { return 1 } return 0 } // FindForkByOwner returns the owner's fork of the given repository, if any. func (s *Service) FindForkByOwner(ctx context.Context, srcRepoID, ownerID int64) (*Repository, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() row := s.db.QueryRowContext(cctx, repoSelectFields+` WHERE r.forked_from_repo_id = ? AND r.owner_id = ? ORDER BY r.created_at ASC LIMIT 1 `, srcRepoID, ownerID) r, err := scanRepo(row) if errors.Is(err, ErrRepoNotFound) { return nil, nil } return r, err } // Fork copies a repository into the owner's namespace. func (s *Service) Fork(ctx context.Context, src *Repository, owner *user.User, name string) (*Repository, error) { name = strings.TrimSpace(name) if name == "" { name = src.Name } if err := ValidateRepoName(name); err != nil { return nil, err } srcPath, err := s.DiskPath(src.OwnerUsername, src.Name) if err != nil { return nil, err } dstPath, err := s.DiskPath(owner.Username, name) if err != nil { return nil, err } cctx, cancel := context.WithTimeout(ctx, 120*time.Second) defer cancel() var exists int err = s.db.QueryRowContext(cctx, ` SELECT 1 FROM repositories WHERE owner_id = ? AND LOWER(name) = LOWER(?) `, owner.ID, name).Scan(&exists) if err == nil { return nil, ErrRepoAlreadyExists } else if !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("check fork name: %w", err) } if err := git.CloneBare(cctx, srcPath, dstPath); err != nil { return nil, fmt.Errorf("copy repository: %w", err) } now := time.Now().UTC() srcID := src.ID res, err := s.db.ExecContext(cctx, ` INSERT INTO repositories (owner_id, name, description, is_private, default_branch, enable_issues, enable_pulls, enable_releases, enable_activity, forked_from_repo_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, owner.ID, name, src.Description, boolInt(src.IsPrivate), src.DefaultBranch, boolInt(src.EnableIssues), boolInt(src.EnablePulls), boolInt(src.EnableReleases), boolInt(src.EnableActivity), srcID, now, now) if err != nil { _ = os.RemoveAll(dstPath) if strings.Contains(strings.ToLower(err.Error()), "unique") { return nil, ErrRepoAlreadyExists } return nil, fmt.Errorf("insert fork: %w", err) } id, _ := res.LastInsertId() return &Repository{ ID: id, OwnerID: owner.ID, OwnerUsername: owner.Username, OwnerAvatarURL: owner.AvatarURL, Name: name, Description: src.Description, IsPrivate: src.IsPrivate, DefaultBranch: src.DefaultBranch, EnableIssues: src.EnableIssues, EnablePulls: src.EnablePulls, EnableReleases: src.EnableReleases, EnableActivity: src.EnableActivity, ForkedFromRepoID: &srcID, ForkedFromOwner: src.OwnerUsername, ForkedFromName: src.Name, CreatedAt: now, UpdatedAt: now, }, nil } // ListForks returns repositories forked from the given repository. func (s *Service) ListForks(ctx context.Context, srcRepoID int64, limit int) ([]Repository, error) { if limit <= 0 || limit > 100 { limit = 20 } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() rows, err := s.db.QueryContext(cctx, repoSelectFields+` WHERE r.forked_from_repo_id = ? ORDER BY r.updated_at DESC LIMIT ? `, srcRepoID, limit) if err != nil { return nil, fmt.Errorf("list forks: %w", err) } defer rows.Close() return scanRepos(rows) } func scanRepo(row *sql.Row) (*Repository, error) { var r Repository var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int var forkedFromID sql.NullInt64 var forkedFromOwner, forkedFromName sql.NullString err := row.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, &enableIssues, &enablePulls, &enableReleases, &enableActivity, &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount, &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount, &r.CreatedAt, &r.UpdatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrRepoNotFound } return nil, fmt.Errorf("scan repository: %w", err) } r.IsPrivate = isPriv == 1 r.EnableIssues = enableIssues == 1 r.EnablePulls = enablePulls == 1 r.EnableReleases = enableReleases == 1 r.EnableActivity = enableActivity == 1 if forkedFromID.Valid { id := forkedFromID.Int64 r.ForkedFromRepoID = &id } r.ForkedFromOwner = forkedFromOwner.String r.ForkedFromName = forkedFromName.String return &r, nil } func scanRepos(rows *sql.Rows) ([]Repository, error) { var list []Repository for rows.Next() { var r Repository var isPriv, enableIssues, enablePulls, enableReleases, enableActivity int var forkedFromID sql.NullInt64 var forkedFromOwner, forkedFromName sql.NullString if err := rows.Scan(&r.ID, &r.OwnerID, &r.OwnerUsername, &r.OwnerAvatarURL, &r.AvatarURL, &r.Name, &r.Description, &isPriv, &r.DefaultBranch, &enableIssues, &enablePulls, &enableReleases, &enableActivity, &forkedFromID, &forkedFromOwner, &forkedFromName, &r.ForkCount, &r.OpenIssueCount, &r.OpenPRCount, &r.ReleaseCount, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, fmt.Errorf("scan repository row: %w", err) } r.IsPrivate = isPriv == 1 r.EnableIssues = enableIssues == 1 r.EnablePulls = enablePulls == 1 r.EnableReleases = enableReleases == 1 r.EnableActivity = enableActivity == 1 if forkedFromID.Valid { id := forkedFromID.Int64 r.ForkedFromRepoID = &id } r.ForkedFromOwner = forkedFromOwner.String r.ForkedFromName = forkedFromName.String list = append(list, r) } return list, rows.Err() }