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:
101
services/context/access_log.go
Normal file
101
services/context/access_log.go
Normal 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
408
services/context/api.go
Normal 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
|
||||
}
|
12
services/context/api_org.go
Normal file
12
services/context/api_org.go
Normal 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
|
||||
}
|
50
services/context/api_test.go
Normal file
50
services/context/api_test.go
Normal 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
317
services/context/base.go
Normal 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
|
||||
}
|
93
services/context/captcha.go
Normal file
93
services/context/captcha.go
Normal 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
257
services/context/context.go
Normal 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))
|
||||
}
|
||||
}
|
42
services/context/context_cookie.go
Normal file
42
services/context/context_cookie.go
Normal 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)
|
||||
}
|
29
services/context/context_model.go
Normal file
29
services/context/context_model.go
Normal 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
|
||||
}
|
32
services/context/context_request.go
Normal file
32
services/context/context_request.go
Normal 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
|
||||
}
|
189
services/context/context_response.go
Normal file
189
services/context/context_response.go
Normal 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)
|
||||
}
|
35
services/context/context_template.go
Normal file
35
services/context/context_template.go
Normal 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)
|
||||
}
|
24
services/context/context_test.go
Normal file
24
services/context/context_test.go
Normal 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
242
services/context/csrf.go
Normal 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
280
services/context/org.go
Normal 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
165
services/context/package.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
57
services/context/pagination.go
Normal file
57
services/context/pagination.go
Normal 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")
|
||||
}
|
149
services/context/permission.go
Normal file
149
services/context/permission.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
85
services/context/private.go
Normal file
85
services/context/private.go
Normal 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
1090
services/context/repo.go
Normal file
File diff suppressed because it is too large
Load Diff
103
services/context/response.go
Normal file
103
services/context/response.go
Normal 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),
|
||||
}
|
||||
}
|
105
services/context/upload/upload.go
Normal file
105
services/context/upload/upload.go
Normal 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
|
||||
}
|
||||
}
|
194
services/context/upload/upload_test.go
Normal file
194
services/context/upload/upload_test.go
Normal 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))
|
||||
}
|
||||
}
|
@@ -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
38
services/context/utils.go
Normal 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
99
services/context/xsrf.go
Normal 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
|
||||
}
|
91
services/context/xsrf_test.go
Normal file
91
services/context/xsrf_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user