gitea source for verification 2026-05-22
This commit is contained in:
175
modules/web/handler.go
Normal file
175
modules/web/handler.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/web/routing"
|
||||
"code.gitea.io/gitea/modules/web/types"
|
||||
)
|
||||
|
||||
var responseStatusProviders = map[reflect.Type]func(req *http.Request) types.ResponseStatusProvider{}
|
||||
|
||||
func RegisterResponseStatusProvider[T any](fn func(req *http.Request) types.ResponseStatusProvider) {
|
||||
responseStatusProviders[reflect.TypeFor[T]()] = fn
|
||||
}
|
||||
|
||||
// responseWriter is a wrapper of http.ResponseWriter, to check whether the response has been written
|
||||
type responseWriter struct {
|
||||
respWriter http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
var _ types.ResponseStatusProvider = (*responseWriter)(nil)
|
||||
|
||||
func (r *responseWriter) WrittenStatus() int {
|
||||
return r.status
|
||||
}
|
||||
|
||||
func (r *responseWriter) Header() http.Header {
|
||||
return r.respWriter.Header()
|
||||
}
|
||||
|
||||
func (r *responseWriter) Write(bytes []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
return r.respWriter.Write(bytes)
|
||||
}
|
||||
|
||||
func (r *responseWriter) WriteHeader(statusCode int) {
|
||||
r.status = statusCode
|
||||
r.respWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
var (
|
||||
httpReqType = reflect.TypeFor[*http.Request]()
|
||||
respWriterType = reflect.TypeFor[http.ResponseWriter]()
|
||||
)
|
||||
|
||||
// preCheckHandler checks whether the handler is valid, developers could get first-time feedback, all mistakes could be found at startup
|
||||
func preCheckHandler(fn reflect.Value, argsIn []reflect.Value) {
|
||||
hasStatusProvider := false
|
||||
for _, argIn := range argsIn {
|
||||
if _, hasStatusProvider = argIn.Interface().(types.ResponseStatusProvider); hasStatusProvider {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasStatusProvider {
|
||||
panic(fmt.Sprintf("handler should have at least one ResponseStatusProvider argument, but got %s", fn.Type()))
|
||||
}
|
||||
if fn.Type().NumOut() != 0 {
|
||||
panic(fmt.Sprintf("handler should have no return value other than registered ones, but got %s", fn.Type()))
|
||||
}
|
||||
}
|
||||
|
||||
func prepareHandleArgsIn(resp http.ResponseWriter, req *http.Request, fn reflect.Value, fnInfo *routing.FuncInfo) []reflect.Value {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Error("unable to prepare handler arguments for %s: %v", fnInfo.String(), err)
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
isPreCheck := req == nil
|
||||
|
||||
argsIn := make([]reflect.Value, fn.Type().NumIn())
|
||||
for i := 0; i < fn.Type().NumIn(); i++ {
|
||||
argTyp := fn.Type().In(i)
|
||||
switch argTyp {
|
||||
case respWriterType:
|
||||
argsIn[i] = reflect.ValueOf(resp)
|
||||
case httpReqType:
|
||||
argsIn[i] = reflect.ValueOf(req)
|
||||
default:
|
||||
if argFn, ok := responseStatusProviders[argTyp]; ok {
|
||||
if isPreCheck {
|
||||
argsIn[i] = reflect.ValueOf(&responseWriter{})
|
||||
} else {
|
||||
argsIn[i] = reflect.ValueOf(argFn(req))
|
||||
}
|
||||
} else {
|
||||
panic(fmt.Sprintf("unsupported argument type: %s", argTyp))
|
||||
}
|
||||
}
|
||||
}
|
||||
return argsIn
|
||||
}
|
||||
|
||||
func handleResponse(fn reflect.Value, ret []reflect.Value) {
|
||||
if len(ret) != 0 {
|
||||
panic(fmt.Sprintf("unsupported return values: %s", fn.Type()))
|
||||
}
|
||||
}
|
||||
|
||||
func hasResponseBeenWritten(argsIn []reflect.Value) bool {
|
||||
for _, argIn := range argsIn {
|
||||
if statusProvider, ok := argIn.Interface().(types.ResponseStatusProvider); ok {
|
||||
if statusProvider.WrittenStatus() != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wrapHandlerProvider[T http.Handler](hp func(next http.Handler) T, funcInfo *routing.FuncInfo) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
h := hp(next) // this handle could be dynamically generated, so we can't use it for debug info
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
defer routing.RecordFuncInfo(req.Context(), funcInfo)()
|
||||
h.ServeHTTP(resp, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// toHandlerProvider converts a handler to a handler provider
|
||||
// A handler provider is a function that takes a "next" http.Handler, it can be used as a middleware
|
||||
func toHandlerProvider(handler any) func(next http.Handler) http.Handler {
|
||||
funcInfo := routing.GetFuncInfo(handler)
|
||||
fn := reflect.ValueOf(handler)
|
||||
if fn.Type().Kind() != reflect.Func {
|
||||
panic(fmt.Sprintf("handler must be a function, but got %s", fn.Type()))
|
||||
}
|
||||
|
||||
if hp, ok := handler.(func(next http.Handler) http.Handler); ok {
|
||||
return wrapHandlerProvider(hp, funcInfo)
|
||||
} else if hp, ok := handler.(func(http.Handler) http.HandlerFunc); ok {
|
||||
return wrapHandlerProvider(hp, funcInfo)
|
||||
}
|
||||
|
||||
provider := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(respOrig http.ResponseWriter, req *http.Request) {
|
||||
// wrap the response writer to check whether the response has been written
|
||||
resp := respOrig
|
||||
if _, ok := resp.(types.ResponseStatusProvider); !ok {
|
||||
resp = &responseWriter{respWriter: resp}
|
||||
}
|
||||
|
||||
// prepare the arguments for the handler and do pre-check
|
||||
argsIn := prepareHandleArgsIn(resp, req, fn, funcInfo)
|
||||
if req == nil {
|
||||
preCheckHandler(fn, argsIn)
|
||||
return // it's doing pre-check, just return
|
||||
}
|
||||
|
||||
defer routing.RecordFuncInfo(req.Context(), funcInfo)()
|
||||
ret := fn.Call(argsIn)
|
||||
|
||||
// handle the return value (no-op at the moment)
|
||||
handleResponse(fn, ret)
|
||||
|
||||
// if the response has not been written, call the next handler
|
||||
if next != nil && !hasResponseBeenWritten(argsIn) {
|
||||
next.ServeHTTP(resp, req)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
provider(nil).ServeHTTP(nil, nil) // do a pre-check to make sure all arguments and return values are supported
|
||||
return provider
|
||||
}
|
||||
157
modules/web/middleware/binding.go
Normal file
157
modules/web/middleware/binding.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/validation"
|
||||
|
||||
"gitea.com/go-chi/binding"
|
||||
)
|
||||
|
||||
// Form form binding interface
|
||||
type Form interface {
|
||||
binding.Validator
|
||||
}
|
||||
|
||||
func init() {
|
||||
binding.SetNameMapper(util.ToSnakeCase)
|
||||
}
|
||||
|
||||
// AssignForm assign form values back to the template data.
|
||||
func AssignForm(form any, data map[string]any) {
|
||||
typ := reflect.TypeOf(form)
|
||||
val := reflect.ValueOf(form)
|
||||
|
||||
for typ.Kind() == reflect.Ptr {
|
||||
typ = typ.Elem()
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
|
||||
fieldName := field.Tag.Get("form")
|
||||
// Allow ignored fields in the struct
|
||||
if fieldName == "-" {
|
||||
continue
|
||||
} else if len(fieldName) == 0 {
|
||||
fieldName = util.ToSnakeCase(field.Name)
|
||||
}
|
||||
|
||||
data[fieldName] = val.Field(i).Interface()
|
||||
}
|
||||
}
|
||||
|
||||
func getRuleBody(field reflect.StructField, prefix string) string {
|
||||
for rule := range strings.SplitSeq(field.Tag.Get("binding"), ";") {
|
||||
if strings.HasPrefix(rule, prefix) {
|
||||
return rule[len(prefix) : len(rule)-1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSize get size int form tag
|
||||
func GetSize(field reflect.StructField) string {
|
||||
return getRuleBody(field, "Size(")
|
||||
}
|
||||
|
||||
// GetMinSize get minimal size in form tag
|
||||
func GetMinSize(field reflect.StructField) string {
|
||||
return getRuleBody(field, "MinSize(")
|
||||
}
|
||||
|
||||
// GetMaxSize get max size in form tag
|
||||
func GetMaxSize(field reflect.StructField) string {
|
||||
return getRuleBody(field, "MaxSize(")
|
||||
}
|
||||
|
||||
// GetInclude get include in form tag
|
||||
func GetInclude(field reflect.StructField) string {
|
||||
return getRuleBody(field, "Include(")
|
||||
}
|
||||
|
||||
// Validate validate
|
||||
func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Locale) binding.Errors {
|
||||
if errs.Len() == 0 {
|
||||
return errs
|
||||
}
|
||||
|
||||
data["HasError"] = true
|
||||
// If the field with name errs[0].FieldNames[0] is not found in form
|
||||
// somehow, some code later on will panic on Data["ErrorMsg"].(string).
|
||||
// So initialize it to some default.
|
||||
data["ErrorMsg"] = l.Tr("form.unknown_error")
|
||||
AssignForm(f, data)
|
||||
|
||||
typ := reflect.TypeOf(f)
|
||||
|
||||
if typ.Kind() == reflect.Ptr {
|
||||
typ = typ.Elem()
|
||||
}
|
||||
|
||||
if field, ok := typ.FieldByName(errs[0].FieldNames[0]); ok {
|
||||
fieldName := field.Tag.Get("form")
|
||||
if fieldName != "-" {
|
||||
data["Err_"+field.Name] = true
|
||||
|
||||
trName := field.Tag.Get("locale")
|
||||
if len(trName) == 0 {
|
||||
trName = l.TrString("form." + field.Name)
|
||||
} else {
|
||||
trName = l.TrString(trName)
|
||||
}
|
||||
|
||||
switch errs[0].Classification {
|
||||
case binding.ERR_REQUIRED:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.require_error")
|
||||
case binding.ERR_ALPHA_DASH:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.alpha_dash_error")
|
||||
case binding.ERR_ALPHA_DASH_DOT:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.alpha_dash_dot_error")
|
||||
case validation.ErrGitRefName:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.git_ref_name_error")
|
||||
case binding.ERR_SIZE:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.size_error", GetSize(field))
|
||||
case binding.ERR_MIN_SIZE:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.min_size_error", GetMinSize(field))
|
||||
case binding.ERR_MAX_SIZE:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.max_size_error", GetMaxSize(field))
|
||||
case binding.ERR_EMAIL:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.email_error")
|
||||
case binding.ERR_URL:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.url_error", errs[0].Message)
|
||||
case binding.ERR_INCLUDE:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.include_error", GetInclude(field))
|
||||
case validation.ErrGlobPattern:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.glob_pattern_error", errs[0].Message)
|
||||
case validation.ErrRegexPattern:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.regex_pattern_error", errs[0].Message)
|
||||
case validation.ErrUsername:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.username_error")
|
||||
case validation.ErrInvalidGroupTeamMap:
|
||||
data["ErrorMsg"] = trName + l.TrString("form.invalid_group_team_map_error", errs[0].Message)
|
||||
default:
|
||||
msg := errs[0].Classification
|
||||
if msg != "" && errs[0].Message != "" {
|
||||
msg += ": "
|
||||
}
|
||||
|
||||
msg += errs[0].Message
|
||||
if msg == "" {
|
||||
msg = l.TrString("form.unknown_error")
|
||||
}
|
||||
data["ErrorMsg"] = trName + ": " + msg
|
||||
}
|
||||
return errs
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
85
modules/web/middleware/cookie.go
Normal file
85
modules/web/middleware/cookie.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright 2020 The Macaron Authors
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/session"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
// SetRedirectToCookie convenience function to set the RedirectTo cookie consistently
|
||||
func SetRedirectToCookie(resp http.ResponseWriter, value string) {
|
||||
SetSiteCookie(resp, "redirect_to", value, 0)
|
||||
}
|
||||
|
||||
// DeleteRedirectToCookie convenience function to delete most cookies consistently
|
||||
func DeleteRedirectToCookie(resp http.ResponseWriter) {
|
||||
SetSiteCookie(resp, "redirect_to", "", -1)
|
||||
}
|
||||
|
||||
// GetSiteCookie returns given cookie value from request header.
|
||||
func GetSiteCookie(req *http.Request, name string) string {
|
||||
cookie, err := req.Cookie(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
val, _ := url.QueryUnescape(cookie.Value)
|
||||
return val
|
||||
}
|
||||
|
||||
// SetSiteCookie returns given cookie value from request header.
|
||||
func SetSiteCookie(resp http.ResponseWriter, name, value string, maxAge int) {
|
||||
// Previous versions would use a cookie path with a trailing /.
|
||||
// These are more specific than cookies without a trailing /, so
|
||||
// we need to delete these if they exist.
|
||||
deleteLegacySiteCookie(resp, name)
|
||||
cookie := &http.Cookie{
|
||||
Name: name,
|
||||
Value: url.QueryEscape(value),
|
||||
MaxAge: maxAge,
|
||||
Path: setting.SessionConfig.CookiePath,
|
||||
Domain: setting.SessionConfig.Domain,
|
||||
Secure: setting.SessionConfig.Secure,
|
||||
HttpOnly: true,
|
||||
SameSite: setting.SessionConfig.SameSite,
|
||||
}
|
||||
resp.Header().Add("Set-Cookie", cookie.String())
|
||||
}
|
||||
|
||||
// deleteLegacySiteCookie deletes the cookie with the given name at the cookie
|
||||
// path with a trailing /, which would unintentionally override the cookie.
|
||||
func deleteLegacySiteCookie(resp http.ResponseWriter, name string) {
|
||||
if setting.SessionConfig.CookiePath == "" || strings.HasSuffix(setting.SessionConfig.CookiePath, "/") {
|
||||
// If the cookie path ends with /, no legacy cookies will take
|
||||
// precedence, so do nothing. The exception is that cookies with no
|
||||
// path could override other cookies, but it's complicated and we don't
|
||||
// currently handle that.
|
||||
return
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
Path: setting.SessionConfig.CookiePath + "/",
|
||||
Domain: setting.SessionConfig.Domain,
|
||||
Secure: setting.SessionConfig.Secure,
|
||||
HttpOnly: true,
|
||||
SameSite: setting.SessionConfig.SameSite,
|
||||
}
|
||||
resp.Header().Add("Set-Cookie", cookie.String())
|
||||
}
|
||||
|
||||
func init() {
|
||||
session.BeforeRegenerateSession = append(session.BeforeRegenerateSession, func(resp http.ResponseWriter, _ *http.Request) {
|
||||
// Ensure that a cookie with a trailing slash does not take precedence over
|
||||
// the cookie written by the middleware.
|
||||
deleteLegacySiteCookie(resp, setting.SessionConfig.CookieName)
|
||||
})
|
||||
}
|
||||
40
modules/web/middleware/data.go
Normal file
40
modules/web/middleware/data.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
const ContextDataKeySignedUser = "SignedUser"
|
||||
|
||||
func GetContextData(c context.Context) reqctx.ContextData {
|
||||
if rc := reqctx.GetRequestDataStore(c); rc != nil {
|
||||
return rc.GetData()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CommonTemplateContextData() reqctx.ContextData {
|
||||
return reqctx.ContextData{
|
||||
"PageTitleCommon": setting.AppName,
|
||||
|
||||
"IsLandingPageOrganizations": setting.LandingPageURL == setting.LandingPageOrganizations,
|
||||
|
||||
"ShowRegistrationButton": setting.Service.ShowRegistrationButton,
|
||||
"ShowMilestonesDashboardPage": setting.Service.ShowMilestonesDashboardPage,
|
||||
"ShowFooterVersion": setting.Other.ShowFooterVersion,
|
||||
"DisableDownloadSourceArchives": setting.Repository.DisableDownloadSourceArchives,
|
||||
|
||||
"EnableSwagger": setting.API.EnableSwagger,
|
||||
"EnableOpenIDSignIn": setting.Service.EnableOpenIDSignIn,
|
||||
"PageStartTime": time.Now(),
|
||||
|
||||
"RunModeIsProd": setting.IsProd,
|
||||
}
|
||||
}
|
||||
92
modules/web/middleware/flash.go
Normal file
92
modules/web/middleware/flash.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
)
|
||||
|
||||
// Flash represents a one time data transfer between two requests.
|
||||
type Flash struct {
|
||||
DataStore reqctx.RequestDataStore
|
||||
url.Values
|
||||
ErrorMsg, WarningMsg, InfoMsg, SuccessMsg string
|
||||
}
|
||||
|
||||
func (f *Flash) set(name, msg string, current ...bool) {
|
||||
if f.Values == nil {
|
||||
f.Values = make(map[string][]string)
|
||||
}
|
||||
showInCurrentPage := len(current) > 0 && current[0]
|
||||
if showInCurrentPage {
|
||||
// assign it to the context data, then the template can use ".Flash.XxxMsg" to render the message
|
||||
f.DataStore.GetData()["Flash"] = f
|
||||
} else {
|
||||
// the message map will be saved into the cookie and be shown in next response (a new page response which decodes the cookie)
|
||||
f.Set(name, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func flashMsgStringOrHTML(msg any) string {
|
||||
switch v := msg.(type) {
|
||||
case string:
|
||||
return v
|
||||
case template.HTML:
|
||||
return string(v)
|
||||
}
|
||||
panic(fmt.Sprintf("unknown type: %T", msg))
|
||||
}
|
||||
|
||||
// Error sets error message
|
||||
func (f *Flash) Error(msg any, current ...bool) {
|
||||
f.ErrorMsg = flashMsgStringOrHTML(msg)
|
||||
f.set("error", f.ErrorMsg, current...)
|
||||
}
|
||||
|
||||
// Warning sets warning message
|
||||
func (f *Flash) Warning(msg any, current ...bool) {
|
||||
f.WarningMsg = flashMsgStringOrHTML(msg)
|
||||
f.set("warning", f.WarningMsg, current...)
|
||||
}
|
||||
|
||||
// Info sets info message
|
||||
func (f *Flash) Info(msg any, current ...bool) {
|
||||
f.InfoMsg = flashMsgStringOrHTML(msg)
|
||||
f.set("info", f.InfoMsg, current...)
|
||||
}
|
||||
|
||||
// Success sets success message
|
||||
func (f *Flash) Success(msg any, current ...bool) {
|
||||
f.SuccessMsg = flashMsgStringOrHTML(msg)
|
||||
f.set("success", f.SuccessMsg, current...)
|
||||
}
|
||||
|
||||
func ParseCookieFlashMessage(val string) *Flash {
|
||||
if vals, _ := url.ParseQuery(val); len(vals) > 0 {
|
||||
return &Flash{
|
||||
Values: vals,
|
||||
ErrorMsg: vals.Get("error"),
|
||||
SuccessMsg: vals.Get("success"),
|
||||
InfoMsg: vals.Get("info"),
|
||||
WarningMsg: vals.Get("warning"),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetSiteCookieFlashMessage(dataStore reqctx.RequestDataStore, req *http.Request, cookieName string) (string, *Flash) {
|
||||
// Get the last flash message from cookie
|
||||
lastFlashCookie := GetSiteCookie(req, cookieName)
|
||||
lastFlashMsg := ParseCookieFlashMessage(lastFlashCookie)
|
||||
if lastFlashMsg != nil {
|
||||
lastFlashMsg.DataStore = dataStore
|
||||
return lastFlashCookie, lastFlashMsg
|
||||
}
|
||||
return lastFlashCookie, nil
|
||||
}
|
||||
59
modules/web/middleware/locale.go
Normal file
59
modules/web/middleware/locale.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/translation/i18n"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Locale handle locale
|
||||
func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
|
||||
// 1. Check URL arguments.
|
||||
lang := req.URL.Query().Get("lang")
|
||||
changeLang := lang != ""
|
||||
|
||||
// 2. Get language information from cookies.
|
||||
if len(lang) == 0 {
|
||||
ck, _ := req.Cookie("lang")
|
||||
if ck != nil {
|
||||
lang = ck.Value
|
||||
}
|
||||
}
|
||||
|
||||
// Check again in case someone changes the supported language list.
|
||||
if lang != "" && !i18n.DefaultLocales.HasLang(lang) {
|
||||
lang = ""
|
||||
changeLang = false
|
||||
}
|
||||
|
||||
// 3. Get language information from 'Accept-Language'.
|
||||
// The first element in the list is chosen to be the default language automatically.
|
||||
if len(lang) == 0 {
|
||||
tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
|
||||
tag := translation.Match(tags...)
|
||||
lang = tag.String()
|
||||
}
|
||||
|
||||
if changeLang {
|
||||
SetLocaleCookie(resp, lang, 1<<31-1)
|
||||
}
|
||||
|
||||
return translation.NewLocale(lang)
|
||||
}
|
||||
|
||||
// SetLocaleCookie convenience function to set the locale cookie consistently
|
||||
func SetLocaleCookie(resp http.ResponseWriter, lang string, maxAge int) {
|
||||
SetSiteCookie(resp, "lang", lang, maxAge)
|
||||
}
|
||||
|
||||
// DeleteLocaleCookie convenience function to delete the locale cookie consistently
|
||||
// Setting the lang cookie will trigger the middleware to reset the language to previous state.
|
||||
func DeleteLocaleCookie(resp http.ResponseWriter) {
|
||||
SetSiteCookie(resp, "lang", "", -1)
|
||||
}
|
||||
61
modules/web/routemock.go
Normal file
61
modules/web/routemock.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
// MockAfterMiddlewares is a general mock point, it's between middlewares and the handler
|
||||
const MockAfterMiddlewares = "MockAfterMiddlewares"
|
||||
|
||||
var routeMockPoints = map[string]func(next http.Handler) http.Handler{}
|
||||
|
||||
// RouterMockPoint registers a mock point as a middleware for testing, example:
|
||||
//
|
||||
// r.Use(web.RouterMockPoint("my-mock-point-1"))
|
||||
// r.Get("/foo", middleware2, web.RouterMockPoint("my-mock-point-2"), middleware2, handler)
|
||||
//
|
||||
// Then use web.RouteMock to mock the route execution.
|
||||
// It only takes effect in testing mode (setting.IsInTesting == true).
|
||||
func RouterMockPoint(pointName string) func(next http.Handler) http.Handler {
|
||||
if !setting.IsInTesting {
|
||||
return nil
|
||||
}
|
||||
routeMockPoints[pointName] = nil
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if h := routeMockPoints[pointName]; h != nil {
|
||||
h(next).ServeHTTP(w, r)
|
||||
} else {
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RouteMock uses the registered mock point to mock the route execution, example:
|
||||
//
|
||||
// defer web.RouteMockReset()
|
||||
// web.RouteMock(web.MockAfterMiddlewares, func(ctx *context.Context) {
|
||||
// ctx.WriteResponse(...)
|
||||
// }
|
||||
//
|
||||
// Then the mock function will be executed as a middleware at the mock point.
|
||||
// It only takes effect in testing mode (setting.IsInTesting == true).
|
||||
func RouteMock(pointName string, h any) {
|
||||
if _, ok := routeMockPoints[pointName]; !ok {
|
||||
panic("route mock point not found: " + pointName)
|
||||
}
|
||||
routeMockPoints[pointName] = toHandlerProvider(h)
|
||||
}
|
||||
|
||||
// RouteMockReset resets all mock points (no mock anymore)
|
||||
func RouteMockReset() {
|
||||
for k := range routeMockPoints {
|
||||
routeMockPoints[k] = nil // keep the keys because RouteMock will check the keys to make sure no misspelling
|
||||
}
|
||||
}
|
||||
70
modules/web/routemock_test.go
Normal file
70
modules/web/routemock_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRouteMock(t *testing.T) {
|
||||
setting.IsInTesting = true
|
||||
|
||||
r := NewRouter()
|
||||
middleware1 := func(resp http.ResponseWriter, req *http.Request) {
|
||||
resp.Header().Set("X-Test-Middleware1", "m1")
|
||||
}
|
||||
middleware2 := func(resp http.ResponseWriter, req *http.Request) {
|
||||
resp.Header().Set("X-Test-Middleware2", "m2")
|
||||
}
|
||||
handler := func(resp http.ResponseWriter, req *http.Request) {
|
||||
resp.Header().Set("X-Test-Handler", "h")
|
||||
}
|
||||
r.Get("/foo", middleware1, RouterMockPoint("mock-point"), middleware2, handler)
|
||||
|
||||
// normal request
|
||||
recorder := httptest.NewRecorder()
|
||||
req, err := http.NewRequest(http.MethodGet, "http://localhost:8000/foo", nil)
|
||||
assert.NoError(t, err)
|
||||
r.ServeHTTP(recorder, req)
|
||||
assert.Len(t, recorder.Header(), 3)
|
||||
assert.Equal(t, "m1", recorder.Header().Get("X-Test-Middleware1"))
|
||||
assert.Equal(t, "m2", recorder.Header().Get("X-Test-Middleware2"))
|
||||
assert.Equal(t, "h", recorder.Header().Get("X-Test-Handler"))
|
||||
RouteMockReset()
|
||||
|
||||
// mock at "mock-point"
|
||||
RouteMock("mock-point", func(resp http.ResponseWriter, req *http.Request) {
|
||||
resp.Header().Set("X-Test-MockPoint", "a")
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
})
|
||||
recorder = httptest.NewRecorder()
|
||||
req, err = http.NewRequest(http.MethodGet, "http://localhost:8000/foo", nil)
|
||||
assert.NoError(t, err)
|
||||
r.ServeHTTP(recorder, req)
|
||||
assert.Len(t, recorder.Header(), 2)
|
||||
assert.Equal(t, "m1", recorder.Header().Get("X-Test-Middleware1"))
|
||||
assert.Equal(t, "a", recorder.Header().Get("X-Test-MockPoint"))
|
||||
RouteMockReset()
|
||||
|
||||
// mock at MockAfterMiddlewares
|
||||
RouteMock(MockAfterMiddlewares, func(resp http.ResponseWriter, req *http.Request) {
|
||||
resp.Header().Set("X-Test-MockPoint", "b")
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
})
|
||||
recorder = httptest.NewRecorder()
|
||||
req, err = http.NewRequest(http.MethodGet, "http://localhost:8000/foo", nil)
|
||||
assert.NoError(t, err)
|
||||
r.ServeHTTP(recorder, req)
|
||||
assert.Len(t, recorder.Header(), 3)
|
||||
assert.Equal(t, "m1", recorder.Header().Get("X-Test-Middleware1"))
|
||||
assert.Equal(t, "m2", recorder.Header().Get("X-Test-Middleware2"))
|
||||
assert.Equal(t, "b", recorder.Header().Get("X-Test-MockPoint"))
|
||||
RouteMockReset()
|
||||
}
|
||||
258
modules/web/router.go
Normal file
258
modules/web/router.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
|
||||
"gitea.com/go-chi/binding"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Bind binding an obj to a handler's context data
|
||||
func Bind[T any](_ T) http.HandlerFunc {
|
||||
return func(resp http.ResponseWriter, req *http.Request) {
|
||||
theObj := new(T) // create a new form obj for every request but not use obj directly
|
||||
data := middleware.GetContextData(req.Context())
|
||||
binding.Bind(req, theObj)
|
||||
SetForm(data, theObj)
|
||||
middleware.AssignForm(theObj, data)
|
||||
}
|
||||
}
|
||||
|
||||
// SetForm set the form object
|
||||
func SetForm(dataStore reqctx.ContextDataProvider, obj any) {
|
||||
dataStore.GetData()["__form"] = obj
|
||||
}
|
||||
|
||||
// GetForm returns the validate form information
|
||||
func GetForm(dataStore reqctx.RequestDataStore) any {
|
||||
return dataStore.GetData()["__form"]
|
||||
}
|
||||
|
||||
// Router defines a route based on chi's router
|
||||
type Router struct {
|
||||
chiRouter *chi.Mux
|
||||
curGroupPrefix string
|
||||
curMiddlewares []any
|
||||
}
|
||||
|
||||
// NewRouter creates a new route
|
||||
func NewRouter() *Router {
|
||||
r := chi.NewRouter()
|
||||
return &Router{chiRouter: r}
|
||||
}
|
||||
|
||||
// Use supports two middlewares
|
||||
func (r *Router) Use(middlewares ...any) {
|
||||
for _, m := range middlewares {
|
||||
if m != nil {
|
||||
r.chiRouter.Use(toHandlerProvider(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group mounts a sub-Router along a `pattern` string.
|
||||
func (r *Router) Group(pattern string, fn func(), middlewares ...any) {
|
||||
previousGroupPrefix := r.curGroupPrefix
|
||||
previousMiddlewares := r.curMiddlewares
|
||||
r.curGroupPrefix += pattern
|
||||
r.curMiddlewares = append(r.curMiddlewares, middlewares...)
|
||||
|
||||
fn()
|
||||
|
||||
r.curGroupPrefix = previousGroupPrefix
|
||||
r.curMiddlewares = previousMiddlewares
|
||||
}
|
||||
|
||||
func (r *Router) getPattern(pattern string) string {
|
||||
newPattern := r.curGroupPrefix + pattern
|
||||
if !strings.HasPrefix(newPattern, "/") {
|
||||
newPattern = "/" + newPattern
|
||||
}
|
||||
if newPattern == "/" {
|
||||
return newPattern
|
||||
}
|
||||
return strings.TrimSuffix(newPattern, "/")
|
||||
}
|
||||
|
||||
func isNilOrFuncNil(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
r := reflect.ValueOf(v)
|
||||
return r.Kind() == reflect.Func && r.IsNil()
|
||||
}
|
||||
|
||||
func wrapMiddlewareAndHandler(curMiddlewares, h []any) ([]func(http.Handler) http.Handler, http.HandlerFunc) {
|
||||
handlerProviders := make([]func(http.Handler) http.Handler, 0, len(curMiddlewares)+len(h)+1)
|
||||
for _, m := range curMiddlewares {
|
||||
if !isNilOrFuncNil(m) {
|
||||
handlerProviders = append(handlerProviders, toHandlerProvider(m))
|
||||
}
|
||||
}
|
||||
if len(h) == 0 {
|
||||
panic("no endpoint handler provided")
|
||||
}
|
||||
for i, m := range h {
|
||||
if !isNilOrFuncNil(m) {
|
||||
handlerProviders = append(handlerProviders, toHandlerProvider(m))
|
||||
} else if i == len(h)-1 {
|
||||
panic("endpoint handler can't be nil")
|
||||
}
|
||||
}
|
||||
middlewares := handlerProviders[:len(handlerProviders)-1]
|
||||
handlerFunc := handlerProviders[len(handlerProviders)-1](nil).ServeHTTP
|
||||
mockPoint := RouterMockPoint(MockAfterMiddlewares)
|
||||
if mockPoint != nil {
|
||||
middlewares = append(middlewares, mockPoint)
|
||||
}
|
||||
return middlewares, handlerFunc
|
||||
}
|
||||
|
||||
// Methods adds the same handlers for multiple http "methods" (separated by ",").
|
||||
// If any method is invalid, the lower level router will panic.
|
||||
func (r *Router) Methods(methods, pattern string, h ...any) {
|
||||
middlewares, handlerFunc := wrapMiddlewareAndHandler(r.curMiddlewares, h)
|
||||
fullPattern := r.getPattern(pattern)
|
||||
if strings.Contains(methods, ",") {
|
||||
methods := strings.SplitSeq(methods, ",")
|
||||
for method := range methods {
|
||||
r.chiRouter.With(middlewares...).Method(strings.TrimSpace(method), fullPattern, handlerFunc)
|
||||
}
|
||||
} else {
|
||||
r.chiRouter.With(middlewares...).Method(methods, fullPattern, handlerFunc)
|
||||
}
|
||||
}
|
||||
|
||||
// Mount attaches another Router along ./pattern/*
|
||||
func (r *Router) Mount(pattern string, subRouter *Router) {
|
||||
subRouter.Use(r.curMiddlewares...)
|
||||
r.chiRouter.Mount(r.getPattern(pattern), subRouter.chiRouter)
|
||||
}
|
||||
|
||||
// Any delegate requests for all methods
|
||||
func (r *Router) Any(pattern string, h ...any) {
|
||||
middlewares, handlerFunc := wrapMiddlewareAndHandler(r.curMiddlewares, h)
|
||||
r.chiRouter.With(middlewares...).HandleFunc(r.getPattern(pattern), handlerFunc)
|
||||
}
|
||||
|
||||
// Delete delegate delete method
|
||||
func (r *Router) Delete(pattern string, h ...any) {
|
||||
r.Methods("DELETE", pattern, h...)
|
||||
}
|
||||
|
||||
// Get delegate get method
|
||||
func (r *Router) Get(pattern string, h ...any) {
|
||||
r.Methods("GET", pattern, h...)
|
||||
}
|
||||
|
||||
// Head delegate head method
|
||||
func (r *Router) Head(pattern string, h ...any) {
|
||||
r.Methods("HEAD", pattern, h...)
|
||||
}
|
||||
|
||||
// Post delegate post method
|
||||
func (r *Router) Post(pattern string, h ...any) {
|
||||
r.Methods("POST", pattern, h...)
|
||||
}
|
||||
|
||||
// Put delegate put method
|
||||
func (r *Router) Put(pattern string, h ...any) {
|
||||
r.Methods("PUT", pattern, h...)
|
||||
}
|
||||
|
||||
// Patch delegate patch method
|
||||
func (r *Router) Patch(pattern string, h ...any) {
|
||||
r.Methods("PATCH", pattern, h...)
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.normalizeRequestPath(w, req, r.chiRouter)
|
||||
}
|
||||
|
||||
// NotFound defines a handler to respond whenever a route could not be found.
|
||||
func (r *Router) NotFound(h http.HandlerFunc) {
|
||||
r.chiRouter.NotFound(h)
|
||||
}
|
||||
|
||||
func (r *Router) normalizeRequestPath(resp http.ResponseWriter, req *http.Request, next http.Handler) {
|
||||
normalized := false
|
||||
normalizedPath := req.URL.EscapedPath()
|
||||
if normalizedPath == "" {
|
||||
normalizedPath, normalized = "/", true
|
||||
} else if normalizedPath != "/" {
|
||||
normalized = strings.HasSuffix(normalizedPath, "/")
|
||||
normalizedPath = strings.TrimRight(normalizedPath, "/")
|
||||
}
|
||||
removeRepeatedSlashes := strings.Contains(normalizedPath, "//")
|
||||
normalized = normalized || removeRepeatedSlashes
|
||||
|
||||
// the following code block is a slow-path for replacing all repeated slashes "//" to one single "/"
|
||||
// if the path doesn't have repeated slashes, then no need to execute it
|
||||
if removeRepeatedSlashes {
|
||||
buf := &strings.Builder{}
|
||||
for i := 0; i < len(normalizedPath); i++ {
|
||||
if i == 0 || normalizedPath[i-1] != '/' || normalizedPath[i] != '/' {
|
||||
buf.WriteByte(normalizedPath[i])
|
||||
}
|
||||
}
|
||||
normalizedPath = buf.String()
|
||||
}
|
||||
|
||||
// If the config tells Gitea to use a sub-url path directly without reverse proxy,
|
||||
// then we need to remove the sub-url path from the request URL path.
|
||||
// But "/v2" is special for OCI container registry, it should always be in the root of the site.
|
||||
if setting.UseSubURLPath {
|
||||
remainingPath, ok := strings.CutPrefix(normalizedPath, setting.AppSubURL+"/")
|
||||
if ok {
|
||||
normalizedPath = "/" + remainingPath
|
||||
} else if normalizedPath == setting.AppSubURL {
|
||||
normalizedPath = "/"
|
||||
} else if !strings.HasPrefix(normalizedPath+"/", "/v2/") {
|
||||
// do not respond to other requests, to simulate a real sub-path environment
|
||||
resp.Header().Add("Content-Type", "text/html; charset=utf-8")
|
||||
resp.WriteHeader(http.StatusNotFound)
|
||||
_, _ = resp.Write([]byte(htmlutil.HTMLFormat(`404 page not found, sub-path is: <a href="%s">%s</a>`, setting.AppSubURL, setting.AppSubURL)))
|
||||
return
|
||||
}
|
||||
normalized = true
|
||||
}
|
||||
|
||||
// if the path is normalized, then fill it back to the request
|
||||
if normalized {
|
||||
decodedPath, err := url.PathUnescape(normalizedPath)
|
||||
if err != nil {
|
||||
http.Error(resp, "400 Bad Request: unable to unescape path "+normalizedPath, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.URL.RawPath = normalizedPath
|
||||
req.URL.Path = decodedPath
|
||||
}
|
||||
|
||||
next.ServeHTTP(resp, req)
|
||||
}
|
||||
|
||||
// Combo delegates requests to Combo
|
||||
func (r *Router) Combo(pattern string, h ...any) *Combo {
|
||||
return &Combo{r, pattern, h}
|
||||
}
|
||||
|
||||
// PathGroup creates a group of paths which could be matched by regexp.
|
||||
// It is only designed to resolve some special cases which chi router can't handle.
|
||||
// For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient).
|
||||
func (r *Router) PathGroup(pattern string, fn func(g *RouterPathGroup), h ...any) {
|
||||
g := &RouterPathGroup{r: r, pathParam: "*"}
|
||||
fn(g)
|
||||
r.Any(pattern, append(h, g.ServeHTTP)...)
|
||||
}
|
||||
41
modules/web/router_combo.go
Normal file
41
modules/web/router_combo.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
// Combo represents a tiny group routes with same pattern
|
||||
type Combo struct {
|
||||
r *Router
|
||||
pattern string
|
||||
h []any
|
||||
}
|
||||
|
||||
// Get delegates Get method
|
||||
func (c *Combo) Get(h ...any) *Combo {
|
||||
c.r.Get(c.pattern, append(c.h, h...)...)
|
||||
return c
|
||||
}
|
||||
|
||||
// Post delegates Post method
|
||||
func (c *Combo) Post(h ...any) *Combo {
|
||||
c.r.Post(c.pattern, append(c.h, h...)...)
|
||||
return c
|
||||
}
|
||||
|
||||
// Delete delegates Delete method
|
||||
func (c *Combo) Delete(h ...any) *Combo {
|
||||
c.r.Delete(c.pattern, append(c.h, h...)...)
|
||||
return c
|
||||
}
|
||||
|
||||
// Put delegates Put method
|
||||
func (c *Combo) Put(h ...any) *Combo {
|
||||
c.r.Put(c.pattern, append(c.h, h...)...)
|
||||
return c
|
||||
}
|
||||
|
||||
// Patch delegates Patch method
|
||||
func (c *Combo) Patch(h ...any) *Combo {
|
||||
c.r.Patch(c.pattern, append(c.h, h...)...)
|
||||
return c
|
||||
}
|
||||
169
modules/web/router_path.go
Normal file
169
modules/web/router_path.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type RouterPathGroup struct {
|
||||
r *Router
|
||||
pathParam string
|
||||
matchers []*routerPathMatcher
|
||||
}
|
||||
|
||||
func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
||||
chiCtx := chi.RouteContext(req.Context())
|
||||
path := chiCtx.URLParam(g.pathParam)
|
||||
for _, m := range g.matchers {
|
||||
if m.matchPath(chiCtx, path) {
|
||||
chiCtx.RoutePatterns = append(chiCtx.RoutePatterns, m.pattern)
|
||||
handler := m.handlerFunc
|
||||
for i := len(m.middlewares) - 1; i >= 0; i-- {
|
||||
handler = m.middlewares[i](handler).ServeHTTP
|
||||
}
|
||||
handler(resp, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
g.r.chiRouter.NotFoundHandler().ServeHTTP(resp, req)
|
||||
}
|
||||
|
||||
type RouterPathGroupPattern struct {
|
||||
pattern string
|
||||
re *regexp.Regexp
|
||||
params []routerPathParam
|
||||
middlewares []any
|
||||
}
|
||||
|
||||
// MatchPath matches the request method, and uses regexp to match the path.
|
||||
// The pattern uses "<...>" to define path parameters, for example, "/<name>" (different from chi router)
|
||||
// It is only designed to resolve some special cases that chi router can't handle.
|
||||
// For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient).
|
||||
func (g *RouterPathGroup) MatchPath(methods, pattern string, h ...any) {
|
||||
g.MatchPattern(methods, g.PatternRegexp(pattern), h...)
|
||||
}
|
||||
|
||||
func (g *RouterPathGroup) MatchPattern(methods string, pattern *RouterPathGroupPattern, h ...any) {
|
||||
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...))
|
||||
}
|
||||
|
||||
type routerPathParam struct {
|
||||
name string
|
||||
captureGroup int
|
||||
}
|
||||
|
||||
type routerPathMatcher struct {
|
||||
methods container.Set[string]
|
||||
pattern string
|
||||
re *regexp.Regexp
|
||||
params []routerPathParam
|
||||
middlewares []func(http.Handler) http.Handler
|
||||
handlerFunc http.HandlerFunc
|
||||
}
|
||||
|
||||
func (p *routerPathMatcher) matchPath(chiCtx *chi.Context, path string) bool {
|
||||
if !p.methods.Contains(chiCtx.RouteMethod) {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
pathMatches := p.re.FindStringSubmatchIndex(path) // Golang regexp match pairs [start, end, start, end, ...]
|
||||
if pathMatches == nil {
|
||||
return false
|
||||
}
|
||||
var paramMatches [][]int
|
||||
for i := 2; i < len(pathMatches); {
|
||||
paramMatches = append(paramMatches, []int{pathMatches[i], pathMatches[i+1]})
|
||||
pmIdx := len(paramMatches) - 1
|
||||
end := pathMatches[i+1]
|
||||
i += 2
|
||||
for ; i < len(pathMatches); i += 2 {
|
||||
if pathMatches[i] >= end {
|
||||
break
|
||||
}
|
||||
paramMatches[pmIdx] = append(paramMatches[pmIdx], pathMatches[i], pathMatches[i+1])
|
||||
}
|
||||
}
|
||||
for i, pm := range paramMatches {
|
||||
groupIdx := p.params[i].captureGroup * 2
|
||||
chiCtx.URLParams.Add(p.params[i].name, path[pm[groupIdx]:pm[groupIdx+1]])
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isValidMethod(name string) bool {
|
||||
switch name {
|
||||
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodHead, http.MethodOptions, http.MethodConnect, http.MethodTrace:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newRouterPathMatcher(methods string, patternRegexp *RouterPathGroupPattern, h ...any) *routerPathMatcher {
|
||||
middlewares, handlerFunc := wrapMiddlewareAndHandler(patternRegexp.middlewares, h)
|
||||
p := &routerPathMatcher{methods: make(container.Set[string]), middlewares: middlewares, handlerFunc: handlerFunc}
|
||||
for method := range strings.SplitSeq(methods, ",") {
|
||||
method = strings.TrimSpace(method)
|
||||
if !isValidMethod(method) {
|
||||
panic("invalid HTTP method: " + method)
|
||||
}
|
||||
p.methods.Add(method)
|
||||
}
|
||||
p.pattern, p.re, p.params = patternRegexp.pattern, patternRegexp.re, patternRegexp.params
|
||||
return p
|
||||
}
|
||||
|
||||
func patternRegexp(pattern string, h ...any) *RouterPathGroupPattern {
|
||||
p := &RouterPathGroupPattern{middlewares: slices.Clone(h)}
|
||||
re := []byte{'^'}
|
||||
lastEnd := 0
|
||||
for lastEnd < len(pattern) {
|
||||
start := strings.IndexByte(pattern[lastEnd:], '<')
|
||||
if start == -1 {
|
||||
re = append(re, regexp.QuoteMeta(pattern[lastEnd:])...)
|
||||
break
|
||||
}
|
||||
end := strings.IndexByte(pattern[lastEnd+start:], '>')
|
||||
if end == -1 {
|
||||
panic("invalid pattern: " + pattern)
|
||||
}
|
||||
re = append(re, regexp.QuoteMeta(pattern[lastEnd:lastEnd+start])...)
|
||||
partName, partExp, _ := strings.Cut(pattern[lastEnd+start+1:lastEnd+start+end], ":")
|
||||
lastEnd += start + end + 1
|
||||
|
||||
// TODO: it could support to specify a "capture group" for the name, for example: "/<name[2]:(\d)-(\d)>"
|
||||
// it is not used so no need to implement it now
|
||||
param := routerPathParam{}
|
||||
if partExp == "*" {
|
||||
re = append(re, "(.*?)/?"...)
|
||||
if lastEnd < len(pattern) && pattern[lastEnd] == '/' {
|
||||
lastEnd++ // the "*" pattern is able to handle the last slash, so skip it
|
||||
}
|
||||
} else {
|
||||
partExp = util.IfZero(partExp, "[^/]+")
|
||||
re = append(re, '(')
|
||||
re = append(re, partExp...)
|
||||
re = append(re, ')')
|
||||
}
|
||||
param.name = partName
|
||||
p.params = append(p.params, param)
|
||||
}
|
||||
re = append(re, '$')
|
||||
p.pattern, p.re = pattern, regexp.MustCompile(string(re))
|
||||
return p
|
||||
}
|
||||
|
||||
func (g *RouterPathGroup) PatternRegexp(pattern string, h ...any) *RouterPathGroupPattern {
|
||||
return patternRegexp(pattern, h...)
|
||||
}
|
||||
275
modules/web/router_test.go
Normal file
275
modules/web/router_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func chiURLParamsToMap(chiCtx *chi.Context) map[string]string {
|
||||
pathParams := chiCtx.URLParams
|
||||
m := make(map[string]string, len(pathParams.Keys))
|
||||
for i, key := range pathParams.Keys {
|
||||
if key == "*" && pathParams.Values[i] == "" {
|
||||
continue // chi router will add an empty "*" key if there is a "Mount"
|
||||
}
|
||||
m[key] = pathParams.Values[i]
|
||||
}
|
||||
return util.Iif(len(m) == 0, nil, m)
|
||||
}
|
||||
|
||||
func TestPathProcessor(t *testing.T) {
|
||||
testProcess := func(pattern, uri string, expectedPathParams map[string]string) {
|
||||
chiCtx := chi.NewRouteContext()
|
||||
chiCtx.RouteMethod = "GET"
|
||||
p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound)
|
||||
assert.True(t, p.matchPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri)
|
||||
assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri)
|
||||
}
|
||||
|
||||
// the "<...>" is intentionally designed to distinguish from chi's path parameters, because:
|
||||
// 1. their behaviors are totally different, we do not want to mislead developers
|
||||
// 2. we can write regexp in "<name:\w{3,4}>" easily and parse it easily
|
||||
testProcess("/<p1>/<p2>", "/a/b", map[string]string{"p1": "a", "p2": "b"})
|
||||
testProcess("/<p1:*>", "", map[string]string{"p1": ""}) // this is a special case, because chi router could use empty path
|
||||
testProcess("/<p1:*>", "/", map[string]string{"p1": ""})
|
||||
testProcess("/<p1:*>/<p2>", "/a", map[string]string{"p1": "", "p2": "a"})
|
||||
testProcess("/<p1:*>/<p2>", "/a/b", map[string]string{"p1": "a", "p2": "b"})
|
||||
testProcess("/<p1:*>/<p2>", "/a/b/c", map[string]string{"p1": "a/b", "p2": "c"})
|
||||
}
|
||||
|
||||
func TestRouter(t *testing.T) {
|
||||
buff := &bytes.Buffer{}
|
||||
recorder := httptest.NewRecorder()
|
||||
recorder.Body = buff
|
||||
|
||||
type resultStruct struct {
|
||||
method string
|
||||
pathParams map[string]string
|
||||
handlerMarks []string
|
||||
chiRoutePattern *string
|
||||
}
|
||||
|
||||
var res resultStruct
|
||||
h := func(optMark ...string) func(resp http.ResponseWriter, req *http.Request) {
|
||||
mark := util.OptionalArg(optMark, "")
|
||||
return func(resp http.ResponseWriter, req *http.Request) {
|
||||
chiCtx := chi.RouteContext(req.Context())
|
||||
res.method = req.Method
|
||||
res.pathParams = chiURLParamsToMap(chiCtx)
|
||||
res.chiRoutePattern = util.ToPointer(chiCtx.RoutePattern())
|
||||
if mark != "" {
|
||||
res.handlerMarks = append(res.handlerMarks, mark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopMark := func(optMark ...string) func(resp http.ResponseWriter, req *http.Request) {
|
||||
mark := util.OptionalArg(optMark, "")
|
||||
return func(resp http.ResponseWriter, req *http.Request) {
|
||||
if stop := req.FormValue("stop"); stop != "" && (mark == "" || mark == stop) {
|
||||
h(stop)(resp, req)
|
||||
resp.WriteHeader(http.StatusOK)
|
||||
} else if mark != "" {
|
||||
res.handlerMarks = append(res.handlerMarks, mark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r := NewRouter()
|
||||
r.NotFound(h("not-found:/"))
|
||||
r.Get("/{username}/{reponame}/{type:issues|pulls}", h("list-issues-a")) // this one will never be called
|
||||
r.Group("/{username}/{reponame}", func() {
|
||||
r.Get("/{type:issues|pulls}", h("list-issues-b"))
|
||||
r.Group("", func() {
|
||||
r.Get("/{type:issues|pulls}/{index}", h("view-issue"))
|
||||
}, stopMark())
|
||||
r.Group("/issues/{index}", func() {
|
||||
r.Post("/update", h("update-issue"))
|
||||
})
|
||||
})
|
||||
|
||||
m := NewRouter()
|
||||
m.NotFound(h("not-found:/api/v1"))
|
||||
r.Mount("/api/v1", m)
|
||||
m.Group("/repos", func() {
|
||||
m.Group("/{username}/{reponame}", func() {
|
||||
m.Group("/branches", func() {
|
||||
m.Get("", h())
|
||||
m.Post("", h())
|
||||
m.Group("/{name}", func() {
|
||||
m.Get("", h())
|
||||
m.Patch("", h())
|
||||
m.Delete("", h())
|
||||
})
|
||||
m.PathGroup("/*", func(g *RouterPathGroup) {
|
||||
g.MatchPattern("GET", g.PatternRegexp(`/<dir:*>/<file:[a-z]{1,2}>`, stopMark("s2")), stopMark("s3"), h("match-path"))
|
||||
}, stopMark("s1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
testRoute := func(t *testing.T, methodPath string, expected resultStruct) {
|
||||
t.Run(methodPath, func(t *testing.T) {
|
||||
res = resultStruct{}
|
||||
methodPathFields := strings.Fields(methodPath)
|
||||
req, err := http.NewRequest(methodPathFields[0], methodPathFields[1], nil)
|
||||
assert.NoError(t, err)
|
||||
r.ServeHTTP(recorder, req)
|
||||
if expected.chiRoutePattern == nil {
|
||||
res.chiRoutePattern = nil
|
||||
}
|
||||
assert.Equal(t, expected, res)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("RootRouter", func(t *testing.T) {
|
||||
testRoute(t, "GET /the-user/the-repo/other", resultStruct{
|
||||
method: "GET",
|
||||
handlerMarks: []string{"not-found:/"},
|
||||
chiRoutePattern: util.ToPointer(""),
|
||||
})
|
||||
testRoute(t, "GET /the-user/the-repo/pulls", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"},
|
||||
handlerMarks: []string{"list-issues-b"},
|
||||
})
|
||||
testRoute(t, "GET /the-user/the-repo/issues/123", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
|
||||
handlerMarks: []string{"view-issue"},
|
||||
chiRoutePattern: util.ToPointer("/{username}/{reponame}/{type:issues|pulls}/{index}"),
|
||||
})
|
||||
testRoute(t, "GET /the-user/the-repo/issues/123?stop=hijack", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
|
||||
handlerMarks: []string{"hijack"},
|
||||
})
|
||||
testRoute(t, "POST /the-user/the-repo/issues/123/update", resultStruct{
|
||||
method: "POST",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"},
|
||||
handlerMarks: []string{"update-issue"},
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Sub Router", func(t *testing.T) {
|
||||
testRoute(t, "GET /api/v1/other", resultStruct{
|
||||
method: "GET",
|
||||
handlerMarks: []string{"not-found:/api/v1"},
|
||||
})
|
||||
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"},
|
||||
})
|
||||
|
||||
testRoute(t, "POST /api/v1/repos/the-user/the-repo/branches", resultStruct{
|
||||
method: "POST",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"},
|
||||
})
|
||||
|
||||
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/master", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"},
|
||||
})
|
||||
|
||||
testRoute(t, "PATCH /api/v1/repos/the-user/the-repo/branches/master", resultStruct{
|
||||
method: "PATCH",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"},
|
||||
})
|
||||
|
||||
testRoute(t, "DELETE /api/v1/repos/the-user/the-repo/branches/master", resultStruct{
|
||||
method: "DELETE",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "name": "master"},
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("MatchPath", func(t *testing.T) {
|
||||
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
|
||||
handlerMarks: []string{"s1", "s2", "s3", "match-path"},
|
||||
})
|
||||
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1%2fd2/fn", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1%2fd2/fn", "dir": "d1%2fd2", "file": "fn"},
|
||||
handlerMarks: []string{"s1", "s2", "s3", "match-path"},
|
||||
})
|
||||
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/000", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"},
|
||||
handlerMarks: []string{"s1", "not-found:/api/v1"},
|
||||
})
|
||||
|
||||
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s1", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn"},
|
||||
handlerMarks: []string{"s1"},
|
||||
})
|
||||
|
||||
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s2", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
|
||||
handlerMarks: []string{"s1", "s2"},
|
||||
})
|
||||
|
||||
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s3", resultStruct{
|
||||
method: "GET",
|
||||
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
|
||||
handlerMarks: []string{"s1", "s2", "s3"},
|
||||
chiRoutePattern: util.ToPointer("/api/v1/repos/{username}/{reponame}/branches/<dir:*>/<file:[a-z]{1,2}>"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestRouteNormalizePath(t *testing.T) {
|
||||
type paths struct {
|
||||
EscapedPath, RawPath, Path string
|
||||
}
|
||||
testPath := func(reqPath string, expectedPaths paths) {
|
||||
recorder := httptest.NewRecorder()
|
||||
recorder.Body = bytes.NewBuffer(nil)
|
||||
|
||||
actualPaths := paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"}
|
||||
r := NewRouter()
|
||||
r.Get("/*", func(resp http.ResponseWriter, req *http.Request) {
|
||||
actualPaths.EscapedPath = req.URL.EscapedPath()
|
||||
actualPaths.RawPath = req.URL.RawPath
|
||||
actualPaths.Path = req.URL.Path
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqPath, nil)
|
||||
assert.NoError(t, err)
|
||||
r.ServeHTTP(recorder, req)
|
||||
assert.Equal(t, expectedPaths, actualPaths, "req path = %q", reqPath)
|
||||
}
|
||||
|
||||
// RawPath could be empty if the EscapedPath is the same as escape(Path) and it is already normalized
|
||||
testPath("/", paths{EscapedPath: "/", RawPath: "", Path: "/"})
|
||||
testPath("//", paths{EscapedPath: "/", RawPath: "/", Path: "/"})
|
||||
testPath("/%2f", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"})
|
||||
testPath("///a//b/", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"})
|
||||
|
||||
defer test.MockVariableValue(&setting.UseSubURLPath, true)()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/sub-path")()
|
||||
testPath("/", paths{EscapedPath: "(none)", RawPath: "(none)", Path: "(none)"}) // 404
|
||||
testPath("/sub-path", paths{EscapedPath: "/", RawPath: "/", Path: "/"})
|
||||
testPath("/sub-path/", paths{EscapedPath: "/", RawPath: "/", Path: "/"})
|
||||
testPath("/sub-path//a/b///", paths{EscapedPath: "/a/b", RawPath: "/a/b", Path: "/a/b"})
|
||||
testPath("/sub-path/%2f/", paths{EscapedPath: "/%2f", RawPath: "/%2f", Path: "//"})
|
||||
// "/v2" is special for OCI container registry, it should always be in the root of the site
|
||||
testPath("/v2", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"})
|
||||
testPath("/v2/", paths{EscapedPath: "/v2", RawPath: "/v2", Path: "/v2"})
|
||||
testPath("/v2/%2f", paths{EscapedPath: "/v2/%2f", RawPath: "/v2/%2f", Path: "/v2//"})
|
||||
}
|
||||
56
modules/web/routing/context.go
Normal file
56
modules/web/routing/context.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/gtprof"
|
||||
"code.gitea.io/gitea/modules/reqctx"
|
||||
)
|
||||
|
||||
type contextKeyType struct{}
|
||||
|
||||
var contextKey contextKeyType
|
||||
|
||||
// RecordFuncInfo records a func info into context
|
||||
func RecordFuncInfo(ctx context.Context, funcInfo *FuncInfo) (end func()) {
|
||||
end = func() {}
|
||||
if reqCtx := reqctx.FromContext(ctx); reqCtx != nil {
|
||||
var traceSpan *gtprof.TraceSpan
|
||||
traceSpan, end = gtprof.GetTracer().StartInContext(reqCtx, "http.func")
|
||||
traceSpan.SetAttributeString("func", funcInfo.shortName)
|
||||
}
|
||||
if record, ok := ctx.Value(contextKey).(*requestRecord); ok {
|
||||
record.lock.Lock()
|
||||
record.funcInfo = funcInfo
|
||||
record.lock.Unlock()
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// MarkLongPolling marks the request is a long-polling request, and the logger may output different message for it
|
||||
func MarkLongPolling(resp http.ResponseWriter, req *http.Request) {
|
||||
record, ok := req.Context().Value(contextKey).(*requestRecord)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record.lock.Lock()
|
||||
record.isLongPolling = true
|
||||
record.lock.Unlock()
|
||||
}
|
||||
|
||||
// UpdatePanicError updates a context's error info, a panic may be recovered by other middlewares, but we still need to know that.
|
||||
func UpdatePanicError(ctx context.Context, err any) {
|
||||
record, ok := ctx.Value(contextKey).(*requestRecord)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record.lock.Lock()
|
||||
record.panicError = err
|
||||
record.lock.Unlock()
|
||||
}
|
||||
172
modules/web/routing/funcinfo.go
Normal file
172
modules/web/routing/funcinfo.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
funcInfoMap = map[uintptr]*FuncInfo{}
|
||||
funcInfoNameMap = map[string]*FuncInfo{}
|
||||
funcInfoMapMu sync.RWMutex
|
||||
)
|
||||
|
||||
// FuncInfo contains information about the function to be logged by the router log
|
||||
type FuncInfo struct {
|
||||
file string
|
||||
shortFile string
|
||||
line int
|
||||
name string
|
||||
shortName string
|
||||
}
|
||||
|
||||
// String returns a string form of the FuncInfo for logging
|
||||
func (info *FuncInfo) String() string {
|
||||
if info == nil {
|
||||
return "unknown-handler"
|
||||
}
|
||||
return fmt.Sprintf("%s:%d(%s)", info.shortFile, info.line, info.shortName)
|
||||
}
|
||||
|
||||
// GetFuncInfo returns the FuncInfo for a provided function and friendlyname
|
||||
func GetFuncInfo(fn any, friendlyName ...string) *FuncInfo {
|
||||
// ptr represents the memory position of the function passed in as v.
|
||||
// This will be used as program counter in FuncForPC below
|
||||
ptr := reflect.ValueOf(fn).Pointer()
|
||||
|
||||
// if we have been provided with a friendlyName look for the named funcs
|
||||
if len(friendlyName) == 1 {
|
||||
name := friendlyName[0]
|
||||
funcInfoMapMu.RLock()
|
||||
info, ok := funcInfoNameMap[name]
|
||||
funcInfoMapMu.RUnlock()
|
||||
if ok {
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise attempt to get pre-cached information for this function pointer
|
||||
funcInfoMapMu.RLock()
|
||||
info, ok := funcInfoMap[ptr]
|
||||
funcInfoMapMu.RUnlock()
|
||||
|
||||
if ok {
|
||||
if len(friendlyName) == 1 {
|
||||
name := friendlyName[0]
|
||||
info = copyFuncInfo(info)
|
||||
info.shortName = name
|
||||
|
||||
funcInfoNameMap[name] = info
|
||||
funcInfoMapMu.Lock()
|
||||
funcInfoNameMap[name] = info
|
||||
funcInfoMapMu.Unlock()
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// This is likely the first time we have seen this function
|
||||
//
|
||||
// Get the runtime.func for this function (if we can)
|
||||
f := runtime.FuncForPC(ptr)
|
||||
if f != nil {
|
||||
info = convertToFuncInfo(f)
|
||||
|
||||
// cache this info globally
|
||||
funcInfoMapMu.Lock()
|
||||
funcInfoMap[ptr] = info
|
||||
|
||||
// if we have been provided with a friendlyName override the short name we've generated
|
||||
if len(friendlyName) == 1 {
|
||||
name := friendlyName[0]
|
||||
info = copyFuncInfo(info)
|
||||
info.shortName = name
|
||||
funcInfoNameMap[name] = info
|
||||
}
|
||||
funcInfoMapMu.Unlock()
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// convertToFuncInfo take a runtime.Func and convert it to a logFuncInfo, fill in shorten filename, etc
|
||||
func convertToFuncInfo(f *runtime.Func) *FuncInfo {
|
||||
file, line := f.FileLine(f.Entry())
|
||||
|
||||
info := &FuncInfo{
|
||||
file: strings.ReplaceAll(file, "\\", "/"),
|
||||
line: line,
|
||||
name: f.Name(),
|
||||
}
|
||||
|
||||
// only keep last 2 names in path, fall back to funcName if not
|
||||
info.shortFile = shortenFilename(info.file, info.name)
|
||||
|
||||
// remove package prefix. eg: "xxx.com/pkg1/pkg2.foo" => "pkg2.foo"
|
||||
pos := strings.LastIndexByte(info.name, '/')
|
||||
if pos >= 0 {
|
||||
info.shortName = info.name[pos+1:]
|
||||
} else {
|
||||
info.shortName = info.name
|
||||
}
|
||||
|
||||
// remove ".func[0-9]*" suffix for anonymous func
|
||||
info.shortName = trimAnonymousFunctionSuffix(info.shortName)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func copyFuncInfo(l *FuncInfo) *FuncInfo {
|
||||
return &FuncInfo{
|
||||
file: l.file,
|
||||
shortFile: l.shortFile,
|
||||
line: l.line,
|
||||
name: l.name,
|
||||
shortName: l.shortName,
|
||||
}
|
||||
}
|
||||
|
||||
// shortenFilename generates a short source code filename from a full package path, eg: "code.gitea.io/routers/common/logger_context.go" => "common/logger_context.go"
|
||||
func shortenFilename(filename, fallback string) string {
|
||||
if filename == "" {
|
||||
return fallback
|
||||
}
|
||||
if lastIndex := strings.LastIndexByte(filename, '/'); lastIndex >= 0 {
|
||||
if secondLastIndex := strings.LastIndexByte(filename[:lastIndex], '/'); secondLastIndex >= 0 {
|
||||
return filename[secondLastIndex+1:]
|
||||
}
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
// trimAnonymousFunctionSuffix trims ".func[0-9]*" from the end of anonymous function names, we only want to see the main function names in logs
|
||||
func trimAnonymousFunctionSuffix(name string) string {
|
||||
// if the name is an anonymous name, it should be like "{main-function}.func1", so the length can not be less than 7
|
||||
if len(name) < 7 {
|
||||
return name
|
||||
}
|
||||
|
||||
funcSuffixIndex := strings.LastIndex(name, ".func")
|
||||
if funcSuffixIndex < 0 {
|
||||
return name
|
||||
}
|
||||
|
||||
hasFuncSuffix := true
|
||||
|
||||
// len(".func") = 5
|
||||
for i := funcSuffixIndex + 5; i < len(name); i++ {
|
||||
if name[i] < '0' || name[i] > '9' {
|
||||
hasFuncSuffix = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasFuncSuffix {
|
||||
return name[:funcSuffixIndex]
|
||||
}
|
||||
return name
|
||||
}
|
||||
80
modules/web/routing/funcinfo_test.go
Normal file
80
modules/web/routing/funcinfo_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_shortenFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
filename string
|
||||
fallback string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"code.gitea.io/routers/common/logger_context.go",
|
||||
"NO_FALLBACK",
|
||||
"common/logger_context.go",
|
||||
},
|
||||
{
|
||||
"common/logger_context.go",
|
||||
"NO_FALLBACK",
|
||||
"common/logger_context.go",
|
||||
},
|
||||
{
|
||||
"logger_context.go",
|
||||
"NO_FALLBACK",
|
||||
"logger_context.go",
|
||||
},
|
||||
{
|
||||
"",
|
||||
"USE_FALLBACK",
|
||||
"USE_FALLBACK",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("shortenFilename('%s')", tt.filename), func(t *testing.T) {
|
||||
gotShort := shortenFilename(tt.filename, tt.fallback)
|
||||
assert.Equal(t, tt.expected, gotShort)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_trimAnonymousFunctionSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"notAnonymous",
|
||||
"notAnonymous",
|
||||
},
|
||||
{
|
||||
"anonymous.func1",
|
||||
"anonymous",
|
||||
},
|
||||
{
|
||||
"notAnonymous.funca",
|
||||
"notAnonymous.funca",
|
||||
},
|
||||
{
|
||||
"anonymous.func100",
|
||||
"anonymous",
|
||||
},
|
||||
{
|
||||
"anonymous.func100.func6",
|
||||
"anonymous.func100",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := trimAnonymousFunctionSuffix(tt.name)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
126
modules/web/routing/logger.go
Normal file
126
modules/web/routing/logger.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/web/types"
|
||||
)
|
||||
|
||||
// NewLoggerHandler is a handler that will log routing to the router log taking account of
|
||||
// routing information
|
||||
func NewLoggerHandler() func(next http.Handler) http.Handler {
|
||||
manager := requestRecordsManager{
|
||||
requestRecords: map[uint64]*requestRecord{},
|
||||
}
|
||||
manager.startSlowQueryDetector(3 * time.Second)
|
||||
|
||||
logger := log.GetLogger("router")
|
||||
manager.print = logPrinter(logger)
|
||||
return manager.handler
|
||||
}
|
||||
|
||||
var (
|
||||
startMessage = log.NewColoredValue("started ", log.DEBUG.ColorAttributes()...)
|
||||
slowMessage = log.NewColoredValue("slow ", log.WARN.ColorAttributes()...)
|
||||
pollingMessage = log.NewColoredValue("polling ", log.INFO.ColorAttributes()...)
|
||||
failedMessage = log.NewColoredValue("failed ", log.WARN.ColorAttributes()...)
|
||||
completedMessage = log.NewColoredValue("completed", log.INFO.ColorAttributes()...)
|
||||
unknownHandlerMessage = log.NewColoredValue("completed", log.ERROR.ColorAttributes()...)
|
||||
)
|
||||
|
||||
func logPrinter(logger log.Logger) func(trigger Event, record *requestRecord) {
|
||||
const callerName = "HTTPRequest"
|
||||
logTrace := func(fmt string, args ...any) {
|
||||
logger.Log(2, &log.Event{Level: log.TRACE, Caller: callerName}, fmt, args...)
|
||||
}
|
||||
logInfo := func(fmt string, args ...any) {
|
||||
logger.Log(2, &log.Event{Level: log.INFO, Caller: callerName}, fmt, args...)
|
||||
}
|
||||
logWarn := func(fmt string, args ...any) {
|
||||
logger.Log(2, &log.Event{Level: log.WARN, Caller: callerName}, fmt, args...)
|
||||
}
|
||||
logError := func(fmt string, args ...any) {
|
||||
logger.Log(2, &log.Event{Level: log.ERROR, Caller: callerName}, fmt, args...)
|
||||
}
|
||||
return func(trigger Event, record *requestRecord) {
|
||||
if trigger == StartEvent {
|
||||
if !logger.LevelEnabled(log.TRACE) {
|
||||
// for performance, if the "started" message shouldn't be logged, we just return as early as possible
|
||||
// developers can set the router log level to TRACE to get the "started" request messages.
|
||||
return
|
||||
}
|
||||
// when a request starts, we have no information about the handler function information, we only have the request path
|
||||
req := record.request
|
||||
logTrace("router: %s %v %s for %s", startMessage, log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr)
|
||||
return
|
||||
}
|
||||
|
||||
req := record.request
|
||||
|
||||
// Get data from the record
|
||||
record.lock.Lock()
|
||||
handlerFuncInfo := record.funcInfo.String()
|
||||
isLongPolling := record.isLongPolling
|
||||
isUnknownHandler := record.funcInfo == nil
|
||||
panicErr := record.panicError
|
||||
record.lock.Unlock()
|
||||
|
||||
if trigger == StillExecutingEvent {
|
||||
message := slowMessage
|
||||
logf := logWarn
|
||||
if isLongPolling {
|
||||
logf = logInfo
|
||||
message = pollingMessage
|
||||
}
|
||||
logf("router: %s %v %s for %s, elapsed %v @ %s",
|
||||
message,
|
||||
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
|
||||
log.ColoredTime(time.Since(record.startTime)),
|
||||
handlerFuncInfo,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if panicErr != nil {
|
||||
logWarn("router: %s %v %s for %s, panic in %v @ %s, err=%v",
|
||||
failedMessage,
|
||||
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
|
||||
log.ColoredTime(time.Since(record.startTime)),
|
||||
handlerFuncInfo,
|
||||
panicErr,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var status int
|
||||
if v, ok := record.responseWriter.(types.ResponseStatusProvider); ok {
|
||||
status = v.WrittenStatus()
|
||||
}
|
||||
logf := logInfo
|
||||
// lower the log level for some specific requests, in most cases these logs are not useful
|
||||
if status > 0 && status < 400 &&
|
||||
strings.HasPrefix(req.RequestURI, "/assets/") /* static assets */ ||
|
||||
req.RequestURI == "/user/events" /* Server-Sent Events (SSE) handler */ ||
|
||||
req.RequestURI == "/api/actions/runner.v1.RunnerService/FetchTask" /* Actions Runner polling */ {
|
||||
logf = logTrace
|
||||
}
|
||||
message := completedMessage
|
||||
if isUnknownHandler {
|
||||
logf = logError
|
||||
message = unknownHandlerMessage
|
||||
}
|
||||
|
||||
logf("router: %s %v %s for %s, %v %v in %v @ %s",
|
||||
message,
|
||||
log.ColoredMethod(req.Method), req.RequestURI, req.RemoteAddr,
|
||||
log.ColoredStatus(status), log.ColoredStatus(status, http.StatusText(status)), log.ColoredTime(time.Since(record.startTime)),
|
||||
handlerFuncInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
124
modules/web/routing/logger_manager.go
Normal file
124
modules/web/routing/logger_manager.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/process"
|
||||
)
|
||||
|
||||
// Event indicates when the printer is triggered
|
||||
type Event int
|
||||
|
||||
const (
|
||||
// StartEvent at the beginning of a request
|
||||
StartEvent Event = iota
|
||||
|
||||
// StillExecutingEvent the request is still executing
|
||||
StillExecutingEvent
|
||||
|
||||
// EndEvent the request has ended (either completed or failed)
|
||||
EndEvent
|
||||
)
|
||||
|
||||
// Printer is used to output the log for a request
|
||||
type Printer func(trigger Event, record *requestRecord)
|
||||
|
||||
type requestRecordsManager struct {
|
||||
print Printer
|
||||
|
||||
lock sync.Mutex
|
||||
|
||||
requestRecords map[uint64]*requestRecord
|
||||
count uint64
|
||||
}
|
||||
|
||||
func (manager *requestRecordsManager) startSlowQueryDetector(threshold time.Duration) {
|
||||
go graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
|
||||
ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: SlowQueryDetector", process.SystemProcessType, true)
|
||||
defer finished()
|
||||
// This go-routine checks all active requests every second.
|
||||
// If a request has been running for a long time (eg: /user/events), we also print a log with "still-executing" message
|
||||
// After the "still-executing" log is printed, the record will be removed from the map to prevent from duplicated logs in future
|
||||
|
||||
// We do not care about accurate duration here. It just does the check periodically, 0.5s or 1.5s are all OK.
|
||||
t := time.NewTicker(time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
now := time.Now()
|
||||
|
||||
var slowRequests []*requestRecord
|
||||
|
||||
// find all slow requests with lock
|
||||
manager.lock.Lock()
|
||||
for index, record := range manager.requestRecords {
|
||||
if now.Sub(record.startTime) < threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
slowRequests = append(slowRequests, record)
|
||||
delete(manager.requestRecords, index)
|
||||
}
|
||||
manager.lock.Unlock()
|
||||
|
||||
// print logs for slow requests
|
||||
for _, record := range slowRequests {
|
||||
manager.print(StillExecutingEvent, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *requestRecordsManager) handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
record := &requestRecord{
|
||||
startTime: time.Now(),
|
||||
request: req,
|
||||
responseWriter: w,
|
||||
}
|
||||
|
||||
// generate a record index an insert into the map
|
||||
manager.lock.Lock()
|
||||
record.index = manager.count
|
||||
manager.count++
|
||||
manager.requestRecords[record.index] = record
|
||||
manager.lock.Unlock()
|
||||
|
||||
defer func() {
|
||||
// just in case there is a panic. now the panics are all recovered in middleware.go
|
||||
localPanicErr := recover()
|
||||
if localPanicErr != nil {
|
||||
record.lock.Lock()
|
||||
record.panicError = localPanicErr
|
||||
record.lock.Unlock()
|
||||
}
|
||||
|
||||
// remove from the record map
|
||||
manager.lock.Lock()
|
||||
delete(manager.requestRecords, record.index)
|
||||
manager.lock.Unlock()
|
||||
|
||||
// log the end of the request
|
||||
manager.print(EndEvent, record)
|
||||
|
||||
if localPanicErr != nil {
|
||||
// the panic wasn't recovered before us, so we should pass it up, and let the framework handle the panic error
|
||||
panic(localPanicErr)
|
||||
}
|
||||
}()
|
||||
|
||||
req = req.WithContext(context.WithValue(req.Context(), contextKey, record))
|
||||
manager.print(StartEvent, record)
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
28
modules/web/routing/requestrecord.go
Normal file
28
modules/web/routing/requestrecord.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package routing
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type requestRecord struct {
|
||||
// index of the record in the records map
|
||||
index uint64
|
||||
|
||||
// immutable fields
|
||||
startTime time.Time
|
||||
request *http.Request
|
||||
responseWriter http.ResponseWriter
|
||||
|
||||
// mutex
|
||||
lock sync.RWMutex
|
||||
|
||||
// mutable fields
|
||||
isLongPolling bool
|
||||
funcInfo *FuncInfo
|
||||
panicError any
|
||||
}
|
||||
10
modules/web/types/response.go
Normal file
10
modules/web/types/response.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package types
|
||||
|
||||
// ResponseStatusProvider is an interface to get the written status in the response
|
||||
// Many packages need this interface, so put it in the separate package to avoid import cycle
|
||||
type ResponseStatusProvider interface {
|
||||
WrittenStatus() int
|
||||
}
|
||||
Reference in New Issue
Block a user