1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-22 18:28:37 +00:00

Move context from modules to services (#29440)

Since `modules/context` has to depend on `models` and many other
packages, it should be moved from `modules/context` to
`services/context` according to design principles. There is no logic
code change on this PR, only move packages.

- Move `code.gitea.io/gitea/modules/context` to
`code.gitea.io/gitea/services/context`
- Move `code.gitea.io/gitea/modules/contexttest` to
`code.gitea.io/gitea/services/contexttest` because of depending on
context
- Move `code.gitea.io/gitea/modules/upload` to
`code.gitea.io/gitea/services/context/upload` because of depending on
context
This commit is contained in:
Lunny Xiao
2024-02-27 15:12:22 +08:00
committed by GitHub
parent e55926ebfe
commit 29f149bd9f
360 changed files with 369 additions and 375 deletions

View File

@@ -0,0 +1,101 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"bytes"
"fmt"
"net"
"net/http"
"strings"
"text/template"
"time"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/middleware"
)
type routerLoggerOptions struct {
req *http.Request
Identity *string
Start *time.Time
ResponseWriter http.ResponseWriter
Ctx map[string]any
RequestID *string
}
const keyOfRequestIDInTemplate = ".RequestID"
// According to:
// TraceId: A valid trace identifier is a 16-byte array with at least one non-zero byte
// MD5 output is 16 or 32 bytes: md5-bytes is 16, md5-hex is 32
// SHA1: similar, SHA1-bytes is 20, SHA1-hex is 40.
// UUID is 128-bit, 32 hex chars, 36 ASCII chars with 4 dashes
// So, we accept a Request ID with a maximum character length of 40
const maxRequestIDByteLength = 40
func parseRequestIDFromRequestHeader(req *http.Request) string {
requestID := "-"
for _, key := range setting.Log.RequestIDHeaders {
if req.Header.Get(key) != "" {
requestID = req.Header.Get(key)
break
}
}
if len(requestID) > maxRequestIDByteLength {
requestID = fmt.Sprintf("%s...", requestID[:maxRequestIDByteLength])
}
return requestID
}
// AccessLogger returns a middleware to log access logger
func AccessLogger() func(http.Handler) http.Handler {
logger := log.GetLogger("access")
needRequestID := len(setting.Log.RequestIDHeaders) > 0 && strings.Contains(setting.Log.AccessLogTemplate, keyOfRequestIDInTemplate)
logTemplate, _ := template.New("log").Parse(setting.Log.AccessLogTemplate)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
start := time.Now()
var requestID string
if needRequestID {
requestID = parseRequestIDFromRequestHeader(req)
}
reqHost, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
reqHost = req.RemoteAddr
}
next.ServeHTTP(w, req)
rw := w.(ResponseWriter)
identity := "-"
data := middleware.GetContextData(req.Context())
if signedUser, ok := data[middleware.ContextDataKeySignedUser].(*user_model.User); ok {
identity = signedUser.Name
}
buf := bytes.NewBuffer([]byte{})
err = logTemplate.Execute(buf, routerLoggerOptions{
req: req,
Identity: &identity,
Start: &start,
ResponseWriter: rw,
Ctx: map[string]any{
"RemoteAddr": req.RemoteAddr,
"RemoteHost": reqHost,
"Req": req,
},
RequestID: &requestID,
})
if err != nil {
log.Error("Could not execute access logger template: %v", err.Error())
}
logger.Info("%s", buf.String())
})
}
}

408
services/context/api.go Normal file
View File

@@ -0,0 +1,408 @@
// Copyright 2016 The Gogs Authors. All rights reserved.
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
mc "code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
web_types "code.gitea.io/gitea/modules/web/types"
"gitea.com/go-chi/cache"
)
// APIContext is a specific context for API service
type APIContext struct {
*Base
Cache cache.Cache
Doer *user_model.User // current signed-in user
IsSigned bool
IsBasicAuth bool
ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer
Repo *Repository
Org *APIOrganization
Package *Package
}
func init() {
web.RegisterResponseStatusProvider[*APIContext](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(apiContextKey).(*APIContext)
})
}
// Currently, we have the following common fields in error response:
// * message: the message for end users (it shouldn't be used for error type detection)
// if we need to indicate some errors, we should introduce some new fields like ErrorCode or ErrorType
// * url: the swagger document URL
// APIError is error format response
// swagger:response error
type APIError struct {
Message string `json:"message"`
URL string `json:"url"`
}
// APIValidationError is error format response related to input validation
// swagger:response validationError
type APIValidationError struct {
Message string `json:"message"`
URL string `json:"url"`
}
// APIInvalidTopicsError is error format response to invalid topics
// swagger:response invalidTopicsError
type APIInvalidTopicsError struct {
Message string `json:"message"`
InvalidTopics []string `json:"invalidTopics"`
}
// APIEmpty is an empty response
// swagger:response empty
type APIEmpty struct{}
// APIForbiddenError is a forbidden error response
// swagger:response forbidden
type APIForbiddenError struct {
APIError
}
// APINotFound is a not found empty response
// swagger:response notFound
type APINotFound struct{}
// APIConflict is a conflict empty response
// swagger:response conflict
type APIConflict struct{}
// APIRedirect is a redirect response
// swagger:response redirect
type APIRedirect struct{}
// APIString is a string response
// swagger:response string
type APIString string
// APIRepoArchivedError is an error that is raised when an archived repo should be modified
// swagger:response repoArchivedError
type APIRepoArchivedError struct {
APIError
}
// ServerError responds with error message, status is 500
func (ctx *APIContext) ServerError(title string, err error) {
ctx.Error(http.StatusInternalServerError, title, err)
}
// Error responds with an error message to client with given obj as the message.
// If status is 500, also it prints error to log.
func (ctx *APIContext) Error(status int, title string, obj any) {
var message string
if err, ok := obj.(error); ok {
message = err.Error()
} else {
message = fmt.Sprintf("%s", obj)
}
if status == http.StatusInternalServerError {
log.ErrorWithSkip(1, "%s: %s", title, message)
if setting.IsProd && !(ctx.Doer != nil && ctx.Doer.IsAdmin) {
message = ""
}
}
ctx.JSON(status, APIError{
Message: message,
URL: setting.API.SwaggerURL,
})
}
// InternalServerError responds with an error message to the client with the error as a message
// and the file and line of the caller.
func (ctx *APIContext) InternalServerError(err error) {
log.ErrorWithSkip(1, "InternalServerError: %v", err)
var message string
if !setting.IsProd || (ctx.Doer != nil && ctx.Doer.IsAdmin) {
message = err.Error()
}
ctx.JSON(http.StatusInternalServerError, APIError{
Message: message,
URL: setting.API.SwaggerURL,
})
}
type apiContextKeyType struct{}
var apiContextKey = apiContextKeyType{}
// GetAPIContext returns a context for API routes
func GetAPIContext(req *http.Request) *APIContext {
return req.Context().Value(apiContextKey).(*APIContext)
}
func genAPILinks(curURL *url.URL, total, pageSize, curPage int) []string {
page := NewPagination(total, pageSize, curPage, 0)
paginater := page.Paginater
links := make([]string, 0, 4)
if paginater.HasNext() {
u := *curURL
queries := u.Query()
queries.Set("page", fmt.Sprintf("%d", paginater.Next()))
u.RawQuery = queries.Encode()
links = append(links, fmt.Sprintf("<%s%s>; rel=\"next\"", setting.AppURL, u.RequestURI()[1:]))
}
if !paginater.IsLast() {
u := *curURL
queries := u.Query()
queries.Set("page", fmt.Sprintf("%d", paginater.TotalPages()))
u.RawQuery = queries.Encode()
links = append(links, fmt.Sprintf("<%s%s>; rel=\"last\"", setting.AppURL, u.RequestURI()[1:]))
}
if !paginater.IsFirst() {
u := *curURL
queries := u.Query()
queries.Set("page", "1")
u.RawQuery = queries.Encode()
links = append(links, fmt.Sprintf("<%s%s>; rel=\"first\"", setting.AppURL, u.RequestURI()[1:]))
}
if paginater.HasPrevious() {
u := *curURL
queries := u.Query()
queries.Set("page", fmt.Sprintf("%d", paginater.Previous()))
u.RawQuery = queries.Encode()
links = append(links, fmt.Sprintf("<%s%s>; rel=\"prev\"", setting.AppURL, u.RequestURI()[1:]))
}
return links
}
// SetLinkHeader sets pagination link header by given total number and page size.
func (ctx *APIContext) SetLinkHeader(total, pageSize int) {
links := genAPILinks(ctx.Req.URL, total, pageSize, ctx.FormInt("page"))
if len(links) > 0 {
ctx.RespHeader().Set("Link", strings.Join(links, ","))
ctx.AppendAccessControlExposeHeaders("Link")
}
}
// APIContexter returns apicontext as middleware
func APIContexter() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
base, baseCleanUp := NewBaseContext(w, req)
ctx := &APIContext{
Base: base,
Cache: mc.GetCache(),
Repo: &Repository{PullRequest: &PullRequest{}},
Org: &APIOrganization{},
}
defer baseCleanUp()
ctx.Base.AppendContextValue(apiContextKey, ctx)
ctx.Base.AppendContextValueFunc(gitrepo.RepositoryContextKey, func() any { return ctx.Repo.GitRepo })
// If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid.
if ctx.Req.Method == "POST" && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") {
if err := ctx.Req.ParseMultipartForm(setting.Attachment.MaxSize << 20); err != nil && !strings.Contains(err.Error(), "EOF") { // 32MB max size
ctx.InternalServerError(err)
return
}
}
httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 0, "no-transform")
ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
next.ServeHTTP(ctx.Resp, ctx.Req)
})
}
}
// NotFound handles 404s for APIContext
// String will replace message, errors will be added to a slice
func (ctx *APIContext) NotFound(objs ...any) {
message := ctx.Locale.TrString("error.not_found")
var errors []string
for _, obj := range objs {
// Ignore nil
if obj == nil {
continue
}
if err, ok := obj.(error); ok {
errors = append(errors, err.Error())
} else {
message = obj.(string)
}
}
ctx.JSON(http.StatusNotFound, map[string]any{
"message": message,
"url": setting.API.SwaggerURL,
"errors": errors,
})
}
// ReferencesGitRepo injects the GitRepo into the Context
// you can optional skip the IsEmpty check
func ReferencesGitRepo(allowEmpty ...bool) func(ctx *APIContext) (cancel context.CancelFunc) {
return func(ctx *APIContext) (cancel context.CancelFunc) {
// Empty repository does not have reference information.
if ctx.Repo.Repository.IsEmpty && !(len(allowEmpty) != 0 && allowEmpty[0]) {
return nil
}
// For API calls.
if ctx.Repo.GitRepo == nil {
gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository)
if err != nil {
ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Open Repository %v failed", ctx.Repo.Repository.FullName()), err)
return cancel
}
ctx.Repo.GitRepo = gitRepo
// We opened it, we should close it
return func() {
// If it's been set to nil then assume someone else has closed it.
if ctx.Repo.GitRepo != nil {
_ = ctx.Repo.GitRepo.Close()
}
}
}
return cancel
}
}
// RepoRefForAPI handles repository reference names when the ref name is not explicitly given
func RepoRefForAPI(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ctx := GetAPIContext(req)
if ctx.Repo.GitRepo == nil {
ctx.InternalServerError(fmt.Errorf("no open git repo"))
return
}
if ref := ctx.FormTrim("ref"); len(ref) > 0 {
commit, err := ctx.Repo.GitRepo.GetCommit(ref)
if err != nil {
if git.IsErrNotExist(err) {
ctx.NotFound()
} else {
ctx.Error(http.StatusInternalServerError, "GetCommit", err)
}
return
}
ctx.Repo.Commit = commit
ctx.Repo.CommitID = ctx.Repo.Commit.ID.String()
ctx.Repo.TreePath = ctx.Params("*")
next.ServeHTTP(w, req)
return
}
refName := getRefName(ctx.Base, ctx.Repo, RepoRefAny)
var err error
if ctx.Repo.GitRepo.IsBranchExist(refName) {
ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName)
if err != nil {
ctx.InternalServerError(err)
return
}
ctx.Repo.CommitID = ctx.Repo.Commit.ID.String()
} else if ctx.Repo.GitRepo.IsTagExist(refName) {
ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetTagCommit(refName)
if err != nil {
ctx.InternalServerError(err)
return
}
ctx.Repo.CommitID = ctx.Repo.Commit.ID.String()
} else if len(refName) == ctx.Repo.GetObjectFormat().FullLength() {
ctx.Repo.CommitID = refName
ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetCommit(refName)
if err != nil {
ctx.NotFound("GetCommit", err)
return
}
} else {
ctx.NotFound(fmt.Errorf("not exist: '%s'", ctx.Params("*")))
return
}
next.ServeHTTP(w, req)
})
}
// HasAPIError returns true if error occurs in form validation.
func (ctx *APIContext) HasAPIError() bool {
hasErr, ok := ctx.Data["HasError"]
if !ok {
return false
}
return hasErr.(bool)
}
// GetErrMsg returns error message in form validation.
func (ctx *APIContext) GetErrMsg() string {
msg, _ := ctx.Data["ErrorMsg"].(string)
if msg == "" {
msg = "invalid form data"
}
return msg
}
// NotFoundOrServerError use error check function to determine if the error
// is about not found. It responds with 404 status code for not found error,
// or error context description for logging purpose of 500 server error.
func (ctx *APIContext) NotFoundOrServerError(logMsg string, errCheck func(error) bool, logErr error) {
if errCheck(logErr) {
ctx.JSON(http.StatusNotFound, nil)
return
}
ctx.Error(http.StatusInternalServerError, "NotFoundOrServerError", logMsg)
}
// IsUserSiteAdmin returns true if current user is a site admin
func (ctx *APIContext) IsUserSiteAdmin() bool {
return ctx.IsSigned && ctx.Doer.IsAdmin
}
// IsUserRepoAdmin returns true if current user is admin in current repo
func (ctx *APIContext) IsUserRepoAdmin() bool {
return ctx.Repo.IsAdmin()
}
// IsUserRepoWriter returns true if current user has write privilege in current repo
func (ctx *APIContext) IsUserRepoWriter(unitTypes []unit.Type) bool {
for _, unitType := range unitTypes {
if ctx.Repo.CanWrite(unitType) {
return true
}
}
return false
}

