From c32e08be38126c904b93a2eb1a5f02ab31ada8e4 Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Mon, 8 Jun 2026 13:34:53 -0500 Subject: [PATCH] Refactor codebase for Phase 3 --- PLAN.md | 194 -- README.md | 64 + cmd/gitocean/main.go | 3134 +--------------------- internal/app/auth.go | 290 ++ internal/app/cli_admin.go | 123 + internal/app/cli_auth.go | 104 + internal/app/cli_helpers.go | 341 +++ internal/app/cli_init.go | 52 + internal/app/cli_pr.go | 247 ++ internal/app/cli_repo.go | 403 +++ internal/app/core.go | 125 + internal/app/git_http.go | 154 ++ internal/app/git_http_test.go | 31 + internal/app/parse_test.go | 62 + internal/app/pulls.go | 390 +++ internal/app/repos.go | 431 +++ internal/app/routing.go | 132 + internal/app/server.go | 178 ++ internal/app/shared.go | 45 + internal/backup/backup.go | 117 + internal/config/config.go | 88 + internal/config/config_test.go | 44 + internal/dbutil/mysql.go | 68 + internal/dbutil/mysql_test.go | 10 + internal/gitutil/git.go | 59 + internal/httputil/json.go | 27 + internal/model/model.go | 90 + internal/validate/names.go | 22 + internal/validate/names_test.go | 33 + plans/PHASE_1_PLAN.md | 337 +++ PHASE_2_PLAN.md => plans/PHASE_2_PLAN.md | 0 plans/PHASE_3_PLAN.md | 646 +++++ 32 files changed, 4718 insertions(+), 3323 deletions(-) delete mode 100644 PLAN.md create mode 100644 README.md create mode 100644 internal/app/auth.go create mode 100644 internal/app/cli_admin.go create mode 100644 internal/app/cli_auth.go create mode 100644 internal/app/cli_helpers.go create mode 100644 internal/app/cli_init.go create mode 100644 internal/app/cli_pr.go create mode 100644 internal/app/cli_repo.go create mode 100644 internal/app/core.go create mode 100644 internal/app/git_http.go create mode 100644 internal/app/git_http_test.go create mode 100644 internal/app/parse_test.go create mode 100644 internal/app/pulls.go create mode 100644 internal/app/repos.go create mode 100644 internal/app/routing.go create mode 100644 internal/app/server.go create mode 100644 internal/app/shared.go create mode 100644 internal/backup/backup.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/dbutil/mysql.go create mode 100644 internal/dbutil/mysql_test.go create mode 100644 internal/gitutil/git.go create mode 100644 internal/httputil/json.go create mode 100644 internal/model/model.go create mode 100644 internal/validate/names.go create mode 100644 internal/validate/names_test.go create mode 100644 plans/PHASE_1_PLAN.md rename PHASE_2_PLAN.md => plans/PHASE_2_PLAN.md (100%) create mode 100644 plans/PHASE_3_PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 95fe535..0000000 --- a/PLAN.md +++ /dev/null @@ -1,194 +0,0 @@ -# Gitocean Implementation Plan - -## Product decisions - -- Build a Git repository hosting platform in Go. -- Store bare Git repositories under `storage/repos/{owner}/{repo}.git`. -- Use MySQL for metadata. -- Support HTTP Smart Git only. SSH is out of scope for MVP. -- Support CLI + API only. No web UI. -- Registration is open, but registration happens through the CLI/API only. -- Users register with email, username, and password. -- Users can log in with either email + password or username + password. -- Login returns a temporary auth token that expires after 7 days. -- The CLI stores this token locally for API calls and can approve it into Git's credential helper for Git HTTP operations. -- Repository names allow lowercase letters, numbers, `.`, `_`, and `-`. -- Usernames allow lowercase letters, numbers, `_`, and `-`. -- New repositories use `main` as the default branch. -- Pull request numbers are per target repository. - -## Permissions - -### Repository visibility - -- `public`: anyone can clone/fetch. Only the owner can push/delete/manage. -- `private`: only the owner can clone/fetch/push/delete/manage. - -### Writes - -- MVP is owner-only. -- There are no collaborators, organizations, teams, or fine-grained permissions yet. -- Open collaboration is supported through pull requests against public repositories. - -## Storage layout - -```text -storage/ - repos/ - alice/ - demo.git/ - bob/ - project.git/ -``` - -Each repository directory is a bare Git repository. - -## MySQL schema - -Core tables: - -- `users` - - id - - email - - username - - password_hash - - created_at -- `auth_tokens` - - id - - user_id - - token_hash - - expires_at - - revoked_at - - created_at -- `repositories` - - id - - owner_user_id - - name - - visibility: `public` or `private` - - forked_from_repository_id nullable - - created_at - - updated_at -- `pull_requests` - - id - - target_repository_id - - number - - author_user_id - - source_repository_id - - source_branch - - target_branch - - title - - description - - status: `open`, `closed`, or `merged` - - closed_at nullable - - merged_at nullable - - created_at - - updated_at - -## API - -### Auth - -```http -POST /api/register -POST /api/login -POST /api/logout -GET /api/me -``` - -### Repositories - -```http -POST /api/repos -GET /api/repos/search?q=demo&scope=all -GET /api/repos/search?q=demo&scope=mine -GET /api/repos/{owner}/{repo} -DELETE /api/repos/{owner}/{repo}?force=false -POST /api/repos/{owner}/{repo}/fork -``` - -### Pull requests - -```http -POST /api/repos/{owner}/{repo}/pulls -GET /api/repos/{owner}/{repo}/pulls -GET /api/repos/{owner}/{repo}/pulls/{number} -POST /api/repos/{owner}/{repo}/pulls/{number}/close -POST /api/repos/{owner}/{repo}/pulls/{number}/merge -``` - -### Git HTTP - -```http -/{owner}/{repo}.git/info/refs -/{owner}/{repo}.git/git-upload-pack -/{owner}/{repo}.git/git-receive-pack -``` - -## CLI commands - -```bash -gitocean server --addr :8080 --dsn 'user:pass@tcp(127.0.0.1:3306)/gitocean?parseTime=true' --storage storage - -gitocean register -gitocean login -gitocean logout -gitocean whoami - -gitocean repo create NAME --public -gitocean repo create NAME --private -gitocean repo delete OWNER/NAME [--force] -gitocean repo search QUERY --all -gitocean repo search QUERY --mine -gitocean repo fork OWNER/NAME [--name NEW_NAME] - -gitocean clone OWNER/NAME - -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 -gitocean pr view OWNER/REPO NUMBER -gitocean pr close OWNER/REPO NUMBER -gitocean pr merge OWNER/REPO NUMBER -``` - -## Pull request rules - -### Same-repository PRs - -- Source and target repository are the same. -- Only the repository owner can create same-repo PRs. -- Source and target branches must both exist. - -### Cross-repository PRs - -- Source repository branch points to target repository branch. -- Source repository must be owned by the PR author. -- Target repository must be public, unless the author is also the target owner. -- Source and target branches must both exist. -- Only the target repository owner can close or merge the PR. - -## Merge behavior - -MVP merge mode is a normal merge commit: - -1. Clone target repo into a temporary worktree. -2. Checkout target branch. -3. Add/fetch source repo branch. -4. Run `git merge --no-ff FETCH_HEAD`. -5. Push the result back to the target branch. -6. Mark PR as merged. - -If conflicts occur, return an error and keep the PR open. - -## Implementation milestones - -1. Project scaffold and build setup. -2. MySQL migrations. -3. User registration, login, logout, and token auth. -4. Repository create, delete, detail, search. -5. HTTP Smart Git integration using `git http-backend`. -6. CLI auth and repository commands. -7. Fork support. -8. Pull request create/list/view/close. -9. Pull request merge. -10. Tests, hardening, and UX polish. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e176b68 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Gitocean + +Gitocean is a small Go-based Git hosting platform for personal use. It provides a CLI, JSON HTTP API, MySQL metadata storage, and HTTP Smart Git transport. There is no web UI and no SSH transport. + +## Quickstart + +```bash +go run ./cmd/gitocean init +go run ./cmd/gitocean server --config storage/config.json +``` + +In another shell: + +```bash +go run ./cmd/gitocean register +go run ./cmd/gitocean login +go run ./cmd/gitocean repo create demo --public +go run ./cmd/gitocean clone USER/demo +``` + +## Storage + +Bare repositories are stored under: + +```text +storage/repos/{owner}/{repo}.git +``` + +Server config defaults to: + +```text +storage/config.json +``` + +CLI auth config defaults to: + +```text +~/.config/gitocean/config.json +``` + +## Development + +Run checks with: + +```bash +gofmt -w . +go test ./... +go vet ./... +``` + +## Code layout + +```text +cmd/gitocean/ thin executable entrypoint +internal/app/ application command dispatch and domain handlers +internal/model/ shared data models +internal/config/ client/server config helpers +internal/dbutil/ MySQL connection helpers +internal/gitutil/ reusable Git command helpers +internal/httputil/ JSON HTTP helpers +internal/backup/ backup/restore helpers +internal/validate/ name and branch validators +plans/ phase plans +``` diff --git a/cmd/gitocean/main.go b/cmd/gitocean/main.go index 7920d7d..7bbc96e 100644 --- a/cmd/gitocean/main.go +++ b/cmd/gitocean/main.go @@ -1,3143 +1,19 @@ package main import ( - "bytes" - "context" - "crypto/rand" - "crypto/sha256" - "database/sql" - "encoding/base64" - "encoding/hex" - "encoding/json" "errors" - "flag" "fmt" - "io" - "net/http" - "net/url" "os" - "os/exec" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - "github.com/go-sql-driver/mysql" - "golang.org/x/crypto/bcrypt" + "gitocean/internal/app" ) -const ( - defaultServerURL = "http://localhost:8080" - defaultStorage = "storage" - tokenTTL = 7 * 24 * time.Hour -) - -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}$`) -) - -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 Server struct { - db *sql.DB - storage string - publicURL string -} - -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"` -} - func main() { - if len(os.Args) < 2 { - usage() - os.Exit(2) - } - - var err error - switch os.Args[1] { - case "init": - err = cliInit(os.Args[2:]) - case "server": - err = runServer(os.Args[2:]) - case "register": - err = cliRegister(os.Args[2:]) - case "login": - err = cliLogin(os.Args[2:]) - case "logout": - err = cliLogout(os.Args[2:]) - case "whoami": - err = cliWhoami(os.Args[2:]) - case "clone": - err = cliClone(os.Args[2:]) - case "repo": - err = cliRepo(os.Args[2:]) - case "pr": - err = cliPR(os.Args[2:]) - case "token": - err = cliToken(os.Args[2:]) - case "admin": - err = cliAdmin(os.Args[2:]) - case "backup": - err = cliBackup(os.Args[2:]) - case "help", "-h", "--help": - usage() - default: - err = fmt.Errorf("unknown command %q", os.Args[1]) - } - if err != nil { + if err := app.Run(os.Args[1:]); err != nil { + if errors.Is(err, app.ErrUsage) { + os.Exit(2) + } fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } - -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] - 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 - gitocean pr view OWNER/REPO NUMBER - 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|revoke|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`) -} - -// ---------------- 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) { - 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 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 -} - -// ---------------- 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") -} - -// ---------------- 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") - } -} - -// ---------------- 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}) -} - -// ---------------- 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()}) -} - -// ---------------- 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) -} - -// ---------------- 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 -} - -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 - } - printJSON(out) - 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 - } - printJSON(user) - 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() -} - -func cliRepo(args []string) error { - if len(args) < 1 { - return errors.New("usage: gitocean repo ") - } - 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) - var out any - if err := apiRequest(context.Background(), cfg.Server, http.MethodDelete, path, cfg.Token, nil, &out); err != nil { - return err - } - printJSON(out) - return nil -} - -func cliRepoSearch(args []string) error { - 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 - } - 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 - } - printJSON(out) - 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 ") - } - 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]) - } -} - -func cliPR(args []string) error { - if len(args) < 1 { - return errors.New("usage: gitocean pr ") - } - 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 { - 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 - } - 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 { - 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 - } - 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 -} - -func cliToken(args []string) error { - if len(args) < 1 { - return errors.New("usage: gitocean token ") - } - cfg, err := loadConfig() - if err != nil { - return err - } - switch args[0] { - case "list": - var out []TokenInfo - if err := apiRequest(context.Background(), cfg.Server, http.MethodGet, "/api/tokens", cfg.Token, nil, &out); err != nil { - return err - } - 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") - } -} - -// ---------------- 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 { - if v := os.Getenv("GITOOCEAN_SERVER"); v != "" { - return strings.TrimRight(v, "/") - } - if cfg, err := loadServerConfig(defaultServerConfigPath()); err == nil && cfg.PublicURL != "" { - return strings.TrimRight(cfg.PublicURL, "/") - } - return defaultServerURL -} - -func configPath() 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 loadConfig() (Config, error) { - b, err := os.ReadFile(configPath()) - if err != nil { - return Config{}, errors.New("not logged in; run gitocean login") - } - var cfg Config - if err := json.Unmarshal(b, &cfg); err != nil { - return Config{}, err - } - if cfg.Server == "" || cfg.Token == "" { - return Config{}, errors.New("invalid config; run gitocean login") - } - return cfg, nil -} - -func saveConfig(cfg Config) error { - path := configPath() - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err - } - b, _ := json.MarshalIndent(cfg, "", " ") - return os.WriteFile(path, b, 0600) -} - -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 { - if v := os.Getenv("GITOOCEAN_SERVER_CONFIG"); v != "" { - return v - } - return filepath.Join(defaultStorage, "config.json") -} - -func loadServerConfig(path string) (ServerConfig, error) { - b, err := os.ReadFile(path) - if err != nil { - return ServerConfig{}, err - } - var cfg ServerConfig - if err := json.Unmarshal(b, &cfg); err != nil { - return ServerConfig{}, err - } - return cfg, nil -} - -func saveServerConfig(path string, cfg ServerConfig) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - b, _ := json.MarshalIndent(cfg, "", " ") - return os.WriteFile(path, b, 0600) -} - -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) { - 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 []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, RefInfo{Name: parts[0], Commit: parts[1], Date: date, Message: parts[3]}) - } - return refs, nil -} - -func createBackup(cfg 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 restoreBackup(cfg 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()) - }) -} - -func isDuplicateColumnError(err error) bool { - var mysqlErr *mysql.MySQLError - return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060 -} - -// ---------------- Shared helpers ---------------- - -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}) -} - -func isReservedName(s string) bool { - switch s { - case "api", "admin", "storage", "git", "repos", ".", "..": - return true - default: - return false - } -} - -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 { - if err := runGit("", "init", "--bare", path); err != nil { - return err - } - return runGit("", "--git-dir", path, "symbolic-ref", "HEAD", "refs/heads/main") -} - -func gitBranchExists(repoPath, branch string) bool { - if !branchRE.MatchString(branch) { - return false - } - cmd := exec.Command("git", "--git-dir", repoPath, "rev-parse", "--verify", "refs/heads/"+branch) - return cmd.Run() == nil -} - -func runGit(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 -} diff --git a/internal/app/auth.go b/internal/app/auth.go new file mode 100644 index 0000000..35a3df9 --- /dev/null +++ b/internal/app/auth.go @@ -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") + } +} diff --git a/internal/app/cli_admin.go b/internal/app/cli_admin.go new file mode 100644 index 0000000..f6129e0 --- /dev/null +++ b/internal/app/cli_admin.go @@ -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 ") + } + 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") + } +} diff --git a/internal/app/cli_auth.go b/internal/app/cli_auth.go new file mode 100644 index 0000000..30d4d21 --- /dev/null +++ b/internal/app/cli_auth.go @@ -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() +} diff --git a/internal/app/cli_helpers.go b/internal/app/cli_helpers.go new file mode 100644 index 0000000..434dcd3 --- /dev/null +++ b/internal/app/cli_helpers.go @@ -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) +} diff --git a/internal/app/cli_init.go b/internal/app/cli_init.go new file mode 100644 index 0000000..b91da2d --- /dev/null +++ b/internal/app/cli_init.go @@ -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 +} diff --git a/internal/app/cli_pr.go b/internal/app/cli_pr.go new file mode 100644 index 0000000..8956ddf --- /dev/null +++ b/internal/app/cli_pr.go @@ -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 ") + } + 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 +} diff --git a/internal/app/cli_repo.go b/internal/app/cli_repo.go new file mode 100644 index 0000000..f191853 --- /dev/null +++ b/internal/app/cli_repo.go @@ -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 ") + } + 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 ") + } + 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]) + } +} diff --git a/internal/app/core.go b/internal/app/core.go new file mode 100644 index 0000000..57917cf --- /dev/null +++ b/internal/app/core.go @@ -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`) +} diff --git a/internal/app/git_http.go b/internal/app/git_http.go new file mode 100644 index 0000000..4207379 --- /dev/null +++ b/internal/app/git_http.go @@ -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) +} diff --git a/internal/app/git_http_test.go b/internal/app/git_http_test.go new file mode 100644 index 0000000..69440b7 --- /dev/null +++ b/internal/app/git_http_test.go @@ -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) + } +} diff --git a/internal/app/parse_test.go b/internal/app/parse_test.go new file mode 100644 index 0000000..97ef6a2 --- /dev/null +++ b/internal/app/parse_test.go @@ -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) + } +} diff --git a/internal/app/pulls.go b/internal/app/pulls.go new file mode 100644 index 0000000..3197495 --- /dev/null +++ b/internal/app/pulls.go @@ -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()}) +} diff --git a/internal/app/repos.go b/internal/app/repos.go new file mode 100644 index 0000000..7844004 --- /dev/null +++ b/internal/app/repos.go @@ -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}) +} diff --git a/internal/app/routing.go b/internal/app/routing.go new file mode 100644 index 0000000..11295bc --- /dev/null +++ b/internal/app/routing.go @@ -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") +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..d1ce15d --- /dev/null +++ b/internal/app/server.go @@ -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 +} diff --git a/internal/app/shared.go b/internal/app/shared.go new file mode 100644 index 0000000..fabc3a5 --- /dev/null +++ b/internal/app/shared.go @@ -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...) +} diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000..a864677 --- /dev/null +++ b/internal/backup/backup.go @@ -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()) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..293be06 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c7788c9 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/dbutil/mysql.go b/internal/dbutil/mysql.go new file mode 100644 index 0000000..34c1500 --- /dev/null +++ b/internal/dbutil/mysql.go @@ -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 +} diff --git a/internal/dbutil/mysql_test.go b/internal/dbutil/mysql_test.go new file mode 100644 index 0000000..a4c606e --- /dev/null +++ b/internal/dbutil/mysql_test.go @@ -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) + } +} diff --git a/internal/gitutil/git.go b/internal/gitutil/git.go new file mode 100644 index 0000000..43e3b77 --- /dev/null +++ b/internal/gitutil/git.go @@ -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 +} diff --git a/internal/httputil/json.go b/internal/httputil/json.go new file mode 100644 index 0000000..5019bb9 --- /dev/null +++ b/internal/httputil/json.go @@ -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}) +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..cd4ba7a --- /dev/null +++ b/internal/model/model.go @@ -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"` +} diff --git a/internal/validate/names.go b/internal/validate/names.go new file mode 100644 index 0000000..005de44 --- /dev/null +++ b/internal/validate/names.go @@ -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 + } +} diff --git a/internal/validate/names_test.go b/internal/validate/names_test.go new file mode 100644 index 0000000..6d21a66 --- /dev/null +++ b/internal/validate/names_test.go @@ -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") + } +} diff --git a/plans/PHASE_1_PLAN.md b/plans/PHASE_1_PLAN.md new file mode 100644 index 0000000..1c78af9 --- /dev/null +++ b/plans/PHASE_1_PLAN.md @@ -0,0 +1,337 @@ +# Gitocean Phase 1 Plan: MVP + +Phase 1 focuses on building the minimum viable Git hosting platform: CLI + API only, MySQL metadata, repositories stored on disk, HTTP Smart Git transport, public/private repositories, search, forks, and pull requests. + +This phase intentionally avoids a web UI, SSH Git transport, organizations, teams, CI/CD, and enterprise features. The goal is a working personal Git hosting foundation. + +## Goals + +- Build a Go-based Git repository hosting platform. +- Store bare repositories under `storage/repos/{owner}/{repo}.git`. +- Use MySQL for metadata. +- Support HTTP Smart Git clone/fetch/push. +- Provide CLI-based registration, login, repository management, search, forks, and pull requests. +- Support public and private repositories. +- Use temporary 7-day API/Git auth tokens. +- Keep permissions simple: owner-only writes and private access in MVP. + +--- + +## 1. Product decisions + +### Platform shape + +- Gitocean is a CLI + API Git hosting system. +- No Web UI in Phase 1. +- No SSH Git transport in Phase 1. +- Repositories are stored as bare Git repositories on disk. +- Metadata is stored in MySQL. + +### Registration and login + +- Registration is open. +- Users register with: + - email + - username + - password +- Users can log in with either: + - email + password + - username + password +- Login returns a temporary token that expires after 7 days. +- The CLI stores the token locally for API calls. +- The CLI can approve the token into Git's credential helper for HTTP Git operations. + +### Naming defaults + +- CLI binary name: `gitocean`. +- Default branch: `main`. +- Repository names allow lowercase letters, numbers, `.`, `_`, and `-`. +- Usernames allow lowercase letters, numbers, `_`, and `-`. +- Pull request numbers are scoped per target repository. + +--- + +## 2. Permissions + +### Repository visibility + +Public repositories: + +- Anyone can clone/fetch. +- Only the owner can push. +- Only the owner can delete or manage settings. + +Private repositories: + +- Only the owner can clone/fetch. +- Only the owner can push. +- Only the owner can delete or manage settings. + +### Writes + +- Phase 1 uses owner-only writes. +- No collaborators, organizations, teams, or fine-grained permissions yet. +- Outside collaboration happens through pull requests against public repositories. + +--- + +## 3. Storage layout + +Repositories live under: + +```text +storage/repos/{owner}/{repo}.git +``` + +Example: + +```text +storage/ + repos/ + alice/ + demo.git/ + bob/ + project.git/ +``` + +Each repository directory is a bare Git repository created with `git init --bare`. + +--- + +## 4. MySQL schema + +Core tables: + +### `users` + +- `id` +- `email` +- `username` +- `password_hash` +- `created_at` + +### `auth_tokens` + +- `id` +- `user_id` +- `token_hash` +- `expires_at` +- `revoked_at` +- `created_at` + +### `repositories` + +- `id` +- `owner_user_id` +- `name` +- `visibility`: `public` or `private` +- `forked_from_repository_id` nullable +- `created_at` +- `updated_at` + +### `pull_requests` + +- `id` +- `target_repository_id` +- `number` +- `author_user_id` +- `source_repository_id` +- `source_branch` +- `target_branch` +- `title` +- `description` +- `status`: `open`, `closed`, or `merged` +- `closed_at` nullable +- `merged_at` nullable +- `created_at` +- `updated_at` + +--- + +## 5. API routes + +### Auth + +```http +POST /api/register +POST /api/login +POST /api/logout +GET /api/me +``` + +### Repositories + +```http +POST /api/repos +GET /api/repos/search?q=demo&scope=all +GET /api/repos/search?q=demo&scope=mine +GET /api/repos/{owner}/{repo} +DELETE /api/repos/{owner}/{repo}?force=false +POST /api/repos/{owner}/{repo}/fork +``` + +### Pull requests + +```http +POST /api/repos/{owner}/{repo}/pulls +GET /api/repos/{owner}/{repo}/pulls +GET /api/repos/{owner}/{repo}/pulls/{number} +POST /api/repos/{owner}/{repo}/pulls/{number}/close +POST /api/repos/{owner}/{repo}/pulls/{number}/merge +``` + +### Git HTTP + +```http +/{owner}/{repo}.git/info/refs +/{owner}/{repo}.git/git-upload-pack +/{owner}/{repo}.git/git-receive-pack +``` + +--- + +## 6. CLI commands + +### Server + +```bash +gitocean server --addr :8080 --dsn 'user:pass@tcp(127.0.0.1:3306)/gitocean?parseTime=true' --storage storage +``` + +### Auth + +```bash +gitocean register +gitocean login +gitocean logout +gitocean whoami +``` + +### Repositories + +```bash +gitocean repo create NAME --public +gitocean repo create NAME --private +gitocean repo delete OWNER/NAME [--force] +gitocean repo search QUERY --all +gitocean repo search QUERY --mine +gitocean repo fork OWNER/NAME [--name NEW_NAME] +``` + +### Clone + +```bash +gitocean clone OWNER/NAME +``` + +### Pull requests + +```bash +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 +gitocean pr view OWNER/REPO NUMBER +gitocean pr close OWNER/REPO NUMBER +gitocean pr merge OWNER/REPO NUMBER +``` + +--- + +## 7. Git HTTP behavior + +Use Git's built-in Smart HTTP backend: + +```bash +git http-backend +``` + +Required behavior: + +- Public clone/fetch works without auth. +- Private clone/fetch requires owner auth. +- Push always requires auth. +- Push is owner-only. +- Git username is the account username. +- Git password is the 7-day token. + +--- + +## 8. Fork behavior + +- Logged-in users can fork public repositories. +- Private repositories can only be forked by the owner in Phase 1. +- Forks are copied as bare repositories under the new owner's storage path. +- Fork metadata stores `forked_from_repository_id`. + +--- + +## 9. Pull request rules + +### Same-repository PRs + +- Source and target repository are the same. +- Only the repository owner can create same-repo PRs. +- Source and target branches must both exist. + +### Cross-repository PRs + +- Source repository branch points to target repository branch. +- Source repository must be owned by the PR author. +- Target repository must be public, unless the author is also the target owner. +- Source and target branches must both exist. +- Only the target repository owner can close or merge the PR. + +--- + +## 10. Merge behavior + +Phase 1 merge mode is a normal merge commit: + +1. Clone the target repo into a temporary worktree. +2. Checkout the target branch. +3. Add/fetch the source repo branch. +4. Run `git merge --no-ff FETCH_HEAD`. +5. Push the result back to the target branch. +6. Mark the PR as merged. + +If conflicts occur: + +- Return an error. +- Keep the PR open. +- Do not modify PR status. + +--- + +## 11. Implementation milestones + +1. Project scaffold and build setup. +2. MySQL migrations. +3. User registration, login, logout, and token auth. +4. Repository create, delete, detail, and search. +5. HTTP Smart Git integration using `git http-backend`. +6. CLI auth and repository commands. +7. Fork support. +8. Pull request create/list/view/close. +9. Pull request merge. +10. Tests, hardening, and UX polish. + +--- + +## Phase 1 completion criteria + +Phase 1 is complete when: + +- The server starts with MySQL metadata storage. +- Missing MySQL databases can be created automatically. +- Users can register, login, logout, and run `whoami`. +- Login tokens expire after 7 days. +- Users can create public/private repositories. +- Public repositories can be cloned/fetched without auth. +- Private repositories require owner auth. +- Owners can push over HTTP. +- Non-owners cannot push. +- Users can search visible repositories. +- Users can fork allowed repositories. +- Users can create, list, view, close, and merge pull requests. +- `go test ./...` passes. +- `go vet ./...` passes. diff --git a/PHASE_2_PLAN.md b/plans/PHASE_2_PLAN.md similarity index 100% rename from PHASE_2_PLAN.md rename to plans/PHASE_2_PLAN.md diff --git a/plans/PHASE_3_PLAN.md b/plans/PHASE_3_PLAN.md new file mode 100644 index 0000000..b9594e9 --- /dev/null +++ b/plans/PHASE_3_PLAN.md @@ -0,0 +1,646 @@ +# Phase 3 Plan: Split the God File into an Organized Codebase + +## Goal + +Refactor the current monolithic `cmd/gitocean/main.go` into a maintainable, testable, multi-package Go codebase without changing user-facing behavior. + +Phase 3 is primarily a structural refactor. The CLI commands, API routes, Git HTTP behavior, MySQL schema, storage layout, and auth rules should continue to work exactly as they do after Phase 2. + +Phase 3 should also finish documenting and preserving the CLI UX direction from Phase 2: human-readable CLI output by default, with JSON available only as an explicit opt-in where useful. This does **not** mean removing JSON from the HTTP API; API requests and responses should remain JSON. + +## Non-goals + +- Do not add a Web UI. +- Do not add SSH Git transport. +- Do not replace MySQL. +- Do not redesign API routes unless a compatibility shim is kept. +- Do not introduce large frameworks. +- Do not implement new product features until the refactor is stable. +- Do not remove JSON from the HTTP API. Only CLI output should become human-readable by default. + +## Current problem + +`cmd/gitocean/main.go` currently contains everything: + +- Type definitions +- CLI command routing and command implementations +- HTTP server bootstrapping +- API route dispatch +- Auth/token logic +- User/repository/PR/collaborator handlers +- Git HTTP backend integration +- MySQL migrations and DB helpers +- Config loading/saving +- Git helper functions +- Backup/restore logic +- Output formatting +- Generic HTTP JSON helpers + +This makes the code hard to test, hard to extend, and risky to modify. + +## Refactor principles + +1. **No behavior changes first** + - Move code into packages with minimal edits. + - Keep route paths, JSON payloads, CLI command names, flags, and output stable unless explicitly noted. + +2. **Small, reviewable steps** + - Move one domain at a time. + - Run `gofmt`, `go test ./...`, and `go vet ./...` after each major move. + +3. **Human-readable CLI by default** + - During CLI extraction, audit commands that still print raw JSON. + - Convert default CLI output to concise human-readable text/tables. + - Keep `--json` as an explicit escape hatch for scripting where appropriate. + - Do not change API JSON payloads. + +4. **Thin `main` package** + - `cmd/gitocean/main.go` should only call into an application package. + +5. **Reusable packages** + - Shared utilities should live in `internal/` packages, not in `cmd/`. + +6. **Domain boundaries over technical dumping grounds** + - Prefer packages like `repos`, `pulls`, `auth`, and `gitserver` over one huge `utils` package. + +7. **Testable dependencies** + - Handlers and services should accept dependencies through structs. + - Avoid package-level global state except constants and regex validators. + +## Target directory layout + +```text +cmd/ + gitocean/ + main.go # Tiny entrypoint only + +internal/ + app/ + app.go # Top-level CLI dispatch / application orchestration + usage.go # Help text + + config/ + client.go # CLI config: ~/.config/gitocean/config.json + server.go # Server config: storage/config.json + env.go # Environment defaults + + db/ + mysql.go # MySQL open/create database logic + migrate.go # Migrations + + model/ + user.go + repository.go + pull_request.go + collaborator.go + token.go + ref.go + + httpapi/ + server.go # Server struct and ServeHTTP + router.go # API route dispatch + json.go # decodeJSON/writeJSON/writeError + + auth/ + handlers.go # register/login/logout/me handlers + service.go # token generation, hashing, bearer/basic auth + password.go # password hashing helpers if needed + + tokens/ + handlers.go # token list/revoke/prune handlers + + admin/ + handlers.go # admin users/repos/storage/token routes + + repos/ + handlers.go # repo create/get/update/delete/search/fork + service.go # repo access checks and repo loading + collaborators.go # collaborator handlers and role logic + validate.go # repo/user name validation helpers if not shared + + pulls/ + handlers.go # PR create/list/view/close/merge/diff/comments + service.go # PR loading, scanning, merge/diff helpers + + gitserver/ + http.go # Smart HTTP route parsing and permissions + backend.go # git http-backend CGI bridge + + gitutil/ + git.go # runGit, gitInitBare, gitBranchExists, gitRefs + + cli/ + root.go # CLI command dispatch + auth.go # register/login/logout/whoami + repo.go # repo command dispatch + repo_create.go + repo_publish.go + repo_view.go + repo_collaborators.go + pr.go + token.go + admin.go + backup.go + output.go # CLI formatting helpers + parse.go # splitOwnerRepo, splitRepoBranch, flag parsing + prompt.go + api_client.go # CLI HTTP client helper + + backup/ + backup.go # createBackup/restoreBackup/mysqlCLIArgs/copyDir + + validate/ + names.go # username/repo/branch validation and reserved names +``` + +The exact final package names can change during implementation, but the direction should stay the same: small domain packages with clear responsibilities. + +## Dependency direction + +Recommended dependency flow: + +```text +cmd/gitocean + -> internal/app + -> internal/cli + -> internal/httpapi + -> internal/config + -> internal/db + +httpapi + -> auth, tokens, admin, repos, pulls, gitserver + -> model + +repos/pulls/auth/etc. + -> model + -> gitutil where needed + -> validate where needed + +cli + -> config + -> model + -> backup where needed +``` + +Avoid circular dependencies by keeping shared types in `internal/model` and shared helpers in targeted utility packages. + +## Proposed milestones + +### Milestone 1: Prepare shared model and validation packages + +Move pure data/types and validators first. + +Create: + +- `internal/model` +- `internal/validate` + +Move: + +- `User` +- `Repository` +- `PullRequest` +- `ServerConfig` +- `RefInfo` +- `Collaborator` +- `PRComment` +- `TokenInfo` +- username/repo/branch regex validation +- `isReservedName` + +Acceptance: + +- `cmd/gitocean/main.go` still builds. +- All references use `model.User`, `model.Repository`, etc. +- `go test ./...` passes. + +### Milestone 2: Extract config and generic helpers + +Create: + +- `internal/config` +- `internal/httpapi/json.go` +- `internal/gitutil` + +Move: + +- CLI config path/load/save +- server config path/load/save +- env server URL helper +- JSON request/response helpers where appropriate +- `gitInitBare` +- `gitBranchExists` +- `gitRefs` +- `runGit` + +Acceptance: + +- No behavior changes. +- CLI login still saves config. +- Server still reads config and env overrides. +- Git helper functions are reusable outside `main`. + +### Milestone 3: Extract DB connection and migrations + +Create: + +- `internal/db` + +Move: + +- `openMySQLAndCreateDatabaseIfMissing` +- `quoteMySQLIdentifier` +- `migrate` +- `isDuplicateColumnError` + +Acceptance: + +- Server starts from empty DB. +- Missing database auto-create behavior still works. +- Migrations are isolated and testable. + +### Milestone 4: Extract HTTP API server shell + +Create: + +- `internal/httpapi/server.go` +- `internal/httpapi/router.go` + +Move: + +- `Server` struct +- `ServeHTTP` +- top-level API route dispatch +- subroute dispatch helpers + +Keep domain handler method bodies temporarily in `httpapi` if needed, then move them by domain in later milestones. + +Acceptance: + +- `runServer` constructs `httpapi.Server`. +- API route paths remain unchanged. +- Git HTTP routes still reach the backend. + +### Milestone 5: Extract auth and token domains + +Create: + +- `internal/auth` +- `internal/tokens` + +Move: + +- register/login/logout/me handlers +- token creation/hash helpers +- bearer/basic auth helpers +- token list/revoke/prune handlers +- direct admin-user creation helper used by `init` + +Acceptance: + +- Registration still makes the first user admin. +- Login still returns 7-day tokens. +- CLI login/whoami/logout still work. +- Git basic auth still works with username/token. + +### Milestone 6: Extract repository domain + +Create: + +- `internal/repos` + +Move: + +- repo create/get/update/delete/search/fork handlers +- repository load/scan helpers +- repo access helpers: + - `requireReadableRepo` + - `canReadRepo` + - `canWriteRepo` + - `collaboratorRole` +- collaborator list/add/remove handlers + +Acceptance: + +- Public/private visibility rules still work. +- Private collaborators can read when allowed. +- Write collaborators can push when allowed. +- Archived repositories still reject pushes. +- Repo CLI commands still work. + +### Milestone 7: Extract pull request domain + +Create: + +- `internal/pulls` + +Move: + +- PR create/list/view/close/merge handlers +- PR comments handlers +- PR diff handler +- `prSelectSQL` +- `loadPR` +- `scanOnePR` +- `scanPRs` +- `mergePR` +- `prDiff` + +Acceptance: + +- Same-repo PRs still work. +- Cross-repo PRs still work. +- PR merge still uses Git commands correctly. +- PR diff/comments endpoints still work. + +### Milestone 8: Extract Git Smart HTTP server + +Create: + +- `internal/gitserver` + +Move: + +- `handleGitHTTP` +- `parseGitPath` +- `gitService` +- `runGitHTTPBackend` +- `writeCGIResponse` + +Design note: + +`gitserver` should depend on a small permission interface instead of importing the entire repo handler package if possible: + +```go +type RepoAccess interface { + LoadRepo(owner, name string) (model.Repository, error) + CanReadRepo(repo model.Repository, user model.User, authed bool) bool + CanWriteRepo(repo model.Repository, user model.User) bool + UserFromBasic(r *http.Request) (model.User, bool) +} +``` + +Acceptance: + +- Public clone/fetch still works without auth. +- Private clone/fetch works for owner/collaborators only. +- Push requires auth. +- Archived repos reject pushes. +- Git HTTP backend response handling remains correct. + +### Milestone 9: Extract CLI package + +Create: + +- `internal/cli` + +Move: + +- command dispatch +- `usage` +- `cliInit` +- auth CLI commands +- repo CLI commands +- PR CLI commands +- token/admin/backup CLI commands +- CLI API request helper +- output formatting helpers +- prompt helpers +- parsing helpers +- Git credential approval helper + +Acceptance: + +- `cmd/gitocean/main.go` is reduced to roughly: + +```go +package main + +import ( + "fmt" + "os" + + "gitocean/internal/app" +) + +func main() { + if err := app.Run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} +``` + +- Every existing CLI command still works. +- Help text is unchanged except for intentional wording cleanup. + +### Milestone 10: Extract backup package + +Create: + +- `internal/backup` + +Move: + +- `createBackup` +- `restoreBackup` +- `mysqlCLIArgs` +- `copyDir` + +Acceptance: + +- `gitocean backup create FILE` still works. +- `gitocean backup restore FILE` still works. +- Backup logic is testable independently. + +### Milestone 11: Add package-level tests + +Add focused unit tests where possible. + +Suggested tests: + +- `internal/validate` + - username validation + - repo name validation + - branch validation + - reserved names + +- `internal/cli` + - `splitOwnerRepo` + - `splitRepoBranch` + - `parseRepoCreateArgs` + - `parseRepoPublishArgs` + - boolean flag removal + +- `internal/config` + - client config load/save round trip + - server config load/save round trip + - env override behavior + +- `internal/gitserver` + - `parseGitPath` + - `gitService` + - CGI response parsing + +- `internal/db` + - MySQL identifier quoting + +Acceptance: + +- `go test ./...` includes meaningful tests. +- Tests avoid requiring a live MySQL instance unless explicitly integration-tagged. + +### Milestone 12: CLI output audit + +After the CLI package has been extracted, audit every CLI command for output style. + +Commands should default to human-readable output: + +- Short success messages for create/update/delete actions. +- Tables for lists. +- Labeled fields for detail views. +- Helpful next-step commands where useful. +- Clear auth/session error messages. + +JSON should be retained only as an explicit opt-in for scripting, for example: + +```bash +gitocean repo view OWNER/REPO --json +gitocean repo search QUERY --json +gitocean pr view OWNER/REPO NUMBER --json +gitocean token list --json +``` + +Acceptance: + +- No normal CLI command prints raw JSON by default. +- Commands that are useful in scripts have a documented `--json` option. +- HTTP API responses remain JSON and are not changed by this milestone. + +### Milestone 13: Documentation cleanup + +Update docs/plans after the refactor: + +- Update `plans/PLAN.md` if architecture descriptions changed. +- Update `plans/PHASE_2_PLAN.md` if current layout references are stale. +- Add a short `README.md` if missing with: + - quickstart + - server setup + - CLI commands + - storage layout + - development workflow + +Acceptance: + +- New contributors can find where CLI, API, DB, Git HTTP, and models live. + +## Suggested implementation order + +Recommended commit sequence: + +1. `Add Phase 3 refactor plan` +2. `Move shared models and validators` +3. `Extract config and Git utilities` +4. `Extract database setup and migrations` +5. `Extract HTTP API server shell` +6. `Extract auth and token handlers` +7. `Extract repository handlers` +8. `Extract pull request handlers` +9. `Extract Git HTTP backend` +10. `Extract CLI commands` +11. `Extract backup utilities` +12. `Audit CLI output and JSON escape hatches` +13. `Add package-level tests and documentation` + +## Compatibility checklist + +After each milestone, run: + +```bash +gofmt -w . +go test ./... +go vet ./... +``` + +Before declaring Phase 3 complete, manually verify: + +```bash +gitocean init +gitocean server --config storage/config.json +gitocean register +gitocean login +gitocean whoami +gitocean repo create demo --public +gitocean repo view USER/demo +gitocean repo branches USER/demo +gitocean repo tags USER/demo +gitocean repo search demo --all +gitocean clone USER/demo +gitocean repo publish demo2 --public +gitocean repo fork USER/demo +gitocean pr create --from USER/demo-fork:main --to USER/demo:main --title "Test PR" +gitocean pr list USER/demo +gitocean pr view USER/demo 1 +gitocean pr diff USER/demo 1 +gitocean pr comment USER/demo 1 "Looks good" +gitocean pr comments USER/demo 1 +gitocean token list +gitocean admin users list +gitocean backup create backup.tar.gz +``` + +Also verify Git HTTP manually: + +```bash +git clone http://localhost:8080/USER/demo.git +cd demo +echo test >> README.md +git add README.md +git commit -m "test push" +git push origin main +``` + +## Risks and mitigations + +### Risk: Circular package dependencies + +Mitigation: + +- Put shared structs in `internal/model`. +- Put small interfaces between domains where needed. +- Keep HTTP response helpers separate from domain services. + +### Risk: Refactor changes behavior accidentally + +Mitigation: + +- Move code first, improve code second. +- Keep commits small. +- Add tests for parsing/routing helpers early. + +### Risk: Handlers still know too much about SQL + +Mitigation: + +- Phase 3 can keep SQL in domain packages. +- A later phase can introduce repository/store interfaces if needed. + +### Risk: `utils` package becomes another god package + +Mitigation: + +- Only use utility packages for genuinely cross-domain helpers. +- Prefer domain packages. + +## Phase 3 completion criteria + +Phase 3 is complete when: + +- `cmd/gitocean/main.go` is a thin entrypoint. +- No package contains unrelated CLI, API, DB, Git, and model code together. +- Domain packages have clear responsibilities. +- Existing CLI commands and API routes still work. +- CLI output is human-readable by default, with `--json` opt-in where useful. +- `go test ./...` passes. +- `go vet ./...` passes. +- Meaningful unit tests exist for parsing, validation, config, and Git HTTP helpers.