Files
GitOcean-Old/internal/app/repos.go
T
2026-06-08 15:03:41 -05:00

432 lines
15 KiB
Go

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 != "public" {
writeError(w, http.StatusForbidden, "only public repositories can be forked")
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})
}