aboutsummaryrefslogtreecommitdiff
path: root/forged/internal/incoming
diff options
context:
space:
mode:
Diffstat (limited to 'forged/internal/incoming')
-rw-r--r--forged/internal/incoming/hooks/config.go6
-rw-r--r--forged/internal/incoming/hooks/hooks.go80
-rw-r--r--forged/internal/incoming/lmtp/config.go9
-rw-r--r--forged/internal/incoming/lmtp/lmtp.go70
-rw-r--r--forged/internal/incoming/ssh/config.go9
-rw-r--r--forged/internal/incoming/ssh/ssh.go89
-rw-r--r--forged/internal/incoming/web/authn.go33
-rw-r--r--forged/internal/incoming/web/config.go16
-rw-r--r--forged/internal/incoming/web/handler.go77
-rw-r--r--forged/internal/incoming/web/handlers/group.go92
-rw-r--r--forged/internal/incoming/web/handlers/index.go40
-rw-r--r--forged/internal/incoming/web/handlers/not_implemented.go22
-rw-r--r--forged/internal/incoming/web/handlers/repo/handler.go15
-rw-r--r--forged/internal/incoming/web/handlers/repo/index.go20
-rw-r--r--forged/internal/incoming/web/handlers/repo/raw.go19
-rw-r--r--forged/internal/incoming/web/handlers/repo/tree.go19
-rw-r--r--forged/internal/incoming/web/handlers/special/login.go115
-rw-r--r--forged/internal/incoming/web/router.go428
-rw-r--r--forged/internal/incoming/web/server.go70
-rw-r--r--forged/internal/incoming/web/templates/load.go31
-rw-r--r--forged/internal/incoming/web/templates/renderer.go23
-rw-r--r--forged/internal/incoming/web/types/types.go45
22 files changed, 1328 insertions, 0 deletions
diff --git a/forged/internal/incoming/hooks/config.go b/forged/internal/incoming/hooks/config.go
new file mode 100644
index 0000000..0d23dc0
--- /dev/null
+++ b/forged/internal/incoming/hooks/config.go
@@ -0,0 +1,6 @@
+package hooks
+
+type Config struct {
+ Socket string `scfg:"socket"`
+ Execs string `scfg:"execs"`
+}
diff --git a/forged/internal/incoming/hooks/hooks.go b/forged/internal/incoming/hooks/hooks.go
new file mode 100644
index 0000000..dfdf172
--- /dev/null
+++ b/forged/internal/incoming/hooks/hooks.go
@@ -0,0 +1,80 @@
+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
+ global *global.Global
+}
+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, global *global.Global) (server *Server) {
+ return &Server{
+ socketPath: config.Socket,
+ executablesPath: config.Execs,
+ hookMap: cmap.Map[string, hookInfo]{},
+ global: global,
+ }
+}
+
+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()
+}
diff --git a/forged/internal/incoming/lmtp/config.go b/forged/internal/incoming/lmtp/config.go
new file mode 100644
index 0000000..6241608
--- /dev/null
+++ b/forged/internal/incoming/lmtp/config.go
@@ -0,0 +1,9 @@
+package lmtp
+
+type Config struct {
+ Socket string `scfg:"socket"`
+ Domain string `scfg:"domain"`
+ MaxSize int64 `scfg:"max_size"`
+ WriteTimeout uint32 `scfg:"write_timeout"`
+ ReadTimeout uint32 `scfg:"read_timeout"`
+}
diff --git a/forged/internal/incoming/lmtp/lmtp.go b/forged/internal/incoming/lmtp/lmtp.go
new file mode 100644
index 0000000..a7782a2
--- /dev/null
+++ b/forged/internal/incoming/lmtp/lmtp.go
@@ -0,0 +1,70 @@
+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
+ global *global.Global
+}
+
+func New(config Config, global *global.Global) (server *Server) {
+ return &Server{
+ socket: config.Socket,
+ domain: config.Domain,
+ maxSize: config.MaxSize,
+ writeTimeout: config.WriteTimeout,
+ readTimeout: config.ReadTimeout,
+ global: global,
+ }
+}
+
+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()
+}
diff --git a/forged/internal/incoming/ssh/config.go b/forged/internal/incoming/ssh/config.go
new file mode 100644
index 0000000..7d22cc1
--- /dev/null
+++ b/forged/internal/incoming/ssh/config.go
@@ -0,0 +1,9 @@
+package ssh
+
+type Config struct {
+ Net string `scfg:"net"`
+ Addr string `scfg:"addr"`
+ Key string `scfg:"key"`
+ Root string `scfg:"root"`
+ ShutdownTimeout uint32 `scfg:"shutdown_timeout"`
+}
diff --git a/forged/internal/incoming/ssh/ssh.go b/forged/internal/incoming/ssh/ssh.go
new file mode 100644
index 0000000..527cd28
--- /dev/null
+++ b/forged/internal/incoming/ssh/ssh.go
@@ -0,0 +1,89 @@
+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
+ net string
+ addr string
+ root string
+ shutdownTimeout uint32
+ global *global.Global
+}
+
+func New(config Config, global *global.Global) (server *Server, err error) {
+ server = &Server{
+ net: config.Net,
+ addr: config.Addr,
+ root: config.Root,
+ shutdownTimeout: config.ShutdownTimeout,
+ global: global,
+ } //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.global.SSHPubkey = misc.BytesToString(gossh.MarshalAuthorizedKey(server.privkey.PublicKey()))
+ server.global.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")
+}
diff --git a/forged/internal/incoming/web/authn.go b/forged/internal/incoming/web/authn.go
new file mode 100644
index 0000000..46263ee
--- /dev/null
+++ b/forged/internal/incoming/web/authn.go
@@ -0,0 +1,33 @@
+package web
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/jackc/pgx/v5"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+func userResolver(r *http.Request) (string, string, error) {
+ cookie, err := r.Cookie("session")
+ if err != nil {
+ if errors.Is(err, http.ErrNoCookie) {
+ return "", "", nil
+ }
+ return "", "", err
+ }
+
+ tokenHash := sha256.Sum256([]byte(cookie.Value))
+
+ session, err := types.Base(r).Queries.GetUserFromSession(r.Context(), tokenHash[:])
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return "", "", nil
+ }
+ return "", "", err
+ }
+
+ return fmt.Sprint(session.UserID), session.Username, nil
+}
diff --git a/forged/internal/incoming/web/config.go b/forged/internal/incoming/web/config.go
new file mode 100644
index 0000000..8d32b34
--- /dev/null
+++ b/forged/internal/incoming/web/config.go
@@ -0,0 +1,16 @@
+package web
+
+type Config struct {
+ Net string `scfg:"net"`
+ Addr string `scfg:"addr"`
+ Root string `scfg:"root"`
+ CookieExpiry int `scfg:"cookie_expiry"`
+ ReadTimeout uint32 `scfg:"read_timeout"`
+ WriteTimeout uint32 `scfg:"write_timeout"`
+ IdleTimeout uint32 `scfg:"idle_timeout"`
+ MaxHeaderBytes int `scfg:"max_header_bytes"`
+ ReverseProxy bool `scfg:"reverse_proxy"`
+ ShutdownTimeout uint32 `scfg:"shutdown_timeout"`
+ TemplatesPath string `scfg:"templates_path"`
+ StaticPath string `scfg:"static_path"`
+}
diff --git a/forged/internal/incoming/web/handler.go b/forged/internal/incoming/web/handler.go
new file mode 100644
index 0000000..20f7e79
--- /dev/null
+++ b/forged/internal/incoming/web/handler.go
@@ -0,0 +1,77 @@
+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"
+ specialHandlers "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/handlers/special"
+ "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/templates"
+)
+
+type handler struct {
+ r *Router
+}
+
+func NewHandler(cfg Config, global *global.Global, queries *queries.Queries) *handler {
+ h := &handler{r: NewRouter().ReverseProxy(cfg.ReverseProxy).Global(global).Queries(queries).UserResolver(userResolver)}
+
+ 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)
+ loginHTTP := specialHandlers.NewLoginHTTP(renderer, cfg.CookieExpiry)
+ groupHTTP := handlers.NewGroupHTTP(renderer)
+ repoHTTP := repoHandlers.NewHTTP(renderer)
+ notImpl := handlers.NewNotImplementedHTTP(renderer)
+
+ // Index
+ h.r.GET("/", indexHTTP.Index)
+
+ // Top-level utilities
+ h.r.ANY("-/login", loginHTTP.Login)
+ h.r.ANY("-/users", notImpl.Handle)
+
+ // Group index
+ h.r.GET("@group/", groupHTTP.Index)
+ h.r.POST("@group/", groupHTTP.Post)
+
+ // 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)
+}
diff --git a/forged/internal/incoming/web/handlers/group.go b/forged/internal/incoming/web/handlers/group.go
new file mode 100644
index 0000000..3201491
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/group.go
@@ -0,0 +1,92 @@
+package handlers
+
+import (
+ "log/slog"
+ "net/http"
+ "strconv"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ "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 (h *GroupHTTP) Index(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
+ base := wtypes.Base(r)
+ userID, err := strconv.ParseInt(base.UserID, 10, 64)
+ if err != nil {
+ userID = 0
+ }
+
+ queryParams := queries.GetGroupByPathParams{
+ Column1: base.URLSegments,
+ UserID: userID,
+ }
+ p, err := base.Queries.GetGroupByPath(r.Context(), queryParams)
+ if err != nil {
+ slog.Error("failed to get group ID by path", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ subgroups, err := base.Queries.GetSubgroups(r.Context(), &p.ID)
+ if err != nil {
+ slog.Error("failed to get subgroups", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ // TODO: gracefully fail this part of the page
+ }
+ repos, err := base.Queries.GetReposInGroup(r.Context(), p.ID)
+ if err != nil {
+ slog.Error("failed to get repos in group", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ // TODO: gracefully fail this part of the page
+ }
+ err = h.r.Render(w, "group", struct {
+ BaseData *wtypes.BaseData
+ Subgroups []queries.GetSubgroupsRow
+ Repos []queries.GetReposInGroupRow
+ Description string
+ DirectAccess bool
+ }{
+ BaseData: base,
+ Subgroups: subgroups,
+ Repos: repos,
+ Description: p.Description,
+ DirectAccess: p.HasRole,
+ })
+ if err != nil {
+ slog.Error("failed to render index page", "error", err)
+ }
+}
+
+func (h *GroupHTTP) Post(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
+ base := wtypes.Base(r)
+ userID, err := strconv.ParseInt(base.UserID, 10, 64)
+ if err != nil {
+ userID = 0
+ }
+
+ queryParams := queries.GetGroupByPathParams{
+ Column1: base.URLSegments,
+ UserID: userID,
+ }
+ p, err := base.Queries.GetGroupByPath(r.Context(), queryParams)
+ if err != nil {
+ slog.Error("failed to get group ID by path", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ if !p.HasRole {
+ http.Error(w, "You do not have the necessary permissions to create repositories in this group.", http.StatusForbidden)
+ return
+ }
+}
diff --git a/forged/internal/incoming/web/handlers/index.go b/forged/internal/incoming/web/handlers/index.go
new file mode 100644
index 0000000..22e6201
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/index.go
@@ -0,0 +1,40 @@
+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 (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
+ }{
+ BaseData: types.Base(r),
+ Groups: groups,
+ })
+ if err != nil {
+ log.Println("failed to render index page", "error", err)
+ }
+}
diff --git a/forged/internal/incoming/web/handlers/not_implemented.go b/forged/internal/incoming/web/handlers/not_implemented.go
new file mode 100644
index 0000000..6813c88
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/not_implemented.go
@@ -0,0 +1,22 @@
+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 {
+ r templates.Renderer
+}
+
+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)
+}
diff --git a/forged/internal/incoming/web/handlers/repo/handler.go b/forged/internal/incoming/web/handlers/repo/handler.go
new file mode 100644
index 0000000..2881d7d
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/repo/handler.go
@@ -0,0 +1,15 @@
+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,
+ }
+}
diff --git a/forged/internal/incoming/web/handlers/repo/index.go b/forged/internal/incoming/web/handlers/repo/index.go
new file mode 100644
index 0000000..1a804b2
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/repo/index.go
@@ -0,0 +1,20 @@
+package repo
+
+import (
+ "net/http"
+ "strings"
+
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+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,
+ })
+}
diff --git a/forged/internal/incoming/web/handlers/repo/raw.go b/forged/internal/incoming/web/handlers/repo/raw.go
new file mode 100644
index 0000000..e421f45
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/repo/raw.go
@@ -0,0 +1,19 @@
+package repo
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+func (h *HTTP) Raw(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
+ base := wtypes.Base(r)
+ repo := v["repo"]
+ rest := v["rest"]
+ if base.DirMode && rest != "" && !strings.HasSuffix(rest, "/") {
+ rest += "/"
+ }
+ _, _ = w.Write([]byte(fmt.Sprintf("raw: repo=%q path=%q", repo, rest)))
+}
diff --git a/forged/internal/incoming/web/handlers/repo/tree.go b/forged/internal/incoming/web/handlers/repo/tree.go
new file mode 100644
index 0000000..3432244
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/repo/tree.go
@@ -0,0 +1,19 @@
+package repo
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+)
+
+func (h *HTTP) Tree(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
+ base := wtypes.Base(r)
+ repo := v["repo"]
+ rest := v["rest"] // may be ""
+ if base.DirMode && rest != "" && !strings.HasSuffix(rest, "/") {
+ rest += "/"
+ }
+ _, _ = w.Write([]byte(fmt.Sprintf("tree: repo=%q path=%q", repo, rest)))
+}
diff --git a/forged/internal/incoming/web/handlers/special/login.go b/forged/internal/incoming/web/handlers/special/login.go
new file mode 100644
index 0000000..0287c47
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/special/login.go
@@ -0,0 +1,115 @@
+package handlers
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "errors"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/argon2id"
+ "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/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 LoginHTTP struct {
+ r templates.Renderer
+ cookieExpiry int
+}
+
+func NewLoginHTTP(r templates.Renderer, cookieExpiry int) *LoginHTTP {
+ return &LoginHTTP{
+ r: r,
+ cookieExpiry: cookieExpiry,
+ }
+}
+
+func (h *LoginHTTP) Login(w http.ResponseWriter, r *http.Request, _ wtypes.Vars) {
+ renderLoginPage := func(loginError string) bool {
+ err := h.r.Render(w, "login", struct {
+ BaseData *types.BaseData
+ LoginError string
+ }{
+ BaseData: types.Base(r),
+ LoginError: loginError,
+ })
+ if err != nil {
+ log.Println("failed to render login page", "error", err)
+ http.Error(w, "Failed to render login page", http.StatusInternalServerError)
+ return true
+ }
+ return false
+ }
+
+ if r.Method == http.MethodGet {
+ renderLoginPage("")
+ return
+ }
+
+ username := r.PostFormValue("username")
+ password := r.PostFormValue("password")
+
+ userCreds, err := types.Base(r).Queries.GetUserCreds(r.Context(), &username)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ renderLoginPage("User not found")
+ return
+ }
+ log.Println("failed to get user credentials", "error", err)
+ http.Error(w, "Failed to get user credentials", http.StatusInternalServerError)
+ return
+ }
+
+ if userCreds.PasswordHash == "" {
+ renderLoginPage("No password set for this user")
+ return
+ }
+
+ passwordMatches, err := argon2id.ComparePasswordAndHash(password, userCreds.PasswordHash)
+ if err != nil {
+ log.Println("failed to compare password and hash", "error", err)
+ http.Error(w, "Failed to verify password", http.StatusInternalServerError)
+ return
+ }
+
+ if !passwordMatches {
+ renderLoginPage("Invalid password")
+ return
+ }
+
+ cookieValue := rand.Text()
+
+ now := time.Now()
+ expiry := now.Add(time.Duration(h.cookieExpiry) * time.Second)
+
+ cookie := &http.Cookie{
+ Name: "session",
+ Value: cookieValue,
+ SameSite: http.SameSiteLaxMode,
+ HttpOnly: true,
+ Secure: false, // TODO
+ Expires: expiry,
+ Path: "/",
+ } //exhaustruct:ignore
+
+ http.SetCookie(w, cookie)
+
+ tokenHash := sha256.Sum256(misc.StringToBytes(cookieValue))
+
+ err = types.Base(r).Queries.InsertSession(r.Context(), queries.InsertSessionParams{
+ UserID: userCreds.ID,
+ TokenHash: tokenHash[:],
+ ExpiresAt: pgtype.Timestamptz{
+ Time: expiry,
+ Valid: true,
+ },
+ })
+
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
diff --git a/forged/internal/incoming/web/router.go b/forged/internal/incoming/web/router.go
new file mode 100644
index 0000000..8356191
--- /dev/null
+++ b/forged/internal/incoming/web/router.go
@@ -0,0 +1,428 @@
+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 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 *global.Global
+ reverseProxy bool
+ queries *queries.Queries
+}
+
+func NewRouter() *Router { return &Router{} }
+
+func (r *Router) Global(g *global.Global) *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
+ }
+ }
+
+ bd := &wtypes.BaseData{
+ Global: r.global,
+ URLSegments: segments,
+ DirMode: dirMode,
+ Queries: r.queries,
+ }
+ req = req.WithContext(wtypes.WithBaseData(req.Context(), bd))
+
+ 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
+ var matchedRaw string
+
+ for _, rt := range r.routes {
+ ok, vars, sepIdx := match(rt.segs, segments)
+ if !ok {
+ continue
+ }
+ pathMatched = true
+ matchedRaw = rt.rawPattern
+
+ 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
+ }
+ }
+
+ bd.SeparatorIndex = sepIdx
+ if g := vars["group"]; g == "" {
+ bd.GroupPath = []string{}
+ } else {
+ bd.GroupPath = strings.Split(g, "/")
+ }
+
+ if rt.method != "" && !(rt.method == method || (method == http.MethodHead && rt.method == http.MethodGet)) {
+ continue
+ }
+
+ 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 {
+ w.Header().Set("Allow", allowForPattern(r.routes, matchedRaw))
+ 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
+ }
+ return
+}
+
+var (
+ errDupRefSpec = fmt.Errorf("duplicate ref specifications")
+)
diff --git a/forged/internal/incoming/web/server.go b/forged/internal/incoming/web/server.go
new file mode 100644
index 0000000..465657c
--- /dev/null
+++ b/forged/internal/incoming/web/server.go
@@ -0,0 +1,70 @@
+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
+ global *global.Global
+}
+
+func New(config Config, global *global.Global, queries *queries.Queries) *Server {
+ httpServer := &http.Server{
+ Handler: NewHandler(config, global, 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,
+ global: global,
+ }
+}
+
+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")
+}
diff --git a/forged/internal/incoming/web/templates/load.go b/forged/internal/incoming/web/templates/load.go
new file mode 100644
index 0000000..4a6fc49
--- /dev/null
+++ b/forged/internal/incoming/web/templates/load.go
@@ -0,0 +1,31 @@
+package templates
+
+import (
+ "html/template"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+func MustParseDir(dir string, funcs template.FuncMap) *template.Template {
+ base := template.New("").Funcs(funcs)
+
+ err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ _, err = base.Parse(string(b))
+ return err
+ })
+ if err != nil {
+ panic(err)
+ }
+ return base
+}
diff --git a/forged/internal/incoming/web/templates/renderer.go b/forged/internal/incoming/web/templates/renderer.go
new file mode 100644
index 0000000..1e2f325
--- /dev/null
+++ b/forged/internal/incoming/web/templates/renderer.go
@@ -0,0 +1,23 @@
+package templates
+
+import (
+ "html/template"
+ "net/http"
+)
+
+type Renderer interface {
+ Render(w http.ResponseWriter, name string, data any) error
+}
+
+type tmplRenderer struct {
+ t *template.Template
+}
+
+func New(t *template.Template) Renderer {
+ return &tmplRenderer{t: t}
+}
+
+func (r *tmplRenderer) Render(w http.ResponseWriter, name string, data any) error {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ return r.t.ExecuteTemplate(w, name, data)
+}
diff --git a/forged/internal/incoming/web/types/types.go b/forged/internal/incoming/web/types/types.go
new file mode 100644
index 0000000..bacce24
--- /dev/null
+++ b/forged/internal/incoming/web/types/types.go
@@ -0,0 +1,45 @@
+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 {
+ UserID string
+ Username string
+ URLSegments []string
+ DirMode bool
+ GroupPath []string
+ SeparatorIndex int
+ RefType string
+ RefName string
+ Global *global.Global
+ 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)