Lindenii Project Forge
Unspaghetti the handler
web { # What network transport should we listen on? # Examples: tcp tcp4 tcp6 unix net tcp # What address to listen on? # Examples for net tcp*: 127.0.0.1:8080 :80 # Example for unix: /var/run/lindenii/forge/http.sock addr :8080 # How many seconds should cookies be remembered before they are purged? cookie_expiry 604800 # What is the canonical URL of the web root? root https://forge.example.org # General HTTP server context timeout settings. It's recommended to # set them slightly higher than usual as Git operations over large # 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. reverse_proxy true } irc { tls true net tcp addr irc.runxiyu.org:6697 sendq 6000 nick forge-test user forge gecos "Lindenii Forge Test" } git { # Where should newly-created Git repositories be stored? repo_dir /var/lib/lindenii/forge/repos # Where should git2d listen on? socket /var/run/lindenii/forge/git2d.sock # Where should we put git2d? daemon_path /usr/libexec/lindenii/forge/git2d } ssh { # What network transport should we listen on? # This should be "tcp" in almost all cases. net tcp # What address to listen on? addr :22 # What is the path to the SSH host key? Generate it with ssh-keygen. # The key must have an empty password. key /etc/lindenii/ssh_host_ed25519_key # What is the canonical SSH URL? root ssh://forge.example.org } general { 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 } hooks { # On which UNIX domain socket should we listen for hook callbacks on? socket /var/run/lindenii/forge/hooks.sock # Where should hook executables be put? execs /usr/libexec/lindenii/forge/hooks } lmtp { # On which UNIX domain socket should we listen for LMTP on? socket /var/run/lindenii/forge/lmtp.sock # What's the maximum acceptable message size? max_size 1000000 # What is our domainpart? domain forge.example.org # General timeouts read_timeout 300 write_timeout 300 } pprof { # What network to listen on for pprof? net tcp # What address to listen on? addr localhost:28471 }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> // Package database provides stubs and wrappers for databases. package database import ( "context" "fmt" "github.com/jackc/pgx/v5/pgxpool" ) // Database is a wrapper around pgxpool.Pool to provide a common interface for // other packages in the forge. type Database struct { *pgxpool.Pool } // Open opens a new database connection pool using the provided connection // string. It returns a Database instance and an error if any occurs. // It is run indefinitely in the background. func Open(ctx context.Context, config Config) (Database, error) { db, err := pgxpool.New(ctx, config.Conn) if err != nil { err = fmt.Errorf("create pgxpool: %w", err) } return Database{db}, err } type Config struct {
Type string `scfg:"type"`
Conn string `scfg:"conn"` }
// 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)
}
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) }
package web import ( "context" "errors" "fmt" "net" "net/http" "time" "go.lindenii.runxiyu.org/forge/forged/internal/common/misc" ) type Server struct { net string addr string root string httpServer *http.Server shutdownTimeout uint32 } type Config struct { Net string `scfg:"net"` Addr string `scfg:"addr"` Root string `scfg:"root"` CookieExpiry int `scfg:"cookie_expiry"` ReadTimeout uint32 `scfg:"read_timeout"` WriteTimeout uint32 `scfg:"write_timeout"` IdleTimeout uint32 `scfg:"idle_timeout"` MaxHeaderBytes int `scfg:"max_header_bytes"` ReverseProxy bool `scfg:"reverse_proxy"` ShutdownTimeout uint32 `scfg:"shutdown_timeout"` } 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, MaxHeaderBytes: config.MaxHeaderBytes, } //exhaustruct:ignore return &Server{ net: config.Net, addr: config.Addr, root: config.Root, shutdownTimeout: config.ShutdownTimeout, httpServer: httpServer, } } func (server *Server) Run(ctx context.Context) (err error) { server.httpServer.BaseContext = func(_ net.Listener) context.Context { return ctx } listener, err := misc.Listen(ctx, server.net, server.addr) if err != nil { return fmt.Errorf("listen for web: %w", err) } defer func() { _ = listener.Close() }() stop := context.AfterFunc(ctx, func() { shCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Duration(server.shutdownTimeout)*time.Second) defer cancel() _ = server.httpServer.Shutdown(shCtx) _ = listener.Close() }) defer stop() err = server.httpServer.Serve(listener) if err != nil { if errors.Is(err, http.ErrServerClosed) || ctx.Err() != nil { return nil } return fmt.Errorf("serve web: %w", err) } panic("unreachable") }
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) }