b624337b4a
Repositories tab — lists all public repos as cards with owner/name link, description, default branch chip, last-updated time; sort by recently updated / newest / name A–Z; prev/next pagination Users tab — grid of user cards with avatar/initials, username, join date; pagination Skeleton loaders while fetching, opacity fade during page transitions All state (tab, sort, query) reflected in the URL so links are shareable
238 lines
8.5 KiB
Go
238 lines
8.5 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)
|
|
prSettingsH := handlers.NewPRSettingsHandler(engine)
|
|
lfsH := handlers.NewLFSHandler(engine)
|
|
exploreH := handlers.NewExploreHandler(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("/explore/repos", exploreH.Repos)
|
|
r.Get("/explore/users", exploreH.Users)
|
|
|
|
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.Route("/default-reviewers", func(r chi.Router) {
|
|
r.Get("/", prSettingsH.ListDefaultReviewers)
|
|
r.With(csrf).Post("/", prSettingsH.AddDefaultReviewer)
|
|
r.With(csrf).Delete("/{username}", prSettingsH.RemoveDefaultReviewer)
|
|
})
|
|
r.Get("/default-description", prSettingsH.GetDefaultDescription)
|
|
r.With(csrf).Put("/default-description", prSettingsH.UpdateDefaultDescription)
|
|
r.Get("/excluded-files", prSettingsH.GetExcludedFiles)
|
|
r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles)
|
|
r.Get("/lfs-settings", lfsH.Get)
|
|
r.With(csrf).Put("/lfs-settings", lfsH.Update)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
}
|