View File

@@ -0,0 +1,12 @@
// Copyright 2016 The Gogs Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import "code.gitea.io/gitea/models/organization"
// APIOrganization contains organization and team
type APIOrganization struct {
Organization *organization.Organization
Team *organization.Team
}

View File

@@ -0,0 +1,50 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"net/url"
"strconv"
"testing"
"code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestGenAPILinks(t *testing.T) {
setting.AppURL = "http://localhost:3000/"
kases := map[string][]string{
"api/v1/repos/jerrykan/example-repo/issues?state=all": {
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=2&state=all>; rel="next"`,
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=5&state=all>; rel="last"`,
},
"api/v1/repos/jerrykan/example-repo/issues?state=all&page=1": {
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=2&state=all>; rel="next"`,
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=5&state=all>; rel="last"`,
},
"api/v1/repos/jerrykan/example-repo/issues?state=all&page=2": {
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=3&state=all>; rel="next"`,
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=5&state=all>; rel="last"`,
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=1&state=all>; rel="first"`,
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=1&state=all>; rel="prev"`,
},
"api/v1/repos/jerrykan/example-repo/issues?state=all&page=5": {
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=1&state=all>; rel="first"`,
`<http://localhost:3000/api/v1/repos/jerrykan/example-repo/issues?page=4&state=all>; rel="prev"`,
},
}
for req, response := range kases {
u, err := url.Parse(setting.AppURL + req)
assert.NoError(t, err)
p := u.Query().Get("page")
curPage, _ := strconv.Atoi(p)
links := genAPILinks(u, 100, 20, curPage)
assert.EqualValues(t, links, response)
}
}

317
services/context/base.go Normal file
View File

@@ -0,0 +1,317 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"context"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
"github.com/go-chi/chi/v5"
)
type contextValuePair struct {
key any
valueFn func() any
}
type Base struct {
originCtx context.Context
contextValues []contextValuePair
Resp ResponseWriter
Req *http.Request
// Data is prepared by ContextDataStore middleware, this field only refers to the pre-created/prepared ContextData.
// Although it's mainly used for MVC templates, sometimes it's also used to pass data between middlewares/handler
Data middleware.ContextData
// Locale is mainly for Web context, although the API context also uses it in some cases: message response, form validation
Locale translation.Locale
}
func (b *Base) Deadline() (deadline time.Time, ok bool) {
return b.originCtx.Deadline()
}
func (b *Base) Done() <-chan struct{} {
return b.originCtx.Done()
}
func (b *Base) Err() error {
return b.originCtx.Err()
}
func (b *Base) Value(key any) any {
for _, pair := range b.contextValues {
if pair.key == key {
return pair.valueFn()
}
}
return b.originCtx.Value(key)
}
func (b *Base) AppendContextValueFunc(key any, valueFn func() any) any {
b.contextValues = append(b.contextValues, contextValuePair{key, valueFn})
return b
}
func (b *Base) AppendContextValue(key, value any) any {
b.contextValues = append(b.contextValues, contextValuePair{key, func() any { return value }})
return b
}
func (b *Base) GetData() middleware.ContextData {
return b.Data
}
// AppendAccessControlExposeHeaders append headers by name to "Access-Control-Expose-Headers" header
func (b *Base) AppendAccessControlExposeHeaders(names ...string) {
val := b.RespHeader().Get("Access-Control-Expose-Headers")
if len(val) != 0 {
b.RespHeader().Set("Access-Control-Expose-Headers", fmt.Sprintf("%s, %s", val, strings.Join(names, ", ")))
} else {
b.RespHeader().Set("Access-Control-Expose-Headers", strings.Join(names, ", "))
}
}
// SetTotalCountHeader set "X-Total-Count" header
func (b *Base) SetTotalCountHeader(total int64) {
b.RespHeader().Set("X-Total-Count", fmt.Sprint(total))
b.AppendAccessControlExposeHeaders("X-Total-Count")
}
// Written returns true if there are something sent to web browser
func (b *Base) Written() bool {
return b.Resp.WrittenStatus() != 0
}
func (b *Base) WrittenStatus() int {
return b.Resp.WrittenStatus()
}
// Status writes status code
func (b *Base) Status(status int) {
b.Resp.WriteHeader(status)
}
// Write writes data to web browser
func (b *Base) Write(bs []byte) (int, error) {
return b.Resp.Write(bs)
}
// RespHeader returns the response header
func (b *Base) RespHeader() http.Header {
return b.Resp.Header()
}
// Error returned an error to web browser
func (b *Base) Error(status int, contents ...string) {
v := http.StatusText(status)
if len(contents) > 0 {
v = contents[0]
}
http.Error(b.Resp, v, status)
}
// JSON render content as JSON
func (b *Base) JSON(status int, content any) {
b.Resp.Header().Set("Content-Type", "application/json;charset=utf-8")
b.Resp.WriteHeader(status)
if err := json.NewEncoder(b.Resp).Encode(content); err != nil {
log.Error("Render JSON failed: %v", err)
}
}
// RemoteAddr returns the client machine ip address
func (b *Base) RemoteAddr() string {
return b.Req.RemoteAddr
}
// Params returns the param on route
func (b *Base) Params(p string) string {
s, _ := url.PathUnescape(chi.URLParam(b.Req, strings.TrimPrefix(p, ":")))
return s
}
func (b *Base) PathParamRaw(p string) string {
return chi.URLParam(b.Req, strings.TrimPrefix(p, ":"))
}
// ParamsInt64 returns the param on route as int64
func (b *Base) ParamsInt64(p string) int64 {
v, _ := strconv.ParseInt(b.Params(p), 10, 64)
return v
}
// SetParams set params into routes
func (b *Base) SetParams(k, v string) {
chiCtx := chi.RouteContext(b)
chiCtx.URLParams.Add(strings.TrimPrefix(k, ":"), url.PathEscape(v))
}
// FormString returns the first value matching the provided key in the form as a string
func (b *Base) FormString(key string) string {
return b.Req.FormValue(key)
}
// FormStrings returns a string slice for the provided key from the form
func (b *Base) FormStrings(key string) []string {
if b.Req.Form == nil {
if err := b.Req.ParseMultipartForm(32 << 20); err != nil {
return nil
}
}
if v, ok := b.Req.Form[key]; ok {
return v
}
return nil
}
// FormTrim returns the first value for the provided key in the form as a space trimmed string
func (b *Base) FormTrim(key string) string {
return strings.TrimSpace(b.Req.FormValue(key))
}
// FormInt returns the first value for the provided key in the form as an int
func (b *Base) FormInt(key string) int {
v, _ := strconv.Atoi(b.Req.FormValue(key))
return v
}
// FormInt64 returns the first value for the provided key in the form as an int64
func (b *Base) FormInt64(key string) int64 {
v, _ := strconv.ParseInt(b.Req.FormValue(key), 10, 64)
return v
}
// FormBool returns true if the value for the provided key in the form is "1", "true" or "on"
func (b *Base) FormBool(key string) bool {
s := b.Req.FormValue(key)
v, _ := strconv.ParseBool(s)
v = v || strings.EqualFold(s, "on")
return v
}
// FormOptionalBool returns an OptionalBoolTrue or OptionalBoolFalse if the value
// for the provided key exists in the form else it returns OptionalBoolNone
func (b *Base) FormOptionalBool(key string) util.OptionalBool {
value := b.Req.FormValue(key)
if len(value) == 0 {
return util.OptionalBoolNone
}
s := b.Req.FormValue(key)
v, _ := strconv.ParseBool(s)
v = v || strings.EqualFold(s, "on")
return util.OptionalBoolOf(v)
}
func (b *Base) SetFormString(key, value string) {
_ = b.Req.FormValue(key) // force parse form
b.Req.Form.Set(key, value)
}
// PlainTextBytes renders bytes as plain text
func (b *Base) plainTextInternal(skip, status int, bs []byte) {
statusPrefix := status / 100
if statusPrefix == 4 || statusPrefix == 5 {
log.Log(skip, log.TRACE, "plainTextInternal (status=%d): %s", status, string(bs))
}
b.Resp.Header().Set("Content-Type", "text/plain;charset=utf-8")
b.Resp.Header().Set("X-Content-Type-Options", "nosniff")
b.Resp.WriteHeader(status)
if _, err := b.Resp.Write(bs); err != nil {
log.ErrorWithSkip(skip, "plainTextInternal (status=%d): write bytes failed: %v", status, err)
}
}
// PlainTextBytes renders bytes as plain text
func (b *Base) PlainTextBytes(status int, bs []byte) {
b.plainTextInternal(2, status, bs)
}
// PlainText renders content as plain text
func (b *Base) PlainText(status int, text string) {
b.plainTextInternal(2, status, []byte(text))
}
// Redirect redirects the request
func (b *Base) Redirect(location string, status ...int) {
code := http.StatusSeeOther
if len(status) == 1 {
code = status[0]
}
if strings.Contains(location, "://") || strings.HasPrefix(location, "//") {
// Some browsers (Safari) have buggy behavior for Cookie + Cache + External Redirection, eg: /my-path => https://other/path
// 1. the first request to "/my-path" contains cookie
// 2. some time later, the request to "/my-path" doesn't contain cookie (caused by Prevent web tracking)
// 3. Gitea's Sessioner doesn't see the session cookie, so it generates a new session id, and returns it to browser
// 4. then the browser accepts the empty session, then the user is logged out
// So in this case, we should remove the session cookie from the response header
removeSessionCookieHeader(b.Resp)
}
// in case the request is made by htmx, have it redirect the browser instead of trying to follow the redirect inside htmx
if b.Req.Header.Get("HX-Request") == "true" {
b.Resp.Header().Set("HX-Redirect", location)
// we have to return a non-redirect status code so XMLHTTPRequest will not immediately follow the redirect
// so as to give htmx redirect logic a chance to run
b.Status(http.StatusNoContent)
return
}
http.Redirect(b.Resp, b.Req, location, code)
}
type ServeHeaderOptions httplib.ServeHeaderOptions
func (b *Base) SetServeHeaders(opt *ServeHeaderOptions) {
httplib.ServeSetHeaders(b.Resp, (*httplib.ServeHeaderOptions)(opt))
}
// ServeContent serves content to http request
func (b *Base) ServeContent(r io.ReadSeeker, opts *ServeHeaderOptions) {
httplib.ServeSetHeaders(b.Resp, (*httplib.ServeHeaderOptions)(opts))
http.ServeContent(b.Resp, b.Req, opts.Filename, opts.LastModified, r)
}
// Close frees all resources hold by Context
func (b *Base) cleanUp() {
if b.Req != nil && b.Req.MultipartForm != nil {
_ = b.Req.MultipartForm.RemoveAll() // remove the temp files buffered to tmp directory
}
}
func (b *Base) Tr(msg string, args ...any) template.HTML {
return b.Locale.Tr(msg, args...)
}
func (b *Base) TrN(cnt any, key1, keyN string, args ...any) template.HTML {
return b.Locale.TrN(cnt, key1, keyN, args...)
}
func NewBaseContext(resp http.ResponseWriter, req *http.Request) (b *Base, closeFunc func()) {
b = &Base{
originCtx: req.Context(),
Req: req,
Resp: WrapResponseWriter(resp),
Locale: middleware.Locale(resp, req),
Data: middleware.GetContextData(req.Context()),
}
b.AppendContextValue(translation.ContextKey, b.Locale)
b.Req = b.Req.WithContext(b)
return b, b.cleanUp
}

