Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
f7dde29539536a67687cbeff9662a9617e077150
Author
Runxi Yu <me@runxiyu.org>
Author date
Mon, 18 Aug 2025 03:12:41 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Mon, 18 Aug 2025 03:12:41 +0800
Actions
Make the index page work
package global

type GlobalData struct {
	ForgeTitle     string
	ForgeVersion   string
	SSHPubkey      string
	SSHFingerprint string
}
package hooks

import (
	"context"
	"errors"
	"fmt"
	"net"
	"time"

	"github.com/gliderlabs/ssh"
	"go.lindenii.runxiyu.org/forge/forged/internal/common/cmap"
	"go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
	"go.lindenii.runxiyu.org/forge/forged/internal/global"
)

type Server struct {
	hookMap         cmap.Map[string, hookInfo]
	socketPath      string
	executablesPath string
	globalData      *global.GlobalData
}
type hookInfo struct {
	session      ssh.Session
	pubkey       string
	directAccess bool
	repoPath     string
	userID       int
	userType     string
	repoID       int
	groupPath    []string
	repoName     string
	contribReq   string
}

func New(config Config) (server *Server) {
func New(config Config, globalData *global.GlobalData) (server *Server) {
	return &Server{
		socketPath:      config.Socket,
		executablesPath: config.Execs,
		hookMap:         cmap.Map[string, hookInfo]{},
		globalData:      globalData,
	}
}

func (server *Server) Run(ctx context.Context) error {
	listener, _, err := misc.ListenUnixSocket(ctx, server.socketPath)
	if err != nil {
		return fmt.Errorf("listen unix socket for hooks: %w", err)
	}
	defer func() {
		_ = listener.Close()
	}()

	stop := context.AfterFunc(ctx, func() {
		_ = listener.Close()
	})
	defer stop()

	for {
		conn, err := listener.Accept()
		if err != nil {
			if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
				return nil
			}
			return fmt.Errorf("accept conn: %w", err)
		}

		go server.handleConn(ctx, conn)
	}
}

func (server *Server) handleConn(ctx context.Context, conn net.Conn) {
	defer func() {
		_ = conn.Close()
	}()
	unblock := context.AfterFunc(ctx, func() {
		_ = conn.SetDeadline(time.Now())
		_ = conn.Close()
	})
	defer unblock()
}
package lmtp

import (
	"context"
	"errors"
	"fmt"
	"net"
	"time"

	"go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
	"go.lindenii.runxiyu.org/forge/forged/internal/global"
)

type Server struct {
	socket       string
	domain       string
	maxSize      int64
	writeTimeout uint32
	readTimeout  uint32
	globalData   *global.GlobalData
}

func New(config Config) (server *Server) {
func New(config Config, globalData *global.GlobalData) (server *Server) {
	return &Server{
		socket:       config.Socket,
		domain:       config.Domain,
		maxSize:      config.MaxSize,
		writeTimeout: config.WriteTimeout,
		readTimeout:  config.ReadTimeout,
		globalData:   globalData,
	}
}

func (server *Server) Run(ctx context.Context) error {
	listener, _, err := misc.ListenUnixSocket(ctx, server.socket)
	if err != nil {
		return fmt.Errorf("listen unix socket for LMTP: %w", err)
	}
	defer func() {
		_ = listener.Close()
	}()

	stop := context.AfterFunc(ctx, func() {
		_ = listener.Close()
	})
	defer stop()

	for {
		conn, err := listener.Accept()
		if err != nil {
			if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
				return nil
			}
			return fmt.Errorf("accept conn: %w", err)
		}

		go server.handleConn(ctx, conn)
	}
}

func (server *Server) handleConn(ctx context.Context, conn net.Conn) {
	defer func() {
		_ = conn.Close()
	}()
	unblock := context.AfterFunc(ctx, func() {
		_ = conn.SetDeadline(time.Now())
		_ = conn.Close()
	})
	defer unblock()
}
package ssh

import (
	"context"
	"errors"
	"fmt"
	"os"
	"time"

	gliderssh "github.com/gliderlabs/ssh"
	"go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
	"go.lindenii.runxiyu.org/forge/forged/internal/global"
	gossh "golang.org/x/crypto/ssh"
)

type Server struct {
	gliderServer    *gliderssh.Server
	privkey         gossh.Signer
	pubkeyString    string
	pubkeyFP        string
	net             string
	addr            string
	root            string
	shutdownTimeout uint32
	globalData      *global.GlobalData
}

