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, ":")
}