Refactor codebase for Phase 3
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------- Auth API ----------------
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
|
||||
in.Username = strings.ToLower(strings.TrimSpace(in.Username))
|
||||
if !strings.Contains(in.Email, "@") || len(in.Email) > 255 {
|
||||
writeError(w, http.StatusBadRequest, "invalid email")
|
||||
return
|
||||
}
|
||||
if !usernameRE.MatchString(in.Username) || isReservedName(in.Username) {
|
||||
writeError(w, http.StatusBadRequest, "invalid or reserved username")
|
||||
return
|
||||
}
|
||||
if len(in.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "password hash failed")
|
||||
return
|
||||
}
|
||||
var userCount int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
|
||||
isAdmin := userCount == 0
|
||||
res, err := s.db.Exec(`INSERT INTO users (email, username, password_hash, is_admin) VALUES (?, ?, ?, ?)`, in.Email, in.Username, string(hash), isAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusConflict, "email or username already exists")
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
writeJSON(w, http.StatusCreated, User{ID: id, Email: in.Email, Username: in.Username, IsAdmin: isAdmin})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
login := strings.ToLower(strings.TrimSpace(in.Login))
|
||||
var user User
|
||||
var hash string
|
||||
err := s.db.QueryRow(`SELECT id, email, username, is_admin, password_hash FROM users WHERE email = ? OR username = ?`, login, login).Scan(&user.ID, &user.Email, &user.Username, &user.IsAdmin, &hash)
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
token, expiresAt, err := s.createToken(user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "token creation failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": user, "token": token, "expires_at": expiresAt})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE token_hash = ?`, hashToken(token))
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.requireBearerUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) createToken(userID int64) (string, time.Time, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
token := "go_" + base64.RawURLEncoding.EncodeToString(raw)
|
||||
expiresAt := time.Now().UTC().Add(tokenTTL)
|
||||
_, err := s.db.Exec(`INSERT INTO auth_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)`, userID, hashToken(token), expiresAt)
|
||||
return token, expiresAt, err
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
return strings.TrimSpace(h[7:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) requireBearerUser(w http.ResponseWriter, r *http.Request) (User, bool) {
|
||||
user, err := s.userFromToken(bearerToken(r), "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
func (s *Server) optionalBearerUser(r *http.Request) (User, bool) {
|
||||
user, err := s.userFromToken(bearerToken(r), "")
|
||||
return user, err == nil
|
||||
}
|
||||
|
||||
func (s *Server) userFromToken(token, requiredUsername string) (User, error) {
|
||||
if token == "" {
|
||||
return User{}, errors.New("missing token")
|
||||
}
|
||||
var user User
|
||||
query := `SELECT u.id, u.email, u.username, u.is_admin
|
||||
FROM auth_tokens t JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = ? AND t.revoked_at IS NULL AND t.expires_at > UTC_TIMESTAMP()`
|
||||
args := []any{hashToken(token)}
|
||||
if requiredUsername != "" {
|
||||
query += ` AND u.username = ?`
|
||||
args = append(args, requiredUsername)
|
||||
}
|
||||
err := s.db.QueryRow(query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.IsAdmin)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Server) userFromBasic(r *http.Request) (User, bool) {
|
||||
username, token, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return User{}, false
|
||||
}
|
||||
user, err := s.userFromToken(token, strings.ToLower(username))
|
||||
return user, err == nil
|
||||
}
|
||||
|
||||
func (s *Server) handleTokenRoutes(w http.ResponseWriter, r *http.Request, path string) {
|
||||
user, ok := s.requireBearerUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if path == "" || path == "/" {
|
||||
if r.Method == http.MethodGet {
|
||||
s.handleTokenList(w, user)
|
||||
return
|
||||
}
|
||||
}
|
||||
if path == "/prune" && r.Method == http.MethodPost {
|
||||
_, err := s.db.Exec(`DELETE FROM auth_tokens WHERE user_id = ? AND (expires_at <= UTC_TIMESTAMP() OR revoked_at IS NOT NULL)`, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "pruned"})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(path, "/") && r.Method == http.MethodDelete {
|
||||
id, err := strconv.ParseInt(strings.TrimPrefix(path, "/"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid token id")
|
||||
return
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE auth_tokens SET revoked_at = UTC_TIMESTAMP() WHERE id = ? AND user_id = ?`, id, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
|
||||
func (s *Server) handleTokenList(w http.ResponseWriter, user User) {
|
||||
rows, err := s.db.Query(`SELECT id, created_at, expires_at, revoked_at, revoked_at IS NULL AND expires_at > UTC_TIMESTAMP() FROM auth_tokens WHERE user_id = ? ORDER BY created_at DESC`, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []TokenInfo
|
||||
for rows.Next() {
|
||||
var t TokenInfo
|
||||
var revoked sql.NullTime
|
||||
if err := rows.Scan(&t.ID, &t.CreatedAt, &t.ExpiresAt, &revoked, &t.Active); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if revoked.Valid {
|
||||
t.RevokedAt = &revoked.Time
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminRoutes(w http.ResponseWriter, r *http.Request, path string) {
|
||||
user, ok := s.requireBearerUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !user.IsAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/users/"):
|
||||
username := strings.TrimPrefix(path, "/users/")
|
||||
if username == user.Username {
|
||||
writeError(w, http.StatusBadRequest, "cannot delete yourself")
|
||||
return
|
||||
}
|
||||
_, err := s.db.Exec(`DELETE FROM users WHERE username = ?`, strings.ToLower(username))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
case r.Method == http.MethodGet && path == "/users":
|
||||
rows, err := s.db.Query(`SELECT id, email, username, is_admin FROM users ORDER BY username`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.IsAdmin); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
case r.Method == http.MethodGet && path == "/repos":
|
||||
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 ORDER BY u.username, r.name`)
|
||||
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)
|
||||
case r.Method == http.MethodPost && path == "/tokens/prune":
|
||||
_, err := s.db.Exec(`DELETE FROM auth_tokens WHERE expires_at <= UTC_TIMESTAMP() OR revoked_at IS NOT NULL`)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "pruned"})
|
||||
default:
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func cliToken(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("usage: gitocean token <list|revoke|prune>")
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
jsonOut := len(args) > 1 && args[1] == "--json"
|
||||
if len(args) > 2 || (len(args) == 2 && !jsonOut) {
|
||||
return errors.New("usage: gitocean token list [--json]")
|
||||
}
|
||||
var out []TokenInfo
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/tokens", cfg.Token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
for _, t := range out {
|
||||
status := "revoked/expired"
|
||||
if t.Active {
|
||||
status = "active"
|
||||
}
|
||||
fmt.Printf("%-6d %-15s created=%s expires=%s\n", t.ID, status, t.CreatedAt.Format(time.RFC3339), t.ExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
case "revoke":
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: gitocean token revoke TOKEN_ID")
|
||||
}
|
||||
return apiRequest(context.Background(), cfg.Server, http.MethodDelete, "/api/tokens/"+args[1], cfg.Token, nil, nil)
|
||||
case "prune":
|
||||
return apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/tokens/prune", cfg.Token, nil, nil)
|
||||
default:
|
||||
return fmt.Errorf("unknown token command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func cliAdmin(args []string) error {
|
||||
if len(args) < 2 {
|
||||
return errors.New("usage: gitocean admin users list | repos list | storage check | tokens prune")
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] + " " + args[1] {
|
||||
case "users list":
|
||||
var out []User
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/admin/users", cfg.Token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range out {
|
||||
fmt.Printf("%-20s %-30s admin=%v\n", u.Username, u.Email, u.IsAdmin)
|
||||
}
|
||||
return nil
|
||||
case "users delete":
|
||||
if len(args) != 3 {
|
||||
return errors.New("usage: gitocean admin users delete USER")
|
||||
}
|
||||
return apiRequest(context.Background(), cfg.Server, http.MethodDelete, "/api/admin/users/"+strings.ToLower(args[2]), cfg.Token, nil, nil)
|
||||
case "repos list":
|
||||
var out []Repository
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/admin/repos", cfg.Token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
printRepoTable(out)
|
||||
return nil
|
||||
case "storage check":
|
||||
serverCfg, err := loadServerConfig(defaultServerConfigPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.Walk(filepath.Join(serverCfg.Storage, "repos"), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info != nil && info.IsDir() && strings.HasSuffix(path, ".git") {
|
||||
fmt.Println(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
case "tokens prune":
|
||||
return apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/admin/tokens/prune", cfg.Token, nil, nil)
|
||||
default:
|
||||
return errors.New("usage: gitocean admin users list | repos list | storage check | tokens prune")
|
||||
}
|
||||
}
|
||||
|
||||
func cliBackup(args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: gitocean backup create FILE | restore FILE")
|
||||
}
|
||||
cfg, err := loadServerConfig(defaultServerConfigPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] {
|
||||
case "create":
|
||||
return createBackup(cfg, args[1])
|
||||
case "restore":
|
||||
return restoreBackup(cfg, args[1])
|
||||
default:
|
||||
return errors.New("usage: gitocean backup create FILE | restore FILE")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func cliRegister(args []string) error {
|
||||
fs := flag.NewFlagSet("register", flag.ExitOnError)
|
||||
server := fs.String("server", envServer(), "server URL")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
email := prompt("Email: ")
|
||||
username := prompt("Username: ")
|
||||
password := prompt("Password: ")
|
||||
var out User
|
||||
if err := apiRequest(context.Background(), *server, http.MethodPost, "/api/register", "", map[string]string{"email": email, "username": username, "password": password}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Registered %s <%s>\n", out.Username, out.Email)
|
||||
if out.IsAdmin {
|
||||
fmt.Println("This is the first user, so it is an admin account.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliLogin(args []string) error {
|
||||
fs := flag.NewFlagSet("login", flag.ExitOnError)
|
||||
server := fs.String("server", envServer(), "server URL")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
login := prompt("Email or username: ")
|
||||
password := prompt("Password: ")
|
||||
var out struct {
|
||||
User User `json:"user"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
if err := apiRequest(context.Background(), *server, http.MethodPost, "/api/login", "", map[string]string{"login": login, "password": password}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := Config{Server: strings.TrimRight(*server, "/"), Username: out.User.Username, Token: out.Token, ExpiresAt: out.ExpiresAt}
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
approveGitCredential(cfg)
|
||||
fmt.Printf("logged in as %s; token expires %s\n", cfg.Username, cfg.ExpiresAt.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliLogout(args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/logout", cfg.Token, nil, nil)
|
||||
_ = os.Remove(configPath())
|
||||
fmt.Println("logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliWhoami(args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var user User
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/me", cfg.Token, nil, &user); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Username: %s\n", user.Username)
|
||||
fmt.Printf("Email: %s\n", user.Email)
|
||||
fmt.Printf("Admin: %v\n", user.IsAdmin)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliClone(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: gitocean clone OWNER/REPO")
|
||||
}
|
||||
server := envServer()
|
||||
if cfg, err := loadConfig(); err == nil && cfg.Server != "" {
|
||||
server = cfg.Server
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remote := fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(server, "/"), owner, repo)
|
||||
cmd := exec.Command("git", "clone", remote)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitocean/internal/backup"
|
||||
appconfig "gitocean/internal/config"
|
||||
"gitocean/internal/dbutil"
|
||||
"gitocean/internal/gitutil"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------- CLI helpers ----------------
|
||||
|
||||
func apiRequest(ctx context.Context, server, method, path, token string, body any, out any) error {
|
||||
server = strings.TrimRight(server, "/")
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, server+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
var e struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(b, &e) == nil && e.Error != "" {
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("%s\nYour session may have expired. Run:\n gitocean login", e.Error)
|
||||
}
|
||||
return fmt.Errorf("%s", e.Error)
|
||||
}
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
if out != nil && len(b) > 0 {
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envServer() string {
|
||||
return appconfig.EnvServer()
|
||||
}
|
||||
|
||||
func configPath() string {
|
||||
return appconfig.ClientConfigPath()
|
||||
}
|
||||
|
||||
func loadConfig() (Config, error) {
|
||||
return appconfig.LoadClient()
|
||||
}
|
||||
|
||||
func saveConfig(cfg Config) error {
|
||||
return appconfig.SaveClient(cfg)
|
||||
}
|
||||
|
||||
func approveGitCredential(cfg Config) {
|
||||
u, err := url.Parse(cfg.Server)
|
||||
if err != nil || u.Host == "" || u.Scheme == "" {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command("git", "credential", "approve")
|
||||
cmd.Stdin = strings.NewReader(fmt.Sprintf("protocol=%s\nhost=%s\nusername=%s\npassword=%s\n\n", u.Scheme, u.Host, cfg.Username, cfg.Token))
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
func prompt(label string) string {
|
||||
fmt.Print(label)
|
||||
var s string
|
||||
fmt.Scanln(&s)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func printJSON(v any) {
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
|
||||
func splitOwnerRepo(s string) (string, string, error) {
|
||||
parts := strings.Split(s, "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", fmt.Errorf("expected OWNER/REPO, got %q", s)
|
||||
}
|
||||
return strings.ToLower(parts[0]), strings.ToLower(parts[1]), nil
|
||||
}
|
||||
|
||||
func splitRepoBranch(s string) (owner, repo, branch string, err error) {
|
||||
left, branch, ok := strings.Cut(s, ":")
|
||||
if !ok || branch == "" {
|
||||
return "", "", "", fmt.Errorf("expected OWNER/REPO:BRANCH, got %q", s)
|
||||
}
|
||||
owner, repo, err = splitOwnerRepo(left)
|
||||
return owner, repo, branch, err
|
||||
}
|
||||
|
||||
func parseRepoCreateArgs(args []string) (name, visibility, description string, jsonOut bool, err error) {
|
||||
pub, priv := false, false
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--public" || arg == "-public":
|
||||
pub = true
|
||||
case arg == "--private" || arg == "-private":
|
||||
priv = true
|
||||
case arg == "--json":
|
||||
jsonOut = true
|
||||
case arg == "--description":
|
||||
if i+1 >= len(args) {
|
||||
return "", "", "", false, errors.New("--description requires a value")
|
||||
}
|
||||
i++
|
||||
description = args[i]
|
||||
case strings.HasPrefix(arg, "--description="):
|
||||
description = strings.TrimPrefix(arg, "--description=")
|
||||
default:
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
return "", "", "", false, fmt.Errorf("unknown flag %s", arg)
|
||||
}
|
||||
if name != "" {
|
||||
return "", "", "", false, errors.New("usage: gitocean repo create NAME (--public|--private)")
|
||||
}
|
||||
name = arg
|
||||
}
|
||||
}
|
||||
if name == "" || pub == priv {
|
||||
return "", "", "", false, errors.New("usage: gitocean repo create NAME (--public|--private)")
|
||||
}
|
||||
visibility = "private"
|
||||
if pub {
|
||||
visibility = "public"
|
||||
}
|
||||
return name, visibility, description, jsonOut, nil
|
||||
}
|
||||
|
||||
func parseRepoPublishArgs(args []string) (name, visibility, description, remoteName, branch string, err error) {
|
||||
remoteName = "origin"
|
||||
branch = "main"
|
||||
filtered := []string{}
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--remote":
|
||||
if i+1 >= len(args) {
|
||||
return "", "", "", "", "", errors.New("--remote requires a value")
|
||||
}
|
||||
i++
|
||||
remoteName = args[i]
|
||||
case "--branch":
|
||||
if i+1 >= len(args) {
|
||||
return "", "", "", "", "", errors.New("--branch requires a value")
|
||||
}
|
||||
i++
|
||||
branch = args[i]
|
||||
default:
|
||||
filtered = append(filtered, args[i])
|
||||
}
|
||||
}
|
||||
name, visibility, description, _, err = parseRepoCreateArgs(filtered)
|
||||
if err != nil {
|
||||
return "", "", "", "", "", err
|
||||
}
|
||||
return name, visibility, description, remoteName, branch, nil
|
||||
}
|
||||
|
||||
func removeBoolFlag(args *[]string, flagName string) bool {
|
||||
in := *args
|
||||
out := in[:0]
|
||||
found := false
|
||||
for _, arg := range in {
|
||||
if arg == flagName {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
*args = out
|
||||
return found
|
||||
}
|
||||
|
||||
func repoCloneURL(server, owner, repo string) string {
|
||||
return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(server, "/"), owner, repo)
|
||||
}
|
||||
|
||||
func printRepoCreated(server string, repo Repository) {
|
||||
remote := repoCloneURL(server, repo.Owner, repo.Name)
|
||||
fmt.Printf("Created %s repository %s/%s\n\n", repo.Visibility, repo.Owner, repo.Name)
|
||||
fmt.Println("Clone:")
|
||||
fmt.Printf(" git clone %s\n\n", remote)
|
||||
fmt.Println("Add existing repo:")
|
||||
fmt.Printf(" git remote add origin %s\n", remote)
|
||||
fmt.Printf(" git branch -M %s\n", repo.DefaultBranch)
|
||||
fmt.Printf(" git push -u origin %s\n", repo.DefaultBranch)
|
||||
}
|
||||
|
||||
func printRepoView(server string, repo Repository) {
|
||||
fmt.Printf("%s/%s\n", repo.Owner, repo.Name)
|
||||
fmt.Printf("Visibility: %s\n", repo.Visibility)
|
||||
fmt.Printf("Default branch: %s\n", repo.DefaultBranch)
|
||||
fmt.Printf("Archived: %v\n", repo.Archived)
|
||||
if repo.Description != "" {
|
||||
fmt.Printf("Description: %s\n", repo.Description)
|
||||
}
|
||||
fmt.Println("\nClone:")
|
||||
fmt.Printf(" git clone %s\n", repoCloneURL(server, repo.Owner, repo.Name))
|
||||
}
|
||||
|
||||
func printRepoTable(repos []Repository) {
|
||||
fmt.Printf("%-28s %-10s %-8s %s\n", "REPOSITORY", "VISIBILITY", "ARCHIVED", "DESCRIPTION")
|
||||
for _, r := range repos {
|
||||
fmt.Printf("%-28s %-10s %-8v %s\n", r.Owner+"/"+r.Name, r.Visibility, r.Archived, r.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func printRefs(kind string, refs []RefInfo) {
|
||||
fmt.Printf("%-28s %-10s %-20s %s\n", strings.ToUpper(strings.TrimSuffix(kind, "es")), "COMMIT", "DATE", "MESSAGE")
|
||||
for _, r := range refs {
|
||||
commit := r.Commit
|
||||
if len(commit) > 10 {
|
||||
commit = commit[:10]
|
||||
}
|
||||
fmt.Printf("%-28s %-10s %-20s %s\n", r.Name, commit, r.Date.Format("2006-01-02 15:04"), r.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func printPR(pr PullRequest) {
|
||||
fmt.Printf("#%d %s: %s\n", pr.Number, pr.Status, pr.Title)
|
||||
fmt.Printf("Author: %s\n", pr.Author)
|
||||
fmt.Printf("From: %s/%s:%s\n", pr.SourceOwner, pr.SourceRepo, pr.SourceBranch)
|
||||
fmt.Printf("To: %s/%s:%s\n", pr.TargetOwner, pr.TargetRepo, pr.TargetBranch)
|
||||
if pr.Description != "" {
|
||||
fmt.Printf("\n%s\n", pr.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func apiTextRequest(ctx context.Context, server, path, token string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(server, "/")+path, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func defaultServerConfigPath() string {
|
||||
return appconfig.DefaultServerConfigPath()
|
||||
}
|
||||
|
||||
func loadServerConfig(path string) (ServerConfig, error) {
|
||||
return appconfig.LoadServer(path)
|
||||
}
|
||||
|
||||
func saveServerConfig(path string, cfg ServerConfig) error {
|
||||
return appconfig.SaveServer(path, cfg)
|
||||
}
|
||||
|
||||
func promptDefault(label, def string) string {
|
||||
fmt.Printf("%s [%s]: ", label, def)
|
||||
var s string
|
||||
fmt.Scanln(&s)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func createUserDirect(db *sql.DB, email, username, password string, isAdmin bool) error {
|
||||
if !strings.Contains(email, "@") || !usernameRE.MatchString(username) || len(password) < 8 {
|
||||
return errors.New("invalid email, username, or password")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO users (email, username, password_hash, is_admin) VALUES (?, ?, ?, ?)`, email, username, string(hash), isAdmin)
|
||||
return err
|
||||
}
|
||||
|
||||
func gitRefs(repoPath, prefix string) ([]RefInfo, error) {
|
||||
return gitutil.Refs(repoPath, prefix)
|
||||
}
|
||||
|
||||
func createBackup(cfg ServerConfig, file string) error {
|
||||
return backup.Create(cfg, file)
|
||||
}
|
||||
|
||||
func restoreBackup(cfg ServerConfig, file string) error {
|
||||
return backup.Restore(cfg, file)
|
||||
}
|
||||
|
||||
func mysqlCLIArgs(dsn string) ([]string, string, error) {
|
||||
return backup.MySQLCLIArgs(dsn)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return backup.CopyDir(src, dst)
|
||||
}
|
||||
|
||||
func isDuplicateColumnError(err error) bool {
|
||||
return dbutil.IsDuplicateColumnError(err)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------- CLI ----------------
|
||||
|
||||
func cliInit(args []string) error {
|
||||
fs := flag.NewFlagSet("init", flag.ExitOnError)
|
||||
configFile := fs.String("config", defaultServerConfigPath(), "server config file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := ServerConfig{}
|
||||
cfg.Addr = promptDefault("Listen address", ":8080")
|
||||
cfg.PublicURL = strings.TrimRight(promptDefault("Public URL", defaultServerURL), "/")
|
||||
cfg.Storage = promptDefault("Storage directory", defaultStorage)
|
||||
cfg.MySQLDSN = promptDefault("MySQL DSN", "root:pass@tcp(127.0.0.1:3306)/gitocean?parseTime=true")
|
||||
|
||||
db, err := openMySQLAndCreateDatabaseIfMissing(cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(cfg.Storage, "repos"), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(promptDefault("Create first admin user?", "n"), "y") {
|
||||
email := strings.ToLower(prompt("Email: "))
|
||||
username := strings.ToLower(prompt("Username: "))
|
||||
password := prompt("Password: ")
|
||||
if err := createUserDirect(db, email, username, password, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Created admin user %s\n", username)
|
||||
}
|
||||
if err := saveServerConfig(*configFile, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Initialized Gitocean at %s\n\n", *configFile)
|
||||
fmt.Println("Start the server with:")
|
||||
fmt.Printf(" gitocean server --config %s\n", *configFile)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func cliPR(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("usage: gitocean pr <create|list|view|diff|checkout|comment|comments|close|merge>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "create":
|
||||
return cliPRCreate(args[1:])
|
||||
case "list":
|
||||
return cliPRList(args[1:])
|
||||
case "view":
|
||||
return cliPRView(args[1:])
|
||||
case "diff":
|
||||
return cliPRDiff(args[1:])
|
||||
case "checkout":
|
||||
return cliPRCheckout(args[1:])
|
||||
case "comment":
|
||||
return cliPRComment(args[1:])
|
||||
case "comments":
|
||||
return cliPRComments(args[1:])
|
||||
case "close":
|
||||
return cliPRAction(args[1:], "close")
|
||||
case "merge":
|
||||
return cliPRAction(args[1:], "merge")
|
||||
default:
|
||||
return fmt.Errorf("unknown pr command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func cliPRCreate(args []string) error {
|
||||
fs := flag.NewFlagSet("pr create", flag.ExitOnError)
|
||||
repoFlag := fs.String("repo", "", "target repo for same-repository PR")
|
||||
from := fs.String("from", "", "source branch or OWNER/REPO:BRANCH")
|
||||
to := fs.String("to", "", "target branch or OWNER/REPO:BRANCH")
|
||||
title := fs.String("title", "", "title")
|
||||
desc := fs.String("description", "", "description")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *from == "" || *to == "" || *title == "" {
|
||||
return errors.New("usage: gitocean pr create --from SRC --to DST --title TITLE [--description DESC]")
|
||||
}
|
||||
var targetOwner, targetRepo, targetBranch, sourceOwner, sourceRepo, sourceBranch string
|
||||
if *repoFlag != "" {
|
||||
var err error
|
||||
targetOwner, targetRepo, err = splitOwnerRepo(*repoFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceOwner, sourceRepo = targetOwner, targetRepo
|
||||
sourceBranch, targetBranch = *from, *to
|
||||
} else {
|
||||
var err error
|
||||
sourceOwner, sourceRepo, sourceBranch, err = splitRepoBranch(*from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetOwner, targetRepo, targetBranch, err = splitRepoBranch(*to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]string{"source_owner": sourceOwner, "source_repo": sourceRepo, "source_branch": sourceBranch, "target_branch": targetBranch, "title": *title, "description": *desc}
|
||||
var out PullRequest
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/pulls", targetOwner, targetRepo), cfg.Token, body, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
printPR(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliPRList(args []string) error {
|
||||
jsonOut := removeBoolFlag(&args, "--json")
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: gitocean pr list OWNER/REPO")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server, token := envServer(), ""
|
||||
if cfg, err := loadConfig(); err == nil {
|
||||
server, token = cfg.Server, cfg.Token
|
||||
}
|
||||
var out []PullRequest
|
||||
if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls", owner, repo), token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
for _, pr := range out {
|
||||
fmt.Printf("#%-4d %-8s %-30s %s/%s:%s -> %s\n", pr.Number, pr.Status, pr.Title, pr.SourceOwner, pr.SourceRepo, pr.SourceBranch, pr.TargetBranch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliPRView(args []string) error {
|
||||
jsonOut := removeBoolFlag(&args, "--json")
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: gitocean pr view OWNER/REPO NUMBER")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server, token := envServer(), ""
|
||||
if cfg, err := loadConfig(); err == nil {
|
||||
server, token = cfg.Server, cfg.Token
|
||||
}
|
||||
var out PullRequest
|
||||
if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls/%s", owner, repo, args[1]), token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
printPR(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliPRDiff(args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: gitocean pr diff OWNER/REPO NUMBER")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server, token := envServer(), ""
|
||||
if cfg, err := loadConfig(); err == nil {
|
||||
server, token = cfg.Server, cfg.Token
|
||||
}
|
||||
text, err := apiTextRequest(context.Background(), server, fmt.Sprintf("/api/repos/%s/%s/pulls/%s/diff", owner, repo, args[1]), token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Print(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliPRCheckout(args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: gitocean pr checkout OWNER/REPO NUMBER")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server, token := envServer(), ""
|
||||
if cfg, err := loadConfig(); err == nil {
|
||||
server, token = cfg.Server, cfg.Token
|
||||
}
|
||||
var pr PullRequest
|
||||
if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls/%s", owner, repo, args[1]), token, nil, &pr); err != nil {
|
||||
return err
|
||||
}
|
||||
branch := "pr-" + args[1]
|
||||
remote := repoCloneURL(server, pr.SourceOwner, pr.SourceRepo)
|
||||
if err := runGit("", "fetch", remote, pr.SourceBranch+":"+branch); err != nil {
|
||||
_ = runGit("", "branch", "-D", branch)
|
||||
if err := runGit("", "fetch", remote, pr.SourceBranch+":"+branch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runGit("", "checkout", branch)
|
||||
}
|
||||
|
||||
func cliPRComment(args []string) error {
|
||||
if len(args) < 3 {
|
||||
return errors.New("usage: gitocean pr comment OWNER/REPO NUMBER \"text\"")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.Join(args[2:], " ")
|
||||
var out PRComment
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/pulls/%s/comments", owner, repo, args[1]), cfg.Token, map[string]string{"body": body}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Commented as %s\n", out.Author)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliPRComments(args []string) error {
|
||||
if len(args) != 2 {
|
||||
return errors.New("usage: gitocean pr comments OWNER/REPO NUMBER")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server, token := envServer(), ""
|
||||
if cfg, err := loadConfig(); err == nil {
|
||||
server, token = cfg.Server, cfg.Token
|
||||
}
|
||||
var out []PRComment
|
||||
if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/pulls/%s/comments", owner, repo, args[1]), token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range out {
|
||||
fmt.Printf("%s by %s:\n%s\n\n", c.CreatedAt.Format(time.RFC3339), c.Author, c.Body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliPRAction(args []string, action string) error {
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("usage: gitocean pr %s OWNER/REPO NUMBER", action)
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out PullRequest
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/pulls/%s/%s", owner, repo, args[1], action), cfg.Token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
printPR(out)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func cliRepo(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("usage: gitocean repo <create|publish|view|branches|tags|set|archive|unarchive|delete|search|fork|collaborators|collaborator>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "create":
|
||||
return cliRepoCreate(args[1:])
|
||||
case "publish":
|
||||
return cliRepoPublish(args[1:])
|
||||
case "view":
|
||||
return cliRepoView(args[1:])
|
||||
case "branches":
|
||||
return cliRepoRefs(args[1:], "branches")
|
||||
case "tags":
|
||||
return cliRepoRefs(args[1:], "tags")
|
||||
case "set":
|
||||
return cliRepoSet(args[1:])
|
||||
case "archive":
|
||||
return cliRepoArchive(args[1:], true)
|
||||
case "unarchive":
|
||||
return cliRepoArchive(args[1:], false)
|
||||
case "delete":
|
||||
return cliRepoDelete(args[1:])
|
||||
case "search":
|
||||
return cliRepoSearch(args[1:])
|
||||
case "fork":
|
||||
return cliRepoFork(args[1:])
|
||||
case "collaborators":
|
||||
return cliRepoCollaborators(args[1:])
|
||||
case "collaborator":
|
||||
return cliRepoCollaborator(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown repo command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func cliRepoCreate(args []string) error {
|
||||
name, visibility, description, jsonOut, err := parseRepoCreateArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out Repository
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/repos", cfg.Token, map[string]string{"name": name, "visibility": visibility, "description": description}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
printRepoCreated(cfg.Server, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliRepoPublish(args []string) error {
|
||||
name, visibility, description, remoteName, branch, err := parseRepoPublishArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runGit("", "rev-parse", "--is-inside-work-tree"); err != nil {
|
||||
return errors.New("repo publish must be run inside a Git repository")
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out Repository
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, "/api/repos", cfg.Token, map[string]string{"name": name, "visibility": visibility, "description": description}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
remote := repoCloneURL(cfg.Server, out.Owner, out.Name)
|
||||
if err := runGit("", "remote", "get-url", remoteName); err != nil {
|
||||
if err := runGit("", "remote", "add", remoteName, remote); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := runGit("", "remote", "set-url", remoteName, remote); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = runGit("", "branch", "-M", branch)
|
||||
cmd := exec.Command("git", "push", "-u", remoteName, branch)
|
||||
cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
if branch != out.DefaultBranch {
|
||||
body := map[string]any{"default_branch": branch}
|
||||
_ = apiRequest(context.Background(), cfg.Server, http.MethodPatch, fmt.Sprintf("/api/repos/%s/%s", out.Owner, out.Name), cfg.Token, body, &out)
|
||||
}
|
||||
fmt.Printf("Published %s/%s to %s\n", out.Owner, out.Name, remote)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliRepoView(args []string) error {
|
||||
jsonOut := removeBoolFlag(&args, "--json")
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: gitocean repo view OWNER/REPO")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server, token := envServer(), ""
|
||||
if cfg, err := loadConfig(); err == nil {
|
||||
server, token = cfg.Server, cfg.Token
|
||||
}
|
||||
var out Repository
|
||||
if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s", owner, repo), token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
printRepoView(server, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliRepoRefs(args []string, kind string) error {
|
||||
jsonOut := removeBoolFlag(&args, "--json")
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("usage: gitocean repo %s OWNER/REPO", kind)
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server, token := envServer(), ""
|
||||
if cfg, err := loadConfig(); err == nil {
|
||||
server, token = cfg.Server, cfg.Token
|
||||
}
|
||||
var out []RefInfo
|
||||
if err := apiRequest(context.Background(), server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/%s", owner, repo, kind), token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
printRefs(kind, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliRepoSet(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("usage: gitocean repo set OWNER/REPO [--description TEXT] [--visibility public|private] [--default-branch main]")
|
||||
}
|
||||
repoRef := args[0]
|
||||
body := map[string]any{}
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--description":
|
||||
if i+1 >= len(args) {
|
||||
return errors.New("--description requires a value")
|
||||
}
|
||||
i++
|
||||
body["description"] = args[i]
|
||||
case "--visibility":
|
||||
if i+1 >= len(args) {
|
||||
return errors.New("--visibility requires a value")
|
||||
}
|
||||
i++
|
||||
body["visibility"] = args[i]
|
||||
case "--default-branch":
|
||||
if i+1 >= len(args) {
|
||||
return errors.New("--default-branch requires a value")
|
||||
}
|
||||
i++
|
||||
body["default_branch"] = args[i]
|
||||
default:
|
||||
return fmt.Errorf("unknown flag %s", args[i])
|
||||
}
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return errors.New("no settings provided")
|
||||
}
|
||||
return cliRepoPatch(repoRef, body)
|
||||
}
|
||||
|
||||
func cliRepoArchive(args []string, archived bool) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: gitocean repo archive OWNER/REPO")
|
||||
}
|
||||
return cliRepoPatch(args[0], map[string]any{"archived": archived})
|
||||
}
|
||||
|
||||
func cliRepoPatch(repoRef string, body map[string]any) error {
|
||||
owner, repo, err := splitOwnerRepo(repoRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out Repository
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodPatch, fmt.Sprintf("/api/repos/%s/%s", owner, repo), cfg.Token, body, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
printRepoView(cfg.Server, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliRepoDelete(args []string) error {
|
||||
var repoRef string
|
||||
force := false
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--force", "-force":
|
||||
force = true
|
||||
default:
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
return fmt.Errorf("unknown flag %s", arg)
|
||||
}
|
||||
if repoRef != "" {
|
||||
return errors.New("usage: gitocean repo delete OWNER/REPO [--force]")
|
||||
}
|
||||
repoRef = arg
|
||||
}
|
||||
}
|
||||
if repoRef == "" {
|
||||
return errors.New("usage: gitocean repo delete OWNER/REPO [--force]")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(repoRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/api/repos/%s/%s?force=%v", owner, repo, force)
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodDelete, path, cfg.Token, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Deleted %s/%s\n", owner, repo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliRepoSearch(args []string) error {
|
||||
jsonOut := removeBoolFlag(&args, "--json")
|
||||
var query string
|
||||
scope := "all"
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--mine", "-mine":
|
||||
scope = "mine"
|
||||
case "--all", "-all":
|
||||
scope = "all"
|
||||
default:
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
return fmt.Errorf("unknown flag %s", arg)
|
||||
}
|
||||
if query != "" {
|
||||
return errors.New("usage: gitocean repo search QUERY [--all|--mine]")
|
||||
}
|
||||
query = arg
|
||||
}
|
||||
}
|
||||
if query == "" {
|
||||
return errors.New("usage: gitocean repo search QUERY [--all|--mine]")
|
||||
}
|
||||
server := envServer()
|
||||
token := ""
|
||||
if cfg, err := loadConfig(); err == nil {
|
||||
server, token = cfg.Server, cfg.Token
|
||||
}
|
||||
path := "/api/repos/search?q=" + url.QueryEscape(query) + "&scope=" + scope
|
||||
var out []Repository
|
||||
if err := apiRequest(context.Background(), server, http.MethodGet, path, token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
printJSON(out)
|
||||
return nil
|
||||
}
|
||||
printRepoTable(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliRepoFork(args []string) error {
|
||||
var repoRef string
|
||||
newName := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--name" || arg == "-name":
|
||||
if i+1 >= len(args) {
|
||||
return errors.New("--name requires a value")
|
||||
}
|
||||
i++
|
||||
newName = args[i]
|
||||
case strings.HasPrefix(arg, "--name="):
|
||||
newName = strings.TrimPrefix(arg, "--name=")
|
||||
case strings.HasPrefix(arg, "-name="):
|
||||
newName = strings.TrimPrefix(arg, "-name=")
|
||||
default:
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
return fmt.Errorf("unknown flag %s", arg)
|
||||
}
|
||||
if repoRef != "" {
|
||||
return errors.New("usage: gitocean repo fork OWNER/REPO [--name NEW_NAME]")
|
||||
}
|
||||
repoRef = arg
|
||||
}
|
||||
}
|
||||
if repoRef == "" {
|
||||
return errors.New("usage: gitocean repo fork OWNER/REPO [--name NEW_NAME]")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(repoRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out Repository
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/fork", owner, repo), cfg.Token, map[string]string{"name": newName}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Forked %s/%s to %s/%s\n", owner, repo, out.Owner, out.Name)
|
||||
fmt.Printf("Clone: git clone %s\n", repoCloneURL(cfg.Server, out.Owner, out.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliRepoCollaborators(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: gitocean repo collaborators OWNER/REPO")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out []Collaborator
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, fmt.Sprintf("/api/repos/%s/%s/collaborators", owner, repo), cfg.Token, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range out {
|
||||
fmt.Printf("%-20s %s\n", c.Username, c.Role)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cliRepoCollaborator(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("usage: gitocean repo collaborator <add|remove>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "add":
|
||||
if len(args) != 5 || args[3] != "--role" {
|
||||
return errors.New("usage: gitocean repo collaborator add OWNER/REPO USER --role read|write")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out map[string]string
|
||||
if err := apiRequest(context.Background(), cfg.Server, http.MethodPost, fmt.Sprintf("/api/repos/%s/%s/collaborators", owner, repo), cfg.Token, map[string]string{"username": args[2], "role": args[4]}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Added %s as %s\n", out["username"], out["role"])
|
||||
return nil
|
||||
case "remove":
|
||||
if len(args) != 3 {
|
||||
return errors.New("usage: gitocean repo collaborator remove OWNER/REPO USER")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return apiRequest(context.Background(), cfg.Server, http.MethodDelete, fmt.Sprintf("/api/repos/%s/%s/collaborators/%s", owner, repo, strings.ToLower(args[2])), cfg.Token, nil, nil)
|
||||
default:
|
||||
return fmt.Errorf("unknown collaborator command %q", args[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
appconfig "gitocean/internal/config"
|
||||
"gitocean/internal/model"
|
||||
"gitocean/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultServerURL = appconfig.DefaultServerURL
|
||||
defaultStorage = appconfig.DefaultStorage
|
||||
tokenTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUsage = errors.New("usage")
|
||||
|
||||
usernameRE = validate.UsernameRE
|
||||
repoNameRE = validate.RepoNameRE
|
||||
branchRE = validate.BranchRE
|
||||
)
|
||||
|
||||
type User = model.User
|
||||
type Repository = model.Repository
|
||||
type PullRequest = model.PullRequest
|
||||
type ServerConfig = model.ServerConfig
|
||||
type RefInfo = model.RefInfo
|
||||
type Collaborator = model.Collaborator
|
||||
type PRComment = model.PRComment
|
||||
type TokenInfo = model.TokenInfo
|
||||
type Config = model.Config
|
||||
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
storage string
|
||||
publicURL string
|
||||
}
|
||||
|
||||
func Run(args []string) error {
|
||||
if len(args) < 1 {
|
||||
usage()
|
||||
return ErrUsage
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "init":
|
||||
return cliInit(args[1:])
|
||||
case "server":
|
||||
return runServer(args[1:])
|
||||
case "register":
|
||||
return cliRegister(args[1:])
|
||||
case "login":
|
||||
return cliLogin(args[1:])
|
||||
case "logout":
|
||||
return cliLogout(args[1:])
|
||||
case "whoami":
|
||||
return cliWhoami(args[1:])
|
||||
case "clone":
|
||||
return cliClone(args[1:])
|
||||
case "repo":
|
||||
return cliRepo(args[1:])
|
||||
case "pr":
|
||||
return cliPR(args[1:])
|
||||
case "token":
|
||||
return cliToken(args[1:])
|
||||
case "admin":
|
||||
return cliAdmin(args[1:])
|
||||
case "backup":
|
||||
return cliBackup(args[1:])
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Println(`gitocean - CLI and server for a small Git hosting platform
|
||||
|
||||
Commands:
|
||||
gitocean init [--config storage/config.json]
|
||||
gitocean server [--config storage/config.json] [--dsn DSN] [--addr :8080] [--storage storage]
|
||||
gitocean register [--server URL]
|
||||
gitocean login [--server URL]
|
||||
gitocean logout
|
||||
gitocean whoami
|
||||
gitocean clone OWNER/REPO
|
||||
gitocean repo create NAME (--public|--private) [--description TEXT]
|
||||
gitocean repo publish NAME (--public|--private) [--remote origin] [--branch main]
|
||||
gitocean repo view OWNER/REPO
|
||||
gitocean repo branches OWNER/REPO
|
||||
gitocean repo tags OWNER/REPO
|
||||
gitocean repo set OWNER/REPO [--description TEXT] [--visibility public|private] [--default-branch main]
|
||||
gitocean repo delete OWNER/REPO [--force]
|
||||
gitocean repo search QUERY [--all|--mine] [--json]
|
||||
gitocean repo fork OWNER/REPO [--name NEW_NAME]
|
||||
gitocean repo collaborators OWNER/REPO
|
||||
gitocean repo collaborator add OWNER/REPO USER --role read|write
|
||||
gitocean repo collaborator remove OWNER/REPO USER
|
||||
gitocean pr create --from OWNER/REPO:BRANCH --to OWNER/REPO:BRANCH --title TITLE [--description DESC]
|
||||
gitocean pr create --repo OWNER/REPO --from BRANCH --to BRANCH --title TITLE [--description DESC]
|
||||
gitocean pr list OWNER/REPO [--json]
|
||||
gitocean pr view OWNER/REPO NUMBER [--json]
|
||||
gitocean pr diff OWNER/REPO NUMBER
|
||||
gitocean pr checkout OWNER/REPO NUMBER
|
||||
gitocean pr comment OWNER/REPO NUMBER "text"
|
||||
gitocean pr comments OWNER/REPO NUMBER
|
||||
gitocean pr close OWNER/REPO NUMBER
|
||||
gitocean pr merge OWNER/REPO NUMBER
|
||||
gitocean token list [--json]
|
||||
gitocean token revoke TOKEN_ID
|
||||
gitocean token prune
|
||||
gitocean admin users list
|
||||
gitocean admin users delete USER
|
||||
gitocean admin repos list
|
||||
gitocean admin storage check
|
||||
gitocean backup create FILE
|
||||
gitocean backup restore FILE`)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------- Git HTTP ----------------
|
||||
|
||||
func (s *Server) handleGitHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
owner, repoName, _, ok := parseGitPath(r.URL.Path)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "invalid git path")
|
||||
return
|
||||
}
|
||||
repo, err := s.loadRepo(owner, repoName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "repository not found")
|
||||
return
|
||||
}
|
||||
service := gitService(r)
|
||||
user, authed := s.userFromBasic(r)
|
||||
if service == "git-receive-pack" {
|
||||
if !authed {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="gitocean"`)
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
if repo.Archived {
|
||||
writeError(w, http.StatusForbidden, "repository is archived")
|
||||
return
|
||||
}
|
||||
if !s.canWriteRepo(repo, user) {
|
||||
writeError(w, http.StatusForbidden, "write access required")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if repo.Visibility == "private" {
|
||||
if !authed {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="gitocean"`)
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
if !s.canReadRepo(repo, user, authed) {
|
||||
writeError(w, http.StatusForbidden, "not allowed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
s.runGitHTTPBackend(w, r, authed, user.Username)
|
||||
}
|
||||
|
||||
func parseGitPath(path string) (owner, repo, rest string, ok bool) {
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
idx := strings.Index(path, ".git")
|
||||
if idx < 0 {
|
||||
return "", "", "", false
|
||||
}
|
||||
repoPart := path[:idx]
|
||||
rest = path[idx+len(".git"):]
|
||||
parts := strings.Split(repoPart, "/")
|
||||
if len(parts) != 2 {
|
||||
return "", "", "", false
|
||||
}
|
||||
return parts[0], parts[1], rest, true
|
||||
}
|
||||
|
||||
func gitService(r *http.Request) string {
|
||||
if svc := r.URL.Query().Get("service"); svc != "" {
|
||||
return svc
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/git-receive-pack") {
|
||||
return "git-receive-pack"
|
||||
}
|
||||
return "git-upload-pack"
|
||||
}
|
||||
|
||||
func (s *Server) runGitHTTPBackend(w http.ResponseWriter, r *http.Request, authed bool, username string) {
|
||||
root, _ := filepath.Abs(filepath.Join(s.storage, "repos"))
|
||||
env := append(os.Environ(),
|
||||
"GIT_PROJECT_ROOT="+root,
|
||||
"GIT_HTTP_EXPORT_ALL=1",
|
||||
"PATH_INFO="+r.URL.Path,
|
||||
"REQUEST_METHOD="+r.Method,
|
||||
"QUERY_STRING="+r.URL.RawQuery,
|
||||
"REMOTE_ADDR="+r.RemoteAddr,
|
||||
)
|
||||
if ct := r.Header.Get("Content-Type"); ct != "" {
|
||||
env = append(env, "CONTENT_TYPE="+ct)
|
||||
}
|
||||
if r.ContentLength >= 0 {
|
||||
env = append(env, fmt.Sprintf("CONTENT_LENGTH=%d", r.ContentLength))
|
||||
}
|
||||
if authed {
|
||||
env = append(env, "REMOTE_USER="+username, "AUTH_TYPE=Basic")
|
||||
}
|
||||
cmd := exec.Command("git", "http-backend")
|
||||
cmd.Env = env
|
||||
cmd.Stdin = r.Body
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, strings.TrimSpace(errBuf.String()))
|
||||
return
|
||||
}
|
||||
writeCGIResponse(w, out.Bytes())
|
||||
}
|
||||
|
||||
func writeCGIResponse(w http.ResponseWriter, data []byte) {
|
||||
sep := []byte("\r\n\r\n")
|
||||
idx := bytes.Index(data, sep)
|
||||
if idx < 0 {
|
||||
sep = []byte("\n\n")
|
||||
idx = bytes.Index(data, sep)
|
||||
}
|
||||
if idx < 0 {
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
headers := string(data[:idx])
|
||||
body := data[idx+len(sep):]
|
||||
status := http.StatusOK
|
||||
for _, line := range strings.Split(headers, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
v = strings.TrimSpace(v)
|
||||
if strings.EqualFold(k, "Status") {
|
||||
fields := strings.Fields(v)
|
||||
if len(fields) > 0 {
|
||||
if n, err := strconv.Atoi(fields[0]); err == nil {
|
||||
status = n
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseGitPath(t *testing.T) {
|
||||
owner, repo, rest, ok := parseGitPath("/Alice/Demo.git/info/refs")
|
||||
if !ok {
|
||||
t.Fatal("expected path to parse")
|
||||
}
|
||||
if owner != "Alice" || repo != "Demo" || rest != "/info/refs" {
|
||||
t.Fatalf("unexpected parse: %s %s %s", owner, repo, rest)
|
||||
}
|
||||
if _, _, _, ok := parseGitPath("/bad/path"); ok {
|
||||
t.Fatal("expected invalid path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitService(t *testing.T) {
|
||||
req := &http.Request{Method: http.MethodGet, URL: &url.URL{RawQuery: "service=git-upload-pack"}}
|
||||
if got := gitService(req); got != "git-upload-pack" {
|
||||
t.Fatalf("unexpected upload-pack service: %q", got)
|
||||
}
|
||||
req = &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/alice/demo.git/git-receive-pack"}}
|
||||
if got := gitService(req); got != "git-receive-pack" {
|
||||
t.Fatalf("unexpected receive-pack service: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSplitOwnerRepo(t *testing.T) {
|
||||
owner, repo, err := splitOwnerRepo("Alice/Demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner != "alice" || repo != "demo" {
|
||||
t.Fatalf("unexpected split: %s/%s", owner, repo)
|
||||
}
|
||||
if _, _, err := splitOwnerRepo("bad"); err == nil {
|
||||
t.Fatal("expected invalid repo ref to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRepoBranch(t *testing.T) {
|
||||
owner, repo, branch, err := splitRepoBranch("Alice/Demo:feature/x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner != "alice" || repo != "demo" || branch != "feature/x" {
|
||||
t.Fatalf("unexpected split: %s/%s:%s", owner, repo, branch)
|
||||
}
|
||||
if _, _, _, err := splitRepoBranch("alice/demo"); err == nil {
|
||||
t.Fatal("expected missing branch to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoCreateArgs(t *testing.T) {
|
||||
name, visibility, description, jsonOut, err := parseRepoCreateArgs([]string{"demo", "--public", "--description", "hello", "--json"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "demo" || visibility != "public" || description != "hello" || !jsonOut {
|
||||
t.Fatalf("unexpected parse result: %q %q %q %v", name, visibility, description, jsonOut)
|
||||
}
|
||||
if _, _, _, _, err := parseRepoCreateArgs([]string{"demo"}); err == nil {
|
||||
t.Fatal("expected missing visibility to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoPublishArgs(t *testing.T) {
|
||||
name, visibility, description, remote, branch, err := parseRepoPublishArgs([]string{"demo", "--private", "--description=hello", "--remote", "upstream", "--branch", "trunk"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "demo" || visibility != "private" || description != "hello" || remote != "upstream" || branch != "trunk" {
|
||||
t.Fatalf("unexpected parse result: %q %q %q %q %q", name, visibility, description, remote, branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveBoolFlag(t *testing.T) {
|
||||
args := []string{"repo", "--json", "extra"}
|
||||
if !removeBoolFlag(&args, "--json") {
|
||||
t.Fatal("expected flag to be removed")
|
||||
}
|
||||
if len(args) != 2 || args[0] != "repo" || args[1] != "extra" {
|
||||
t.Fatalf("unexpected args: %#v", args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------- Pull request API ----------------
|
||||
|
||||
func (s *Server) handlePRCreate(w http.ResponseWriter, r *http.Request, targetOwner, targetName string) {
|
||||
user, ok := s.requireBearerUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
target, err := s.loadRepo(targetOwner, targetName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "target repository not found")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
SourceOwner string `json:"source_owner"`
|
||||
SourceRepo string `json:"source_repo"`
|
||||
SourceBranch string `json:"source_branch"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
in.SourceOwner = strings.ToLower(strings.TrimSpace(in.SourceOwner))
|
||||
in.SourceRepo = strings.ToLower(strings.TrimSpace(in.SourceRepo))
|
||||
in.SourceBranch = strings.TrimSpace(in.SourceBranch)
|
||||
in.TargetBranch = strings.TrimSpace(in.TargetBranch)
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
if in.SourceOwner == "" {
|
||||
in.SourceOwner = target.Owner
|
||||
}
|
||||
if in.SourceRepo == "" {
|
||||
in.SourceRepo = target.Name
|
||||
}
|
||||
if in.Title == "" || !branchRE.MatchString(in.SourceBranch) || !branchRE.MatchString(in.TargetBranch) {
|
||||
writeError(w, http.StatusBadRequest, "title and valid source/target branches are required")
|
||||
return
|
||||
}
|
||||
source, err := s.loadRepo(in.SourceOwner, in.SourceRepo)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "source repository not found")
|
||||
return
|
||||
}
|
||||
if source.ID == target.ID {
|
||||
if target.OwnerUserID != user.ID {
|
||||
writeError(w, http.StatusForbidden, "same-repository PRs require repository ownership")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if source.OwnerUserID != user.ID {
|
||||
writeError(w, http.StatusForbidden, "source repository must be owned by you")
|
||||
return
|
||||
}
|
||||
if target.Visibility != "public" && target.OwnerUserID != user.ID {
|
||||
writeError(w, http.StatusForbidden, "target repository is private")
|
||||
return
|
||||
}
|
||||
}
|
||||
if !gitBranchExists(s.repoPath(source.Owner, source.Name), in.SourceBranch) {
|
||||
writeError(w, http.StatusBadRequest, "source branch does not exist")
|
||||
return
|
||||
}
|
||||
if !gitBranchExists(s.repoPath(target.Owner, target.Name), in.TargetBranch) {
|
||||
writeError(w, http.StatusBadRequest, "target branch does not exist")
|
||||
return
|
||||
}
|
||||
var number int
|
||||
_ = s.db.QueryRow(`SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE target_repository_id = ?`, target.ID).Scan(&number)
|
||||
res, err := s.db.Exec(`INSERT INTO pull_requests (target_repository_id, number, author_user_id, source_repository_id, source_branch, target_branch, title, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, target.ID, number, user.ID, source.ID, in.SourceBranch, in.TargetBranch, in.Title, in.Description)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
pr, _ := s.loadPR(target.ID, number)
|
||||
pr.ID = id
|
||||
writeJSON(w, http.StatusCreated, pr)
|
||||
}
|
||||
|
||||
func (s *Server) handlePRList(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
|
||||
}
|
||||
rows, err := s.db.Query(prSelectSQL()+` WHERE pr.target_repository_id = ? ORDER BY pr.number DESC`, repo.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
prs, err := scanPRs(rows)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, prs)
|
||||
}
|
||||
|
||||
func (s *Server) handlePRView(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
|
||||
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
|
||||
}
|
||||
pr, err := s.loadPR(repo.ID, number)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "pull request not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, pr)
|
||||
}
|
||||
|
||||
func (s *Server) handlePRClose(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
|
||||
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 target owner can close pull requests")
|
||||
return
|
||||
}
|
||||
res, err := s.db.Exec(`UPDATE pull_requests SET status = 'closed', closed_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ? AND status = 'open'`, repo.ID, number)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
writeError(w, http.StatusConflict, "pull request is not open")
|
||||
return
|
||||
}
|
||||
pr, _ := s.loadPR(repo.ID, number)
|
||||
writeJSON(w, http.StatusOK, pr)
|
||||
}
|
||||
|
||||
func (s *Server) handlePRMerge(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
|
||||
user, ok := s.requireBearerUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
target, err := s.loadRepo(owner, name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "repository not found")
|
||||
return
|
||||
}
|
||||
if target.OwnerUserID != user.ID {
|
||||
writeError(w, http.StatusForbidden, "only target owner can merge pull requests")
|
||||
return
|
||||
}
|
||||
pr, err := s.loadPR(target.ID, number)
|
||||
if err != nil || pr.Status != "open" {
|
||||
writeError(w, http.StatusConflict, "pull request is not open")
|
||||
return
|
||||
}
|
||||
if err := s.mergePR(pr); err != nil {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
_, err = s.db.Exec(`UPDATE pull_requests SET status = 'merged', merged_at = UTC_TIMESTAMP() WHERE target_repository_id = ? AND number = ?`, target.ID, number)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
pr, _ = s.loadPR(target.ID, number)
|
||||
writeJSON(w, http.StatusOK, pr)
|
||||
}
|
||||
|
||||
func prSelectSQL() string {
|
||||
return `SELECT pr.id, pr.number, pr.target_repository_id, pr.source_repository_id, pr.author_user_id,
|
||||
au.username, su.username, sr.name, pr.source_branch, tu.username, tr.name, pr.target_branch,
|
||||
pr.title, pr.description, pr.status, pr.created_at, pr.updated_at, pr.closed_at, pr.merged_at
|
||||
FROM pull_requests pr
|
||||
JOIN users au ON au.id = pr.author_user_id
|
||||
JOIN repositories sr ON sr.id = pr.source_repository_id
|
||||
JOIN users su ON su.id = sr.owner_user_id
|
||||
JOIN repositories tr ON tr.id = pr.target_repository_id
|
||||
JOIN users tu ON tu.id = tr.owner_user_id`
|
||||
}
|
||||
|
||||
func (s *Server) loadPR(targetRepoID int64, number int) (PullRequest, error) {
|
||||
row := s.db.QueryRow(prSelectSQL()+` WHERE pr.target_repository_id = ? AND pr.number = ?`, targetRepoID, number)
|
||||
prs, err := scanOnePR(row)
|
||||
return prs, err
|
||||
}
|
||||
|
||||
type scanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanOnePR(row scanner) (PullRequest, error) {
|
||||
var pr PullRequest
|
||||
var closedAt, mergedAt sql.NullTime
|
||||
err := row.Scan(&pr.ID, &pr.Number, &pr.TargetRepositoryID, &pr.SourceRepositoryID, &pr.AuthorUserID,
|
||||
&pr.Author, &pr.SourceOwner, &pr.SourceRepo, &pr.SourceBranch, &pr.TargetOwner, &pr.TargetRepo, &pr.TargetBranch,
|
||||
&pr.Title, &pr.Description, &pr.Status, &pr.CreatedAt, &pr.UpdatedAt, &closedAt, &mergedAt)
|
||||
if closedAt.Valid {
|
||||
pr.ClosedAt = &closedAt.Time
|
||||
}
|
||||
if mergedAt.Valid {
|
||||
pr.MergedAt = &mergedAt.Time
|
||||
}
|
||||
return pr, err
|
||||
}
|
||||
|
||||
func scanPRs(rows *sql.Rows) ([]PullRequest, error) {
|
||||
var prs []PullRequest
|
||||
for rows.Next() {
|
||||
pr, err := scanOnePR(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prs = append(prs, pr)
|
||||
}
|
||||
return prs, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) mergePR(pr PullRequest) error {
|
||||
targetPath := s.repoPath(pr.TargetOwner, pr.TargetRepo)
|
||||
sourcePath := s.repoPath(pr.SourceOwner, pr.SourceRepo)
|
||||
tmp, err := os.MkdirTemp("", "gitocean-merge-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
work := filepath.Join(tmp, "work")
|
||||
if err := runGit("", "clone", targetPath, work); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runGit(work, "config", "user.name", "gitocean"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runGit(work, "config", "user.email", "gitocean@localhost"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runGit(work, "checkout", pr.TargetBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runGit(work, "remote", "add", "source", sourcePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runGit(work, "fetch", "source", pr.SourceBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := fmt.Sprintf("Merge pull request #%d from %s/%s:%s", pr.Number, pr.SourceOwner, pr.SourceRepo, pr.SourceBranch)
|
||||
if err := runGit(work, "merge", "--no-ff", "FETCH_HEAD", "-m", msg); err != nil {
|
||||
return fmt.Errorf("merge failed, likely due to conflicts: %w", err)
|
||||
}
|
||||
if err := runGit(work, "push", "origin", "HEAD:"+pr.TargetBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePRDiff(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
|
||||
repo, ok := s.requireReadableRepo(w, r, owner, name)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pr, err := s.loadPR(repo.ID, number)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "pull request not found")
|
||||
return
|
||||
}
|
||||
diff, err := s.prDiff(pr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte(diff))
|
||||
}
|
||||
|
||||
func (s *Server) prDiff(pr PullRequest) (string, error) {
|
||||
tmp, err := os.MkdirTemp("", "gitocean-diff-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
work := filepath.Join(tmp, "work")
|
||||
if err := runGit("", "clone", "--no-checkout", s.repoPath(pr.TargetOwner, pr.TargetRepo), work); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := runGit(work, "remote", "add", "source", s.repoPath(pr.SourceOwner, pr.SourceRepo)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := runGit(work, "fetch", "origin", pr.TargetBranch); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := runGit(work, "fetch", "source", pr.SourceBranch); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.Command("git", "diff", "--patch", "origin/"+pr.TargetBranch+"...FETCH_HEAD")
|
||||
cmd.Dir = work
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git diff failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePRCommentsList(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
|
||||
repo, ok := s.requireReadableRepo(w, r, owner, name)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pr, err := s.loadPR(repo.ID, number)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "pull request not found")
|
||||
return
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT c.id, u.username, c.body, c.created_at, c.updated_at FROM pull_request_comments c JOIN users u ON u.id = c.author_user_id WHERE c.pull_request_id = ? ORDER BY c.created_at`, pr.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PRComment
|
||||
for rows.Next() {
|
||||
var c PRComment
|
||||
if err := rows.Scan(&c.ID, &c.Author, &c.Body, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handlePRCommentCreate(w http.ResponseWriter, r *http.Request, owner, name string, number int) {
|
||||
user, ok := s.requireBearerUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
repo, err := s.loadRepo(owner, name)
|
||||
if err != nil || !s.canReadRepo(repo, user, true) {
|
||||
writeError(w, http.StatusNotFound, "repository not found")
|
||||
return
|
||||
}
|
||||
pr, err := s.loadPR(repo.ID, number)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "pull request not found")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
body := strings.TrimSpace(in.Body)
|
||||
if body == "" {
|
||||
writeError(w, http.StatusBadRequest, "comment body is required")
|
||||
return
|
||||
}
|
||||
res, err := s.db.Exec(`INSERT INTO pull_request_comments (pull_request_id, author_user_id, body) VALUES (?, ?, ?)`, pr.ID, user.ID, body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
writeJSON(w, http.StatusCreated, PRComment{ID: id, Author: user.Username, Body: body, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()})
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------- HTTP routing ----------------
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
s.handleAPI(w, r)
|
||||
return
|
||||
}
|
||||
if strings.Contains(r.URL.Path, ".git") {
|
||||
s.handleGitHTTP(w, r)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
|
||||
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && path == "/register":
|
||||
s.handleRegister(w, r)
|
||||
case r.Method == http.MethodPost && path == "/login":
|
||||
s.handleLogin(w, r)
|
||||
case r.Method == http.MethodPost && path == "/logout":
|
||||
s.handleLogout(w, r)
|
||||
case r.Method == http.MethodGet && path == "/me":
|
||||
s.handleMe(w, r)
|
||||
case strings.HasPrefix(path, "/tokens"):
|
||||
s.handleTokenRoutes(w, r, strings.TrimPrefix(path, "/tokens"))
|
||||
case strings.HasPrefix(path, "/admin"):
|
||||
s.handleAdminRoutes(w, r, strings.TrimPrefix(path, "/admin"))
|
||||
case r.Method == http.MethodPost && path == "/repos":
|
||||
s.handleRepoCreate(w, r)
|
||||
case r.Method == http.MethodGet && path == "/repos/search":
|
||||
s.handleRepoSearch(w, r)
|
||||
case strings.HasPrefix(path, "/repos/"):
|
||||
s.handleRepoSubroute(w, r, strings.TrimPrefix(path, "/repos/"))
|
||||
default:
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRepoSubroute(w http.ResponseWriter, r *http.Request, rest string) {
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) < 2 {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
owner, repo := parts[0], parts[1]
|
||||
if len(parts) == 2 {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.handleRepoGet(w, r, owner, repo)
|
||||
case http.MethodPatch:
|
||||
s.handleRepoUpdate(w, r, owner, repo)
|
||||
case http.MethodDelete:
|
||||
s.handleRepoDelete(w, r, owner, repo)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(parts) == 3 && parts[2] == "fork" && r.Method == http.MethodPost {
|
||||
s.handleRepoFork(w, r, owner, repo)
|
||||
return
|
||||
}
|
||||
if len(parts) == 3 && parts[2] == "branches" && r.Method == http.MethodGet {
|
||||
s.handleRepoBranches(w, r, owner, repo)
|
||||
return
|
||||
}
|
||||
if len(parts) == 3 && parts[2] == "tags" && r.Method == http.MethodGet {
|
||||
s.handleRepoTags(w, r, owner, repo)
|
||||
return
|
||||
}
|
||||
if len(parts) >= 3 && parts[2] == "collaborators" {
|
||||
s.handleCollaborators(w, r, owner, repo, parts[3:])
|
||||
return
|
||||
}
|
||||
if len(parts) >= 3 && parts[2] == "pulls" {
|
||||
if len(parts) == 3 {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.handlePRList(w, r, owner, repo)
|
||||
case http.MethodPost:
|
||||
s.handlePRCreate(w, r, owner, repo)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(parts) >= 4 {
|
||||
n, err := strconv.Atoi(parts[3])
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid pull request number")
|
||||
return
|
||||
}
|
||||
if len(parts) == 4 && r.Method == http.MethodGet {
|
||||
s.handlePRView(w, r, owner, repo, n)
|
||||
return
|
||||
}
|
||||
if len(parts) == 5 && r.Method == http.MethodGet && parts[4] == "diff" {
|
||||
s.handlePRDiff(w, r, owner, repo, n)
|
||||
return
|
||||
}
|
||||
if len(parts) == 5 && parts[4] == "comments" {
|
||||
if r.Method == http.MethodGet {
|
||||
s.handlePRCommentsList(w, r, owner, repo, n)
|
||||
} else if r.Method == http.MethodPost {
|
||||
s.handlePRCommentCreate(w, r, owner, repo, n)
|
||||
} else {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(parts) == 5 && r.Method == http.MethodPost && parts[4] == "close" {
|
||||
s.handlePRClose(w, r, owner, repo, n)
|
||||
return
|
||||
}
|
||||
if len(parts) == 5 && r.Method == http.MethodPost && parts[4] == "merge" {
|
||||
s.handlePRMerge(w, r, owner, repo, n)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitocean/internal/dbutil"
|
||||
)
|
||||
|
||||
// ---------------- Server boot and migrations ----------------
|
||||
|
||||
func runServer(args []string) error {
|
||||
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||
configFile := fs.String("config", defaultServerConfigPath(), "server config file")
|
||||
addr := fs.String("addr", "", "HTTP listen address")
|
||||
dsn := fs.String("dsn", os.Getenv("GITOOCEAN_DSN"), "MySQL DSN")
|
||||
storage := fs.String("storage", "", "storage directory")
|
||||
publicURL := fs.String("public-url", "", "public server URL")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, _ := loadServerConfig(*configFile)
|
||||
if *addr != "" {
|
||||
cfg.Addr = *addr
|
||||
}
|
||||
if *dsn != "" {
|
||||
cfg.MySQLDSN = *dsn
|
||||
}
|
||||
if *storage != "" {
|
||||
cfg.Storage = *storage
|
||||
}
|
||||
if *publicURL != "" {
|
||||
cfg.PublicURL = *publicURL
|
||||
}
|
||||
if cfg.Addr == "" {
|
||||
cfg.Addr = ":8080"
|
||||
}
|
||||
if cfg.Storage == "" {
|
||||
cfg.Storage = defaultStorage
|
||||
}
|
||||
if cfg.PublicURL == "" {
|
||||
cfg.PublicURL = defaultServerURL
|
||||
}
|
||||
if cfg.MySQLDSN == "" {
|
||||
return errors.New("--dsn, GITOOCEAN_DSN, or server config mysql_dsn is required; run gitocean init")
|
||||
}
|
||||
|
||||
db, err := openMySQLAndCreateDatabaseIfMissing(cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(cfg.Storage, "repos"), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s := &Server{db: db, storage: cfg.Storage, publicURL: strings.TrimRight(cfg.PublicURL, "/")}
|
||||
fmt.Printf("gitocean listening on %s, storage=%s, public_url=%s\n", cfg.Addr, cfg.Storage, s.publicURL)
|
||||
return http.ListenAndServe(cfg.Addr, s)
|
||||
}
|
||||
|
||||
func openMySQLAndCreateDatabaseIfMissing(dsn string) (*sql.DB, error) {
|
||||
return dbutil.OpenMySQLAndCreateDatabaseIfMissing(dsn)
|
||||
}
|
||||
|
||||
func quoteMySQLIdentifier(name string) string {
|
||||
return dbutil.QuoteMySQLIdentifier(name)
|
||||
}
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
username VARCHAR(32) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_admin BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
revoked_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_auth_tokens_user_id (user_id),
|
||||
INDEX idx_auth_tokens_expires_at (expires_at),
|
||||
CONSTRAINT fk_auth_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS repositories (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
visibility ENUM('public','private') NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
default_branch VARCHAR(200) NOT NULL DEFAULT 'main',
|
||||
archived BOOLEAN NOT NULL DEFAULT false,
|
||||
forked_from_repository_id BIGINT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_owner_name (owner_user_id, name),
|
||||
INDEX idx_repositories_name (name),
|
||||
CONSTRAINT fk_repositories_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_repositories_fork FOREIGN KEY (forked_from_repository_id) REFERENCES repositories(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS repository_collaborators (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
repository_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
role ENUM('read','write') NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_repo_user (repository_id, user_id),
|
||||
CONSTRAINT fk_collab_repo FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_collab_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS pull_requests (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
target_repository_id BIGINT NOT NULL,
|
||||
number INT NOT NULL,
|
||||
author_user_id BIGINT NOT NULL,
|
||||
source_repository_id BIGINT NOT NULL,
|
||||
source_branch VARCHAR(200) NOT NULL,
|
||||
target_branch VARCHAR(200) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status ENUM('open','closed','merged') NOT NULL DEFAULT 'open',
|
||||
closed_at TIMESTAMP NULL,
|
||||
merged_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_target_number (target_repository_id, number),
|
||||
INDEX idx_pr_target (target_repository_id),
|
||||
INDEX idx_pr_source (source_repository_id),
|
||||
CONSTRAINT fk_pr_target FOREIGN KEY (target_repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_pr_source FOREIGN KEY (source_repository_id) REFERENCES repositories(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_pr_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS pull_request_comments (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
pull_request_id BIGINT NOT NULL,
|
||||
author_user_id BIGINT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_pr_comments_pr (pull_request_id),
|
||||
CONSTRAINT fk_pr_comments_pr FOREIGN KEY (pull_request_id) REFERENCES pull_requests(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_pr_comments_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
alterations := []string{
|
||||
`ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT false`,
|
||||
`ALTER TABLE repositories ADD COLUMN description TEXT NULL`,
|
||||
`ALTER TABLE repositories ADD COLUMN default_branch VARCHAR(200) NOT NULL DEFAULT 'main'`,
|
||||
`ALTER TABLE repositories ADD COLUMN archived BOOLEAN NOT NULL DEFAULT false`,
|
||||
}
|
||||
for _, stmt := range alterations {
|
||||
if _, err := db.Exec(stmt); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitocean/internal/gitutil"
|
||||
"gitocean/internal/httputil"
|
||||
"gitocean/internal/validate"
|
||||
)
|
||||
|
||||
// ---------------- Shared helpers ----------------
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
|
||||
return httputil.DecodeJSON(w, r, out)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
httputil.WriteJSON(w, status, v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
httputil.WriteError(w, status, msg)
|
||||
}
|
||||
|
||||
func isReservedName(s string) bool {
|
||||
return validate.ReservedName(s)
|
||||
}
|
||||
|
||||
func (s *Server) repoPath(owner, name string) string {
|
||||
return filepath.Join(s.storage, "repos", strings.ToLower(owner), strings.ToLower(name)+".git")
|
||||
}
|
||||
|
||||
func gitInitBare(path string) error {
|
||||
return gitutil.InitBare(path)
|
||||
}
|
||||
|
||||
func gitBranchExists(repoPath, branch string) bool {
|
||||
return gitutil.BranchExists(repoPath, branch)
|
||||
}
|
||||
|
||||
func runGit(dir string, args ...string) error {
|
||||
return gitutil.Run(dir, args...)
|
||||
}
|
||||
Reference in New Issue
Block a user