snapshot of https://github.com/go-gitea/gitea.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped
Some checks failed
cache-seeder / prune (push) Has been skipped
cache-seeder / frontend (push) Failing after 3s
cache-seeder / python (push) Failing after 3s
cache-seeder / gobuild (push) Failing after 4s
cache-seeder / lint (bindata, lint-backend) (push) Failing after 3s
giteabot backport / giteabot (push) Has been skipped
giteabot / giteabot (push) Has been skipped
cache-prune / prune (push) Has been skipped
release-nightly-snapcraft / build-and-publish (ubuntu-24.04) (push) Has been cancelled
release-nightly-snapcraft / build-and-publish (ubuntu-24.04-arm) (push) Has been cancelled
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
cron-renovate / cron-renovate (push) Has been skipped
This commit is contained in:
200
modules/web/middleware/binding.go
Normal file
200
modules/web/middleware/binding.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/validation"
|
||||
|
||||
"gitea.com/go-chi/binding" //nolint:depguard // this package wraps it
|
||||
)
|
||||
|
||||
type (
|
||||
ValidateContext = structs.ValidateContext
|
||||
FormDefaultValidator = structs.FormDefaultValidator
|
||||
)
|
||||
|
||||
type Form interface {
|
||||
Validate(ctx *ValidateContext, errs validation.BindingErrors) validation.BindingErrors
|
||||
}
|
||||
|
||||
// BindFormAny binds the request to the form of type T and returns the pointer to the form and any binding errors.
|
||||
// Only the rules defined in the struct field's "binding" tag are applied.
|
||||
// It can bind to any struct, doesn't call the struct's "Form.Validate" interface.
|
||||
func BindFormAny[T any](req *http.Request, binder *binding.Binder, _ T) (ret *T, _ validation.BindingErrors) {
|
||||
typ := reflect.TypeFor[T]()
|
||||
if typ.Kind() != reflect.Struct {
|
||||
panic("BindFormAny: template type must be a struct and the function returns its pointer")
|
||||
}
|
||||
form := new(T)
|
||||
errs := binder.Bind(req, form)
|
||||
return form, errs
|
||||
}
|
||||
|
||||
// BindFormValidate binds the request to the form of type T which must be a pointer implementing Form interface
|
||||
// After binding, the Form.Validate is also called so we can do more validation checks
|
||||
func BindFormValidate[T Form](req *http.Request, binder *binding.Binder) (ret T, _ validation.BindingErrors) {
|
||||
locale := req.Context().Value(translation.ContextKey).(translation.Locale) //nolint:forcetypeassert // must exist
|
||||
ptrType := reflect.TypeFor[T]()
|
||||
structType := ptrType.Elem()
|
||||
ptrVal := reflect.New(structType)
|
||||
form := ptrVal.Interface().(Form) //nolint:forcetypeassert // must implement Form
|
||||
errs := binder.Bind(req, form)
|
||||
errs = form.Validate(&ValidateContext{Locale: locale}, errs)
|
||||
return form.(T), errs //nolint:forcetypeassert // must be type T
|
||||
}
|
||||
|
||||
// AssignForm assign form values back to the template data, the template variable names are in "snake_case"
|
||||
func AssignForm(form any, data map[string]any) {
|
||||
typ := reflect.TypeOf(form)
|
||||
val := reflect.ValueOf(form)
|
||||
|
||||
for typ.Kind() == reflect.Pointer {
|
||||
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, ruleName string) string {
|
||||
prefix := ruleName + "("
|
||||
for rule := range strings.SplitSeq(field.Tag.Get("binding"), ";") {
|
||||
if strings.HasPrefix(rule, prefix) {
|
||||
return rule[len(prefix) : len(rule)-1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func AddValidationError(errs validation.BindingErrors, fieldName, errorMsg string) validation.BindingErrors {
|
||||
errs.Add([]string{fieldName}, validation.ErrCustomMessage, errorMsg)
|
||||
return errs
|
||||
}
|
||||
|
||||
func getFieldDisplayNameForMessage(f any, l translation.Locale, fieldNames []string) (field reflect.StructField, ok bool, displayName string) {
|
||||
if len(fieldNames) == 0 {
|
||||
return field, false, ""
|
||||
}
|
||||
typ := reflect.TypeOf(f)
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
typ = typ.Elem()
|
||||
}
|
||||
|
||||
fieldName := fieldNames[0]
|
||||
field, fieldExists := typ.FieldByName(fieldName)
|
||||
if !fieldExists {
|
||||
for tryField := range typ.Fields() {
|
||||
if util.ToSnakeCase(tryField.Name) == fieldName || tryField.Tag.Get("form") == fieldName {
|
||||
field, fieldExists = tryField, true
|
||||
}
|
||||
}
|
||||
if !fieldExists {
|
||||
return field, false, ""
|
||||
}
|
||||
}
|
||||
|
||||
if field.Tag.Get("form") == "-" {
|
||||
return field, false, ""
|
||||
}
|
||||
|
||||
trKeyFallback := "form." + field.Name
|
||||
trKey := util.IfZero(field.Tag.Get("locale"), trKeyFallback)
|
||||
if l.HasKey(trKey) {
|
||||
displayName = l.TrString(trKey)
|
||||
} else {
|
||||
displayName = field.Name
|
||||
}
|
||||
return field, true, displayName
|
||||
}
|
||||
|
||||
func BuildValidationErrorForUser(f any, l translation.Locale, bindingErrs validation.BindingErrors) (errorMessage, errorFieldName string, fieldNames []string) {
|
||||
if bindingErrs.Len() == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
bindingErr := bindingErrs[0]
|
||||
fieldNames, classification, bindingErrMsg := bindingErr.FieldNames, bindingErr.Classification, bindingErr.Message
|
||||
field, ok, fieldDisplayName := getFieldDisplayNameForMessage(f, l, fieldNames)
|
||||
if !ok {
|
||||
return l.TrString("error.occurred"), "", fieldNames
|
||||
}
|
||||
|
||||
errorFieldName = field.Name
|
||||
switch classification {
|
||||
case binding.ERR_REQUIRED:
|
||||
errorMessage = l.TrString("form.require_error", fieldDisplayName)
|
||||
case binding.ERR_ALPHA_DASH:
|
||||
errorMessage = l.TrString("form.alpha_dash_error", fieldDisplayName)
|
||||
case binding.ERR_ALPHA_DASH_DOT:
|
||||
errorMessage = l.TrString("form.alpha_dash_dot_error", fieldDisplayName)
|
||||
case binding.ERR_MIN_SIZE:
|
||||
errorMessage = l.TrString("form.min_size_error", fieldDisplayName, getRuleBody(field, "MinSize"))
|
||||
case binding.ERR_MAX_SIZE:
|
||||
errorMessage = l.TrString("form.max_size_error", fieldDisplayName, getRuleBody(field, "MaxSize"))
|
||||
case binding.ERR_RANGE:
|
||||
rangeMin, rangeMax, _ := strings.Cut(getRuleBody(field, "Range"), ",")
|
||||
errorMessage = l.TrString("form.range_error", fieldDisplayName, rangeMin, rangeMax)
|
||||
case binding.ERR_EMAIL:
|
||||
errorMessage = l.TrString("form.email_error", fieldDisplayName)
|
||||
case binding.ERR_URL:
|
||||
errorMessage = l.TrString("form.url_error", fieldDisplayName)
|
||||
case binding.ERR_IN:
|
||||
ruleBody := getRuleBody(field, "In")
|
||||
if strings.HasPrefix(ruleBody, ",") {
|
||||
ruleBody = "(empty)" + ruleBody
|
||||
}
|
||||
errorMessage = l.TrString("form.in_error", fieldDisplayName, ruleBody)
|
||||
case binding.ERR_INCLUDE:
|
||||
errorMessage = l.TrString("form.include_error", fieldDisplayName, getRuleBody(field, "Include"))
|
||||
|
||||
case validation.ErrCustomMessage:
|
||||
errorMessage = bindingErrMsg
|
||||
case validation.ErrGitRefName:
|
||||
errorMessage = l.TrString("form.git_ref_name_error", fieldDisplayName)
|
||||
case validation.ErrGlobPattern:
|
||||
errorMessage = l.TrString("form.glob_pattern_error", fieldDisplayName, bindingErrMsg)
|
||||
case validation.ErrRegexPattern:
|
||||
errorMessage = l.TrString("form.regex_pattern_error", fieldDisplayName, bindingErrMsg)
|
||||
case validation.ErrUsername:
|
||||
errorMessage = l.TrString("form.username_error", fieldDisplayName)
|
||||
case validation.ErrInvalidGroupTeamMap:
|
||||
errorMessage = l.TrString("form.invalid_group_team_map_error", fieldDisplayName, bindingErrMsg)
|
||||
case validation.ErrInvalidBadgeSlug:
|
||||
errorMessage = l.TrString("form.invalid_slug_error", fieldDisplayName)
|
||||
default:
|
||||
setting.PanicInDevOrTesting("unknown binding error classification for field %T.%s: %v, err: %s", f, errorFieldName, classification, bindingErrMsg)
|
||||
var msg string
|
||||
if classification != "" && bindingErrMsg != "" {
|
||||
msg = classification + ": " + bindingErrMsg
|
||||
} else {
|
||||
msg = util.IfZero(bindingErrMsg, classification)
|
||||
if msg == "" {
|
||||
setting.PanicInDevOrTesting("no error message for binding error: %v", bindingErr)
|
||||
}
|
||||
msg = util.IfZero(msg, "unknown error")
|
||||
}
|
||||
errorMessage = l.TrString("form.field_invalid_message", fieldDisplayName, msg)
|
||||
}
|
||||
return errorMessage, errorFieldName, fieldNames
|
||||
}
|
||||
28
modules/web/middleware/binding_test.go
Normal file
28
modules/web/middleware/binding_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/validation"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type testRangeForm struct {
|
||||
FormDefaultValidator
|
||||
Hours int `binding:"Range(0,1000)"`
|
||||
}
|
||||
|
||||
func TestBuildValidationErrorForUser(t *testing.T) {
|
||||
// an out-of-range value must reach its own message instead of the panicking "default" branch
|
||||
form := &testRangeForm{Hours: 2000}
|
||||
errs := validation.Binder().Validate(t.Context(), form)
|
||||
errorMessage, errorFieldName, fieldNames := BuildValidationErrorForUser(form, translation.MockLocale{}, errs)
|
||||
assert.Equal(t, "form.range_error:form.Hours,0,1000", errorMessage)
|
||||
assert.Equal(t, "Hours", errorFieldName)
|
||||
assert.Equal(t, []string{"Hours"}, fieldNames)
|
||||
}
|
||||
107
modules/web/middleware/cookie.go
Normal file
107
modules/web/middleware/cookie.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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"
|
||||
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieWebBannerDismissed = "gitea_disbnr"
|
||||
CookieTheme = "gitea_theme"
|
||||
cookieRedirectTo = "redirect_to"
|
||||
)
|
||||
|
||||
func GetRedirectToCookie(req *http.Request) string {
|
||||
return GetSiteCookie(req, cookieRedirectTo)
|
||||
}
|
||||
|
||||
// SetRedirectToCookie convenience function to set the RedirectTo cookie consistently
|
||||
func SetRedirectToCookie(resp http.ResponseWriter, value string) {
|
||||
SetSiteCookie(resp, cookieRedirectTo, value, 0)
|
||||
}
|
||||
|
||||
// DeleteRedirectToCookie convenience function to delete most cookies consistently
|
||||
func DeleteRedirectToCookie(resp http.ResponseWriter) {
|
||||
SetSiteCookie(resp, cookieRedirectTo, "", -1)
|
||||
}
|
||||
|
||||
func RedirectLinkUserLogin(req *http.Request) string {
|
||||
if req.Header.Get("X-Gitea-Fetch-Action") != "" {
|
||||
// when building the redirect link for a fetch request, the current link might be a partial page,
|
||||
// so we only redirect to the login page without redirect_to parameter
|
||||
return setting.AppSubURL + "/user/login"
|
||||
}
|
||||
return setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(setting.AppSubURL+req.URL.RequestURI())
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// HINT: INSTALL-PAGE-COOKIE-INIT: the cookie system is not properly initialized on the Install page, so there is no CookiePath
|
||||
cookie := &http.Cookie{
|
||||
Name: name,
|
||||
Value: url.QueryEscape(value),
|
||||
MaxAge: maxAge,
|
||||
Path: util.IfZero(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)
|
||||
})
|
||||
}
|
||||
48
modules/web/middleware/data.go
Normal file
48
modules/web/middleware/data.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
const (
|
||||
ContextDataKeySignedUser = "SignedUser"
|
||||
// ContextDataKeyImpersonator holds the admin acting as the signed-in user, if any.
|
||||
ContextDataKeyImpersonator = "Impersonator"
|
||||
// ContextDataKeyAuthCredential names the credential the request authenticated with, e.g. "access-token:42".
|
||||
ContextDataKeyAuthCredential = "AuthCredential"
|
||||
)
|
||||
|
||||
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,
|
||||
"ViteModeIsDev": public.IsViteDevMode(),
|
||||
}
|
||||
}
|
||||
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"
|
||||
|
||||
"gitea.dev/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
|
||||
}
|
||||
71
modules/web/middleware/locale.go
Normal file
71
modules/web/middleware/locale.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/translation/i18n"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// maxAcceptLanguageLen bounds the Accept-Language header before it reaches
|
||||
// language.ParseAcceptLanguage. That parser has quadratic-time behavior on long
|
||||
// malformed inputs, and its built-in guard only counts "-" separators while the
|
||||
// scanner treats "_" as an alias for "-", so a "_"-heavy header slips past the
|
||||
// guard and burns CPU. Only the leading (highest-priority) languages are used, so
|
||||
// truncating a longer header is safe.
|
||||
const maxAcceptLanguageLen = 200
|
||||
|
||||
// parseAcceptLanguage parses the Accept-Language header after bounding its length
|
||||
// to avoid a quadratic-time DoS on attacker-controlled input.
|
||||
func parseAcceptLanguage(header string) []language.Tag {
|
||||
if len(header) > maxAcceptLanguageLen {
|
||||
header = header[:maxAcceptLanguageLen]
|
||||
}
|
||||
tags, _, _ := language.ParseAcceptLanguage(header)
|
||||
return tags
|
||||
}
|
||||
|
||||
// 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 := 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)
|
||||
}
|
||||
27
modules/web/middleware/locale_test.go
Normal file
27
modules/web/middleware/locale_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseAcceptLanguage(t *testing.T) {
|
||||
// a normal header is parsed and its leading language preserved
|
||||
tags := parseAcceptLanguage("de-DE,de;q=0.9,en;q=0.8")
|
||||
assert.NotEmpty(t, tags)
|
||||
assert.Equal(t, "de-DE", tags[0].String())
|
||||
|
||||
// an oversized "_"-separated header would drive ParseAcceptLanguage into its
|
||||
// quadratic-time path (the built-in guard only counts "-"); the length bound
|
||||
// keeps the input passed to the parser small so it cannot be used for a DoS.
|
||||
malicious := strings.Repeat("_aaaaaaaaa", 1<<16) // ~640 KiB, zero "-" characters
|
||||
assert.Greater(t, len(malicious), maxAcceptLanguageLen)
|
||||
tags = parseAcceptLanguage(malicious)
|
||||
// no panic / hang, and nothing meaningful is parsed out of the garbage
|
||||
assert.Empty(t, tags)
|
||||
}
|
||||
Reference in New Issue
Block a user