func New(config Config) (server *Server, err error) {
func New(config Config, globalData *global.GlobalData) (server *Server, err error) {
	server = &Server{
		net:             config.Net,
		addr:            config.Addr,
		root:            config.Root,
		shutdownTimeout: config.ShutdownTimeout,
		globalData:      globalData,
	} //exhaustruct:ignore

	var privkeyBytes []byte

	privkeyBytes, err = os.ReadFile(config.Key)
	if err != nil {
		return server, fmt.Errorf("read SSH private key: %w", err)
	}

	server.privkey, err = gossh.ParsePrivateKey(privkeyBytes)
	if err != nil {
		return server, fmt.Errorf("parse SSH private key: %w", err)
	}

	server.pubkeyString = misc.BytesToString(gossh.MarshalAuthorizedKey(server.privkey.PublicKey()))
	server.pubkeyFP = gossh.FingerprintSHA256(server.privkey.PublicKey())
	server.globalData.SSHPubkey = misc.BytesToString(gossh.MarshalAuthorizedKey(server.privkey.PublicKey()))
	server.globalData.SSHFingerprint = gossh.FingerprintSHA256(server.privkey.PublicKey())

	server.gliderServer = &gliderssh.Server{
		Handler:                    handle,
		PublicKeyHandler:           func(ctx gliderssh.Context, key gliderssh.PublicKey) bool { return true },
		KeyboardInteractiveHandler: func(ctx gliderssh.Context, challenge gossh.KeyboardInteractiveChallenge) bool { return true },
	} //exhaustruct:ignore
	server.gliderServer.AddHostKey(server.privkey)

	return server, nil
}

func (server *Server) Run(ctx context.Context) (err error) {
	listener, err := misc.Listen(ctx, server.net, server.addr)
	if err != nil {
		return fmt.Errorf("listen for SSH: %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.gliderServer.Shutdown(shCtx)
		_ = listener.Close()
	})
	defer stop()

	err = server.gliderServer.Serve(listener)
	if err != nil {
		if errors.Is(err, gliderssh.ErrServerClosed) || ctx.Err() != nil {
			return nil
		}
		return fmt.Errorf("serve SSH: %w", err)
	}
	panic("unreachable")
}

func handle(session gliderssh.Session) {
	panic("SSH server handler not implemented yet")
}
package web

import (
	"html/template"
	"net/http"

	"go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
	"go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
	"go.lindenii.runxiyu.org/forge/forged/internal/global"
	handlers "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/handlers"
	repoHandlers "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/handlers/repo"
	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
)

type handler struct {
	r *Router
}

func NewHandler(cfg Config) http.Handler {
	h := &handler{r: NewRouter().ReverseProxy(cfg.ReverseProxy)}
func NewHandler(cfg Config, globalData *global.GlobalData, queries *queries.Queries) *handler {
	h := &handler{r: NewRouter().ReverseProxy(cfg.ReverseProxy).Global(globalData).Queries(queries)}

	staticFS := http.FileServer(http.Dir(cfg.StaticPath))
	h.r.ANYHTTP("-/static/*rest",
		http.StripPrefix("/-/static/", staticFS),
		WithDirIfEmpty("rest"),
	)

	funcs := template.FuncMap{
		"path_escape":       misc.PathEscape,
		"query_escape":      misc.QueryEscape,
		"minus":             misc.Minus,
		"first_line":        misc.FirstLine,
		"dereference_error": misc.DereferenceOrZero[error],
	}
	t := templates.MustParseDir(cfg.TemplatesPath, funcs)
	renderer := templates.New(t)

	indexHTTP := handlers.NewIndexHTTP(renderer)
	groupHTTP := handlers.NewGroupHTTP(renderer)
	repoHTTP := repoHandlers.NewHTTP(renderer)
	notImpl := handlers.NewNotImplementedHTTP()
	notImpl := handlers.NewNotImplementedHTTP(renderer)

	// Index
	h.r.GET("/", indexHTTP.Index)

	// Top-level utilities
	h.r.ANY("-/login", notImpl.Handle)
	h.r.ANY("-/users", notImpl.Handle)

	// Group index
	h.r.GET("@group/", groupHTTP.Index)

	// Repo index
	h.r.GET("@group/-/repos/:repo/", repoHTTP.Index)

	// Repo (not implemented yet)
	h.r.ANY("@group/-/repos/:repo/info", notImpl.Handle)
	h.r.ANY("@group/-/repos/:repo/git-upload-pack", notImpl.Handle)

	// Repo features
	h.r.GET("@group/-/repos/:repo/branches/", notImpl.Handle)
	h.r.GET("@group/-/repos/:repo/log/", notImpl.Handle)
	h.r.GET("@group/-/repos/:repo/commit/:commit", notImpl.Handle)
	h.r.GET("@group/-/repos/:repo/tree/*rest", repoHTTP.Tree, WithDirIfEmpty("rest"))
	h.r.GET("@group/-/repos/:repo/raw/*rest", repoHTTP.Raw, WithDirIfEmpty("rest"))
	h.r.GET("@group/-/repos/:repo/contrib/", notImpl.Handle)
	h.r.GET("@group/-/repos/:repo/contrib/:mr", notImpl.Handle)

	return h
}

func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	h.r.ServeHTTP(w, r)
}
package handlers

import (
	"net/http"
	"strings"

	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
	wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
)

type GroupHTTP struct {
	r templates.Renderer
}

func NewGroupHTTP(r templates.Renderer) *GroupHTTP { return &GroupHTTP{r: r} }
func NewGroupHTTP(r templates.Renderer) *GroupHTTP {
	return &GroupHTTP{
		r: r,
	}
}

func (h *GroupHTTP) Index(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
	base := wtypes.Base(r)
	_ = h.r.Render(w, "group/index.html", struct {
		GroupPath string
	}{
		GroupPath: "/" + strings.Join(base.GroupPath, "/") + "/",
	})
}

package handlers

import (
	"log"
	"net/http"

	"go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
	wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
)

type IndexHTTP struct {
	r templates.Renderer
}

func NewIndexHTTP(r templates.Renderer) *IndexHTTP { return &IndexHTTP{r: r} }
func NewIndexHTTP(r templates.Renderer) *IndexHTTP {
	return &IndexHTTP{
		r: r,
	}
}

func (h *IndexHTTP) Index(w http.ResponseWriter, _ *http.Request, _ wtypes.Vars) {
	err := h.r.Render(w, "index", struct {
		Title string
func (h *IndexHTTP) Index(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
	groups, err := types.Base(r).Queries.GetRootGroups(r.Context())
	if err != nil {
		http.Error(w, "failed to get root groups", http.StatusInternalServerError)
		log.Println("failed to get root groups", "error", err)
		return
	}
	err = h.r.Render(w, "index", struct {
		BaseData *types.BaseData
		Groups   []queries.GetRootGroupsRow
	}{
		Title: "Home",
		BaseData: types.Base(r),
		Groups:   groups,
	})
	if err != nil {
		log.Println("failed to render index page", "error", err)
	}
}
package handlers

import (
	"net/http"

	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
	wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
)

type NotImplementedHTTP struct{}
type NotImplementedHTTP struct {
	r templates.Renderer
}

func NewNotImplementedHTTP() *NotImplementedHTTP { return &NotImplementedHTTP{} }
func NewNotImplementedHTTP(r templates.Renderer) *NotImplementedHTTP {
	return &NotImplementedHTTP{
		r: r,
	}
}

func (h *NotImplementedHTTP) Handle(w http.ResponseWriter, _ *http.Request, _ wtypes.Vars) {
	http.Error(w, "not implemented", http.StatusNotImplemented)
}
package repo

import (
	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
)

type HTTP struct {
	r templates.Renderer
}

func NewHTTP(r templates.Renderer) *HTTP {
	return &HTTP{
		r: r,
	}
}
package repo

import (
	"net/http"
	"strings"

	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
	wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
)

type HTTP struct {
	r templates.Renderer
}

func NewHTTP(r templates.Renderer) *HTTP { return &HTTP{r: r} }

func (h *HTTP) Index(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
	base := wtypes.Base(r)
	repo := v["repo"]
	_ = h.r.Render(w, "repo/index.html", struct {
		Group string
		Repo  string
	}{
		Group: "/" + strings.Join(base.GroupPath, "/") + "/",
		Repo:  repo,
	})
}

package web

import (
	"fmt"
	"net/http"
	"net/url"
	"sort"
	"strings"

	"go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
	"go.lindenii.runxiyu.org/forge/forged/internal/global"
	wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
)

type UserResolver func(*http.Request) (id int, username string, err error)
type UserResolver func(*http.Request) (id string, username string, err error)

type ErrorRenderers struct {
	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          wtypes.HandlerFunc
	hh         http.Handler
	priority   int
}

type Router struct {
	routes       []route
	errors       ErrorRenderers
	user         UserResolver
	global       any
	global       *global.GlobalData
	reverseProxy bool
	queries      *queries.Queries
}

func NewRouter() *Router { return &Router{} }

func (r *Router) Global(v any) *Router                { r.global = v; return r }
func (r *Router) Global(g *global.GlobalData) *Router {
	r.global = g
	return r
}
func (r *Router) Queries(q *queries.Queries) *Router {
	r.queries = q
	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 wtypes.HandlerFunc, opts ...RouteOption) {
	r.handle("GET", pattern, f, nil, opts...)
}

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 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 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, &wtypes.BaseData{Global: r.global}, "Error parsing request URI: "+err.Error())
		return
	}
	for _, s := range segments {
		if strings.Contains(s, ":") {
			r.err400Colon(w, &wtypes.BaseData{Global: r.global})
			return
		}
	}

	// Prepare base data; vars are attached per-route below.
	bd := &wtypes.BaseData{
		Global:      r.global,
		URLSegments: segments,
		DirMode:     dirMode,
		Queries:     r.queries,
	}

	bd.RefType, bd.RefName, err = GetParamRefTypeName(req)
	if err != nil {
		r.err400(w, bd, "Error parsing ref query parameters: "+err.Error())
		return
	}

	if r.user != nil {
		uid, uname, uerr := r.user(req)
		if uerr != nil {
			r.err500(w, bd, "Error getting user info from request: "+uerr.Error())
			return
		}
		bd.UserID = uid
		bd.Username = uname
	}

	method := req.Method
	var pathMatched bool // for 405 detection

	for _, rt := range r.routes {
		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) {
				return
			}
		}

		// 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, "/")
		}

		// 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
		}

		if rt.h != nil {
			rt.h(w, req, wtypes.Vars(vars))
		} else if rt.hh != nil {
			rt.hh.ServeHTTP(w, req)
		} else {
			r.err500(w, bd, "route has no handler")
		}
		return
	}
	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 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, b, msg)
		return
	}
	http.Error(w, msg, http.StatusBadRequest)
}