View File

@@ -0,0 +1,93 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"fmt"
"sync"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/hcaptcha"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/mcaptcha"
"code.gitea.io/gitea/modules/recaptcha"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/turnstile"
"gitea.com/go-chi/captcha"
)
var (
imageCaptchaOnce sync.Once
cpt *captcha.Captcha
)
// GetImageCaptcha returns global image captcha
func GetImageCaptcha() *captcha.Captcha {
imageCaptchaOnce.Do(func() {
cpt = captcha.NewCaptcha(captcha.Options{
SubURL: setting.AppSubURL,
})
cpt.Store = cache.GetCache()
})
return cpt
}
// SetCaptchaData sets common captcha data
func SetCaptchaData(ctx *Context) {
if !setting.Service.EnableCaptcha {
return
}
ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha
ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
ctx.Data["Captcha"] = GetImageCaptcha()
ctx.Data["CaptchaType"] = setting.Service.CaptchaType
ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
}
const (
gRecaptchaResponseField = "g-recaptcha-response"
hCaptchaResponseField = "h-captcha-response"
mCaptchaResponseField = "m-captcha-response"
cfTurnstileResponseField = "cf-turnstile-response"
)
// VerifyCaptcha verifies Captcha data
// No-op if captchas are not enabled
func VerifyCaptcha(ctx *Context, tpl base.TplName, form any) {
if !setting.Service.EnableCaptcha {
return
}
var valid bool
var err error
switch setting.Service.CaptchaType {
case setting.ImageCaptcha:
valid = GetImageCaptcha().VerifyReq(ctx.Req)
case setting.ReCaptcha:
valid, err = recaptcha.Verify(ctx, ctx.Req.Form.Get(gRecaptchaResponseField))
case setting.HCaptcha:
valid, err = hcaptcha.Verify(ctx, ctx.Req.Form.Get(hCaptchaResponseField))
case setting.MCaptcha:
valid, err = mcaptcha.Verify(ctx, ctx.Req.Form.Get(mCaptchaResponseField))
case setting.CfTurnstile:
valid, err = turnstile.Verify(ctx, ctx.Req.Form.Get(cfTurnstileResponseField))
default:
ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType))
return
}
if err != nil {
log.Debug("%v", err)
}
if !valid {
ctx.Data["Err_Captcha"] = true
ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tpl, form)
}
}

257
services/context/context.go Normal file
View File

@@ -0,0 +1,257 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"context"
"encoding/hex"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strings"
"time"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
mc "code.gitea.io/gitea/modules/cache"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/modules/web/middleware"
web_types "code.gitea.io/gitea/modules/web/types"
"gitea.com/go-chi/cache"
"gitea.com/go-chi/session"
)
// Render represents a template render
type Render interface {
TemplateLookup(tmpl string, templateCtx context.Context) (templates.TemplateExecutor, error)
HTML(w io.Writer, status int, name string, data any, templateCtx context.Context) error
}
// Context represents context of a request.
type Context struct {
*Base
TemplateContext TemplateContext
Render Render
PageData map[string]any // data used by JavaScript modules in one page, it's `window.config.pageData`
Cache cache.Cache
Csrf CSRFProtector
Flash *middleware.Flash
Session session.Store
Link string // current request URL (without query string)
Doer *user_model.User // current signed-in user
IsSigned bool
IsBasicAuth bool
ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer
Repo *Repository
Org *Organization
Package *Package
}
type TemplateContext map[string]any
func init() {
web.RegisterResponseStatusProvider[*Context](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(WebContextKey).(*Context)
})
}
type webContextKeyType struct{}
var WebContextKey = webContextKeyType{}
func GetWebContext(req *http.Request) *Context {
ctx, _ := req.Context().Value(WebContextKey).(*Context)
return ctx
}
// ValidateContext is a special context for form validation middleware. It may be different from other contexts.
type ValidateContext struct {
*Base
}
// GetValidateContext gets a context for middleware form validation
func GetValidateContext(req *http.Request) (ctx *ValidateContext) {
if ctxAPI, ok := req.Context().Value(apiContextKey).(*APIContext); ok {
ctx = &ValidateContext{Base: ctxAPI.Base}
} else if ctxWeb, ok := req.Context().Value(WebContextKey).(*Context); ok {
ctx = &ValidateContext{Base: ctxWeb.Base}
} else {
panic("invalid context, expect either APIContext or Context")
}
return ctx
}
func NewTemplateContextForWeb(ctx *Context) TemplateContext {
tmplCtx := NewTemplateContext(ctx)
tmplCtx["Locale"] = ctx.Base.Locale
tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
return tmplCtx
}
func NewWebContext(base *Base, render Render, session session.Store) *Context {
ctx := &Context{
Base: base,
Render: render,
Session: session,
Cache: mc.GetCache(),
Link: setting.AppSubURL + strings.TrimSuffix(base.Req.URL.EscapedPath(), "/"),
Repo: &Repository{PullRequest: &PullRequest{}},
Org: &Organization{},
}
ctx.TemplateContext = NewTemplateContextForWeb(ctx)
ctx.Flash = &middleware.Flash{DataStore: ctx, Values: url.Values{}}
return ctx
}
// Contexter initializes a classic context for a request.
func Contexter() func(next http.Handler) http.Handler {
rnd := templates.HTMLRenderer()
csrfOpts := CsrfOptions{
Secret: hex.EncodeToString(setting.GetGeneralTokenSigningSecret()),
Cookie: setting.CSRFCookieName,
SetCookie: true,
Secure: setting.SessionConfig.Secure,
CookieHTTPOnly: setting.CSRFCookieHTTPOnly,
Header: "X-Csrf-Token",
CookieDomain: setting.SessionConfig.Domain,
CookiePath: setting.SessionConfig.CookiePath,
SameSite: setting.SessionConfig.SameSite,
}
if !setting.IsProd {
CsrfTokenRegenerationInterval = 5 * time.Second // in dev, re-generate the tokens more aggressively for debug purpose
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base, baseCleanUp := NewBaseContext(resp, req)
defer baseCleanUp()
ctx := NewWebContext(base, rnd, session.GetSession(req))
ctx.Data.MergeFrom(middleware.CommonTemplateContextData())
ctx.Data["Context"] = ctx // TODO: use "ctx" in template and remove this
ctx.Data["CurrentURL"] = setting.AppSubURL + req.URL.RequestURI()
ctx.Data["Link"] = ctx.Link
// PageData is passed by reference, and it will be rendered to `window.config.pageData` in `head.tmpl` for JavaScript modules
ctx.PageData = map[string]any{}
ctx.Data["PageData"] = ctx.PageData
ctx.Base.AppendContextValue(WebContextKey, ctx)
ctx.Base.AppendContextValueFunc(gitrepo.RepositoryContextKey, func() any { return ctx.Repo.GitRepo })
ctx.Csrf = PrepareCSRFProtector(csrfOpts, ctx)
// Get the last flash message from cookie
lastFlashCookie := middleware.GetSiteCookie(ctx.Req, CookieNameFlash)
if vals, _ := url.ParseQuery(lastFlashCookie); len(vals) > 0 {
// store last Flash message into the template data, to render it
ctx.Data["Flash"] = &middleware.Flash{
DataStore: ctx,
Values: vals,
ErrorMsg: vals.Get("error"),
SuccessMsg: vals.Get("success"),
InfoMsg: vals.Get("info"),
WarningMsg: vals.Get("warning"),
}
}
// if there are new messages in the ctx.Flash, write them into cookie
ctx.Resp.Before(func(resp ResponseWriter) {
if val := ctx.Flash.Encode(); val != "" {
middleware.SetSiteCookie(ctx.Resp, CookieNameFlash, val, 0)
} else if lastFlashCookie != "" {
middleware.SetSiteCookie(ctx.Resp, CookieNameFlash, "", -1)
}
})
// If request sends files, parse them here otherwise the Query() can't be parsed and the CsrfToken will be invalid.
if ctx.Req.Method == "POST" && strings.Contains(ctx.Req.Header.Get("Content-Type"), "multipart/form-data") {
if err := ctx.Req.ParseMultipartForm(setting.Attachment.MaxSize << 20); err != nil && !strings.Contains(err.Error(), "EOF") { // 32MB max size
ctx.ServerError("ParseMultipartForm", err)
return
}
}
httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 0, "no-transform")
ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
ctx.Data["SystemConfig"] = setting.Config()
ctx.Data["CsrfToken"] = ctx.Csrf.GetToken()
ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + ctx.Data["CsrfToken"].(string) + `">`)
// FIXME: do we really always need these setting? There should be someway to have to avoid having to always set these
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
ctx.Data["DisableStars"] = setting.Repository.DisableStars
ctx.Data["EnableActions"] = setting.Actions.Enabled
ctx.Data["ManifestData"] = setting.ManifestData
ctx.Data["UnitWikiGlobalDisabled"] = unit.TypeWiki.UnitGlobalDisabled()
ctx.Data["UnitIssuesGlobalDisabled"] = unit.TypeIssues.UnitGlobalDisabled()
ctx.Data["UnitPullsGlobalDisabled"] = unit.TypePullRequests.UnitGlobalDisabled()
ctx.Data["UnitProjectsGlobalDisabled"] = unit.TypeProjects.UnitGlobalDisabled()
ctx.Data["UnitActionsGlobalDisabled"] = unit.TypeActions.UnitGlobalDisabled()
ctx.Data["AllLangs"] = translation.AllLangs()
next.ServeHTTP(ctx.Resp, ctx.Req)
})
}
}
// HasError returns true if error occurs in form validation.
// Attention: this function changes ctx.Data and ctx.Flash
func (ctx *Context) HasError() bool {
hasErr, ok := ctx.Data["HasError"]
if !ok {
return false
}
ctx.Flash.ErrorMsg = ctx.GetErrMsg()
ctx.Data["Flash"] = ctx.Flash
return hasErr.(bool)
}
// GetErrMsg returns error message in form validation.
func (ctx *Context) GetErrMsg() string {
msg, _ := ctx.Data["ErrorMsg"].(string)
if msg == "" {
msg = "invalid form data"
}
return msg
}
func (ctx *Context) JSONRedirect(redirect string) {
ctx.JSON(http.StatusOK, map[string]any{"redirect": redirect})
}
func (ctx *Context) JSONOK() {
ctx.JSON(http.StatusOK, map[string]any{"ok": true}) // this is only a dummy response, frontend seldom uses it
}
func (ctx *Context) JSONError(msg any) {
switch v := msg.(type) {
case string:
ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": v, "renderFormat": "text"})
case template.HTML:
ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": v, "renderFormat": "html"})
default:
panic(fmt.Sprintf("unsupported type: %T", msg))
}
}

