diff options
author | Runxi Yu <me@runxiyu.org> | 2025-08-12 11:01:07 +0800 |
---|---|---|
committer | Runxi Yu <me@runxiyu.org> | 2025-09-13 19:08:22 +0800 |
commit | 5717faed659a9eeb86c528ab56822c42eca1ad3f (patch) | |
tree | 92e6662628a51c03c52300d2fd98173716a82882 /forged/internal/incoming/web | |
parent | Remove forge-specific functions from misc (diff) | |
download | forge-5717faed659a9eeb86c528ab56822c42eca1ad3f.tar.gz forge-5717faed659a9eeb86c528ab56822c42eca1ad3f.tar.zst forge-5717faed659a9eeb86c528ab56822c42eca1ad3f.zip |
Refactor
Diffstat (limited to 'forged/internal/incoming/web')
-rw-r--r-- | forged/internal/incoming/web/authn.go | 33 | ||||
-rw-r--r-- | forged/internal/incoming/web/config.go | 16 | ||||
-rw-r--r-- | forged/internal/incoming/web/handler.go | 77 | ||||
-rw-r--r-- | forged/internal/incoming/web/handlers/group.go | 92 | ||||
-rw-r--r-- | forged/internal/incoming/web/handlers/index.go | 40 | ||||
-rw-r--r-- | forged/internal/incoming/web/handlers/not_implemented.go | 22 | ||||
-rw-r--r-- | forged/internal/incoming/web/handlers/repo/handler.go | 15 | ||||
-rw-r--r-- | forged/internal/incoming/web/handlers/repo/index.go | 20 | ||||
-rw-r--r-- | forged/internal/incoming/web/handlers/repo/raw.go | 19 | ||||
-rw-r--r-- | forged/internal/incoming/web/handlers/repo/tree.go | 19 | ||||
-rw-r--r-- | forged/internal/incoming/web/handlers/special/login.go | 115 | ||||
-rw-r--r-- | forged/internal/incoming/web/router.go | 428 | ||||
-rw-r--r-- | forged/internal/incoming/web/server.go | 70 | ||||
-rw-r--r-- | forged/internal/incoming/web/templates/load.go | 31 | ||||
-rw-r--r-- | forged/internal/incoming/web/templates/renderer.go | 23 | ||||
-rw-r--r-- | forged/internal/incoming/web/types/types.go | 45 |
16 files changed, 1065 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..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) |