Lindenii Project Forge
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 – {{ .global.forge_title -}}</title>
<title>Index – {{ .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 -}}