View File

@@ -0,0 +1,42 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"net/http"
"strings"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web/middleware"
)
const CookieNameFlash = "gitea_flash"
func removeSessionCookieHeader(w http.ResponseWriter) {
cookies := w.Header()["Set-Cookie"]
w.Header().Del("Set-Cookie")
for _, cookie := range cookies {
if strings.HasPrefix(cookie, setting.SessionConfig.CookieName+"=") {
continue
}
w.Header().Add("Set-Cookie", cookie)
}
}
// SetSiteCookie convenience function to set most cookies consistently
// CSRF and a few others are the exception here
func (ctx *Context) SetSiteCookie(name, value string, maxAge int) {
middleware.SetSiteCookie(ctx.Resp, name, value, maxAge)
}
// DeleteSiteCookie convenience function to delete most cookies consistently
// CSRF and a few others are the exception here
func (ctx *Context) DeleteSiteCookie(name string) {
middleware.SetSiteCookie(ctx.Resp, name, "", -1)
}
// GetSiteCookie returns given cookie value from request header.
func (ctx *Context) GetSiteCookie(name string) string {
return middleware.GetSiteCookie(ctx.Req, name)
}

View File

@@ -0,0 +1,29 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"code.gitea.io/gitea/models/unit"
)
// IsUserSiteAdmin returns true if current user is a site admin
func (ctx *Context) IsUserSiteAdmin() bool {
return ctx.IsSigned && ctx.Doer.IsAdmin
}
// IsUserRepoAdmin returns true if current user is admin in current repo
func (ctx *Context) IsUserRepoAdmin() bool {
return ctx.Repo.IsAdmin()
}
// IsUserRepoWriter returns true if current user has write privilege in current repo
func (ctx *Context) IsUserRepoWriter(unitTypes []unit.Type) bool {
for _, unitType := range unitTypes {
if ctx.Repo.CanWrite(unitType) {
return true
}
}
return false
}

View File

@@ -0,0 +1,32 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"io"
"net/http"
"strings"
)
// UploadStream returns the request body or the first form file
// Only form files need to get closed.
func (ctx *Context) UploadStream() (rd io.ReadCloser, needToClose bool, err error) {
contentType := strings.ToLower(ctx.Req.Header.Get("Content-Type"))
if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") || strings.HasPrefix(contentType, "multipart/form-data") {
if err := ctx.Req.ParseMultipartForm(32 << 20); err != nil {
return nil, false, err
}
if ctx.Req.MultipartForm.File == nil {
return nil, false, http.ErrMissingFile
}
for _, files := range ctx.Req.MultipartForm.File {
if len(files) > 0 {
r, err := files[0].Open()
return r, true, err
}
}
return nil, false, http.ErrMissingFile
}
return ctx.Req.Body, false, nil
}

View File

@@ -0,0 +1,189 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web/middleware"
)
// RedirectToUser redirect to a differently-named user
func RedirectToUser(ctx *Base, userName string, redirectUserID int64) {
user, err := user_model.GetUserByID(ctx, redirectUserID)
if err != nil {
ctx.Error(http.StatusInternalServerError, "unable to get user")
return
}
redirectPath := strings.Replace(
ctx.Req.URL.EscapedPath(),
url.PathEscape(userName),
url.PathEscape(user.Name),
1,
)
if ctx.Req.URL.RawQuery != "" {
redirectPath += "?" + ctx.Req.URL.RawQuery
}
ctx.Redirect(path.Join(setting.AppSubURL, redirectPath), http.StatusTemporaryRedirect)
}
// RedirectToFirst redirects to first not empty URL
func (ctx *Context) RedirectToFirst(location ...string) {
for _, loc := range location {
if len(loc) == 0 {
continue
}
if httplib.IsRiskyRedirectURL(loc) {
continue
}
ctx.Redirect(loc)
return
}
ctx.Redirect(setting.AppSubURL + "/")
}
const tplStatus500 base.TplName = "status/500"
// HTML calls Context.HTML and renders the template to HTTP response
func (ctx *Context) HTML(status int, name base.TplName) {
log.Debug("Template: %s", name)
tmplStartTime := time.Now()
if !setting.IsProd {
ctx.Data["TemplateName"] = name
}
ctx.Data["TemplateLoadTimes"] = func() string {
return strconv.FormatInt(time.Since(tmplStartTime).Nanoseconds()/1e6, 10) + "ms"
}
err := ctx.Render.HTML(ctx.Resp, status, string(name), ctx.Data, ctx.TemplateContext)
if err == nil {
return
}
// if rendering fails, show error page
if name != tplStatus500 {
err = fmt.Errorf("failed to render template: %s, error: %s", name, templates.HandleTemplateRenderingError(err))
ctx.ServerError("Render failed", err) // show the 500 error page
} else {
ctx.PlainText(http.StatusInternalServerError, "Unable to render status/500 page, the template system is broken, or Gitea can't find your template files.")
return
}
}
// JSONTemplate renders the template as JSON response
// keep in mind that the template is processed in HTML context, so JSON-things should be handled carefully, eg: by JSEscape
func (ctx *Context) JSONTemplate(tmpl base.TplName) {
t, err := ctx.Render.TemplateLookup(string(tmpl), nil)
if err != nil {
ctx.ServerError("unable to find template", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/json")
if err = t.Execute(ctx.Resp, ctx.Data); err != nil {
ctx.ServerError("unable to execute template", err)
}
}
// RenderToString renders the template content to a string
func (ctx *Context) RenderToString(name base.TplName, data map[string]any) (string, error) {
var buf strings.Builder
err := ctx.Render.HTML(&buf, http.StatusOK, string(name), data, ctx.TemplateContext)
return buf.String(), err
}
// RenderWithErr used for page has form validation but need to prompt error to users.
func (ctx *Context) RenderWithErr(msg any, tpl base.TplName, form any) {
if form != nil {
middleware.AssignForm(form, ctx.Data)
}
ctx.Flash.Error(msg, true)
ctx.HTML(http.StatusOK, tpl)
}
// NotFound displays a 404 (Not Found) page and prints the given error, if any.
func (ctx *Context) NotFound(logMsg string, logErr error) {
ctx.notFoundInternal(logMsg, logErr)
}
func (ctx *Context) notFoundInternal(logMsg string, logErr error) {
if logErr != nil {
log.Log(2, log.DEBUG, "%s: %v", logMsg, logErr)
if !setting.IsProd {
ctx.Data["ErrorMsg"] = logErr
}
}
// response simple message if Accept isn't text/html
showHTML := false
for _, part := range ctx.Req.Header["Accept"] {
if strings.Contains(part, "text/html") {
showHTML = true
break
}
}
if !showHTML {
ctx.plainTextInternal(3, http.StatusNotFound, []byte("Not found.\n"))
return
}
ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
ctx.Data["Title"] = "Page Not Found"
ctx.HTML(http.StatusNotFound, base.TplName("status/404"))
}
// ServerError displays a 500 (Internal Server Error) page and prints the given error, if any.
func (ctx *Context) ServerError(logMsg string, logErr error) {
ctx.serverErrorInternal(logMsg, logErr)
}
func (ctx *Context) serverErrorInternal(logMsg string, logErr error) {
if logErr != nil {
log.ErrorWithSkip(2, "%s: %v", logMsg, logErr)
if _, ok := logErr.(*net.OpError); ok || errors.Is(logErr, &net.OpError{}) {
// This is an error within the underlying connection
// and further rendering will not work so just return
return
}
// it's safe to show internal error to admin users, and it helps
if !setting.IsProd || (ctx.Doer != nil && ctx.Doer.IsAdmin) {
ctx.Data["ErrorMsg"] = fmt.Sprintf("%s, %s", logMsg, logErr)
}
}
ctx.Data["Title"] = "Internal Server Error"
ctx.HTML(http.StatusInternalServerError, tplStatus500)
}
// NotFoundOrServerError use error check function to determine if the error
// is about not found. It responds with 404 status code for not found error,
// or error context description for logging purpose of 500 server error.
// TODO: remove the "errCheck" and use util.ErrNotFound to check
func (ctx *Context) NotFoundOrServerError(logMsg string, errCheck func(error) bool, logErr error) {
if errCheck(logErr) {
ctx.notFoundInternal(logMsg, logErr)
return
}
ctx.serverErrorInternal(logMsg, logErr)
}

View File

@@ -0,0 +1,35 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"context"
"time"
)
var _ context.Context = TemplateContext(nil)
func NewTemplateContext(ctx context.Context) TemplateContext {
return TemplateContext{"_ctx": ctx}
}
func (c TemplateContext) parentContext() context.Context {
return c["_ctx"].(context.Context)
}
func (c TemplateContext) Deadline() (deadline time.Time, ok bool) {
return c.parentContext().Deadline()
}
func (c TemplateContext) Done() <-chan struct{} {
return c.parentContext().Done()
}
func (c TemplateContext) Err() error {
return c.parentContext().Err()
}
func (c TemplateContext) Value(key any) any {
return c.parentContext().Value(key)
}

View File

@@ -0,0 +1,24 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"net/http"
"net/http/httptest"
"testing"
"code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestRemoveSessionCookieHeader(t *testing.T) {
w := httptest.NewRecorder()
w.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "foo"}).String())
w.Header().Add("Set-Cookie", (&http.Cookie{Name: "other", Value: "bar"}).String())
assert.Len(t, w.Header().Values("Set-Cookie"), 2)
removeSessionCookieHeader(w)
assert.Len(t, w.Header().Values("Set-Cookie"), 1)
assert.Contains(t, "other=bar", w.Header().Get("Set-Cookie"))
}

