Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
ba43cd193c78eb70c4734d95f5864bfffde3277d
Author
Runxi Yu <me@runxiyu.org>
Author date
Sun, 17 Aug 2025 16:10:12 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Sun, 17 Aug 2025 16:10:12 +0800
Actions
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)
}