432 lines
15 KiB
Go
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 == "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})
|
|
}
|