242
services/context/csrf.go Normal file
View File

@@ -0,0 +1,242 @@
// Copyright 2013 Martini Authors
// Copyright 2014 The Macaron Authors
// Copyright 2021 The Gitea Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"): you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
// SPDX-License-Identifier: Apache-2.0
// a middleware that generates and validates CSRF tokens.
package context
import (
"encoding/base32"
"fmt"
"net/http"
"strconv"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
)
// CSRFProtector represents a CSRF protector and is used to get the current token and validate the token.
type CSRFProtector interface {
// GetHeaderName returns HTTP header to search for token.
GetHeaderName() string
// GetFormName returns form value to search for token.
GetFormName() string
// GetToken returns the token.
GetToken() string
// Validate validates the token in http context.
Validate(ctx *Context)
// DeleteCookie deletes the cookie
DeleteCookie(ctx *Context)
}
type csrfProtector struct {
opt CsrfOptions
// Token generated to pass via header, cookie, or hidden form value.
Token string
// This value must be unique per user.
ID string
}
// GetHeaderName returns the name of the HTTP header for csrf token.
func (c *csrfProtector) GetHeaderName() string {
return c.opt.Header
}
// GetFormName returns the name of the form value for csrf token.
func (c *csrfProtector) GetFormName() string {
return c.opt.Form
}
// GetToken returns the current token. This is typically used
// to populate a hidden form in an HTML template.
func (c *csrfProtector) GetToken() string {
return c.Token
}
// CsrfOptions maintains options to manage behavior of Generate.
type CsrfOptions struct {
// The global secret value used to generate Tokens.
Secret string
// HTTP header used to set and get token.
Header string
// Form value used to set and get token.
Form string
// Cookie value used to set and get token.
Cookie string
// Cookie domain.
CookieDomain string
// Cookie path.
CookiePath string
CookieHTTPOnly bool
// SameSite set the cookie SameSite type
SameSite http.SameSite
// Key used for getting the unique ID per user.
SessionKey string
// oldSessionKey saves old value corresponding to SessionKey.
oldSessionKey string
// If true, send token via X-Csrf-Token header.
SetHeader bool
// If true, send token via _csrf cookie.
SetCookie bool
// Set the Secure flag to true on the cookie.
Secure bool
// Disallow Origin appear in request header.
Origin bool
// Cookie lifetime. Default is 0
CookieLifeTime int
}
func prepareDefaultCsrfOptions(opt CsrfOptions) CsrfOptions {
if opt.Secret == "" {
randBytes, err := util.CryptoRandomBytes(8)
if err != nil {
// this panic can be handled by the recover() in http handlers
panic(fmt.Errorf("failed to generate random bytes: %w", err))
}
opt.Secret = base32.StdEncoding.EncodeToString(randBytes)
}
if opt.Header == "" {
opt.Header = "X-Csrf-Token"
}
if opt.Form == "" {
opt.Form = "_csrf"
}
if opt.Cookie == "" {
opt.Cookie = "_csrf"
}
if opt.CookiePath == "" {
opt.CookiePath = "/"
}
if opt.SessionKey == "" {
opt.SessionKey = "uid"
}
if opt.CookieLifeTime == 0 {
opt.CookieLifeTime = int(CsrfTokenTimeout.Seconds())
}
opt.oldSessionKey = "_old_" + opt.SessionKey
return opt
}
func newCsrfCookie(c *csrfProtector, value string) *http.Cookie {
return &http.Cookie{
Name: c.opt.Cookie,
Value: value,
Path: c.opt.CookiePath,
Domain: c.opt.CookieDomain,
MaxAge: c.opt.CookieLifeTime,
Secure: c.opt.Secure,
HttpOnly: c.opt.CookieHTTPOnly,
SameSite: c.opt.SameSite,
}
}
// PrepareCSRFProtector returns a CSRFProtector to be used for every request.
// Additionally, depending on options set, generated tokens will be sent via Header and/or Cookie.
func PrepareCSRFProtector(opt CsrfOptions, ctx *Context) CSRFProtector {
opt = prepareDefaultCsrfOptions(opt)
x := &csrfProtector{opt: opt}
if opt.Origin && len(ctx.Req.Header.Get("Origin")) > 0 {
return x
}
x.ID = "0"
uidAny := ctx.Session.Get(opt.SessionKey)
if uidAny != nil {
switch uidVal := uidAny.(type) {
case string:
x.ID = uidVal
case int64:
x.ID = strconv.FormatInt(uidVal, 10)
default:
log.Error("invalid uid type in session: %T", uidAny)
}
}
oldUID := ctx.Session.Get(opt.oldSessionKey)
uidChanged := oldUID == nil || oldUID.(string) != x.ID
cookieToken := ctx.GetSiteCookie(opt.Cookie)
needsNew := true
if uidChanged {
_ = ctx.Session.Set(opt.oldSessionKey, x.ID)
} else if cookieToken != "" {
// If cookie token presents, re-use existing unexpired token, else generate a new one.
if issueTime, ok := ParseCsrfToken(cookieToken); ok {
dur := time.Since(issueTime) // issueTime is not a monotonic-clock, the server time may change a lot to an early time.
if dur >= -CsrfTokenRegenerationInterval && dur <= CsrfTokenRegenerationInterval {
x.Token = cookieToken
needsNew = false
}
}
}
if needsNew {
// FIXME: actionId.
x.Token = GenerateCsrfToken(x.opt.Secret, x.ID, "POST", time.Now())
if opt.SetCookie {
cookie := newCsrfCookie(x, x.Token)
ctx.Resp.Header().Add("Set-Cookie", cookie.String())
}
}
if opt.SetHeader {
ctx.Resp.Header().Add(opt.Header, x.Token)
}
return x
}
func (c *csrfProtector) validateToken(ctx *Context, token string) {
if !ValidCsrfToken(token, c.opt.Secret, c.ID, "POST", time.Now()) {
c.DeleteCookie(ctx)
if middleware.IsAPIPath(ctx.Req) {
// currently, there should be no access to the APIPath with CSRF token. because templates shouldn't use the `/api/` endpoints.
http.Error(ctx.Resp, "Invalid CSRF token.", http.StatusBadRequest)
} else {
ctx.Flash.Error(ctx.Tr("error.invalid_csrf"))
ctx.Redirect(setting.AppSubURL + "/")
}
}
}
// Validate should be used as a per route middleware. It attempts to get a token from an "X-Csrf-Token"
// HTTP header and then a "_csrf" form value. If one of these is found, the token will be validated.
// If this validation fails, custom Error is sent in the reply.
// If neither a header nor form value is found, http.StatusBadRequest is sent.
func (c *csrfProtector) Validate(ctx *Context) {
if token := ctx.Req.Header.Get(c.GetHeaderName()); token != "" {
c.validateToken(ctx, token)
return
}
if token := ctx.Req.FormValue(c.GetFormName()); token != "" {
c.validateToken(ctx, token)
return
}
c.validateToken(ctx, "") // no csrf token, use an empty token to respond error
}
func (c *csrfProtector) DeleteCookie(ctx *Context) {
if c.opt.SetCookie {
cookie := newCsrfCookie(c, "")
cookie.MaxAge = -1
ctx.Resp.Header().Add("Set-Cookie", cookie.String())
}
}

280
services/context/org.go Normal file
View File