func (r *Router) err400Colon(w http.ResponseWriter, b *wtypes.BaseData) {
	if r.errors.BadRequestColon != nil {
		r.errors.BadRequestColon(w, b)
		return
	}
	http.Error(w, "bad request", http.StatusBadRequest)
}

func (r *Router) err404(w http.ResponseWriter, b *wtypes.BaseData) {
	if r.errors.NotFound != nil {
		r.errors.NotFound(w, b)
		return
	}
	http.NotFound(w, nil)
}

func (r *Router) err500(w http.ResponseWriter, b *wtypes.BaseData, msg string) {
	if r.errors.ServerError != nil {
		r.errors.ServerError(w, b, msg)
		return
	}
	http.Error(w, msg, http.StatusInternalServerError)
}

func GetParamRefTypeName(request *http.Request) (retRefType, retRefName string, err error) {
	rawQuery := request.URL.RawQuery
	queryValues, err := url.ParseQuery(rawQuery)
	if err != nil {
		return
	}
	done := false
	for _, refType := range []string{"commit", "branch", "tag"} {
		refName, ok := queryValues[refType]
		if ok {
			if done {
				err = errDupRefSpec
				return
			}
			done = true
			if len(refName) != 1 {
				err = errDupRefSpec
				return
			}
			retRefName = refName[0]
			retRefType = refType
		}
	}
	if !done {
		retRefType = ""
		retRefName = ""
		err = nil // actually returning empty strings is enough?
	}
	return
}

