f211cfc7db
CRUD rules with pattern (exact or glob like release/*), requirePR, blockForcePush, bypass user list Enforcement via pkt-line parsing inside the git HTTP handler — before any data reaches git http-backend, each ref update is extracted and checked against stored rules Direct push to main with requirePR: true → 403 with message; push to unprotected branches still works Inline checkboxes in the UI update rules immediately Branching model — stored config: GET/PUT per repo, defaults to feature/bugfix/release/hotfix prefixes Toggle enabled/disabled, custom prefix per type with live preview No enforcement (naming guide only, as Bitbucket does) Merge strategies — enforced in PR merge endpoint: GET/PUT per repo, defaults all three allowed Merge handler now accepts strategy: "merge"|"squash"|"rebase" in request body, checks against stored policy Disallowed strategy → 409 with clear error; allowed strategy → merges and fires pull_request webhook Must have at least one strategy enabled (validated server-side) Webhooks — full delivery with HMAC: CRUD with title, URL, secret (optional), events (push/pull_request/issue), active toggle Test button sends live HTTP POST to the configured URL and shows status code in UI FireWebhooks() fires asynchronously from PR merge and can be called from any handler X-ForgeBucket-Signature-256: sha256=<hmac> header when secret is set Last delivery status and timestamp stored on webhook record and shown in list
221 lines
7.7 KiB
Go
221 lines
7.7 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"github.com/gorilla/sessions"
|
|
"xorm.io/xorm"
|
|
|
|
"github.com/forgeo/forgebucket/internal/api/handlers"
|
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
|
"github.com/forgeo/forgebucket/internal/config"
|
|
)
|
|
|
|
func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFiles fs.FS) http.Handler {
|
|
r := chi.NewRouter()
|
|
|
|
r.Use(chimiddleware.Logger)
|
|
r.Use(chimiddleware.RealIP)
|
|
r.Use(chimiddleware.Recoverer)
|
|
r.Use(cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"http://localhost:5173", cfg.InstanceURL},
|
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
|
AllowCredentials: true,
|
|
MaxAge: 300,
|
|
}))
|
|
|
|
csrf := middleware.CSRF(!cfg.Debug)
|
|
auth := middleware.NewAuth(store, engine, handlers.LookupAccessToken)
|
|
|
|
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)
|
|
memberH := handlers.NewMemberHandler(engine)
|
|
keyH := handlers.NewDeployKeyHandler(engine)
|
|
tokenH := handlers.NewAccessTokenHandler(engine)
|
|
workflowH := handlers.NewWorkflowHandler(engine)
|
|
webhookH := handlers.NewWebhookHandler(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) {
|
|
|
|
// ── Public ────────────────────────────────────────────────────────────
|
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
|
|
// Generates a CSRF token + cookie. SPA calls this once on load.
|
|
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
|
|
token, err := middleware.NewCSRFToken(w, !cfg.Debug)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"could not generate CSRF token"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"token": token})
|
|
})
|
|
|
|
// ── Auth (CSRF validated, no session required) ─────────────────────────
|
|
r.With(csrf).Post("/auth/register", userH.Register)
|
|
r.With(csrf).Post("/auth/login", userH.Login)
|
|
r.With(csrf).Post("/auth/logout", userH.Logout)
|
|
|
|
// ── Protected (session + CSRF for mutations) ──────────────────────────
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.Require)
|
|
|
|
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.With(csrf).Post("/import", repoH.Import)
|
|
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("/avatar", repoH.GetAvatar)
|
|
r.With(csrf).Post("/avatar", repoH.UploadAvatar)
|
|
r.Get("/blob", repoH.Blob)
|
|
r.With(csrf).Put("/blob", repoH.UpdateBlob)
|
|
r.Get("/commits", repoH.Commits)
|
|
r.Get("/branches", repoH.Branches)
|
|
r.Get("/diff", repoH.Diff)
|
|
r.Route("/pulls", func(r chi.Router) {
|
|
r.Get("/", prH.List)
|
|
r.With(csrf).Post("/", prH.Create)
|
|
r.Get("/{prID}", prH.Get)
|
|
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)
|
|
})
|
|
r.Route("/members", func(r chi.Router) {
|
|
r.Get("/", memberH.List)
|
|
r.With(csrf).Post("/", memberH.Add)
|
|
r.With(csrf).Patch("/{username}", memberH.UpdatePermission)
|
|
r.With(csrf).Delete("/{username}", memberH.Remove)
|
|
})
|
|
r.Route("/keys", func(r chi.Router) {
|
|
r.Get("/", keyH.List)
|
|
r.With(csrf).Post("/", keyH.Create)
|
|
r.With(csrf).Delete("/{keyID}", keyH.Delete)
|
|
})
|
|
r.Route("/tokens", func(r chi.Router) {
|
|
r.Get("/", tokenH.List)
|
|
r.With(csrf).Post("/", tokenH.Create)
|
|
r.With(csrf).Delete("/{tokenID}", tokenH.Delete)
|
|
})
|
|
r.Route("/branch-protections", func(r chi.Router) {
|
|
r.Get("/", workflowH.ListBranchProtections)
|
|
r.With(csrf).Post("/", workflowH.CreateBranchProtection)
|
|
r.With(csrf).Patch("/{bpID}", workflowH.UpdateBranchProtection)
|
|
r.With(csrf).Delete("/{bpID}", workflowH.DeleteBranchProtection)
|
|
})
|
|
r.Get("/branching-model", workflowH.GetBranchingModel)
|
|
r.With(csrf).Put("/branching-model", workflowH.UpdateBranchingModel)
|
|
r.Get("/merge-strategies", workflowH.GetMergeStrategies)
|
|
r.With(csrf).Put("/merge-strategies", workflowH.UpdateMergeStrategies)
|
|
r.Route("/webhooks", func(r chi.Router) {
|
|
r.Get("/", webhookH.List)
|
|
r.With(csrf).Post("/", webhookH.Create)
|
|
r.With(csrf).Patch("/{whID}", webhookH.Update)
|
|
r.With(csrf).Delete("/{whID}", webhookH.Delete)
|
|
r.With(csrf).Post("/{whID}/test", webhookH.Test)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
r.With(auth.Optional).Get("/ws", wsH.Hub)
|
|
|
|
// In debug mode proxy non-API routes to the Vite dev server so :8080 works too.
|
|
// In production the built React app is embedded and served from staticFiles.
|
|
if cfg.Debug {
|
|
r.Handle("/*", viteProxy("http://localhost:5173"))
|
|
} else {
|
|
r.Handle("/*", spaHandler(staticFiles))
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
func viteProxy(target string) http.Handler {
|
|
proxy := &httputil.ReverseProxy{
|
|
Rewrite: func(r *httputil.ProxyRequest) {
|
|
r.SetURL(mustParseURL(target))
|
|
r.Out.Host = r.In.Host
|
|
},
|
|
}
|
|
return proxy
|
|
}
|
|
|
|
func mustParseURL(raw string) *url.URL {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func spaHandler(staticFiles fs.FS) http.Handler {
|
|
fileServer := http.FileServer(http.FS(staticFiles))
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, err := staticFiles.Open(r.URL.Path)
|
|
if err != nil {
|
|
r.URL.Path = "/"
|
|
}
|
|
fileServer.ServeHTTP(w, r)
|
|
})
|
|
}
|