Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
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 -}}