var (
	errDupRefSpec = fmt.Errorf("duplicate ref specifications")
)
package web

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"time"

	"go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
	"go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
	"go.lindenii.runxiyu.org/forge/forged/internal/global"
)

type Server struct {
	net             string
	addr            string
	root            string
	httpServer      *http.Server
	shutdownTimeout uint32
	globalData      *global.GlobalData
}

func New(config Config) (server *Server) {
func New(config Config, globalData *global.GlobalData, queries *queries.Queries) *Server {
	httpServer := &http.Server{
		Handler:        NewHandler(config),
		Handler:        NewHandler(config, globalData, queries),
		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,
		globalData:      globalData,
	}
}

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 types

import (
	"context"
	"net/http"

	"go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
	"go.lindenii.runxiyu.org/forge/forged/internal/global"
)

// 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
	UserID         string
	Username       string
	URLSegments    []string
	DirMode        bool
	GroupPath      []string
	SeparatorIndex int
	RefType        string
	RefName        string
	Global         *global.GlobalData
	Queries        *queries.Queries
}

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)
package server

import (
	"context"
	"fmt"

	"go.lindenii.runxiyu.org/forge/forged/internal/config"
	"go.lindenii.runxiyu.org/forge/forged/internal/database"
	"go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
	"go.lindenii.runxiyu.org/forge/forged/internal/global"
	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/hooks"
	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/lmtp"
	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/ssh"
	"go.lindenii.runxiyu.org/forge/forged/internal/incoming/web"
	"golang.org/x/sync/errgroup"
)

