Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
Refactor handlers structure and add BaseData
// internal/incoming/web/handler.go
package web import ( "net/http" "path/filepath"
handlers "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/handlers" repoHandlers "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/handlers/repo"
)
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"),
)
// Feature handler instances indexHTTP := handlers.NewIndexHTTP() repoHTTP := repoHandlers.NewHTTP()
// Index
h.r.GET("/", h.index)
h.r.GET("/", indexHTTP.Index)
// Top-level utilities
h.r.ANY("-/login", h.notImplemented)
h.r.ANY("-/users", h.notImplemented)
// Group index
// Group index (kept local for now; migrate later)
h.r.GET("@group/", h.groupIndex)
// Repo index
h.r.GET("@group/-/repos/:repo/", h.repoIndex)
// Repo index (handled by repoHTTP)
h.r.GET("@group/-/repos/:repo/", repoHTTP.Index)
// Repo
// Repo (kept local for now)
h.r.ANY("@group/-/repos/:repo/info", h.notImplemented)
h.r.ANY("@group/-/repos/:repo/git-upload-pack", h.notImplemented)
// Repo features
// Repo features (kept local for now)
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)
return h
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.r.ServeHTTP(w, r)
}
package handlers
import (
"net/http"
wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
)
type IndexHTTP struct{}
func NewIndexHTTP() *IndexHTTP { return &IndexHTTP{} }
func (h *IndexHTTP) Index(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
_, _ = w.Write([]byte("index: replace with template render"))
}
package repo
import (
"fmt"
"net/http"
"strings"
wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
)
type HTTP struct{}
func NewHTTP() *HTTP { return &HTTP{} }
func (h *HTTP) Index(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
base := wtypes.Base(r)
repo := v["repo"]
_, _ = w.Write([]byte(fmt.Sprintf("repo index: group=%q repo=%q",
"/"+strings.Join(base.GroupPath, "/")+"/", repo)))
}
package web import ( "net/http" "net/url" "sort"
"strconv"
"strings"
)
type ( Params map[string]any HandlerFunc func(http.ResponseWriter, *http.Request, Params)
wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
)
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)
BadRequest func(http.ResponseWriter, *wtypes.BaseData, string) BadRequestColon func(http.ResponseWriter, *wtypes.BaseData) NotFound func(http.ResponseWriter, *wtypes.BaseData) ServerError func(http.ResponseWriter, *wtypes.BaseData, 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
h wtypes.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) {
func (r *Router) GET(pattern string, f wtypes.HandlerFunc, opts ...RouteOption) {
r.handle("GET", pattern, f, nil, opts...)
}
func (r *Router) POST(pattern string, f HandlerFunc, opts ...RouteOption) {
func (r *Router) POST(pattern string, f wtypes.HandlerFunc, opts ...RouteOption) {
r.handle("POST", pattern, f, nil, opts...)
}
func (r *Router) ANY(pattern string, f HandlerFunc, opts ...RouteOption) {
func (r *Router) ANY(pattern string, f wtypes.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) {
func (r *Router) handle(method, pattern string, f wtypes.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)
sort.SliceStable(r.routes, func(i, j int) bool {
return r.routes[i].priority > r.routes[j].priority
})
}
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())
r.err400(w, &wtypes.BaseData{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})
r.err400Colon(w, &wtypes.BaseData{Global: r.global})
return } }
p := Params{
"url_segments": segments,
"dir_mode": dirMode,
"global": r.global,
// Prepare base data; vars are attached per-route below.
bd := &wtypes.BaseData{
Global: r.global,
URLSegments: segments,
DirMode: dirMode,
}
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...
r.err500(w, bd, "Error getting user info from request: "+uerr.Error())
return }
p["user_id"] = uid
p["username"] = uname
if uid == 0 {
p["user_id_string"] = ""
} else {
p["user_id_string"] = strconv.Itoa(uid)
}
bd.UserID = uid bd.Username = uname
} method := req.Method
var pathMatched bool // for 405 detection
for _, rt := range r.routes {
if rt.method != "" &&
!(rt.method == method || (method == http.MethodHead && rt.method == http.MethodGet)) {
continue
}
// TODO: Consider returning 405 on POST/GET mismatches and the like.
ok, vars, sepIdx := match(rt.segs, segments)
if !ok {
continue
}
pathMatched = true
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) {
if v := vars[rt.ifEmptyKey]; v == "" && !dirMode && redirectAddSlash(w, req) {
return } }
for k, v := range vars {
p[k] = v
// Derive group path and separator index on the matched request.
bd.SeparatorIndex = sepIdx
if g := vars["group"]; g == "" {
bd.GroupPath = []string{}
} else {
bd.GroupPath = strings.Split(g, "/")
}
// 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, "/")
}
// Attach BaseData to request context.
req = req.WithContext(wtypes.WithBaseData(req.Context(), bd))
// Enforce method now.
if rt.method != "" &&
!(rt.method == method || (method == http.MethodHead && rt.method == http.MethodGet)) {
// 405 for a path that matched but wrong method
w.Header().Set("Allow", allowForPattern(r.routes, rt.rawPattern))
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
p["separator_index"] = sepIdx
if rt.h != nil {
rt.h(w, req, p)
rt.h(w, req, wtypes.Vars(vars))
} else if rt.hh != nil {
rt.hh.ServeHTTP(w, req)
} else {
r.err500(w, p, "route has no handler")
r.err500(w, bd, "route has no handler")
} return }
r.err404(w, p)
if pathMatched {
// Safety; normally handled above, but keep semantics.
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
r.err404(w, bd)
}
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.StatusTemporaryRedirect)
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.StatusTemporaryRedirect)
return true
}
func (r *Router) err400(w http.ResponseWriter, p Params, msg string) {
func allowForPattern(routes []route, raw string) string {
seen := map[string]struct{}{}
out := make([]string, 0, 4)
for _, rt := range routes {
if rt.rawPattern != raw || rt.method == "" {
continue
}
if _, ok := seen[rt.method]; ok {
continue
}
seen[rt.method] = struct{}{}
out = append(out, rt.method)
}
sort.Strings(out)
return strings.Join(out, ", ")
}
func (r *Router) err400(w http.ResponseWriter, b *wtypes.BaseData, msg string) {
if r.errors.BadRequest != nil {
r.errors.BadRequest(w, p, msg)
r.errors.BadRequest(w, b, msg)
return } http.Error(w, msg, http.StatusBadRequest) }
func (r *Router) err400Colon(w http.ResponseWriter, p Params) {
func (r *Router) err400Colon(w http.ResponseWriter, b *wtypes.BaseData) {
if r.errors.BadRequestColon != nil {
r.errors.BadRequestColon(w, p)
r.errors.BadRequestColon(w, b)
return } http.Error(w, "bad request", http.StatusBadRequest) }
func (r *Router) err404(w http.ResponseWriter, p Params) {
func (r *Router) err404(w http.ResponseWriter, b *wtypes.BaseData) {
if r.errors.NotFound != nil {
r.errors.NotFound(w, p)
r.errors.NotFound(w, b)
return } http.NotFound(w, nil) }
func (r *Router) err500(w http.ResponseWriter, p Params, msg string) {
func (r *Router) err500(w http.ResponseWriter, b *wtypes.BaseData, msg string) {
if r.errors.ServerError != nil {
r.errors.ServerError(w, p, msg)
r.errors.ServerError(w, b, msg)
return } http.Error(w, msg, http.StatusInternalServerError) }
package web import ( "fmt" "net/http" "strings"
wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
)
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, _ wtypes.Vars) {
base := wtypes.Base(r)
_, _ = w.Write([]byte("group index for: /" + strings.Join(base.GroupPath, "/") + "/"))
}
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, "/") {
func (h *handler) repoTree(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
base := wtypes.Base(r)
repo := v["repo"]
rest := v["rest"] // may be ""
if base.DirMode && 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, "/") {
func (h *handler) repoRaw(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
base := wtypes.Base(r)
repo := v["repo"]
rest := v["rest"]
if base.DirMode && 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) {
func (h *handler) notImplemented(w http.ResponseWriter, _ *http.Request, _ wtypes.Vars) {
http.Error(w, "not implemented", http.StatusNotImplemented) }
package types
import (
"context"
"net/http"
)
// BaseData is per-request context computed by the router and read by handlers.
// Keep it small and stable; page-specific data should live in view models.
type BaseData struct {
Global any
UserID int
Username string
URLSegments []string
DirMode bool
GroupPath []string
SeparatorIndex int
}
type ctxKey struct{}
// WithBaseData attaches BaseData to a context.
func WithBaseData(ctx context.Context, b *BaseData) context.Context {
return context.WithValue(ctx, ctxKey{}, b)
}
// Base retrieves BaseData from the request (never nil).
func Base(r *http.Request) *BaseData {
if v, ok := r.Context().Value(ctxKey{}).(*BaseData); ok && v != nil {
return v
}
return &BaseData{}
}
// Vars are route variables captured by the router (e.g., :repo, *rest).
type Vars map[string]string
// HandlerFunc is the router↔handler function contract.
type HandlerFunc func(http.ResponseWriter, *http.Request, Vars)