Refactor codebase for Phase 3

This commit is contained in:
2026-06-08 13:34:53 -05:00
parent 6320f51295
commit c32e08be38
32 changed files with 4718 additions and 3323 deletions
+290
View File
@@ -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")
}
}
+123
View File
@@ -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")
}
}
+104
View 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()
}
+341
View File
@@ -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)
}
+52
View File
@@ -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
}
+247
View File
@@ -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
}
+403
View File
@@ -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])
}
}
+125
View File
@@ -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`)
}
+154
View 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)
}
+31
View File
@@ -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)
}
}
+62
View File
@@ -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)
}
}
+390
View File
@@ -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()})
}
+431
View File
@@ -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})
}
+132
View File
@@ -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")
}
+178
View File
@@ -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
}
+45
View File
@@ -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...)
}
+117
View File
@@ -0,0 +1,117 @@
package backup
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/go-sql-driver/mysql"
"gitocean/internal/model"
)
func Create(cfg model.ServerConfig, file string) error {
tmp, err := os.MkdirTemp("", "gitocean-backup-*")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
metadata := filepath.Join(tmp, "metadata.sql")
mysqlArgs, dbName, err := MySQLCLIArgs(cfg.MySQLDSN)
if err != nil {
return err
}
cmd := exec.Command("mysqldump", append(mysqlArgs, dbName)...)
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("mysqldump failed: %w", err)
}
if err := os.WriteFile(metadata, out, 0600); err != nil {
return err
}
cmd = exec.Command("tar", "-czf", file, "-C", ".", cfg.Storage, "-C", tmp, "metadata.sql")
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd.Run()
}
func Restore(cfg model.ServerConfig, file string) error {
tmp, err := os.MkdirTemp("", "gitocean-restore-*")
if err != nil {
return err
}
defer os.RemoveAll(tmp)
cmd := exec.Command("tar", "-xzf", file, "-C", tmp)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return err
}
mysqlArgs, dbName, err := MySQLCLIArgs(cfg.MySQLDSN)
if err != nil {
return err
}
metadata := filepath.Join(tmp, "metadata.sql")
f, err := os.Open(metadata)
if err != nil {
return err
}
defer f.Close()
cmd = exec.Command("mysql", append(mysqlArgs, dbName)...)
cmd.Stdin = f
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return err
}
return CopyDir(filepath.Join(tmp, cfg.Storage), cfg.Storage)
}
func MySQLCLIArgs(dsn string) ([]string, string, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return nil, "", err
}
args := []string{}
if cfg.User != "" {
args = append(args, "-u"+cfg.User)
}
if cfg.Passwd != "" {
args = append(args, "-p"+cfg.Passwd)
}
if cfg.Net == "tcp" && cfg.Addr != "" {
host, port := cfg.Addr, ""
if strings.Contains(cfg.Addr, ":") {
host, port, _ = strings.Cut(cfg.Addr, ":")
}
if host != "" {
args = append(args, "-h"+host)
}
if port != "" {
args = append(args, "-P"+port)
}
} else if cfg.Net == "unix" && cfg.Addr != "" {
args = append(args, "--socket", cfg.Addr)
}
return args, cfg.DBName, nil
}
func CopyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
to := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(to, info.Mode())
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(to, b, info.Mode())
})
}
+88
View File
@@ -0,0 +1,88 @@
package config
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"gitocean/internal/model"
)
const (
DefaultServerURL = "http://localhost:8080"
DefaultStorage = "storage"
)
func EnvServer() string {
if v := os.Getenv("GITOOCEAN_SERVER"); v != "" {
return strings.TrimRight(v, "/")
}
if cfg, err := LoadServer(DefaultServerConfigPath()); err == nil && cfg.PublicURL != "" {
return strings.TrimRight(cfg.PublicURL, "/")
}
return DefaultServerURL
}
func ClientConfigPath() string {
if v := os.Getenv("GITOOCEAN_CONFIG"); v != "" {
return v
}
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, "gitocean", "config.json")
}
func LoadClient() (model.Config, error) {
b, err := os.ReadFile(ClientConfigPath())
if err != nil {
return model.Config{}, errors.New("not logged in; run gitocean login")
}
var cfg model.Config
if err := json.Unmarshal(b, &cfg); err != nil {
return model.Config{}, err
}
if cfg.Server == "" || cfg.Token == "" {
return model.Config{}, errors.New("invalid config; run gitocean login")
}
return cfg, nil
}
func SaveClient(cfg model.Config) error {
path := ClientConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
b, _ := json.MarshalIndent(cfg, "", " ")
return os.WriteFile(path, b, 0600)
}
func DefaultServerConfigPath() string {
if v := os.Getenv("GITOOCEAN_SERVER_CONFIG"); v != "" {
return v
}
return filepath.Join(DefaultStorage, "config.json")
}
func LoadServer(path string) (model.ServerConfig, error) {
b, err := os.ReadFile(path)
if err != nil {
return model.ServerConfig{}, err
}
var cfg model.ServerConfig
if err := json.Unmarshal(b, &cfg); err != nil {
return model.ServerConfig{}, err
}
return cfg, nil
}
func SaveServer(path string, cfg model.ServerConfig) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
b, _ := json.MarshalIndent(cfg, "", " ")
return os.WriteFile(path, b, 0600)
}
+44
View File
@@ -0,0 +1,44 @@
package config
import (
"path/filepath"
"testing"
"time"
"gitocean/internal/model"
)
func TestClientConfigRoundTrip(t *testing.T) {
t.Setenv("GITOOCEAN_CONFIG", filepath.Join(t.TempDir(), "client.json"))
cfg := model.Config{Server: "http://example.test", Username: "alice", Token: "tok", ExpiresAt: time.Now().UTC()}
if err := SaveClient(cfg); err != nil {
t.Fatal(err)
}
got, err := LoadClient()
if err != nil {
t.Fatal(err)
}
if got.Server != cfg.Server || got.Username != cfg.Username || got.Token != cfg.Token {
t.Fatalf("unexpected config: %#v", got)
}
}
func TestServerConfigRoundTripAndEnvServer(t *testing.T) {
path := filepath.Join(t.TempDir(), "server.json")
t.Setenv("GITOOCEAN_SERVER_CONFIG", path)
t.Setenv("GITOOCEAN_SERVER", "")
cfg := model.ServerConfig{Addr: ":8080", PublicURL: "http://localhost:8080/", Storage: "storage", MySQLDSN: "u:p@tcp(localhost:3306)/gitocean"}
if err := SaveServer(path, cfg); err != nil {
t.Fatal(err)
}
got, err := LoadServer(path)
if err != nil {
t.Fatal(err)
}
if got.PublicURL != cfg.PublicURL || got.MySQLDSN != cfg.MySQLDSN {
t.Fatalf("unexpected server config: %#v", got)
}
if server := EnvServer(); server != "http://localhost:8080" {
t.Fatalf("unexpected env server: %q", server)
}
}
+68
View File
@@ -0,0 +1,68 @@
package dbutil
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/go-sql-driver/mysql"
)
func OpenMySQLAndCreateDatabaseIfMissing(dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err == nil {
return db, nil
} else {
_ = db.Close()
var mysqlErr *mysql.MySQLError
if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1049 {
return nil, err
}
}
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return nil, err
}
if cfg.DBName == "" {
return nil, errors.New("DSN does not include a database name")
}
databaseName := cfg.DBName
cfg.DBName = ""
adminDB, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
return nil, err
}
defer adminDB.Close()
if err := adminDB.Ping(); err != nil {
return nil, err
}
if _, err := adminDB.Exec("CREATE DATABASE IF NOT EXISTS " + QuoteMySQLIdentifier(databaseName) + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
return nil, err
}
fmt.Printf("created database %q\n", databaseName)
db, err = sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, err
}
return db, nil
}
func QuoteMySQLIdentifier(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
func IsDuplicateColumnError(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060
}
+10
View File
@@ -0,0 +1,10 @@
package dbutil
import "testing"
func TestQuoteMySQLIdentifier(t *testing.T) {
got := QuoteMySQLIdentifier("git`ocean")
if got != "`git``ocean`" {
t.Fatalf("unexpected quote: %q", got)
}
}
+59
View File
@@ -0,0 +1,59 @@
package gitutil
import (
"fmt"
"os/exec"
"strings"
"time"
"gitocean/internal/model"
"gitocean/internal/validate"
)
func Run(dir string, args ...string) error {
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
}
return nil
}
func InitBare(path string) error {
if err := Run("", "init", "--bare", path); err != nil {
return err
}
return Run("", "--git-dir", path, "symbolic-ref", "HEAD", "refs/heads/main")
}
func BranchExists(repoPath, branch string) bool {
if !validate.Branch(branch) {
return false
}
cmd := exec.Command("git", "--git-dir", repoPath, "rev-parse", "--verify", "refs/heads/"+branch)
return cmd.Run() == nil
}
func Refs(repoPath, prefix string) ([]model.RefInfo, error) {
cmd := exec.Command("git", "--git-dir", repoPath, "for-each-ref", "--format=%(refname:short)%00%(objectname)%00%(committerdate:iso8601)%00%(subject)", prefix)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("git refs failed: %s", strings.TrimSpace(string(out)))
}
var refs []model.RefInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\x00", 4)
if len(parts) < 4 {
continue
}
date, _ := time.Parse("2006-01-02 15:04:05 -0700", parts[2])
refs = append(refs, model.RefInfo{Name: parts[0], Commit: parts[1], Date: date, Message: parts[3]})
}
return refs, nil
}
+27
View File
@@ -0,0 +1,27 @@
package httputil
import (
"encoding/json"
"net/http"
)
func DecodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
WriteError(w, http.StatusBadRequest, "invalid json: "+err.Error())
return false
}
return true
}
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func WriteError(w http.ResponseWriter, status int, msg string) {
WriteJSON(w, status, map[string]string{"error": msg})
}
+90
View File
@@ -0,0 +1,90 @@
package model
import "time"
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
}
type Repository struct {
ID int64 `json:"id"`
OwnerUserID int64 `json:"-"`
Owner string `json:"owner"`
Name string `json:"name"`
Visibility string `json:"visibility"`
Description string `json:"description"`
DefaultBranch string `json:"default_branch"`
Archived bool `json:"archived"`
ForkedFromID *int64 `json:"forked_from_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PullRequest struct {
ID int64 `json:"id"`
Number int `json:"number"`
TargetRepositoryID int64 `json:"-"`
SourceRepositoryID int64 `json:"-"`
AuthorUserID int64 `json:"-"`
Author string `json:"author"`
SourceOwner string `json:"source_owner"`
SourceRepo string `json:"source_repo"`
SourceBranch string `json:"source_branch"`
TargetOwner string `json:"target_owner"`
TargetRepo string `json:"target_repo"`
TargetBranch string `json:"target_branch"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
MergedAt *time.Time `json:"merged_at,omitempty"`
}
type ServerConfig struct {
Addr string `json:"addr"`
PublicURL string `json:"public_url"`
Storage string `json:"storage"`
MySQLDSN string `json:"mysql_dsn"`
}
type RefInfo struct {
Name string `json:"name"`
Commit string `json:"commit"`
Date time.Time `json:"date"`
Message string `json:"message"`
}
type Collaborator struct {
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
type PRComment struct {
ID int64 `json:"id"`
Author string `json:"author"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type TokenInfo struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
Active bool `json:"active"`
}
type Config struct {
Server string `json:"server"`
Username string `json:"username"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
+22
View File
@@ -0,0 +1,22 @@
package validate
import "regexp"
var (
UsernameRE = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
RepoNameRE = regexp.MustCompile(`^[a-z0-9._-]{1,100}$`)
BranchRE = regexp.MustCompile(`^[A-Za-z0-9._/\-]{1,200}$`)
)
func Username(s string) bool { return UsernameRE.MatchString(s) }
func RepoName(s string) bool { return RepoNameRE.MatchString(s) }
func Branch(s string) bool { return BranchRE.MatchString(s) }
func ReservedName(s string) bool {
switch s {
case "api", "admin", "storage", "git", "repos", ".", "..":
return true
default:
return false
}
}
+33
View File
@@ -0,0 +1,33 @@
package validate
import "testing"
func TestNameValidation(t *testing.T) {
if !Username("alice_1") {
t.Fatal("expected username to be valid")
}
if Username("Alice") || Username("ab") || Username("alice.example") {
t.Fatal("expected invalid usernames to be rejected")
}
if !RepoName("demo.repo_1") {
t.Fatal("expected repo name to be valid")
}
if RepoName("Demo") || RepoName("bad/name") || RepoName("") {
t.Fatal("expected invalid repo names to be rejected")
}
if !Branch("feature/demo-1") {
t.Fatal("expected branch to be valid")
}
if Branch("bad branch") || Branch("") {
t.Fatal("expected invalid branches to be rejected")
}
}
func TestReservedName(t *testing.T) {
if !ReservedName("api") || !ReservedName("..") {
t.Fatal("expected reserved names")
}
if ReservedName("alice") {
t.Fatal("did not expect normal name to be reserved")
}
}