type Server struct {
	config config.Config

	database   database.Database
	hookServer *hooks.Server
	lmtpServer *lmtp.Server
	webServer  *web.Server
	sshServer  *ssh.Server

	globalData struct {
		SSHPubkey      string
		SSHFingerprint string
		Version        string
	}
	globalData global.GlobalData
}

func New(configPath string) (server *Server, err error) {
	server = &Server{} //exhaustruct:ignore

	server.config, err = config.Open(configPath)
	if err != nil {
		return server, fmt.Errorf("open config: %w", err)
	}

	server.hookServer = hooks.New(server.config.Hooks)
	server.lmtpServer = lmtp.New(server.config.LMTP)
	server.webServer = web.New(server.config.Web)
	server.sshServer, err = ssh.New(server.config.SSH)
	queries := queries.New(&server.database)

	server.globalData.ForgeVersion = "unknown" // TODO
	server.globalData.ForgeTitle = server.config.General.Title

	server.hookServer = hooks.New(server.config.Hooks, &server.globalData)
	server.lmtpServer = lmtp.New(server.config.LMTP, &server.globalData)
	server.webServer = web.New(server.config.Web, &server.globalData, queries)
	server.sshServer, err = ssh.New(server.config.SSH, &server.globalData)
	if err != nil {
		return server, fmt.Errorf("create SSH server: %w", err)
	}

	return server, nil
}

func (server *Server) Run(ctx context.Context) (err error) {
	// TODO: Not running git2d because it should be run separately.
	// This needs to be documented somewhere, hence a TODO here for now.

	g, gctx := errgroup.WithContext(ctx)

	server.database, err = database.Open(gctx, server.config.DB)
	if err != nil {
		return fmt.Errorf("open database: %w", err)
	}
	defer server.database.Close()

	g.Go(func() error { return server.hookServer.Run(gctx) })
	g.Go(func() error { return server.lmtpServer.Run(gctx) })
	g.Go(func() error { return server.webServer.Run(gctx) })
	g.Go(func() error { return server.sshServer.Run(gctx) })

	err = g.Wait()
	if err != nil {
		return fmt.Errorf("server error: %w", err)
	}

	err = ctx.Err()
	if err != nil {
		return fmt.Errorf("context exceeded: %w", err)
	}

	return nil
}
-- name: GetRootGroups :many
SELECT name, COALESCE(description, '') FROM groups WHERE parent_group IS NULL;

