aboutsummaryrefslogtreecommitdiff
path: root/forged/internal/incoming/web
diff options
context:
space:
mode:
Diffstat (limited to 'forged/internal/incoming/web')
-rw-r--r--forged/internal/incoming/web/authn.go33
-rw-r--r--forged/internal/incoming/web/handler.go69
-rw-r--r--forged/internal/incoming/web/handlers/group.go156
-rw-r--r--forged/internal/incoming/web/handlers/index.go39
-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.go132
-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.go119
-rw-r--r--forged/internal/incoming/web/router.go419
-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.go35
-rw-r--r--forged/internal/incoming/web/types/types.go37
15 files changed, 1215 insertions, 0 deletions
diff --git a/forged/internal/incoming/web/authn.go b/forged/internal/incoming/web/authn.go
new file mode 100644
index 0000000..9754eb1
--- /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).Global.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/handler.go b/forged/internal/incoming/web/handler.go
new file mode 100644
index 0000000..e0e6ced
--- /dev/null
+++ b/forged/internal/incoming/web/handler.go
@@ -0,0 +1,69 @@
+package web
+
+import (
+ "html/template"
+ "net/http"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
+ "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(global *global.Global) *handler {
+ cfg := global.Config.Web
+ h := &handler{r: NewRouter().ReverseProxy(cfg.ReverseProxy).Global(global).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)
+
+ h.r.GET("/", indexHTTP.Index)
+
+ h.r.ANY("-/login", loginHTTP.Login)
+ h.r.ANY("-/users", notImpl.Handle)
+
+ h.r.GET("@group/", groupHTTP.Index)
+ h.r.POST("@group/", groupHTTP.Post)
+
+ h.r.GET("@group/-/repos/:repo/", repoHTTP.Index)
+ h.r.ANY("@group/-/repos/:repo/info", notImpl.Handle)
+ h.r.ANY("@group/-/repos/:repo/git-upload-pack", notImpl.Handle)
+ 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..4823cb7
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/group.go
@@ -0,0 +1,156 @@
+package handlers
+
+import (
+ "fmt"
+ "log/slog"
+ "net/http"
+ "path/filepath"
+ "strconv"
+
+ "github.com/jackc/pgx/v5"
+ "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"
+ "go.lindenii.runxiyu.org/forge/forged/internal/ipc/git2c"
+)
+
+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.Global.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.Global.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.Global.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.Global.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
+ }
+
+ name := r.PostFormValue("repo_name")
+ desc := r.PostFormValue("repo_desc")
+ contrib := r.PostFormValue("repo_contrib")
+ if name == "" {
+ http.Error(w, "Repo name is required", http.StatusBadRequest)
+ return
+ }
+
+ if contrib == "" || contrib == "public" {
+ contrib = "open"
+ }
+
+ tx, err := base.Global.DB.BeginTx(r.Context(), pgx.TxOptions{})
+ if err != nil {
+ slog.Error("begin tx failed", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ defer func() { _ = tx.Rollback(r.Context()) }()
+
+ txq := base.Global.Queries.WithTx(tx)
+ var descPtr *string
+ if desc != "" {
+ descPtr = &desc
+ }
+ repoID, err := txq.InsertRepo(r.Context(), queries.InsertRepoParams{
+ GroupID: p.ID,
+ Name: name,
+ Description: descPtr,
+ ContribRequirements: contrib,
+ })
+ if err != nil {
+ slog.Error("insert repo failed", "error", err)
+ http.Error(w, "Failed to create repository", http.StatusInternalServerError)
+ return
+ }
+
+ repoPath := filepath.Join(base.Global.Config.Git.RepoDir, fmt.Sprintf("%d.git", repoID))
+
+ gitc, err := git2c.NewClient(r.Context(), base.Global.Config.Git.Socket)
+ if err != nil {
+ slog.Error("git2d connect failed", "error", err)
+ http.Error(w, "Failed to initialize repository (backend)", http.StatusInternalServerError)
+ return
+ }
+ defer func() { _ = gitc.Close() }()
+ if err = gitc.InitRepo(repoPath, base.Global.Config.Hooks.Execs); err != nil {
+ slog.Error("git2d init failed", "error", err)
+ http.Error(w, "Failed to initialize repository", http.StatusInternalServerError)
+ return
+ }
+
+ if err = tx.Commit(r.Context()); err != nil {
+ slog.Error("commit tx failed", "error", err)
+ http.Error(w, "Failed to finalize repository creation", http.StatusInternalServerError)
+ return
+ }
+
+ http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
+}
diff --git a/forged/internal/incoming/web/handlers/index.go b/forged/internal/incoming/web/handlers/index.go
new file mode 100644
index 0000000..a758b07
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/index.go
@@ -0,0 +1,39 @@
+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"
+ 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 := wtypes.Base(r).Global.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 *wtypes.BaseData
+ Groups []queries.GetRootGroupsRow
+ }{
+ BaseData: wtypes.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..c2cb24a
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/repo/index.go
@@ -0,0 +1,132 @@
+package repo
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
+ "go.lindenii.runxiyu.org/forge/forged/internal/database/queries"
+ wtypes "go.lindenii.runxiyu.org/forge/forged/internal/incoming/web/types"
+ "go.lindenii.runxiyu.org/forge/forged/internal/ipc/git2c"
+)
+
+func (h *HTTP) Index(w http.ResponseWriter, r *http.Request, v wtypes.Vars) {
+ base := wtypes.Base(r)
+ repoName := v["repo"]
+ slog.Info("repo index", "group_path", base.GroupPath, "repo", repoName)
+
+ var userID int64
+ if base.UserID != "" {
+ _, _ = fmt.Sscan(base.UserID, &userID)
+ }
+ grp, err := base.Global.Queries.GetGroupByPath(r.Context(), queries.GetGroupByPathParams{
+ Column1: base.GroupPath,
+ UserID: userID,
+ })
+ if err != nil {
+ slog.Error("get group by path", "error", err)
+ http.Error(w, "Group not found", http.StatusNotFound)
+ return
+ }
+
+ repoRow, err := base.Global.Queries.GetRepoByGroupAndName(r.Context(), queries.GetRepoByGroupAndNameParams{
+ GroupID: grp.ID,
+ Name: repoName,
+ })
+ if err != nil {
+ slog.Error("get repo by name", "error", err)
+ http.Error(w, "Repository not found", http.StatusNotFound)
+ return
+ }
+
+ repoPath := filepath.Join(base.Global.Config.Git.RepoDir, fmt.Sprintf("%d.git", repoRow.ID))
+
+ var commits []git2c.Commit
+ var readme template.HTML
+ var commitsErr error
+ var readmeFile *git2c.FilenameContents
+ var cerr error
+ client, err := git2c.NewClient(r.Context(), base.Global.Config.Git.Socket)
+ if err == nil {
+ defer func() { _ = client.Close() }()
+ commits, readmeFile, cerr = client.CmdIndex(repoPath)
+ if cerr != nil {
+ commitsErr = cerr
+ slog.Error("git2d CmdIndex failed", "error", cerr, "path", repoPath)
+ } else if readmeFile != nil {
+ nameLower := strings.ToLower(readmeFile.Filename)
+ if strings.HasSuffix(nameLower, ".md") || strings.HasSuffix(nameLower, ".markdown") || nameLower == "readme" {
+ md := goldmark.New(
+ goldmark.WithExtensions(extension.GFM),
+ )
+ var buf bytes.Buffer
+ if err := md.Convert(readmeFile.Content, &buf); err == nil {
+ readme = template.HTML(buf.String())
+ } else {
+ readme = template.HTML(template.HTMLEscapeString(string(readmeFile.Content)))
+ }
+ } else {
+ readme = template.HTML(template.HTMLEscapeString(string(readmeFile.Content)))
+ }
+ }
+ } else {
+ commitsErr = err
+ slog.Error("git2d connect failed", "error", err)
+ }
+
+ sshRoot := strings.TrimSuffix(base.Global.Config.SSH.Root, "/")
+ httpRoot := strings.TrimSuffix(base.Global.Config.Web.Root, "/")
+ pathPart := misc.SegmentsToURL(base.GroupPath) + "/-/repos/" + url.PathEscape(repoRow.Name)
+ sshURL := ""
+ httpURL := ""
+ if sshRoot != "" {
+ sshURL = sshRoot + "/" + pathPart
+ }
+ if httpRoot != "" {
+ httpURL = httpRoot + "/" + pathPart
+ }
+
+ var notes []string
+ if len(commits) == 0 && commitsErr == nil {
+ notes = append(notes, "This repository has no commits yet.")
+ }
+ if readme == template.HTML("") {
+ notes = append(notes, "No README found in the default branch.")
+ }
+ if sshURL == "" && httpURL == "" {
+ notes = append(notes, "Clone URLs not configured (missing SSH root and HTTP root).")
+ }
+
+ cloneURL := sshURL
+ if cloneURL == "" {
+ cloneURL = httpURL
+ }
+
+ data := map[string]any{
+ "BaseData": base,
+ "group_path": base.GroupPath,
+ "repo_name": repoRow.Name,
+ "repo_description": repoRow.Description,
+ "ssh_clone_url": cloneURL,
+ "ref_name": base.RefName,
+ "commits": commits,
+ "commits_err": &commitsErr,
+ "readme": readme,
+ "notes": notes,
+ "global": map[string]any{
+ "forge_title": base.Global.ForgeTitle,
+ },
+ }
+ if err := h.r.Render(w, "repo_index", data); err != nil {
+ slog.Error("render repo index", "error", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ }
+}
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..8bdfae3
--- /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 += "/"
+ }
+ _, _ = fmt.Fprintf(w, "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..236dd48
--- /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 += "/"
+ }
+ _, _ = fmt.Fprintf(w, "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..5672f1f
--- /dev/null
+++ b/forged/internal/incoming/web/handlers/special/login.go
@@ -0,0 +1,119 @@
+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"
+ 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 *wtypes.BaseData
+ LoginError string
+ }{
+ BaseData: wtypes.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 := wtypes.Base(r).Global.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 = wtypes.Base(r).Global.Queries.InsertSession(r.Context(), queries.InsertSessionParams{
+ UserID: userCreds.ID,
+ TokenHash: tokenHash[:],
+ ExpiresAt: pgtype.Timestamptz{
+ Time: expiry,
+ Valid: true,
+ },
+ })
+ if err != nil {
+ log.Println("failed to insert session", "error", err)
+ http.Error(w, "Failed to create session", http.StatusInternalServerError)
+ return
+ }
+
+ 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..3809afb
--- /dev/null
+++ b/forged/internal/incoming/web/router.go
@@ -0,0 +1,419 @@
+package web
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "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
+}
+
+func NewRouter() *Router { return &Router{} }
+
+func (r *Router) Global(g *global.Global) *Router {
+ r.global = g
+ 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,
+ }
+ 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..ab70aec
--- /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/global"
+)
+
+type Server struct {
+ net string
+ addr string
+ root string
+ httpServer *http.Server
+ shutdownTimeout uint32
+ global *global.Global
+}
+
+func New(global *global.Global) *Server {
+ cfg := global.Config.Web
+ httpServer := &http.Server{
+ Handler: NewHandler(global),
+ ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second,
+ WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
+ IdleTimeout: time.Duration(cfg.IdleTimeout) * time.Second,
+ MaxHeaderBytes: cfg.MaxHeaderBytes,
+ } //exhaustruct:ignore
+ return &Server{
+ net: cfg.Net,
+ addr: cfg.Addr,
+ root: cfg.Root,
+ shutdownTimeout: cfg.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..350e9ec
--- /dev/null
+++ b/forged/internal/incoming/web/templates/renderer.go
@@ -0,0 +1,35 @@
+package templates
+
+import (
+ "bytes"
+ "html/template"
+ "log/slog"
+ "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 {
+ var buf bytes.Buffer
+ if err := r.t.ExecuteTemplate(&buf, name, data); err != nil {
+ slog.Error("template render failed", "name", name, "error", err)
+ return err
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ n, err := w.Write(buf.Bytes())
+ if err != nil {
+ return err
+ }
+ slog.Info("template rendered", "name", name, "bytes", n)
+ return nil
+}
diff --git a/forged/internal/incoming/web/types/types.go b/forged/internal/incoming/web/types/types.go
new file mode 100644
index 0000000..4b9a65a
--- /dev/null
+++ b/forged/internal/incoming/web/types/types.go
@@ -0,0 +1,37 @@
+package types
+
+import (
+ "context"
+ "net/http"
+
+ "go.lindenii.runxiyu.org/forge/forged/internal/global"
+)
+
+type BaseData struct {
+ UserID string
+ Username string
+ URLSegments []string
+ DirMode bool
+ GroupPath []string
+ SeparatorIndex int
+ RefType string
+ RefName string
+ Global *global.Global
+}
+
+type ctxKey struct{}
+
+func WithBaseData(ctx context.Context, b *BaseData) context.Context {
+ return context.WithValue(ctx, ctxKey{}, b)
+}
+
+func Base(r *http.Request) *BaseData {
+ if v, ok := r.Context().Value(ctxKey{}).(*BaseData); ok && v != nil {
+ return v
+ }
+ return &BaseData{}
+}
+
+type Vars map[string]string
+
+type HandlerFunc func(http.ResponseWriter, *http.Request, Vars)