@@ -0,0 +1,280 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors.
// SPDX-License-Identifier: MIT
package context
import (
"strings"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
)
// Organization contains organization context
type Organization struct {
IsOwner bool
IsMember bool
IsTeamMember bool // Is member of team.
IsTeamAdmin bool // In owner team or team that has admin permission level.
Organization *organization.Organization
OrgLink string
CanCreateOrgRepo bool
PublicMemberOnly bool // Only display public members
Team *organization.Team
Teams []*organization.Team
}
func (org *Organization) CanWriteUnit(ctx *Context, unitType unit.Type) bool {
return org.Organization.UnitPermission(ctx, ctx.Doer, unitType) >= perm.AccessModeWrite
}
func (org *Organization) CanReadUnit(ctx *Context, unitType unit.Type) bool {
return org.Organization.UnitPermission(ctx, ctx.Doer, unitType) >= perm.AccessModeRead
}
func GetOrganizationByParams(ctx *Context) {
orgName := ctx.Params(":org")
var err error
ctx.Org.Organization, err = organization.GetOrgByName(ctx, orgName)
if err != nil {
if organization.IsErrOrgNotExist(err) {
redirectUserID, err := user_model.LookupUserRedirect(ctx, orgName)
if err == nil {
RedirectToUser(ctx.Base, orgName, redirectUserID)
} else if user_model.IsErrUserRedirectNotExist(err) {
ctx.NotFound("GetUserByName", err)
} else {
ctx.ServerError("LookupUserRedirect", err)
}
} else {
ctx.ServerError("GetUserByName", err)
}
return
}
}
// HandleOrgAssignment handles organization assignment
func HandleOrgAssignment(ctx *Context, args ...bool) {
var (
requireMember bool
requireOwner bool
requireTeamMember bool
requireTeamAdmin bool
)
if len(args) >= 1 {
requireMember = args[0]
}
if len(args) >= 2 {
requireOwner = args[1]
}
if len(args) >= 3 {
requireTeamMember = args[2]
}
if len(args) >= 4 {
requireTeamAdmin = args[3]
}
var err error
if ctx.ContextUser == nil {
// if Organization is not defined, get it from params
if ctx.Org.Organization == nil {
GetOrganizationByParams(ctx)
if ctx.Written() {
return
}
}
} else if ctx.ContextUser.IsOrganization() {
if ctx.Org == nil {
ctx.Org = &Organization{}
}
ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser)
} else {
// ContextUser is an individual User
return
}
org := ctx.Org.Organization
// Handle Visibility
if org.Visibility != structs.VisibleTypePublic && !ctx.IsSigned {
// We must be signed in to see limited or private organizations
ctx.NotFound("OrgAssignment", err)
return
}
if org.Visibility == structs.VisibleTypePrivate {
requireMember = true
} else if ctx.IsSigned && ctx.Doer.IsRestricted {
requireMember = true
}
ctx.ContextUser = org.AsUser()
ctx.Data["Org"] = org
// Admin has super access.
if ctx.IsSigned && ctx.Doer.IsAdmin {
ctx.Org.IsOwner = true
ctx.Org.IsMember = true
ctx.Org.IsTeamMember = true
ctx.Org.IsTeamAdmin = true
ctx.Org.CanCreateOrgRepo = true
} else if ctx.IsSigned {
ctx.Org.IsOwner, err = org.IsOwnedBy(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("IsOwnedBy", err)
return
}
if ctx.Org.IsOwner {
ctx.Org.IsMember = true
ctx.Org.IsTeamMember = true
ctx.Org.IsTeamAdmin = true
ctx.Org.CanCreateOrgRepo = true
} else {
ctx.Org.IsMember, err = org.IsOrgMember(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("IsOrgMember", err)
return
}
ctx.Org.CanCreateOrgRepo, err = org.CanCreateOrgRepo(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("CanCreateOrgRepo", err)
return
}
}
} else {
// Fake data.
ctx.Data["SignedUser"] = &user_model.User{}
}
if (requireMember && !ctx.Org.IsMember) ||
(requireOwner && !ctx.Org.IsOwner) {
ctx.NotFound("OrgAssignment", err)
return
}
ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner
ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember
ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
ctx.Data["IsPublicMember"] = func(uid int64) bool {
is, _ := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, uid)
return is
}
ctx.Data["CanCreateOrgRepo"] = ctx.Org.CanCreateOrgRepo
ctx.Org.OrgLink = org.AsUser().OrganisationLink()
ctx.Data["OrgLink"] = ctx.Org.OrgLink
// Member
ctx.Org.PublicMemberOnly = ctx.Doer == nil || !ctx.Org.IsMember && !ctx.Doer.IsAdmin
opts := &organization.FindOrgMembersOpts{
OrgID: org.ID,
PublicOnly: ctx.Org.PublicMemberOnly,
}
ctx.Data["NumMembers"], err = organization.CountOrgMembers(ctx, opts)
if err != nil {
ctx.ServerError("CountOrgMembers", err)
return
}
// Team.
if ctx.Org.IsMember {
shouldSeeAllTeams := false
if ctx.Org.IsOwner {
shouldSeeAllTeams = true
} else {
teams, err := org.GetUserTeams(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetUserTeams", err)
return
}
for _, team := range teams {
if team.IncludesAllRepositories && team.AccessMode >= perm.AccessModeAdmin {
shouldSeeAllTeams = true
break
}
}
}
if shouldSeeAllTeams {
ctx.Org.Teams, err = org.LoadTeams(ctx)
if err != nil {
ctx.ServerError("LoadTeams", err)
return
}
} else {
ctx.Org.Teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetUserTeams", err)
return
}
}
ctx.Data["NumTeams"] = len(ctx.Org.Teams)
}
teamName := ctx.Params(":team")
if len(teamName) > 0 {
teamExists := false
for _, team := range ctx.Org.Teams {
if team.LowerName == strings.ToLower(teamName) {
teamExists = true
ctx.Org.Team = team
ctx.Org.IsTeamMember = true
ctx.Data["Team"] = ctx.Org.Team
break
}
}
if !teamExists {
ctx.NotFound("OrgAssignment", err)
return
}
ctx.Data["IsTeamMember"] = ctx.Org.IsTeamMember
if requireTeamMember && !ctx.Org.IsTeamMember {
ctx.NotFound("OrgAssignment", err)
return
}
ctx.Org.IsTeamAdmin = ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.AccessMode >= perm.AccessModeAdmin
ctx.Data["IsTeamAdmin"] = ctx.Org.IsTeamAdmin
if requireTeamAdmin && !ctx.Org.IsTeamAdmin {
ctx.NotFound("OrgAssignment", err)
return
}
}
ctx.Data["ContextUser"] = ctx.ContextUser
ctx.Data["CanReadProjects"] = ctx.Org.CanReadUnit(ctx, unit.TypeProjects)
ctx.Data["CanReadPackages"] = ctx.Org.CanReadUnit(ctx, unit.TypePackages)
ctx.Data["CanReadCode"] = ctx.Org.CanReadUnit(ctx, unit.TypeCode)
ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
if len(ctx.ContextUser.Description) != 0 {
content, err := markdown.RenderString(&markup.RenderContext{
Metas: map[string]string{"mode": "document"},
Ctx: ctx,
}, ctx.ContextUser.Description)
if err != nil {
ctx.ServerError("RenderString", err)
return
}
ctx.Data["RenderedDescription"] = content
}
}
// OrgAssignment returns a middleware to handle organization assignment
func OrgAssignment(args ...bool) func(ctx *Context) {
return func(ctx *Context) {
HandleOrgAssignment(ctx, args...)
}
}

165
services/context/package.go Normal file
View File

@@ -0,0 +1,165 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"fmt"
"net/http"
"code.gitea.io/gitea/models/organization"
packages_model "code.gitea.io/gitea/models/packages"
"code.gitea.io/gitea/models/perm"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/templates"
)
// Package contains owner, access mode and optional the package descriptor
type Package struct {
Owner *user_model.User
AccessMode perm.AccessMode
Descriptor *packages_model.PackageDescriptor
}
type packageAssignmentCtx struct {
*Base
Doer *user_model.User
ContextUser *user_model.User
}
// PackageAssignment returns a middleware to handle Context.Package assignment
func PackageAssignment() func(ctx *Context) {
return func(ctx *Context) {
errorFn := func(status int, title string, obj any) {
err, ok := obj.(error)
if !ok {
err = fmt.Errorf("%s", obj)
}
if status == http.StatusNotFound {
ctx.NotFound(title, err)
} else {
ctx.ServerError(title, err)
}
}
paCtx := &packageAssignmentCtx{Base: ctx.Base, Doer: ctx.Doer, ContextUser: ctx.ContextUser}
ctx.Package = packageAssignment(paCtx, errorFn)
}
}
// PackageAssignmentAPI returns a middleware to handle Context.Package assignment
func PackageAssignmentAPI() func(ctx *APIContext) {
return func(ctx *APIContext) {
paCtx := &packageAssignmentCtx{Base: ctx.Base, Doer: ctx.Doer, ContextUser: ctx.ContextUser}
ctx.Package = packageAssignment(paCtx, ctx.Error)
}
}
func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, string, any)) *Package {
pkg := &Package{
Owner: ctx.ContextUser,
}
var err error
pkg.AccessMode, err = determineAccessMode(ctx.Base, pkg, ctx.Doer)
if err != nil {
errCb(http.StatusInternalServerError, "determineAccessMode", err)
return pkg
}
packageType := ctx.Params("type")
name := ctx.Params("name")
version := ctx.Params("version")
if packageType != "" && name != "" && version != "" {
pv, err := packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version)
if err != nil {
if err == packages_model.ErrPackageNotExist {
errCb(http.StatusNotFound, "GetVersionByNameAndVersion", err)
} else {
errCb(http.StatusInternalServerError, "GetVersionByNameAndVersion", err)
}
return pkg
}
pkg.Descriptor, err = packages_model.GetPackageDescriptor(ctx, pv)
if err != nil {
errCb(http.StatusInternalServerError, "GetPackageDescriptor", err)
return pkg
}
}
return pkg
}
func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.AccessMode, error) {
if setting.Service.RequireSignInView && (doer == nil || doer.IsGhost()) {
return perm.AccessModeNone, nil
}
if doer != nil && !doer.IsGhost() && (!doer.IsActive || doer.ProhibitLogin) {
return perm.AccessModeNone, nil
}
// TODO: ActionUser permission check
accessMode := perm.AccessModeNone
if pkg.Owner.IsOrganization() {
org := organization.OrgFromUser(pkg.Owner)
if doer != nil && !doer.IsGhost() {
// 1. If user is logged in, check all team packages permissions
var err error
accessMode, err = org.GetOrgUserMaxAuthorizeLevel(ctx, doer.ID)
if err != nil {
return accessMode, err
}
// If access mode is less than write check every team for more permissions
// The minimum possible access mode is read for org members
if accessMode < perm.AccessModeWrite {
teams, err := organization.GetUserOrgTeams(ctx, org.ID, doer.ID)
if err != nil {
return accessMode, err
}
for _, t := range teams {
perm := t.UnitAccessMode(ctx, unit.TypePackages)
if accessMode < perm {
accessMode = perm
}
}
}
}
if accessMode == perm.AccessModeNone && organization.HasOrgOrUserVisible(ctx, pkg.Owner, doer) {
// 2. If user is unauthorized or no org member, check if org is visible
accessMode = perm.AccessModeRead
}
} else {
if doer != nil && !doer.IsGhost() {
// 1. Check if user is package owner
if doer.ID == pkg.Owner.ID {
accessMode = perm.AccessModeOwner
} else if pkg.Owner.Visibility == structs.VisibleTypePublic || pkg.Owner.Visibility == structs.VisibleTypeLimited { // 2. Check if package owner is public or limited
accessMode = perm.AccessModeRead
}
} else if pkg.Owner.Visibility == structs.VisibleTypePublic { // 3. Check if package owner is public
accessMode = perm.AccessModeRead
}
}
return accessMode, nil
}
// PackageContexter initializes a package context for a request.
func PackageContexter() func(next http.Handler) http.Handler {
renderer := templates.HTMLRenderer()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base, baseCleanUp := NewBaseContext(resp, req)
defer baseCleanUp()
// it is still needed when rendering 500 page in a package handler
ctx := NewWebContext(base, renderer, nil)
ctx.Base.AppendContextValue(WebContextKey, ctx)
next.ServeHTTP(ctx.Resp, ctx.Req)
})
}
}

View File

@@ -0,0 +1,57 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"fmt"
"html/template"
"net/url"
"strings"
"code.gitea.io/gitea/modules/paginator"
)
// Pagination provides a pagination via paginator.Paginator and additional configurations for the link params used in rendering
type Pagination struct {
Paginater *paginator.Paginator
urlParams []string
}
// NewPagination creates a new instance of the Pagination struct.
// "pagingNum" is "page size" or "limit", "current" is "page"
func NewPagination(total, pagingNum, current, numPages int) *Pagination {
p := &Pagination{}
p.Paginater = paginator.New(total, pagingNum, current, numPages)
return p
}
// AddParam adds a value from context identified by ctxKey as link param under a given paramKey
func (p *Pagination) AddParam(ctx *Context, paramKey, ctxKey string) {
_, exists := ctx.Data[ctxKey]
if !exists {
return
}
paramData := fmt.Sprintf("%v", ctx.Data[ctxKey]) // cast any to string
urlParam := fmt.Sprintf("%s=%v", url.QueryEscape(paramKey), url.QueryEscape(paramData))
p.urlParams = append(p.urlParams, urlParam)
}
// AddParamString adds a string parameter directly
func (p *Pagination) AddParamString(key, value string) {
urlParam := fmt.Sprintf("%s=%v", url.QueryEscape(key), url.QueryEscape(value))
p.urlParams = append(p.urlParams, urlParam)
}
// GetParams returns the configured URL params
func (p *Pagination) GetParams() template.URL {
return template.URL(strings.Join(p.urlParams, "&"))
}
// SetDefaultParams sets common pagination params that are often used
func (p *Pagination) SetDefaultParams(ctx *Context) {
p.AddParam(ctx, "sort", "SortType")
p.AddParam(ctx, "q", "Keyword")
// do not add any more uncommon params here!
p.AddParam(ctx, "t", "queryType")
}

