package app import ( "database/sql" "encoding/json" "net/http" "os" "os/exec" "path/filepath" "strings" ) // ---------------- Repository API ---------------- func (s *Server) handleRepoCreate(w http.ResponseWriter, r *http.Request) { user, ok := s.requireBearerUser(w, r) if !ok { return } var in struct { Name string `json:"name"` Visibility string `json:"visibility"` Description string `json:"description"` } if !decodeJSON(w, r, &in) { return } name := strings.ToLower(strings.TrimSpace(in.Name)) visibility := strings.ToLower(strings.TrimSpace(in.Visibility)) if !repoNameRE.MatchString(name) || isReservedName(name) { writeError(w, http.StatusBadRequest, "invalid repository name") return } if visibility != "public" && visibility != "private" { writeError(w, http.StatusBadRequest, "visibility must be public or private") return } res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility, description, default_branch) VALUES (?, ?, ?, ?, 'main')`, user.ID, name, visibility, strings.TrimSpace(in.Description)) if err != nil { writeError(w, http.StatusConflict, "repository already exists") return } repoID, _ := res.LastInsertId() path := s.repoPath(user.Username, name) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID) writeError(w, http.StatusInternalServerError, err.Error()) return } if err := gitInitBare(path); err != nil { _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repoID) writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusCreated, Repository{ID: repoID, OwnerUserID: user.ID, Owner: user.Username, Name: name, Visibility: visibility, Description: strings.TrimSpace(in.Description), DefaultBranch: "main"}) } func (s *Server) handleRepoSearch(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) scope := r.URL.Query().Get("scope") if scope == "" { scope = "all" } user, authed := s.optionalBearerUser(r) var rows *sql.Rows var err error like := "%" + q + "%" if scope == "mine" { if !authed { writeError(w, http.StatusUnauthorized, "scope=mine requires auth") return } rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id WHERE r.owner_user_id = ? AND (? = '' OR r.name LIKE ?) ORDER BY r.updated_at DESC LIMIT 100`, user.ID, q, like) } else if scope == "all" { if authed { rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id WHERE (r.visibility = 'public' OR r.owner_user_id = ? OR EXISTS (SELECT 1 FROM repository_collaborators c WHERE c.repository_id = r.id AND c.user_id = ?)) AND (? = '' OR r.name LIKE ? OR u.username LIKE ?) ORDER BY r.updated_at DESC LIMIT 100`, user.ID, user.ID, q, like, like) } else { rows, err = s.db.Query(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id WHERE r.visibility = 'public' AND (? = '' OR r.name LIKE ? OR u.username LIKE ?) ORDER BY r.updated_at DESC LIMIT 100`, q, like, like) } } else { writeError(w, http.StatusBadRequest, "scope must be all or mine") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() repos, err := scanRepos(rows) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, repos) } func (s *Server) handleRepoGet(w http.ResponseWriter, r *http.Request, owner, name string) { repo, err := s.loadRepo(owner, name) if err != nil { writeError(w, http.StatusNotFound, "repository not found") return } user, authed := s.optionalBearerUser(r) if !s.canReadRepo(repo, user, authed) { writeError(w, http.StatusNotFound, "repository not found") return } writeJSON(w, http.StatusOK, repo) } func (s *Server) handleRepoDelete(w http.ResponseWriter, r *http.Request, owner, name string) { user, ok := s.requireBearerUser(w, r) if !ok { return } repo, err := s.loadRepo(owner, name) if err != nil { writeError(w, http.StatusNotFound, "repository not found") return } if repo.OwnerUserID != user.ID { writeError(w, http.StatusForbidden, "only the owner can delete this repository") return } force := r.URL.Query().Get("force") == "true" || r.URL.Query().Get("force") == "1" var openCount int _ = s.db.QueryRow(`SELECT COUNT(*) FROM pull_requests WHERE status = 'open' AND (target_repository_id = ? OR source_repository_id = ?)`, repo.ID, repo.ID).Scan(&openCount) if openCount > 0 && !force { writeError(w, http.StatusConflict, "repository has open pull requests; use force=true") return } if force { _, _ = s.db.Exec(`DELETE FROM pull_requests WHERE target_repository_id = ? OR source_repository_id = ?`, repo.ID, repo.ID) } _, err = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, repo.ID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } _ = os.RemoveAll(s.repoPath(owner, name)) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } func (s *Server) handleRepoFork(w http.ResponseWriter, r *http.Request, owner, name string) { user, ok := s.requireBearerUser(w, r) if !ok { return } src, err := s.loadRepo(owner, name) if err != nil { writeError(w, http.StatusNotFound, "repository not found") return } if src.Visibility == "private" && src.OwnerUserID != user.ID { writeError(w, http.StatusForbidden, "cannot fork this private repository") return } var in struct { Name string `json:"name"` } _ = json.NewDecoder(r.Body).Decode(&in) newName := strings.ToLower(strings.TrimSpace(in.Name)) if newName == "" { newName = src.Name } if !repoNameRE.MatchString(newName) || isReservedName(newName) { writeError(w, http.StatusBadRequest, "invalid repository name") return } res, err := s.db.Exec(`INSERT INTO repositories (owner_user_id, name, visibility, description, default_branch, forked_from_repository_id) VALUES (?, ?, ?, ?, ?, ?)`, user.ID, newName, src.Visibility, src.Description, src.DefaultBranch, src.ID) if err != nil { writeError(w, http.StatusConflict, "repository already exists") return } newID, _ := res.LastInsertId() dstPath := s.repoPath(user.Username, newName) if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, newID) writeError(w, http.StatusInternalServerError, err.Error()) return } cmd := exec.Command("git", "clone", "--bare", s.repoPath(src.Owner, src.Name), dstPath) if out, err := cmd.CombinedOutput(); err != nil { _, _ = s.db.Exec(`DELETE FROM repositories WHERE id = ?`, newID) _ = os.RemoveAll(dstPath) writeError(w, http.StatusInternalServerError, string(out)) return } writeJSON(w, http.StatusCreated, Repository{ID: newID, OwnerUserID: user.ID, Owner: user.Username, Name: newName, Visibility: src.Visibility, Description: src.Description, DefaultBranch: src.DefaultBranch, ForkedFromID: &src.ID}) } func (s *Server) loadRepo(owner, name string) (Repository, error) { var repo Repository var fork sql.NullInt64 err := s.db.QueryRow(`SELECT r.id, r.owner_user_id, u.username, r.name, r.visibility, COALESCE(r.description, ''), r.default_branch, r.archived, r.forked_from_repository_id, r.created_at, r.updated_at FROM repositories r JOIN users u ON u.id = r.owner_user_id WHERE u.username = ? AND r.name = ?`, strings.ToLower(owner), strings.ToLower(name)).Scan(&repo.ID, &repo.OwnerUserID, &repo.Owner, &repo.Name, &repo.Visibility, &repo.Description, &repo.DefaultBranch, &repo.Archived, &fork, &repo.CreatedAt, &repo.UpdatedAt) if fork.Valid { repo.ForkedFromID = &fork.Int64 } return repo, err } func scanRepos(rows *sql.Rows) ([]Repository, error) { var repos []Repository for rows.Next() { var repo Repository var fork sql.NullInt64 if err := rows.Scan(&repo.ID, &repo.OwnerUserID, &repo.Owner, &repo.Name, &repo.Visibility, &repo.Description, &repo.DefaultBranch, &repo.Archived, &fork, &repo.CreatedAt, &repo.UpdatedAt); err != nil { return nil, err } if fork.Valid { repo.ForkedFromID = &fork.Int64 } repos = append(repos, repo) } return repos, rows.Err() } func (s *Server) handleRepoUpdate(w http.ResponseWriter, r *http.Request, owner, name string) { user, ok := s.requireBearerUser(w, r) if !ok { return } repo, err := s.loadRepo(owner, name) if err != nil { writeError(w, http.StatusNotFound, "repository not found") return } if repo.OwnerUserID != user.ID { writeError(w, http.StatusForbidden, "only the owner can update this repository") return } var in struct { Description *string `json:"description"` Visibility *string `json:"visibility"` DefaultBranch *string `json:"default_branch"` Archived *bool `json:"archived"` } if !decodeJSON(w, r, &in) { return } if in.Description != nil { repo.Description = strings.TrimSpace(*in.Description) } if in.Visibility != nil { v := strings.ToLower(strings.TrimSpace(*in.Visibility)) if v != "public" && v != "private" { writeError(w, http.StatusBadRequest, "visibility must be public or private") return } repo.Visibility = v } if in.DefaultBranch != nil { b := strings.TrimSpace(*in.DefaultBranch) if !branchRE.MatchString(b) || !gitBranchExists(s.repoPath(repo.Owner, repo.Name), b) { writeError(w, http.StatusBadRequest, "default branch must exist") return } repo.DefaultBranch = b _ = runGit("", "--git-dir", s.repoPath(repo.Owner, repo.Name), "symbolic-ref", "HEAD", "refs/heads/"+b) } if in.Archived != nil { repo.Archived = *in.Archived } _, err = s.db.Exec(`UPDATE repositories SET description = ?, visibility = ?, default_branch = ?, archived = ? WHERE id = ?`, repo.Description, repo.Visibility, repo.DefaultBranch, repo.Archived, repo.ID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, repo) } func (s *Server) handleRepoBranches(w http.ResponseWriter, r *http.Request, owner, name string) { repo, ok := s.requireReadableRepo(w, r, owner, name) if !ok { return } refs, err := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/heads") if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, refs) } func (s *Server) handleRepoTags(w http.ResponseWriter, r *http.Request, owner, name string) { repo, ok := s.requireReadableRepo(w, r, owner, name) if !ok { return } refs, err := gitRefs(s.repoPath(repo.Owner, repo.Name), "refs/tags") if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, refs) } func (s *Server) requireReadableRepo(w http.ResponseWriter, r *http.Request, owner, name string) (Repository, bool) { repo, err := s.loadRepo(owner, name) if err != nil { writeError(w, http.StatusNotFound, "repository not found") return Repository{}, false } user, authed := s.optionalBearerUser(r) if !s.canReadRepo(repo, user, authed) { writeError(w, http.StatusNotFound, "repository not found") return Repository{}, false } return repo, true } func (s *Server) canReadRepo(repo Repository, user User, authed bool) bool { if repo.Visibility == "public" { return true } if !authed { return false } if user.ID == repo.OwnerUserID { return true } return s.collaboratorRole(repo.ID, user.ID) != "" } func (s *Server) canWriteRepo(repo Repository, user User) bool { if user.ID == repo.OwnerUserID { return true } return s.collaboratorRole(repo.ID, user.ID) == "write" } func (s *Server) collaboratorRole(repoID, userID int64) string { var role string _ = s.db.QueryRow(`SELECT role FROM repository_collaborators WHERE repository_id = ? AND user_id = ?`, repoID, userID).Scan(&role) return role } func (s *Server) handleCollaborators(w http.ResponseWriter, r *http.Request, owner, name string, parts []string) { user, ok := s.requireBearerUser(w, r) if !ok { return } repo, err := s.loadRepo(owner, name) if err != nil { writeError(w, http.StatusNotFound, "repository not found") return } if repo.OwnerUserID != user.ID { writeError(w, http.StatusForbidden, "only the owner can manage collaborators") return } if len(parts) == 0 && r.Method == http.MethodGet { s.handleCollaboratorsList(w, repo) return } if len(parts) == 0 && r.Method == http.MethodPost { s.handleCollaboratorAdd(w, r, repo) return } if len(parts) == 1 && r.Method == http.MethodDelete { _, err := s.db.Exec(`DELETE rc FROM repository_collaborators rc JOIN users u ON u.id = rc.user_id WHERE rc.repository_id = ? AND u.username = ?`, repo.ID, strings.ToLower(parts[0])) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) return } writeError(w, http.StatusNotFound, "not found") } func (s *Server) handleCollaboratorsList(w http.ResponseWriter, repo Repository) { rows, err := s.db.Query(`SELECT rc.id, u.username, rc.role, rc.created_at FROM repository_collaborators rc JOIN users u ON u.id = rc.user_id WHERE rc.repository_id = ? ORDER BY u.username`, repo.ID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() var out []Collaborator for rows.Next() { var c Collaborator if err := rows.Scan(&c.ID, &c.Username, &c.Role, &c.CreatedAt); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } out = append(out, c) } writeJSON(w, http.StatusOK, out) } func (s *Server) handleCollaboratorAdd(w http.ResponseWriter, r *http.Request, repo Repository) { var in struct { Username string `json:"username"` Role string `json:"role"` } if !decodeJSON(w, r, &in) { return } username := strings.ToLower(strings.TrimSpace(in.Username)) role := strings.ToLower(strings.TrimSpace(in.Role)) if role != "read" && role != "write" { writeError(w, http.StatusBadRequest, "role must be read or write") return } var userID int64 if err := s.db.QueryRow(`SELECT id FROM users WHERE username = ?`, username).Scan(&userID); err != nil { writeError(w, http.StatusNotFound, "user not found") return } if userID == repo.OwnerUserID { writeError(w, http.StatusBadRequest, "owner is already a collaborator") return } _, err := s.db.Exec(`INSERT INTO repository_collaborators (repository_id, user_id, role) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE role = VALUES(role)`, repo.ID, userID, role) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"username": username, "role": role}) }