Lindenii Project Forge
Make logging in work
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 }
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)}
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", notImpl.Handle)
h.r.ANY("-/login", loginHTTP.Login)
h.r.ANY("-/users", notImpl.Handle) // Group index h.r.GET("@group/", groupHTTP.Index) // Repo index h.r.GET("@group/-/repos/:repo/", repoHTTP.Index) // Repo (not implemented yet) h.r.ANY("@group/-/repos/:repo/info", notImpl.Handle) h.r.ANY("@group/-/repos/:repo/git-upload-pack", notImpl.Handle) // Repo features h.r.GET("@group/-/repos/:repo/branches/", notImpl.Handle) h.r.GET("@group/-/repos/:repo/log/", notImpl.Handle) h.r.GET("@group/-/repos/:repo/commit/:commit", notImpl.Handle) h.r.GET("@group/-/repos/:repo/tree/*rest", repoHTTP.Tree, WithDirIfEmpty("rest")) h.r.GET("@group/-/repos/:repo/raw/*rest", repoHTTP.Raw, WithDirIfEmpty("rest")) h.r.GET("@group/-/repos/:repo/contrib/", notImpl.Handle) h.r.GET("@group/-/repos/:repo/contrib/:mr", notImpl.Handle) return h } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.r.ServeHTTP(w, r) }
package handlers import ( "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) }
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 } } // Prepare base data; vars are attached per-route below. 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 // for 405 detection for _, rt := range r.routes { ok, vars, sepIdx := match(rt.segs, segments) if !ok { continue } pathMatched = true switch rt.wantDir { case dirRequire: if !dirMode && redirectAddSlash(w, req) { return } case dirForbid: if dirMode && redirectDropSlash(w, req) { return } case dirRequireIfEmpty: if v := vars[rt.ifEmptyKey]; v == "" && !dirMode && redirectAddSlash(w, req) { return } } // Derive group path and separator index on the matched request. bd.SeparatorIndex = sepIdx if g := vars["group"]; g == "" { bd.GroupPath = []string{} } else { bd.GroupPath = strings.Split(g, "/") } // Attach BaseData to request context.
req = req.WithContext(wtypes.WithBaseData(req.Context(), bd))
// req = req.WithContext(wtypes.WithBaseData(req.Context(), bd))
// Enforce method now. if rt.method != "" && !(rt.method == method || (method == http.MethodHead && rt.method == http.MethodGet)) { // 405 for a path that matched but wrong method w.Header().Set("Allow", allowForPattern(r.routes, rt.rawPattern)) http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } if rt.h != nil { rt.h(w, req, wtypes.Vars(vars)) } else if rt.hh != nil { rt.hh.ServeHTTP(w, req) } else { r.err500(w, bd, "route has no handler") } return } if pathMatched { // Safety; normally handled above, but keep semantics. http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } r.err404(w, bd) } func compilePattern(pat string) ([]patSeg, int) { if pat == "" || pat == "/" { return nil, 1000 } pat = strings.Trim(pat, "/") raw := strings.Split(pat, "/") segs := make([]patSeg, 0, len(raw)) prio := 0 for i, t := range raw { switch { case t == "@group": if i != 0 { segs = append(segs, patSeg{kind: lit, lit: t}) prio += 10 continue } segs = append(segs, patSeg{kind: group}) prio += 1 case strings.HasPrefix(t, ":"): segs = append(segs, patSeg{kind: param, key: t[1:]}) prio += 5 case strings.HasPrefix(t, "*"): segs = append(segs, patSeg{kind: splat, key: t[1:]}) default: segs = append(segs, patSeg{kind: lit, lit: t}) prio += 10 } } return segs, prio } func match(pat []patSeg, segs []string) (bool, map[string]string, int) { vars := make(map[string]string) i := 0 sepIdx := -1 for pi := 0; pi < len(pat); pi++ { ps := pat[pi] switch ps.kind { case group: start := i for i < len(segs) && segs[i] != "-" { i++ } if start < i { vars["group"] = strings.Join(segs[start:i], "/") } else { vars["group"] = "" } if i < len(segs) && segs[i] == "-" { sepIdx = i } case lit: if i >= len(segs) || segs[i] != ps.lit { return false, nil, -1 } i++ case param: if i >= len(segs) { return false, nil, -1 } vars[ps.key] = segs[i] i++ case splat: if i < len(segs) { vars[ps.key] = strings.Join(segs[i:], "/") i = len(segs) } else { vars[ps.key] = "" } pi = len(pat) } } if i != len(segs) { return false, nil, -1 } return true, vars, sepIdx } func splitAndUnescapePath(escaped string) ([]string, bool, error) { if escaped == "" { return nil, false, nil } dir := strings.HasSuffix(escaped, "/") path := strings.Trim(escaped, "/") if path == "" { return []string{}, dir, nil } raw := strings.Split(path, "/") out := make([]string, 0, len(raw)) for _, seg := range raw { u, err := url.PathUnescape(seg) if err != nil { return nil, dir, err } if u != "" { out = append(out, u) } } return out, dir, nil } func redirectAddSlash(w http.ResponseWriter, r *http.Request) bool { u := *r.URL u.Path = u.EscapedPath() + "/" http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect) return true } func redirectDropSlash(w http.ResponseWriter, r *http.Request) bool { u := *r.URL u.Path = strings.TrimRight(u.EscapedPath(), "/") if u.Path == "" { u.Path = "/" } http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect) return true } func allowForPattern(routes []route, raw string) string { seen := map[string]struct{}{} out := make([]string, 0, 4) for _, rt := range routes { if rt.rawPattern != raw || rt.method == "" { continue } if _, ok := seen[rt.method]; ok { continue } seen[rt.method] = struct{}{} out = append(out, rt.method) } sort.Strings(out) return strings.Join(out, ", ") } func (r *Router) err400(w http.ResponseWriter, b *wtypes.BaseData, msg string) { if r.errors.BadRequest != nil { r.errors.BadRequest(w, b, msg) return } http.Error(w, msg, http.StatusBadRequest) } func (r *Router) err400Colon(w http.ResponseWriter, b *wtypes.BaseData) { if r.errors.BadRequestColon != nil { r.errors.BadRequestColon(w, b) return } http.Error(w, "bad request", http.StatusBadRequest) } func (r *Router) err404(w http.ResponseWriter, b *wtypes.BaseData) { if r.errors.NotFound != nil { r.errors.NotFound(w, b) return } http.NotFound(w, nil) } func (r *Router) err500(w http.ResponseWriter, b *wtypes.BaseData, msg string) { if r.errors.ServerError != nil { r.errors.ServerError(w, b, msg) return } http.Error(w, msg, http.StatusInternalServerError) } func GetParamRefTypeName(request *http.Request) (retRefType, retRefName string, err error) { rawQuery := request.URL.RawQuery queryValues, err := url.ParseQuery(rawQuery) if err != nil { return } done := false for _, refType := range []string{"commit", "branch", "tag"} { refName, ok := queryValues[refType] if ok { if done { err = errDupRefSpec return } done = true if len(refName) != 1 { err = errDupRefSpec return } retRefName = refName[0] retRefType = refType } } if !done { retRefType = "" retRefName = "" err = nil // actually returning empty strings is enough? } return } var ( errDupRefSpec = fmt.Errorf("duplicate ref specifications") )
-- name: GetUserCreds :one SELECT id, COALESCE(password_hash, '') FROM users WHERE username = $1; -- name: InsertSession :exec INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3); -- name: GetUserFromSession :one SELECT user_id, COALESCE(username, '') FROM users u JOIN sessions s ON u.id = s.user_id WHERE s.token_hash = $1;
{{/* SPDX-License-Identifier: AGPL-3.0-only SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> */}} {{- define "login" -}} <!DOCTYPE html> <html lang="en"> <head> {{- template "head_common" . -}}
<title>Login – {{ .global.forge_title -}}</title>
<title>Login – {{ .BaseData.Global.ForgeTitle -}}</title>
</head> <body class="index"> <main>
{{- .login_error -}}
{{- .LoginError -}}
<div class="padding-wrapper"> <form method="POST" enctype="application/x-www-form-urlencoded"> <table> <thead> <tr> <th class="title-row" colspan="2"> Password authentication </th> </tr> </thead> <tbody> <tr> <th scope="row">Username</th> <td class="tdinput"> <input id="usernameinput" name="username" type="text" /> </td> </tr> <tr> <th scope="row">Password</th> <td class="tdinput"> <input id="passwordinput" name="password" type="password" /> </td> </tr> </tbody> <tfoot> <tr> <td class="th-like" colspan="2"> <div class="flex-justify"> <div class="left"> </div> <div class="right"> <input class="btn-primary" type="submit" value="Submit" /> </div> </div> </td> </tr> </tfoot> </table> </form> </div> </main> <footer> {{- template "footer" . -}} </footer> </body> </html> {{- end -}}