mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
addRoute split the PATTERN with splitPath, which cuts on every "/" and knows
nothing about brace groups. routers/web/githttp.go registers
m.Methods("GET,OPTIONS", "/objects/info/{file:[^/]*}", repo.GetTextFile(""))
so the constraint was cut in half into "{file:[^" and "]*}". compileSegment
finds no parameter in that fragment and panics by design — but this is
registration, not request handling, so the panic happens before the listener
opens. The process dies at startup and the whole forge is down; v1.26.28 could
never serve a byte, which read as a data problem because a corrupt queue store
was failing slightly earlier in the same boot and masking it.
splitPattern splits only at depth 0, tracking brace depth rather than a boolean
so a nested constraint like {n:[0-9]{2}} still works. splitPath is left exactly
as it was and still backs request routing: in a request path "{" is an ordinary
character and "/" always separates, so making it brace-aware would be a
different, real bug.
Tests cover the live route end to end (registers, matches /objects/info/packs,
and still refuses to span a separator), the nested-brace case, and an explicit
assertion that splitPath did NOT inherit the new behaviour.
Co-authored-by: Hanzo Dev <[email protected]>
357 lines
10 KiB
Go
357 lines
10 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Mux routes requests to handlers. It replaces the chi router.
|
|
//
|
|
// The tree is over path SEGMENTS, and a node tries its children in order of how
|
|
// specific they are: an exact segment, then patterned segments in the order
|
|
// they were registered, then a trailing wildcard. That ordering is the whole
|
|
// contract — "/user/settings" must win over "/user/{name}" no matter which was
|
|
// registered first, or a user called "settings" takes over the settings page.
|
|
//
|
|
// Matching backtracks. A segment can match here and fail deeper, and the next
|
|
// candidate at this level still has to be tried: "/{username}/{reponame}" and
|
|
// "/{username}/settings" share a first segment, so failing the second segment
|
|
// under one is not failure of the route.
|
|
//
|
|
// Four segment shapes appear in this codebase's 1333 routes, and all four are
|
|
// supported deliberately rather than by accident:
|
|
//
|
|
// users static
|
|
// {id} whole-segment parameter
|
|
// {sha:[a-f0-9]+} parameter with a regexp constraint (43 of them)
|
|
// PACKAGES{format} parameter that is only PART of a segment (CRAN indexes)
|
|
// * trailing wildcard, captured as the "*" parameter
|
|
type Mux struct {
|
|
root *muxNode
|
|
middleware []func(http.Handler) http.Handler
|
|
notFound http.Handler
|
|
}
|
|
|
|
type muxNode struct {
|
|
static map[string]*muxNode
|
|
|
|
// patterned children, in registration order. Order is preserved rather than
|
|
// sorted because two patterns can both match and the earlier registration is
|
|
// the one the author meant to win.
|
|
patterned []*muxPatternChild
|
|
|
|
wildcard *muxNode // trailing "*"
|
|
|
|
// handlers by method. "" is the any-method handler, consulted only after an
|
|
// exact method miss so that a specific registration always beats a catch-all.
|
|
handlers map[string]http.Handler
|
|
|
|
// mounted is a subtree handler: it consumes the remainder of the path.
|
|
mounted http.Handler
|
|
|
|
pattern string // the registered pattern reaching this node, for RoutePattern
|
|
}
|
|
|
|
type muxPatternChild struct {
|
|
node *muxNode
|
|
|
|
// name is the parameter this segment captures.
|
|
name string
|
|
|
|
// re is nil for a plain "{name}" segment, which matches a whole segment and
|
|
// needs no regexp — the common case by far (577 of them), so it does not pay
|
|
// for one.
|
|
re *regexp.Regexp
|
|
}
|
|
|
|
// NewMux returns an empty Mux.
|
|
func NewMux() *Mux {
|
|
return &Mux{root: newMuxNode()}
|
|
}
|
|
|
|
func newMuxNode() *muxNode {
|
|
return &muxNode{static: map[string]*muxNode{}, handlers: map[string]http.Handler{}}
|
|
}
|
|
|
|
// Use adds a middleware run before routing, on every request including misses.
|
|
func (m *Mux) Use(mw func(http.Handler) http.Handler) {
|
|
m.middleware = append(m.middleware, mw)
|
|
}
|
|
|
|
// NotFound sets the handler for requests that match no route.
|
|
func (m *Mux) NotFound(h http.Handler) { m.notFound = h }
|
|
|
|
// NotFoundHandler returns the configured miss handler, or the standard one.
|
|
func (m *Mux) NotFoundHandler() http.Handler {
|
|
if m.notFound != nil {
|
|
return m.notFound
|
|
}
|
|
return http.HandlerFunc(http.NotFound)
|
|
}
|
|
|
|
// Method registers h for one method. An empty method matches any.
|
|
func (m *Mux) Method(method, pattern string, h http.Handler) {
|
|
n := m.addRoute(pattern)
|
|
n.handlers[strings.ToUpper(method)] = h
|
|
}
|
|
|
|
// Mount attaches h to everything under pattern. The subtree handler sees the
|
|
// full path: this forge's mounted routers match on it themselves.
|
|
func (m *Mux) Mount(pattern string, h http.Handler) {
|
|
n := m.addRoute(strings.TrimSuffix(pattern, "/"))
|
|
n.mounted = h
|
|
}
|
|
|
|
// addRoute walks the tree to the node for pattern, creating it as needed.
|
|
func (m *Mux) addRoute(pattern string) *muxNode {
|
|
cur := m.root
|
|
for _, seg := range splitPattern(pattern) {
|
|
switch {
|
|
case seg == "*":
|
|
if cur.wildcard == nil {
|
|
cur.wildcard = newMuxNode()
|
|
}
|
|
cur = cur.wildcard
|
|
case strings.ContainsAny(seg, "{}"):
|
|
cur = cur.childForPattern(seg)
|
|
default:
|
|
next := cur.static[seg]
|
|
if next == nil {
|
|
next = newMuxNode()
|
|
cur.static[seg] = next
|
|
}
|
|
cur = next
|
|
}
|
|
}
|
|
cur.pattern = pattern
|
|
return cur
|
|
}
|
|
|
|
// childForPattern finds or creates the patterned child for one segment.
|
|
func (n *muxNode) childForPattern(seg string) *muxNode {
|
|
name, re := compileSegment(seg)
|
|
for _, c := range n.patterned {
|
|
sameRe := (c.re == nil) == (re == nil) && (re == nil || c.re.String() == re.String())
|
|
if c.name == name && sameRe {
|
|
return c.node
|
|
}
|
|
}
|
|
child := &muxPatternChild{node: newMuxNode(), name: name, re: re}
|
|
n.patterned = append(n.patterned, child)
|
|
return child.node
|
|
}
|
|
|
|
var segmentParamRe = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([^{}]*(?:\{[0-9,]*\}[^{}]*)*))?\}`)
|
|
|
|
// compileSegment turns one pattern segment into a parameter name and, when the
|
|
// segment is more than a bare "{name}", a regexp anchored to the whole segment.
|
|
func compileSegment(seg string) (name string, re *regexp.Regexp) {
|
|
loc := segmentParamRe.FindStringSubmatchIndex(seg)
|
|
if loc == nil {
|
|
// A brace that is not a parameter would silently become a static segment
|
|
// that can never match, so refuse it at registration instead.
|
|
panic("web: not a valid route segment: " + seg)
|
|
}
|
|
sub := segmentParamRe.FindStringSubmatch(seg)
|
|
name, constraint := sub[1], sub[2]
|
|
|
|
whole := loc[0] == 0 && loc[1] == len(seg)
|
|
if whole && constraint == "" {
|
|
return name, nil // plain {name}: no regexp needed
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString("^")
|
|
b.WriteString(regexp.QuoteMeta(seg[:loc[0]]))
|
|
if constraint == "" {
|
|
b.WriteString("(.+)")
|
|
} else {
|
|
b.WriteString("(" + constraint + ")")
|
|
}
|
|
b.WriteString(regexp.QuoteMeta(seg[loc[1]:]))
|
|
b.WriteString("$")
|
|
return name, regexp.MustCompile(b.String())
|
|
}
|
|
|
|
func splitPath(p string) []string {
|
|
p = strings.Trim(p, "/")
|
|
if p == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(p, "/")
|
|
}
|
|
|
|
// splitPattern splits a route PATTERN into segments. It is deliberately not
|
|
// splitPath: a segment may carry a regexp constraint that itself contains "/",
|
|
// and splitting on every "/" cuts the constraint in half. The live example is
|
|
// routers/web/githttp.go
|
|
//
|
|
// m.Methods("GET,OPTIONS", "/objects/info/{file:[^/]*}", ...)
|
|
//
|
|
// which splitPath turns into "{file:[^" + "]*}". compileSegment then finds no
|
|
// parameter in the fragment and panics — at registration, so the process dies
|
|
// before it serves anything and the whole forge is down. splitPath must stay
|
|
// brace-blind for REQUEST paths, where "{" is an ordinary character and "/" is
|
|
// always a separator; only patterns get this treatment.
|
|
//
|
|
// Depth, not a boolean: a constraint may nest one level, as in {n:[0-9]{2}}.
|
|
func splitPattern(p string) []string {
|
|
p = strings.Trim(p, "/")
|
|
if p == "" {
|
|
return nil
|
|
}
|
|
var (
|
|
segs []string
|
|
depth int
|
|
start int
|
|
)
|
|
for i := range len(p) {
|
|
switch p[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
case '/':
|
|
if depth == 0 {
|
|
segs = append(segs, p[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
}
|
|
return append(segs, p[start:])
|
|
}
|
|
|
|
// ServeHTTP routes the request.
|
|
func (m *Mux) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
|
h := http.Handler(http.HandlerFunc(m.route))
|
|
for i := len(m.middleware) - 1; i >= 0; i-- {
|
|
h = m.middleware[i](h)
|
|
}
|
|
h.ServeHTTP(resp, req)
|
|
}
|
|
|
|
func (m *Mux) route(resp http.ResponseWriter, req *http.Request) {
|
|
rc := GetRouteContext(req.Context())
|
|
if rc == nil {
|
|
rc = NewRouteContext()
|
|
req = WithRouteContext(req, rc)
|
|
}
|
|
rc.RouteMethod = req.Method
|
|
|
|
// The path the mux routes on can be overridden — the normalising middleware
|
|
// sets it so that an escaped path routes as its decoded form.
|
|
path := rc.RoutePath
|
|
if path == "" {
|
|
path = req.URL.EscapedPath()
|
|
if req.URL.RawPath != "" {
|
|
path = req.URL.RawPath
|
|
}
|
|
}
|
|
|
|
h, pattern := m.root.match(splitPath(path), req.Method, rc)
|
|
if h == nil {
|
|
m.NotFoundHandler().ServeHTTP(resp, req)
|
|
return
|
|
}
|
|
rc.RoutePatterns = append(rc.RoutePatterns, pattern)
|
|
h.ServeHTTP(resp, req)
|
|
}
|
|
|
|
// match walks segs from n. It records captured parameters into rc as it goes and
|
|
// removes them again when a branch fails, so a failed attempt leaves nothing
|
|
// behind for the branch tried next.
|
|
func (n *muxNode) match(segs []string, method string, rc *RouteContext) (http.Handler, string) {
|
|
if len(segs) == 0 {
|
|
if h := n.handlerFor(method); h != nil {
|
|
return h, n.pattern
|
|
}
|
|
// "/*" matches "/" with an empty remainder. Requiring a segment would
|
|
// make a catch-all decline the one path it most obviously covers.
|
|
if n.wildcard != nil {
|
|
if h := n.wildcard.handlerFor(method); h != nil {
|
|
rc.SetParam("*", "")
|
|
return h, n.wildcard.pattern
|
|
}
|
|
if n.wildcard.mounted != nil {
|
|
rc.SetParam("*", "")
|
|
return n.wildcard.mounted, n.wildcard.pattern
|
|
}
|
|
}
|
|
// A mount registered at exactly this path still serves it.
|
|
if n.mounted != nil {
|
|
return n.mounted, n.pattern
|
|
}
|
|
return nil, ""
|
|
}
|
|
|
|
seg, rest := segs[0], segs[1:]
|
|
|
|
if next, ok := n.static[seg]; ok {
|
|
if h, p := next.match(rest, method, rc); h != nil {
|
|
return h, p
|
|
}
|
|
}
|
|
|
|
for _, c := range n.patterned {
|
|
value := seg
|
|
if c.re != nil {
|
|
sub := c.re.FindStringSubmatch(seg)
|
|
if sub == nil {
|
|
continue
|
|
}
|
|
value = sub[1]
|
|
}
|
|
saved, had := rc.params[c.name]
|
|
rc.SetParam(c.name, value)
|
|
if h, p := c.node.match(rest, method, rc); h != nil {
|
|
return h, p
|
|
}
|
|
if had {
|
|
rc.params[c.name] = saved
|
|
} else {
|
|
rc.deleteParam(c.name)
|
|
}
|
|
}
|
|
|
|
if n.wildcard != nil {
|
|
rc.SetParam("*", strings.Join(segs, "/"))
|
|
if h := n.wildcard.handlerFor(method); h != nil {
|
|
return h, n.wildcard.pattern
|
|
}
|
|
if n.wildcard.mounted != nil {
|
|
return n.wildcard.mounted, n.wildcard.pattern
|
|
}
|
|
rc.deleteParam("*")
|
|
}
|
|
|
|
// A mount consumes whatever is left, so it is tried once the more specific
|
|
// children have all declined.
|
|
if n.mounted != nil {
|
|
rc.SetParam("*", strings.Join(segs, "/"))
|
|
return n.mounted, n.pattern
|
|
}
|
|
return nil, ""
|
|
}
|
|
|
|
func (n *muxNode) handlerFor(method string) http.Handler {
|
|
if h, ok := n.handlers[method]; ok {
|
|
return h
|
|
}
|
|
// HEAD falls back to GET, as net/http does for a body-less response.
|
|
if method == http.MethodHead {
|
|
if h, ok := n.handlers[http.MethodGet]; ok {
|
|
return h
|
|
}
|
|
}
|
|
return n.handlers[""]
|
|
}
|
|
|
|
var _ = fmt.Sprintf // keep fmt for future diagnostics without churn
|