View File

@@ -0,0 +1,149 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"net/http"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/log"
)
// RequireRepoAdmin returns a middleware for requiring repository admin permission
func RequireRepoAdmin() func(ctx *Context) {
return func(ctx *Context) {
if !ctx.IsSigned || !ctx.Repo.IsAdmin() {
ctx.NotFound(ctx.Req.URL.RequestURI(), nil)
return
}
}
}
// RequireRepoWriter returns a middleware for requiring repository write to the specify unitType
func RequireRepoWriter(unitType unit.Type) func(ctx *Context) {
return func(ctx *Context) {
if !ctx.Repo.CanWrite(unitType) {
ctx.NotFound(ctx.Req.URL.RequestURI(), nil)
return
}
}
}
// CanEnableEditor checks if the user is allowed to write to the branch of the repo
func CanEnableEditor() func(ctx *Context) {
return func(ctx *Context) {
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
ctx.NotFound("CanWriteToBranch denies permission", nil)
return
}
}
}
// RequireRepoWriterOr returns a middleware for requiring repository write to one of the unit permission
func RequireRepoWriterOr(unitTypes ...unit.Type) func(ctx *Context) {
return func(ctx *Context) {
for _, unitType := range unitTypes {
if ctx.Repo.CanWrite(unitType) {
return
}
}
ctx.NotFound(ctx.Req.URL.RequestURI(), nil)
}
}
// RequireRepoReader returns a middleware for requiring repository read to the specify unitType
func RequireRepoReader(unitType unit.Type) func(ctx *Context) {
return func(ctx *Context) {
if !ctx.Repo.CanRead(unitType) {
if log.IsTrace() {
if ctx.IsSigned {
log.Trace("Permission Denied: User %-v cannot read %-v in Repo %-v\n"+
"User in Repo has Permissions: %-+v",
ctx.Doer,
unitType,
ctx.Repo.Repository,
ctx.Repo.Permission)
} else {
log.Trace("Permission Denied: Anonymous user cannot read %-v in Repo %-v\n"+
"Anonymous user in Repo has Permissions: %-+v",
unitType,
ctx.Repo.Repository,
ctx.Repo.Permission)
}
}
ctx.NotFound(ctx.Req.URL.RequestURI(), nil)
return
}
}
}
// RequireRepoReaderOr returns a middleware for requiring repository write to one of the unit permission
func RequireRepoReaderOr(unitTypes ...unit.Type) func(ctx *Context) {
return func(ctx *Context) {
for _, unitType := range unitTypes {
if ctx.Repo.CanRead(unitType) {
return
}
}
if log.IsTrace() {
var format string
var args []any
if ctx.IsSigned {
format = "Permission Denied: User %-v cannot read ["
args = append(args, ctx.Doer)
} else {
format = "Permission Denied: Anonymous user cannot read ["
}
for _, unit := range unitTypes {
format += "%-v, "
args = append(args, unit)
}
format = format[:len(format)-2] + "] in Repo %-v\n" +
"User in Repo has Permissions: %-+v"
args = append(args, ctx.Repo.Repository, ctx.Repo.Permission)
log.Trace(format, args...)
}
ctx.NotFound(ctx.Req.URL.RequestURI(), nil)
}
}
// CheckRepoScopedToken check whether personal access token has repo scope
func CheckRepoScopedToken(ctx *Context, repo *repo_model.Repository, level auth_model.AccessTokenScopeLevel) {
if !ctx.IsBasicAuth || ctx.Data["IsApiToken"] != true {
return
}
scope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
if ok { // it's a personal access token but not oauth2 token
var scopeMatched bool
requiredScopes := auth_model.GetRequiredScopes(level, auth_model.AccessTokenScopeCategoryRepository)
// check if scope only applies to public resources
publicOnly, err := scope.PublicOnly()
if err != nil {
ctx.ServerError("HasScope", err)
return
}
if publicOnly && repo.IsPrivate {
ctx.Error(http.StatusForbidden)
return
}
scopeMatched, err = scope.HasScope(requiredScopes...)
if err != nil {
ctx.ServerError("HasScope", err)
return
}
if !scopeMatched {
ctx.Error(http.StatusForbidden)
return
}
}
}

View File

@@ -0,0 +1,85 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"context"
"fmt"
"net/http"
"time"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/web"
web_types "code.gitea.io/gitea/modules/web/types"
)
// PrivateContext represents a context for private routes
type PrivateContext struct {
*Base
Override context.Context
Repo *Repository
}
func init() {
web.RegisterResponseStatusProvider[*PrivateContext](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(privateContextKey).(*PrivateContext)
})
}
// Deadline is part of the interface for context.Context and we pass this to the request context
func (ctx *PrivateContext) Deadline() (deadline time.Time, ok bool) {
if ctx.Override != nil {
return ctx.Override.Deadline()
}
return ctx.Base.Deadline()
}
// Done is part of the interface for context.Context and we pass this to the request context
func (ctx *PrivateContext) Done() <-chan struct{} {
if ctx.Override != nil {
return ctx.Override.Done()
}
return ctx.Base.Done()
}
// Err is part of the interface for context.Context and we pass this to the request context
func (ctx *PrivateContext) Err() error {
if ctx.Override != nil {
return ctx.Override.Err()
}
return ctx.Base.Err()
}
var privateContextKey any = "default_private_context"
// GetPrivateContext returns a context for Private routes
func GetPrivateContext(req *http.Request) *PrivateContext {
return req.Context().Value(privateContextKey).(*PrivateContext)
}
// PrivateContexter returns apicontext as middleware
func PrivateContexter() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
base, baseCleanUp := NewBaseContext(w, req)
ctx := &PrivateContext{Base: base}
defer baseCleanUp()
ctx.Base.AppendContextValue(privateContextKey, ctx)
next.ServeHTTP(ctx.Resp, ctx.Req)
})
}
}
// OverrideContext overrides the underlying request context for Done() etc.
// This function should be used when there is a need for work to continue even if the request has been cancelled.
// Primarily this affects hook/post-receive and hook/proc-receive both of which need to continue working even if
// the underlying request has timed out from the ssh/http push
func OverrideContext(ctx *PrivateContext) (cancel context.CancelFunc) {
// We now need to override the request context as the base for our work because even if the request is cancelled we have to continue this work
ctx.Override, _, cancel = process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), fmt.Sprintf("PrivateContext: %s", ctx.Req.RequestURI), process.RequestProcessType, true)
return cancel
}

1090
services/context/repo.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"net/http"
web_types "code.gitea.io/gitea/modules/web/types"
)
// ResponseWriter represents a response writer for HTTP
type ResponseWriter interface {
http.ResponseWriter
http.Flusher
web_types.ResponseStatusProvider
Before(func(ResponseWriter))
Status() int // used by access logger template
Size() int // used by access logger template
}
var _ ResponseWriter = &Response{}
// Response represents a response
type Response struct {
http.ResponseWriter
written int
status int
befores []func(ResponseWriter)
beforeExecuted bool
}
// Write writes bytes to HTTP endpoint
func (r *Response) Write(bs []byte) (int, error) {
if !r.beforeExecuted {
for _, before := range r.befores {
before(r)
}
r.beforeExecuted = true
}
size, err := r.ResponseWriter.Write(bs)
r.written += size
if err != nil {
return size, err
}
if r.status == 0 {
r.status = http.StatusOK
}
return size, nil
}
func (r *Response) Status() int {
return r.status
}
func (r *Response) Size() int {
return r.written
}
// WriteHeader write status code
func (r *Response) WriteHeader(statusCode int) {
if !r.beforeExecuted {
for _, before := range r.befores {
before(r)
}
r.beforeExecuted = true
}
if r.status == 0 {
r.status = statusCode
r.ResponseWriter.WriteHeader(statusCode)
}
}
// Flush flushes cached data
func (r *Response) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// WrittenStatus returned status code written
func (r *Response) WrittenStatus() int {
return r.status
}
// Before allows for a function to be called before the ResponseWriter has been written to. This is
// useful for setting headers or any other operations that must happen before a response has been written.
func (r *Response) Before(f func(ResponseWriter)) {
r.befores = append(r.befores, f)
}
func WrapResponseWriter(resp http.ResponseWriter) *Response {
if v, ok := resp.(*Response); ok {
return v
}
return &Response{
ResponseWriter: resp,
status: 0,
befores: make([]func(ResponseWriter), 0),
}
}

View File

@@ -0,0 +1,105 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package upload
import (
"mime"
"net/http"
"net/url"
"path"
"regexp"
"strings"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
)
// ErrFileTypeForbidden not allowed file type error
type ErrFileTypeForbidden struct {
Type string
}
// IsErrFileTypeForbidden checks if an error is a ErrFileTypeForbidden.
func IsErrFileTypeForbidden(err error) bool {
_, ok := err.(ErrFileTypeForbidden)
return ok
}
func (err ErrFileTypeForbidden) Error() string {
return "This file extension or type is not allowed to be uploaded."
}
var wildcardTypeRe = regexp.MustCompile(`^[a-z]+/\*$`)
// Verify validates whether a file is allowed to be uploaded.
func Verify(buf []byte, fileName, allowedTypesStr string) error {
allowedTypesStr = strings.ReplaceAll(allowedTypesStr, "|", ",") // compat for old config format
allowedTypes := []string{}
for _, entry := range strings.Split(allowedTypesStr, ",") {
entry = strings.ToLower(strings.TrimSpace(entry))
if entry != "" {
allowedTypes = append(allowedTypes, entry)
}
}
if len(allowedTypes) == 0 {
return nil // everything is allowed
}
fullMimeType := http.DetectContentType(buf)
mimeType, _, err := mime.ParseMediaType(fullMimeType)
if err != nil {
log.Warn("Detected attachment type could not be parsed %s", fullMimeType)
return ErrFileTypeForbidden{Type: fullMimeType}
}
extension := strings.ToLower(path.Ext(fileName))
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers
for _, allowEntry := range allowedTypes {
if allowEntry == "*/*" {
return nil // everything allowed
} else if strings.HasPrefix(allowEntry, ".") && allowEntry == extension {
return nil // extension is allowed
} else if mimeType == allowEntry {
return nil // mime type is allowed
} else if wildcardTypeRe.MatchString(allowEntry) && strings.HasPrefix(mimeType, allowEntry[:len(allowEntry)-1]) {
return nil // wildcard match, e.g. image/*
}
}
log.Info("Attachment with type %s blocked from upload", fullMimeType)
return ErrFileTypeForbidden{Type: fullMimeType}
}
// AddUploadContext renders template values for dropzone
func AddUploadContext(ctx *context.Context, uploadType string) {
if uploadType == "release" {
ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/releases/attachments"
ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/releases/attachments/remove"
ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/releases/attachments"
ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Release.AllowedTypes, "|", ",")
ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize
} else if uploadType == "comment" {
ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/issues/attachments"
ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/issues/attachments/remove"
if len(ctx.Params(":index")) > 0 {
ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/issues/" + url.PathEscape(ctx.Params(":index")) + "/attachments"
} else {
ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/issues/attachments"
}
ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Attachment.AllowedTypes, "|", ",")
ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize
} else if uploadType == "repo" {
ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/upload-file"
ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/upload-remove"
ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/upload-file"
ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ",")
ctx.Data["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize
}
}

