From ba43cd193c78eb70c4734d95f5864bfffde3277d Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Sun, 17 Aug 2025 16:10:12 +0800 Subject: [PATCH] Unspaghetti the handler --- forge.scfg | 5 +---- forged/internal/database/database.go | 1 - forged/internal/incoming/web/handler.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- forged/internal/incoming/web/router.go | 348 +++++++++++++++++++++++++++++++++++++++++++++++++++++ forged/internal/incoming/web/server.go | 2 +- forged/internal/incoming/web/stub.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ diff --git a/forge.scfg b/forge.scfg index a8e74eddfb4b38d8a50c58bb76497898e7b2db81..9ee56e4942c7b2fc8ced6ea552655d7876e7d47b 100644 --- a/forge.scfg +++ b/forge.scfg @@ -20,6 +20,7 @@ # repos may take a long time. read_timeout 120 write_timeout 1800 idle_timeout 120 + max_header_bytes 20000 # Are we running behind a reverse proxy? If so, we will trust # X-Forwarded-For headers. @@ -68,10 +69,6 @@ title "Test Forge" } db { - # What type of database are we connecting to? - # Currently only "postgres" is supported. - type postgres - # What is the connection string? conn postgresql:///lindenii-forge?host=/var/run/postgresql } diff --git a/forged/internal/database/database.go b/forged/internal/database/database.go index 3e0fc972e60177fe45f0c524cc3bb9a916253901..92244b743b334a101b8011f546527468bbc8f8c0 100644 --- a/forged/internal/database/database.go +++ b/forged/internal/database/database.go @@ -29,6 +29,5 @@ return Database{db}, err } type Config struct { - Type string `scfg:"type"` Conn string `scfg:"conn"` } diff --git a/forged/internal/incoming/web/handler.go b/forged/internal/incoming/web/handler.go index e638bf781dca7f043a04c2c767d408a8bd5bf028..9018547e81b69ab6489b18b2a1c46a9491c1c1f1 100644 --- a/forged/internal/incoming/web/handler.go +++ b/forged/internal/incoming/web/handler.go @@ -1,8 +1,55 @@ +// internal/incoming/web/handler.go package web -import "net/http" +import ( + "net/http" + "path/filepath" +) + +type handler struct { + r *Router +} + +func NewHandler(cfg Config) http.Handler { + h := &handler{r: NewRouter().ReverseProxy(cfg.ReverseProxy)} + + // Static files + staticDir := filepath.Join(cfg.Root, "static") + staticFS := http.FileServer(http.Dir(staticDir)) + h.r.ANYHTTP("-/static/*rest", + http.StripPrefix("/-/static/", staticFS), + WithDirIfEmpty("rest"), + ) + + // Index + h.r.GET("/", h.index) + + // Top-level utilities + h.r.ANY("-/login", h.notImplemented) + h.r.ANY("-/users", h.notImplemented) + + // Group index + h.r.GET("@group/", h.groupIndex) + + // Repo index + h.r.GET("@group/-/repos/:repo/", h.repoIndex) + + // Repo + h.r.ANY("@group/-/repos/:repo/info", h.notImplemented) + h.r.ANY("@group/-/repos/:repo/git-upload-pack", h.notImplemented) + + // Repo features + h.r.GET("@group/-/repos/:repo/branches/", h.notImplemented) + h.r.GET("@group/-/repos/:repo/log/", h.notImplemented) + h.r.GET("@group/-/repos/:repo/commit/:commit", h.notImplemented) + h.r.GET("@group/-/repos/:repo/tree/*rest", h.repoTree, WithDirIfEmpty("rest")) + h.r.GET("@group/-/repos/:repo/raw/*rest", h.repoRaw, WithDirIfEmpty("rest")) + h.r.GET("@group/-/repos/:repo/contrib/", h.notImplemented) + h.r.GET("@group/-/repos/:repo/contrib/:mr", h.notImplemented) -type handler struct{} + return h +} func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.r.ServeHTTP(w, r) } diff --git a/forged/internal/incoming/web/router.go b/forged/internal/incoming/web/router.go new file mode 100644 index 0000000000000000000000000000000000000000..35e261e098a75a2ec93e4263ffbc840a966cfb82 --- /dev/null +++ b/forged/internal/incoming/web/router.go @@ -0,0 +1,348 @@ +package web + +import ( + "net/http" + "net/url" + "strconv" + "strings" +) + +type Params map[string]any +type HandlerFunc func(http.ResponseWriter, *http.Request, Params) + +type UserResolver func(*http.Request) (id int, username string, err error) + +type ErrorRenderers struct { + BadRequest func(http.ResponseWriter, Params, string) + BadRequestColon func(http.ResponseWriter, Params) + NotFound func(http.ResponseWriter, Params) + ServerError func(http.ResponseWriter, Params, string) +} + +type dirPolicy int + +const ( + dirIgnore dirPolicy = iota + dirRequire + dirForbid + dirRequireIfEmpty +) + +type patKind uint8 + +const ( + lit patKind = iota + param + splat + group // @group, must be first token +) + +type patSeg struct { + kind patKind + lit string + key string +} + +type route struct { + method string + rawPattern string + wantDir dirPolicy + ifEmptyKey string + segs []patSeg + h HandlerFunc + hh http.Handler + priority int +} + +type Router struct { + routes []route + errors ErrorRenderers + user UserResolver + global any + reverseProxy bool +} + +func NewRouter() *Router { return &Router{} } + +func (r *Router) Global(v any) *Router { r.global = v; return r } +func (r *Router) ReverseProxy(enabled bool) *Router { r.reverseProxy = enabled; return r } +func (r *Router) Errors(e ErrorRenderers) *Router { r.errors = e; return r } +func (r *Router) UserResolver(u UserResolver) *Router { r.user = u; return r } + +type RouteOption func(*route) + +func WithDir() RouteOption { return func(rt *route) { rt.wantDir = dirRequire } } +func WithoutDir() RouteOption { return func(rt *route) { rt.wantDir = dirForbid } } +func WithDirIfEmpty(param string) RouteOption { + return func(rt *route) { rt.wantDir = dirRequireIfEmpty; rt.ifEmptyKey = param } +} + +func (r *Router) GET(pattern string, f HandlerFunc, opts ...RouteOption) { + r.handle("GET", pattern, f, nil, opts...) +} +func (r *Router) POST(pattern string, f HandlerFunc, opts ...RouteOption) { + r.handle("POST", pattern, f, nil, opts...) +} +func (r *Router) ANY(pattern string, f HandlerFunc, opts ...RouteOption) { + r.handle("", pattern, f, nil, opts...) +} +func (r *Router) ANYHTTP(pattern string, hh http.Handler, opts ...RouteOption) { + r.handle("", pattern, nil, hh, opts...) +} + +func (r *Router) handle(method, pattern string, f HandlerFunc, hh http.Handler, opts ...RouteOption) { + want := dirIgnore + if strings.HasSuffix(pattern, "/") { + want = dirRequire + pattern = strings.TrimSuffix(pattern, "/") + } else if pattern != "" { + want = dirForbid + } + segs, prio := compilePattern(pattern) + rt := route{ + method: method, + rawPattern: pattern, + wantDir: want, + segs: segs, + h: f, + hh: hh, + priority: prio, + } + for _, o := range opts { + o(&rt) + } + r.routes = append(r.routes, rt) +} + +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + segments, dirMode, err := splitAndUnescapePath(req.URL.EscapedPath()) + if err != nil { + r.err400(w, Params{"global": r.global}, "Error parsing request URI: "+err.Error()) + return + } + for _, s := range segments { + if strings.Contains(s, ":") { + r.err400Colon(w, Params{"global": r.global}) + return + } + } + + p := Params{ + "url_segments": segments, + "dir_mode": dirMode, + "global": r.global, + } + + if r.user != nil { + uid, uname, uerr := r.user(req) + if uerr != nil { + r.err500(w, p, "Error getting user info from request: "+uerr.Error()) + // TODO: Revamp error handling again... + return + } + p["user_id"] = uid + p["username"] = uname + if uid == 0 { + p["user_id_string"] = "" + } else { + p["user_id_string"] = strconv.Itoa(uid) + } + } + + for _, rt := range r.routes { + if rt.method != "" && rt.method != req.Method { + continue + } + ok, vars, sepIdx := match(rt.segs, segments) + if !ok { + continue + } + switch rt.wantDir { + case dirRequire: + if !dirMode && redirectAddSlash(w, req) { + return + } + case dirForbid: + if dirMode && redirectDropSlash(w, req) { + return + } + case dirRequireIfEmpty: + if v, _ := vars[rt.ifEmptyKey]; v == "" && !dirMode && redirectAddSlash(w, req) { + return + } + } + for k, v := range vars { + p[k] = v + } + // convert "group" (joined) into []string group_path + if g, ok := p["group"].(string); ok { + if g == "" { + p["group_path"] = []string{} + } else { + p["group_path"] = strings.Split(g, "/") + } + } + p["separator_index"] = sepIdx + + if rt.h != nil { + rt.h(w, req, p) + } else if rt.hh != nil { + rt.hh.ServeHTTP(w, req) + } else { + r.err500(w, p, "route has no handler") + } + return + } + r.err404(w, p) +} + +func compilePattern(pat string) ([]patSeg, int) { + if pat == "" || pat == "/" { + return nil, 1000 + } + pat = strings.Trim(pat, "/") + raw := strings.Split(pat, "/") + + segs := make([]patSeg, 0, len(raw)) + prio := 0 + for i, t := range raw { + switch { + case t == "@group": + if i != 0 { + segs = append(segs, patSeg{kind: lit, lit: t}) + prio += 10 + continue + } + segs = append(segs, patSeg{kind: group}) + prio += 1 + case strings.HasPrefix(t, ":"): + segs = append(segs, patSeg{kind: param, key: t[1:]}) + prio += 5 + case strings.HasPrefix(t, "*"): + segs = append(segs, patSeg{kind: splat, key: t[1:]}) + default: + segs = append(segs, patSeg{kind: lit, lit: t}) + prio += 10 + } + } + return segs, prio +} + +func match(pat []patSeg, segs []string) (bool, map[string]string, int) { + vars := make(map[string]string) + i := 0 + sepIdx := -1 + for pi := 0; pi < len(pat); pi++ { + ps := pat[pi] + switch ps.kind { + case group: + start := i + for i < len(segs) && segs[i] != "-" { + i++ + } + if start < i { + vars["group"] = strings.Join(segs[start:i], "/") + } else { + vars["group"] = "" + } + if i < len(segs) && segs[i] == "-" { + sepIdx = i + } + case lit: + if i >= len(segs) || segs[i] != ps.lit { + return false, nil, -1 + } + i++ + case param: + if i >= len(segs) { + return false, nil, -1 + } + vars[ps.key] = segs[i] + i++ + case splat: + if i < len(segs) { + vars[ps.key] = strings.Join(segs[i:], "/") + i = len(segs) + } else { + vars[ps.key] = "" + } + pi = len(pat) + } + } + if i != len(segs) { + return false, nil, -1 + } + return true, vars, sepIdx +} + +func splitAndUnescapePath(escaped string) ([]string, bool, error) { + if escaped == "" { + return nil, false, nil + } + dir := strings.HasSuffix(escaped, "/") + path := strings.Trim(escaped, "/") + if path == "" { + return []string{}, dir, nil + } + raw := strings.Split(path, "/") + out := make([]string, 0, len(raw)) + for _, seg := range raw { + u, err := url.PathUnescape(seg) + if err != nil { + return nil, dir, err + } + if u != "" { + out = append(out, u) + } + } + return out, dir, nil +} + +func redirectAddSlash(w http.ResponseWriter, r *http.Request) bool { + u := *r.URL + u.Path = u.EscapedPath() + "/" + http.Redirect(w, r, u.String(), http.StatusMovedPermanently) + return true +} + +func redirectDropSlash(w http.ResponseWriter, r *http.Request) bool { + u := *r.URL + u.Path = strings.TrimRight(u.EscapedPath(), "/") + if u.Path == "" { + u.Path = "/" + } + http.Redirect(w, r, u.String(), http.StatusMovedPermanently) + return true +} + +func (r *Router) err400(w http.ResponseWriter, p Params, msg string) { + if r.errors.BadRequest != nil { + r.errors.BadRequest(w, p, msg) + return + } + http.Error(w, msg, http.StatusBadRequest) +} + +func (r *Router) err400Colon(w http.ResponseWriter, p Params) { + if r.errors.BadRequestColon != nil { + r.errors.BadRequestColon(w, p) + return + } + http.Error(w, "bad request", http.StatusBadRequest) +} + +func (r *Router) err404(w http.ResponseWriter, p Params) { + if r.errors.NotFound != nil { + r.errors.NotFound(w, p) + return + } + http.NotFound(w, nil) +} + +func (r *Router) err500(w http.ResponseWriter, p Params, msg string) { + if r.errors.ServerError != nil { + r.errors.ServerError(w, p, msg) + return + } + http.Error(w, msg, http.StatusInternalServerError) +} diff --git a/forged/internal/incoming/web/server.go b/forged/internal/incoming/web/server.go index e1afcc2043b71632a74061b0657f59ab41185d04..a965251560ef59db4b3ab3e2261d25019bee4539 100644 --- a/forged/internal/incoming/web/server.go +++ b/forged/internal/incoming/web/server.go @@ -34,7 +34,7 @@ } func New(config Config) (server *Server) { httpServer := &http.Server{ - Handler: &handler{}, + Handler: NewHandler(config), ReadTimeout: time.Duration(config.ReadTimeout) * time.Second, WriteTimeout: time.Duration(config.WriteTimeout) * time.Second, IdleTimeout: time.Duration(config.IdleTimeout) * time.Second, diff --git a/forged/internal/incoming/web/stub.go b/forged/internal/incoming/web/stub.go new file mode 100644 index 0000000000000000000000000000000000000000..72077563cf3378d3f265d4af01e6e81a894ee781 --- /dev/null +++ b/forged/internal/incoming/web/stub.go @@ -0,0 +1,44 @@ +package web + +import ( + "fmt" + "net/http" + "strings" +) + +func (h *handler) index(w http.ResponseWriter, r *http.Request, p Params) { + _, _ = w.Write([]byte("index: replace with template render")) +} + +func (h *handler) groupIndex(w http.ResponseWriter, r *http.Request, p Params) { + g := p["group_path"].([]string) // captured by @group + _, _ = w.Write([]byte("group index for: /" + strings.Join(g, "/") + "/")) +} + +func (h *handler) repoIndex(w http.ResponseWriter, r *http.Request, p Params) { + repo := p["repo"].(string) + g := p["group_path"].([]string) + _, _ = w.Write([]byte(fmt.Sprintf("repo index: group=%q repo=%q", "/"+strings.Join(g, "/")+"/", repo))) +} + +func (h *handler) repoTree(w http.ResponseWriter, r *http.Request, p Params) { + repo := p["repo"].(string) + rest := p["rest"].(string) // may be "" + if p["dir_mode"].(bool) && rest != "" && !strings.HasSuffix(rest, "/") { + rest += "/" + } + _, _ = w.Write([]byte(fmt.Sprintf("tree: repo=%q path=%q", repo, rest))) +} + +func (h *handler) repoRaw(w http.ResponseWriter, r *http.Request, p Params) { + repo := p["repo"].(string) + rest := p["rest"].(string) + if p["dir_mode"].(bool) && rest != "" && !strings.HasSuffix(rest, "/") { + rest += "/" + } + _, _ = w.Write([]byte(fmt.Sprintf("raw: repo=%q path=%q", repo, rest))) +} + +func (h *handler) notImplemented(w http.ResponseWriter, _ *http.Request, _ Params) { + http.Error(w, "not implemented", http.StatusNotImplemented) +} -- 2.48.1