-- name: GetGroupIDDescByPath :one
WITH RECURSIVE group_path_cte AS (
	SELECT
		id,
		parent_group,
		name,
		1 AS depth
	FROM groups
	WHERE name = ($1::text[])[1]
		AND parent_group IS NULL

	UNION ALL

	SELECT
		g.id,
		g.parent_group,
		g.name,
		group_path_cte.depth + 1
	FROM groups g
	JOIN group_path_cte ON g.parent_group = group_path_cte.id
	WHERE g.name = ($1::text[])[group_path_cte.depth + 1]
		AND group_path_cte.depth + 1 <= cardinality($1::text[])
)
SELECT c.id, COALESCE(g.description, '')
FROM group_path_cte c
JOIN groups g ON g.id = c.id
WHERE c.depth = cardinality($1::text[]);
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
*/}}
{{- define "footer" -}}
<a href="https://lindenii.runxiyu.org/forge/">Lindenii Forge</a>
{{ .global.forge_version }}
{{ .BaseData.Global.ForgeVersion }}
(<a href="https://forge.lindenii.runxiyu.org/forge/-/repos/server/">upstream</a>,
<a href="/-/source/LICENSE">license</a>,
<a href="https://webirc.runxiyu.org/kiwiirc/#lindenii">support</a>)
{{- end -}}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
*/}}
{{- define "header" -}}
<header id="main-header">
	<div id="main-header-forge-title">
		<a href="/">{{- .global.forge_title -}}</a>
		<a href="/">{{- .BaseData.Global.ForgeTitle -}}</a>
	</div>
	<nav id="breadcrumb-nav">
		{{- $path := "" -}}
		{{- $url_segments := .url_segments -}}
		{{- $dir_mode := .dir_mode -}}
		{{- $ref_type := .ref_type -}}
		{{- $ref := .ref_name -}}
		{{- $separator_index := .separator_index -}}
		{{- $url_segments := .BaseData.URLSegments -}}
		{{- $dir_mode := .BaseData.DirMode -}}
		{{- $ref_type := .BaseData.RefType -}}
		{{- $ref := .BaseData.RefName -}}
		{{- $separator_index := .BaseData.SeparatorIndex -}}
		{{- if eq $separator_index -1 -}}
			{{- $separator_index = len $url_segments -}}
		{{- end -}}
		{{- range $i := $separator_index -}}
			{{- $segment := index $url_segments $i -}}
			{{- $path = printf "%s/%s" $path $segment -}}
			<span class="breadcrumb-separator">/</span>
			<a href="{{ $path }}{{ if or (ne $i (minus (len $url_segments) 1)) $dir_mode }}/{{ end }}{{- if $ref_type -}}?{{- $ref_type -}}={{- $ref -}}{{- end -}}">{{ $segment }}</a>
		{{- end -}}
	</nav>
	<div id="main-header-user">
		{{- if ne .user_id_string "" -}}
			<a href="/-/users/{{- .user_id_string -}}">{{- .username -}}</a>
		{{- if ne .BaseData.UserID "" -}}
			<a href="/-/users/{{- .BaseData.UserID -}}/">{{- .BaseData.Username -}}</a>
		{{- else -}}
			<a href="/-/login/">Login</a>
		{{- end -}}
	</div>
</header>
{{- end -}}
{{/*
	SPDX-License-Identifier: AGPL-3.0-only
	SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
*/}}
{{- define "index" -}}
<!DOCTYPE html>
<html lang="en">
	<head>
		{{- template "head_common" . -}}
		<title>Index &ndash; {{ .global.forge_title -}}</title>
		<title>Index &ndash; {{ .BaseData.Global.ForgeTitle -}}</title>
	</head>
	<body class="index">
		{{- template "header" . -}}
		<main>
			<div class="padding-wrapper">
				<table class="wide">
					<thead>
						<tr>
							<th colspan="2" class="title-row">Groups</th>
						</tr>
						<tr>
							<th scope="col">Name</th>
							<th scope="col">Description</th>
						</tr>
					</thead>
					<tbody>
						{{- range .groups -}}
						{{- range .Groups -}}
							<tr>
								<td>
									<a href="{{- .Name | path_escape -}}/">{{- .Name -}}</a>
								</td>
								<td>
									{{- .Description -}}
								</td>
							</tr>
						{{- end -}}
					</tbody>
				</table>
				<table class="wide">
					<thead>
						<tr>
							<th colspan="2" class="title-row">
								Info
							</th>
						</tr>
					</thead>
					<tbody>
						<tr>
							<th scope="row">SSH public key</th>
							<td><code class="breakable">{{- .global.server_public_key_string -}}</code></td>
							<td><code class="breakable">{{- .BaseData.Global.SSHPubkey -}}</code></td>
						</tr>
						<tr>
							<th scope="row">SSH fingerprint</th>
							<td><code class="breakable">{{- .global.server_public_key_fingerprint -}}</code></td>
							<td><code class="breakable">{{- .BaseData.Global.SSHFingerprint -}}</code></td>
						</tr>
					</tbody>
				</table>
			</div>
		</main>
		<footer>
			{{- template "footer" . -}}
		</footer>
	</body>
</html>
{{- end -}}