View File

@@ -0,0 +1,194 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package upload
import (
"bytes"
"compress/gzip"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUpload(t *testing.T) {
testContent := []byte(`This is a plain text file.`)
var b bytes.Buffer
w := gzip.NewWriter(&b)
w.Write(testContent)
w.Close()
kases := []struct {
data []byte
fileName string
allowedTypes string
err error
}{
{
data: testContent,
fileName: "test.txt",
allowedTypes: "",
err: nil,
},
{
data: testContent,
fileName: "dir/test.txt",
allowedTypes: "",
err: nil,
},
{
data: testContent,
fileName: "../../../test.txt",
allowedTypes: "",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: ",",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "|",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "*/*",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "*/*,",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "*/*|",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "text/plain",
err: nil,
},
{
data: testContent,
fileName: "dir/test.txt",
allowedTypes: "text/plain",
err: nil,
},
{
data: testContent,
fileName: "/dir.txt/test.js",
allowedTypes: ".js",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: " text/plain ",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: ".txt",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: " .txt,.js",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: " .txt|.js",
err: nil,
},
{
data: testContent,
fileName: "../../test.txt",
allowedTypes: " .txt|.js",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: " .txt ,.js ",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "text/plain, .txt",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "text/*",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "text/*,.js",
err: nil,
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "text/**",
err: ErrFileTypeForbidden{"text/plain; charset=utf-8"},
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: "application/x-gzip",
err: ErrFileTypeForbidden{"text/plain; charset=utf-8"},
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: ".zip",
err: ErrFileTypeForbidden{"text/plain; charset=utf-8"},
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: ".zip,.txtx",
err: ErrFileTypeForbidden{"text/plain; charset=utf-8"},
},
{
data: testContent,
fileName: "test.txt",
allowedTypes: ".zip|.txtx",
err: ErrFileTypeForbidden{"text/plain; charset=utf-8"},
},
{
data: b.Bytes(),
fileName: "test.txt",
allowedTypes: "application/x-gzip",
err: nil,
},
}
for _, kase := range kases {
assert.Equal(t, kase.err, Verify(kase.data, kase.fileName, kase.allowedTypes))
}
}

View File

@@ -9,12 +9,11 @@ import (
"strings"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context"
)
// UserAssignmentWeb returns a middleware to handle context-user assignment for web routes
func UserAssignmentWeb() func(ctx *context.Context) {
return func(ctx *context.Context) {
func UserAssignmentWeb() func(ctx *Context) {
return func(ctx *Context) {
errorFn := func(status int, title string, obj any) {
err, ok := obj.(error)
if !ok {
@@ -32,8 +31,8 @@ func UserAssignmentWeb() func(ctx *context.Context) {
}
// UserIDAssignmentAPI returns a middleware to handle context-user assignment for api routes
func UserIDAssignmentAPI() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
func UserIDAssignmentAPI() func(ctx *APIContext) {
return func(ctx *APIContext) {
userID := ctx.ParamsInt64(":user-id")
if ctx.IsSigned && ctx.Doer.ID == userID {
@@ -53,13 +52,13 @@ func UserIDAssignmentAPI() func(ctx *context.APIContext) {
}
// UserAssignmentAPI returns a middleware to handle context-user assignment for api routes
func UserAssignmentAPI() func(ctx *context.APIContext) {
return func(ctx *context.APIContext) {
func UserAssignmentAPI() func(ctx *APIContext) {
return func(ctx *APIContext) {
ctx.ContextUser = userAssignment(ctx.Base, ctx.Doer, ctx.Error)
}
}
func userAssignment(ctx *context.Base, doer *user_model.User, errCb func(int, string, any)) (contextUser *user_model.User) {
func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, string, any)) (contextUser *user_model.User) {
username := ctx.Params(":username")
if doer != nil && doer.LowerName == strings.ToLower(username) {
@@ -70,7 +69,7 @@ func userAssignment(ctx *context.Base, doer *user_model.User, errCb func(int, st
if err != nil {
if user_model.IsErrUserNotExist(err) {
if redirectUserID, err := user_model.LookupUserRedirect(ctx, username); err == nil {
context.RedirectToUser(ctx, username, redirectUserID)
RedirectToUser(ctx, username, redirectUserID)
} else if user_model.IsErrUserRedirectNotExist(err) {
errCb(http.StatusNotFound, "GetUserByName", err)
} else {

38
services/context/utils.go Normal file
View File

@@ -0,0 +1,38 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package context
import (
"strings"
"time"
)
// GetQueryBeforeSince return parsed time (unix format) from URL query's before and since
func GetQueryBeforeSince(ctx *Base) (before, since int64, err error) {
before, err = parseFormTime(ctx, "before")
if err != nil {
return 0, 0, err
}
since, err = parseFormTime(ctx, "since")
if err != nil {
return 0, 0, err
}
return before, since, nil
}
// parseTime parse time and return unix timestamp
func parseFormTime(ctx *Base, name string) (int64, error) {
value := strings.TrimSpace(ctx.FormString(name))
if len(value) != 0 {
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return 0, err
}
if !t.IsZero() {
return t.Unix(), nil
}
}
return 0, nil
}

99
services/context/xsrf.go Normal file
View File

@@ -0,0 +1,99 @@
// Copyright 2012 Google Inc. All Rights Reserved.
// Copyright 2014 The Macaron Authors
// Copyright 2020 The Gitea Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// SPDX-License-Identifier: Apache-2.0
package context
import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"crypto/subtle"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
)
// CsrfTokenTimeout represents the duration that XSRF tokens are valid.
// It is exported so clients may set cookie timeouts that match generated tokens.
const CsrfTokenTimeout = 24 * time.Hour
// CsrfTokenRegenerationInterval is the interval between token generations, old tokens are still valid before CsrfTokenTimeout
var CsrfTokenRegenerationInterval = 10 * time.Minute
var csrfTokenSep = []byte(":")
// GenerateCsrfToken returns a URL-safe secure XSRF token that expires in CsrfTokenTimeout hours.
// key is a secret key for your application.
// userID is a unique identifier for the user.
// actionID is the action the user is taking (e.g. POSTing to a particular path).
func GenerateCsrfToken(key, userID, actionID string, now time.Time) string {
nowUnixNano := now.UnixNano()
nowUnixNanoStr := strconv.FormatInt(nowUnixNano, 10)
h := hmac.New(sha1.New, []byte(key))
h.Write([]byte(strings.ReplaceAll(userID, ":", "_")))
h.Write(csrfTokenSep)
h.Write([]byte(strings.ReplaceAll(actionID, ":", "_")))
h.Write(csrfTokenSep)
h.Write([]byte(nowUnixNanoStr))
tok := fmt.Sprintf("%s:%s", h.Sum(nil), nowUnixNanoStr)
return base64.RawURLEncoding.EncodeToString([]byte(tok))
}
func ParseCsrfToken(token string) (issueTime time.Time, ok bool) {
data, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return time.Time{}, false
}
pos := bytes.LastIndex(data, csrfTokenSep)
if pos == -1 {
return time.Time{}, false
}
nanos, err := strconv.ParseInt(string(data[pos+1:]), 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(0, nanos), true
}
// ValidCsrfToken returns true if token is a valid and unexpired token returned by Generate.
func ValidCsrfToken(token, key, userID, actionID string, now time.Time) bool {
issueTime, ok := ParseCsrfToken(token)
if !ok {
return false
}
// Check that the token is not expired.
if now.Sub(issueTime) >= CsrfTokenTimeout {
return false
}
// Check that the token is not from the future.
// Allow 1-minute grace period in case the token is being verified on a
// machine whose clock is behind the machine that issued the token.
if issueTime.After(now.Add(1 * time.Minute)) {
return false
}
expected := GenerateCsrfToken(key, userID, actionID, issueTime)
// Check that the token matches the expected value.
// Use constant time comparison to avoid timing attacks.
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
}

View File

@@ -0,0 +1,91 @@
// Copyright 2012 Google Inc. All Rights Reserved.
// Copyright 2014 The Macaron Authors
// Copyright 2020 The Gitea Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// SPDX-License-Identifier: Apache-2.0
package context
import (
"encoding/base64"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
const (
key = "quay"
userID = "12345678"
actionID = "POST /form"
)
var (
now = time.Now()
oneMinuteFromNow = now.Add(1 * time.Minute)
)
func Test_ValidToken(t *testing.T) {
t.Run("Validate token", func(t *testing.T) {
tok := GenerateCsrfToken(key, userID, actionID, now)
assert.True(t, ValidCsrfToken(tok, key, userID, actionID, oneMinuteFromNow))
assert.True(t, ValidCsrfToken(tok, key, userID, actionID, now.Add(CsrfTokenTimeout-1*time.Nanosecond)))
assert.True(t, ValidCsrfToken(tok, key, userID, actionID, now.Add(-1*time.Minute)))
})
}
// Test_SeparatorReplacement tests that separators are being correctly substituted
func Test_SeparatorReplacement(t *testing.T) {
t.Run("Test two separator replacements", func(t *testing.T) {
assert.NotEqual(t, GenerateCsrfToken("foo:bar", "baz", "wah", now),
GenerateCsrfToken("foo", "bar:baz", "wah", now))
})
}
func Test_InvalidToken(t *testing.T) {
t.Run("Test invalid tokens", func(t *testing.T) {
invalidTokenTests := []struct {
name, key, userID, actionID string
t time.Time
}{
{"Bad key", "foobar", userID, actionID, oneMinuteFromNow},
{"Bad userID", key, "foobar", actionID, oneMinuteFromNow},
{"Bad actionID", key, userID, "foobar", oneMinuteFromNow},
{"Expired", key, userID, actionID, now.Add(CsrfTokenTimeout)},
{"More than 1 minute from the future", key, userID, actionID, now.Add(-1*time.Nanosecond - 1*time.Minute)},
}
tok := GenerateCsrfToken(key, userID, actionID, now)
for _, itt := range invalidTokenTests {
assert.False(t, ValidCsrfToken(tok, itt.key, itt.userID, itt.actionID, itt.t))
}
})
}
// Test_ValidateBadData primarily tests that no unexpected panics are triggered during parsing
func Test_ValidateBadData(t *testing.T) {
t.Run("Validate bad data", func(t *testing.T) {
badDataTests := []struct {
name, tok string
}{
{"Invalid Base64", "ASDab24(@)$*=="},
{"No delimiter", base64.URLEncoding.EncodeToString([]byte("foobar12345678"))},
{"Invalid time", base64.URLEncoding.EncodeToString([]byte("foobar:foobar"))},
}
for _, bdt := range badDataTests {
assert.False(t, ValidCsrfToken(bdt.tok, key, userID, actionID, oneMinuteFromNow))
}
})
}