making progress

This commit is contained in:
2026-05-07 02:06:54 +02:00
parent 7b7e2d399c
commit dea186c995
39 changed files with 2021 additions and 67 deletions
+195
View File
@@ -0,0 +1,195 @@
package handlers
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
"github.com/forgeo/forgebucket/internal/models"
)
type GitHTTPHandler struct {
db *xorm.Engine
cfg *config.Config
}
func NewGitHTTPHandler(db *xorm.Engine, cfg *config.Config) *GitHTTPHandler {
return &GitHTTPHandler{db: db, cfg: cfg}
}
// ServeGit is the entry point for all git smart-HTTP requests.
// URL shape: /{owner}/{repo}.git/{service}
func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "owner")
repoGit := chi.URLParam(r, "repoGit") // e.g. "myrepo.git"
repoName := strings.TrimSuffix(repoGit, ".git")
// Resolve repo from DB
var ownerUser models.User
if found, _ := h.db.Where("username = ?", owner).Get(&ownerUser); !found {
http.NotFound(w, r)
return
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", ownerUser.ID, repoName).Get(&repo); !found {
http.NotFound(w, r)
return
}
// Determine service from URL / query string
service := r.URL.Query().Get("service")
if service == "" {
// POST bodies encode the service in the path
path := r.URL.Path
if strings.HasSuffix(path, "/git-upload-pack") {
service = "git-upload-pack"
} else if strings.HasSuffix(path, "/git-receive-pack") {
service = "git-receive-pack"
}
}
// Require authentication for push; allow anonymous read for public repos
var authedUser string
user, authed := h.basicAuth(r)
if authed {
authedUser = user
} else if service == "git-receive-pack" || repo.IsPrivate {
w.Header().Set("WWW-Authenticate", `Basic realm="ForgeBucket"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
// Build PATH_INFO: /{reponame}.git/{suffix}
// chi wildcard gives us everything after /{owner}/{repoGit}
suffix := chi.URLParam(r, "*")
if suffix == "" {
suffix = "/"
} else if !strings.HasPrefix(suffix, "/") {
suffix = "/" + suffix
}
pathInfo := "/" + repoGit + suffix
projectRoot := filepath.Dir(repo.DiskPath)
env := []string{
"GIT_PROJECT_ROOT=" + projectRoot,
"GIT_HTTP_EXPORT_ALL=1",
"PATH_INFO=" + pathInfo,
"QUERY_STRING=" + r.URL.RawQuery,
"REQUEST_METHOD=" + r.Method,
"SERVER_PROTOCOL=HTTP/1.1",
"SERVER_SOFTWARE=ForgeBucket/1.0",
"REMOTE_ADDR=" + r.RemoteAddr,
"HOME=/tmp",
"GIT_TERMINAL_PROMPT=0",
}
if ct := r.Header.Get("Content-Type"); ct != "" {
env = append(env, "CONTENT_TYPE="+ct)
}
if cl := r.ContentLength; cl > 0 {
env = append(env, "CONTENT_LENGTH="+strconv.FormatInt(cl, 10))
}
if authedUser != "" {
env = append(env, "REMOTE_USER="+authedUser)
}
gitExec, err := exec.LookPath("git")
if err != nil {
http.Error(w, "git not found on server", http.StatusInternalServerError)
return
}
if err := runGitBackend(r.Context(), w, r.Body, gitExec, env); err != nil {
http.Error(w, fmt.Sprintf("git http-backend: %v", err), http.StatusInternalServerError)
}
}
// basicAuth validates HTTP Basic Auth credentials against the DB.
func (h *GitHTTPHandler) basicAuth(r *http.Request) (username string, ok bool) {
u, p, hasAuth := r.BasicAuth()
if !hasAuth {
return "", false
}
var user models.User
found, _ := h.db.Where("username = ?", u).Get(&user)
if !found {
return "", false
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(p)) != nil {
return "", false
}
return u, true
}
// runGitBackend executes `git http-backend` as a CGI subprocess and forwards
// its response (headers + body) to the HTTP response writer.
func runGitBackend(ctx context.Context, w http.ResponseWriter, body io.Reader, gitExec string, env []string) error {
cmd := exec.CommandContext(ctx, gitExec, "http-backend")
cmd.Env = env
pr, pw := io.Pipe()
cmd.Stdout = pw
cmd.Stdin = body
var stderrBuf strings.Builder
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("start: %w", err)
}
// Close write-end of pipe once git finishes
done := make(chan error, 1)
go func() {
err := cmd.Wait()
pw.Close()
done <- err
}()
// Parse CGI response: headers then body
br := bufio.NewReader(pr)
statusCode := http.StatusOK
for {
line, err := br.ReadString('\n')
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if strings.HasPrefix(line, "Status:") {
rest := strings.TrimSpace(strings.TrimPrefix(line, "Status:"))
if parts := strings.SplitN(rest, " ", 2); len(parts) >= 1 {
if code, e := strconv.Atoi(parts[0]); e == nil {
statusCode = code
}
}
} else if idx := strings.Index(line, ":"); idx > 0 {
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
w.Header().Set(key, val)
}
if err != nil {
break
}
}
w.WriteHeader(statusCode)
io.Copy(w, br) //nolint:errcheck
waitErr := <-done
if waitErr != nil && stderrBuf.Len() > 0 {
return fmt.Errorf("%w: %s", waitErr, stderrBuf.String())
}
return waitErr
}
+167
View File
@@ -0,0 +1,167 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type IssueHandler struct {
db *xorm.Engine
}
func NewIssueHandler(db *xorm.Engine) *IssueHandler {
return &IssueHandler{db: db}
}
type issueResponse struct {
models.Issue
AuthorName string `json:"authorName"`
}
func (h *IssueHandler) enrichIssue(issue *models.Issue) issueResponse {
var author models.User
h.db.ID(issue.AuthorID).Get(&author)
return issueResponse{Issue: *issue, AuthorName: author.Username}
}
func (h *IssueHandler) List(w http.ResponseWriter, r *http.Request) {
repoID, ok := h.repoID(w, r)
if !ok {
return
}
state := r.URL.Query().Get("state")
if state == "" {
state = "open"
}
var issues []models.Issue
if err := h.db.Where("repo_id = ? AND state = ?", repoID, state).
OrderBy("id DESC").Find(&issues); err != nil {
jsonError(w, "could not list issues", http.StatusInternalServerError)
return
}
result := make([]issueResponse, len(issues))
for i := range issues {
result[i] = h.enrichIssue(&issues[i])
}
if result == nil {
result = []issueResponse{}
}
jsonOK(w, result)
}
func (h *IssueHandler) Create(w http.ResponseWriter, r *http.Request) {
repoID, ok := h.repoID(w, r)
if !ok {
return
}
authorID, _ := middleware.UserIDFromContext(r.Context())
var body struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Title == "" {
jsonError(w, "title is required", http.StatusBadRequest)
return
}
// Auto-increment issue number per repo
count, _ := h.db.Where("repo_id = ?", repoID).Count(&models.Issue{})
number := int(count) + 1
issue := &models.Issue{
RepoID: repoID,
AuthorID: authorID,
Number: number,
Title: body.Title,
Body: body.Body,
State: models.IssueStateOpen,
}
if _, err := h.db.Insert(issue); err != nil {
jsonError(w, "could not create issue", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(h.enrichIssue(issue))
}
func (h *IssueHandler) Get(w http.ResponseWriter, r *http.Request) {
issue, ok := h.lookupIssue(w, r)
if !ok {
return
}
jsonOK(w, h.enrichIssue(issue))
}
func (h *IssueHandler) Close(w http.ResponseWriter, r *http.Request) {
issue, ok := h.lookupIssue(w, r)
if !ok {
return
}
issue.State = models.IssueStateClosed
if _, err := h.db.ID(issue.ID).Cols("state").Update(issue); err != nil {
jsonError(w, "could not close issue", http.StatusInternalServerError)
return
}
jsonOK(w, h.enrichIssue(issue))
}
func (h *IssueHandler) Reopen(w http.ResponseWriter, r *http.Request) {
issue, ok := h.lookupIssue(w, r)
if !ok {
return
}
issue.State = models.IssueStateOpen
if _, err := h.db.ID(issue.ID).Cols("state").Update(issue); err != nil {
jsonError(w, "could not reopen issue", http.StatusInternalServerError)
return
}
jsonOK(w, h.enrichIssue(issue))
}
func (h *IssueHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
}
func (h *IssueHandler) lookupIssue(w http.ResponseWriter, r *http.Request) (*models.Issue, bool) {
repoID, ok := h.repoID(w, r)
if !ok {
return nil, false
}
issueNum, err := strconv.Atoi(chi.URLParam(r, "issueNum"))
if err != nil {
jsonError(w, "invalid issue number", http.StatusBadRequest)
return nil, false
}
var issue models.Issue
if found, _ := h.db.Where("repo_id = ? AND number = ?", repoID, issueNum).Get(&issue); !found {
jsonError(w, "issue not found", http.StatusNotFound)
return nil, false
}
return &issue, true
}
+60 -6
View File
@@ -51,13 +51,9 @@ func (h *RepoHandler) List(w http.ResponseWriter, r *http.Request) {
return
}
// Fetch owner username once (all repos belong to the same user in this query)
var owner models.User
h.db.ID(userID).Get(&owner)
result := make([]repoResponse, len(repos))
for i, repo := range repos {
result[i] = repoResponse{Repository: repo, OwnerName: owner.Username}
for i := range repos {
result[i] = h.withOwnerName(&repos[i])
}
jsonOK(w, result)
}
@@ -193,6 +189,64 @@ func (h *RepoHandler) Commits(w http.ResponseWriter, r *http.Request) {
jsonOK(w, commits)
}
func (h *RepoHandler) Update(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
var body struct {
Description *string `json:"description"`
IsPrivate *bool `json:"isPrivate"`
DefaultBranch *string `json:"defaultBranch"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
cols := []string{}
if body.Description != nil {
repo.Description = *body.Description
cols = append(cols, "description")
}
if body.IsPrivate != nil {
repo.IsPrivate = *body.IsPrivate
cols = append(cols, "is_private")
}
if body.DefaultBranch != nil {
repo.DefaultBranch = *body.DefaultBranch
cols = append(cols, "default_branch")
}
if len(cols) > 0 {
if _, err := h.db.ID(repo.ID).Cols(cols...).Update(repo); err != nil {
jsonError(w, "could not update repository", http.StatusInternalServerError)
return
}
}
jsonOK(w, h.withOwnerName(repo))
}
func (h *RepoHandler) Delete(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if callerID != repo.OwnerID {
jsonError(w, "only the owner can delete a repository", http.StatusForbidden)
return
}
if _, err := h.db.ID(repo.ID).Delete(&models.Repository{}); err != nil {
jsonError(w, "could not delete repository", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *RepoHandler) Diff(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
+106
View File
@@ -0,0 +1,106 @@
package handlers
import (
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/ssh"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type SSHKeyHandler struct {
db *xorm.Engine
}
func NewSSHKeyHandler(db *xorm.Engine) *SSHKeyHandler {
return &SSHKeyHandler{db: db}
}
func (h *SSHKeyHandler) List(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.UserIDFromContext(r.Context())
var keys []models.SSHKey
if err := h.db.Where("user_id = ?", userID).Find(&keys); err != nil {
jsonError(w, "could not list SSH keys", http.StatusInternalServerError)
return
}
if keys == nil {
keys = []models.SSHKey{}
}
jsonOK(w, keys)
}
func (h *SSHKeyHandler) Add(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.UserIDFromContext(r.Context())
var body struct {
Title string `json:"title"`
PublicKey string `json:"publicKey"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Title == "" || body.PublicKey == "" {
jsonError(w, "title and publicKey are required", http.StatusBadRequest)
return
}
// Parse and validate the public key
pubKeyBytes := []byte(strings.TrimSpace(body.PublicKey))
pub, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes)
if err != nil {
jsonError(w, "invalid SSH public key format", http.StatusBadRequest)
return
}
// Compute MD5 fingerprint (standard display format)
fingerprint := fingerprintMD5(pub)
key := &models.SSHKey{
UserID: userID,
Title: body.Title,
Fingerprint: fingerprint,
PublicKey: strings.TrimSpace(body.PublicKey),
}
if _, err := h.db.Insert(key); err != nil {
jsonError(w, "key already exists or could not be saved", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(key)
}
func (h *SSHKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.UserIDFromContext(r.Context())
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
if err != nil {
jsonError(w, "invalid key ID", http.StatusBadRequest)
return
}
n, err := h.db.Where("id = ? AND user_id = ?", keyID, userID).Delete(&models.SSHKey{})
if err != nil || n == 0 {
jsonError(w, "key not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
func fingerprintMD5(pub ssh.PublicKey) string {
hash := md5.Sum(pub.Marshal())
parts := make([]string, len(hash))
for i, b := range hash {
parts[i] = fmt.Sprintf("%02x", b)
}
return strings.Join(parts, ":")
}
+43 -5
View File
@@ -35,11 +35,35 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
csrf := middleware.CSRF(!cfg.Debug)
auth := middleware.NewAuth(store)
repoH := handlers.NewRepoHandler(engine, cfg)
userH := handlers.NewUserHandler(engine, store)
prH := handlers.NewPRHandler(engine)
pipeH := handlers.NewPipelineHandler(engine)
wsH := handlers.NewWSHandler()
repoH := handlers.NewRepoHandler(engine, cfg)
userH := handlers.NewUserHandler(engine, store)
prH := handlers.NewPRHandler(engine)
pipeH := handlers.NewPipelineHandler(engine)
wsH := handlers.NewWSHandler()
gitH := handlers.NewGitHTTPHandler(engine, cfg)
issueH := handlers.NewIssueHandler(engine)
sshKeyH := handlers.NewSSHKeyHandler(engine)
// ── Git smart-HTTP transport ───────────────────────────────────────────────
// These routes MUST be registered before the SPA catch-all and outside CSRF.
// Git clients use HTTP Basic Auth, not the cookie/CSRF flow.
r.Route("/{owner}/{repoGit}", func(r chi.Router) {
// Only activate for paths ending in .git
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
rg := chi.URLParam(req, "repoGit")
if len(rg) < 5 || rg[len(rg)-4:] != ".git" {
// Not a git URL — skip to the next router
http.NotFound(w, req)
return
}
next.ServeHTTP(w, req)
})
})
r.Get("/info/refs", gitH.ServeGit)
r.Post("/git-upload-pack", gitH.ServeGit)
r.Post("/git-receive-pack", gitH.ServeGit)
})
r.Route("/api/v1", func(r chi.Router) {
@@ -71,11 +95,18 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
r.Get("/me", userH.Me)
// SSH key management
r.Get("/user/keys", sshKeyH.List)
r.With(csrf).Post("/user/keys", sshKeyH.Add)
r.With(csrf).Delete("/user/keys/{keyID}", sshKeyH.Delete)
r.Route("/repos", func(r chi.Router) {
r.Get("/", repoH.List)
r.With(csrf).Post("/", repoH.Create)
r.Route("/{owner}/{repo}", func(r chi.Router) {
r.Get("/", repoH.Get)
r.With(csrf).Patch("/", repoH.Update)
r.With(csrf).Delete("/", repoH.Delete)
r.Get("/tree", repoH.Tree)
r.Get("/blob", repoH.Blob)
r.Get("/commits", repoH.Commits)
@@ -87,6 +118,13 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
r.With(csrf).Post("/{prID}/merge", prH.Merge)
r.With(csrf).Post("/{prID}/close", prH.Close)
})
r.Route("/issues", func(r chi.Router) {
r.Get("/", issueH.List)
r.With(csrf).Post("/", issueH.Create)
r.Get("/{issueNum}", issueH.Get)
r.With(csrf).Post("/{issueNum}/close", issueH.Close)
r.With(csrf).Post("/{issueNum}/reopen", issueH.Reopen)
})
r.Route("/pipelines", func(r chi.Router) {
r.Get("/", pipeH.List)
r.Get("/{runID}", pipeH.Get)