summaryrefslogtreecommitdiffstats
path: root/services/context
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /services/context
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--services/context/access_log.go101
-rw-r--r--services/context/api.go459
-rw-r--r--services/context/api_org.go12
-rw-r--r--services/context/api_test.go51
-rw-r--r--services/context/base.go315
-rw-r--r--services/context/base_test.go47
-rw-r--r--services/context/captcha.go118
-rw-r--r--services/context/context.go254
-rw-r--r--services/context/context_cookie.go56
-rw-r--r--services/context/context_model.go29
-rw-r--r--services/context/context_request.go32
-rw-r--r--services/context/context_response.go194
-rw-r--r--services/context/context_template.go35
-rw-r--r--services/context/context_test.go24
-rw-r--r--services/context/csrf.go171
-rw-r--r--services/context/org.go280
-rw-r--r--services/context/package.go165
-rw-r--r--services/context/pagination.go57
-rw-r--r--services/context/permission.go149
-rw-r--r--services/context/private.go85
-rw-r--r--services/context/quota.go200
-rw-r--r--services/context/repo.go1112
-rw-r--r--services/context/repository.go25
-rw-r--r--services/context/response.go103
-rw-r--r--services/context/upload/upload.go105
-rw-r--r--services/context/upload/upload_test.go194
-rw-r--r--services/context/user.go84
-rw-r--r--services/context/utils.go38
-rw-r--r--services/context/xsrf.go99
-rw-r--r--services/context/xsrf_test.go91
-rw-r--r--services/contexttest/context_tests.go208
31 files changed, 4893 insertions, 0 deletions
diff --git a/services/context/access_log.go b/services/context/access_log.go
new file mode 100644
index 0000000..0926748
--- /dev/null
+++ b/services/context/access_log.go
@@ -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())
+ })
+ }
+}
diff --git a/services/context/api.go b/services/context/api.go
new file mode 100644
index 0000000..396ceb5
--- /dev/null
+++ b/services/context/api.go
@@ -0,0 +1,459 @@
+// 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"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ quota_model "code.gitea.io/gitea/models/quota"
+ "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"
+
+ "code.forgejo.org/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
+ Comment *issues_model.Comment
+ Org *APIOrganization
+ Package *Package
+ QuotaGroup *quota_model.Group
+ QuotaRule *quota_model.Rule
+ PublicOnly bool // Whether the request is for a public endpoint
+}
+
+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
+
+type APIError struct {
+ Message string `json:"message"`
+ URL string `json:"url"`
+}
+
+// APIError is error format response
+// swagger:response error
+type swaggerAPIError struct {
+ // in:body
+ Body APIError `json:"body"`
+}
+
+type APIValidationError struct {
+ Message string `json:"message"`
+ URL string `json:"url"`
+}
+
+// APIValidationError is error format response related to input validation
+// swagger:response validationError
+type swaggerAPIValidationError struct {
+ // in:body
+ Body APIValidationError `json:"body"`
+}
+
+type APIInvalidTopicsError struct {
+ Message string `json:"message"`
+ InvalidTopics []string `json:"invalidTopics"`
+}
+
+// APIInvalidTopicsError is error format response to invalid topics
+// swagger:response invalidTopicsError
+type swaggerAPIInvalidTopicsError struct {
+ // in:body
+ Body APIInvalidTopicsError `json:"body"`
+}
+
+// APIEmpty is an empty response
+// swagger:response empty
+type APIEmpty struct{}
+
+type APIUnauthorizedError struct {
+ APIError
+}
+
+// APIUnauthorizedError is a unauthorized error response
+// swagger:response unauthorized
+type swaggerAPUnauthorizedError struct {
+ // in:body
+ Body APIUnauthorizedError `json:"body"`
+}
+
+type APIForbiddenError struct {
+ APIError
+}
+
+// APIForbiddenError is a forbidden error response
+// swagger:response forbidden
+type swaggerAPIForbiddenError struct {
+ // in:body
+ Body APIForbiddenError `json:"body"`
+}
+
+type APINotFound struct {
+ Message string `json:"message"`
+ URL string `json:"url"`
+ Errors []string `json:"errors"`
+}
+
+// APINotFound is a not found error response
+// swagger:response notFound
+type swaggerAPINotFound struct {
+ // in:body
+ Body APINotFound `json:"body"`
+}
+
+// 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
+
+type APIRepoArchivedError struct {
+ APIError
+}
+
+// APIRepoArchivedError is an error that is raised when an archived repo should be modified
+// swagger:response repoArchivedError
+type swaggerAPIRepoArchivedError struct {
+ // in:body
+ Body APIRepoArchivedError `json:"body"`
+}
+
+// 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")
+ errors := make([]string, 0)
+ 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, APINotFound{
+ 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
+}
diff --git a/services/context/api_org.go b/services/context/api_org.go
new file mode 100644
index 0000000..dad02b1
--- /dev/null
+++ b/services/context/api_org.go
@@ -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
+}
diff --git a/services/context/api_test.go b/services/context/api_test.go
new file mode 100644
index 0000000..6064fee
--- /dev/null
+++ b/services/context/api_test.go
@@ -0,0 +1,51 @@
+// 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"
+ "github.com/stretchr/testify/require"
+)
+
+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)
+ require.NoError(t, err)
+
+ p := u.Query().Get("page")
+ curPage, _ := strconv.Atoi(p)
+
+ links := genAPILinks(u, 100, 20, curPage)
+
+ assert.EqualValues(t, links, response)
+ }
+}
diff --git a/services/context/base.go b/services/context/base.go
new file mode 100644
index 0000000..0259e0d
--- /dev/null
+++ b/services/context/base.go
@@ -0,0 +1,315 @@
+// 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/optional"
+ "code.gitea.io/gitea/modules/translation"
+ "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 optional.Some(true) or optional.Some(false) if the value
+// for the provided key exists in the form else it returns optional.None[bool]()
+func (b *Base) FormOptionalBool(key string) optional.Option[bool] {
+ value := b.Req.FormValue(key)
+ if len(value) == 0 {
+ return optional.None[bool]()
+ }
+ s := b.Req.FormValue(key)
+ v, _ := strconv.ParseBool(s)
+ v = v || strings.EqualFold(s, "on")
+ return optional.Some(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)
+ _, _ = b.Resp.Write(bs)
+}
+
+// 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 httplib.IsRiskyRedirectURL(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
+}
diff --git a/services/context/base_test.go b/services/context/base_test.go
new file mode 100644
index 0000000..823f20e
--- /dev/null
+++ b/services/context/base_test.go
@@ -0,0 +1,47 @@
+// Copyright 2024 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 TestRedirect(t *testing.T) {
+ req, _ := http.NewRequest("GET", "/", nil)
+
+ cases := []struct {
+ url string
+ keep bool
+ }{
+ {"http://test", false},
+ {"https://test", false},
+ {"//test", false},
+ {"/://test", true},
+ {"/test", true},
+ }
+ for _, c := range cases {
+ resp := httptest.NewRecorder()
+ b, cleanup := NewBaseContext(resp, req)
+ resp.Header().Add("Set-Cookie", (&http.Cookie{Name: setting.SessionConfig.CookieName, Value: "dummy"}).String())
+ b.Redirect(c.url)
+ cleanup()
+ has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy"
+ assert.Equal(t, c.keep, has, "url = %q", c.url)
+ }
+
+ req, _ = http.NewRequest("GET", "/", nil)
+ resp := httptest.NewRecorder()
+ req.Header.Add("HX-Request", "true")
+ b, cleanup := NewBaseContext(resp, req)
+ b.Redirect("/other")
+ cleanup()
+ assert.Equal(t, "/other", resp.Header().Get("HX-Redirect"))
+ assert.Equal(t, http.StatusNoContent, resp.Code)
+}
diff --git a/services/context/captcha.go b/services/context/captcha.go
new file mode 100644
index 0000000..da837ac
--- /dev/null
+++ b/services/context/captcha.go
@@ -0,0 +1,118 @@
+// 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"
+
+ mc "code.forgejo.org/go-chi/cache"
+ "code.forgejo.org/go-chi/captcha"
+)
+
+var (
+ imageCaptchaOnce sync.Once
+ imageCachePrefix = "captcha:"
+)
+
+type imageCaptchaStore struct {
+ c mc.Cache
+}
+
+func (c *imageCaptchaStore) Set(id string, digits []byte) {
+ if err := c.c.Put(imageCachePrefix+id, string(digits), int64(captcha.Expiration.Seconds())); err != nil {
+ log.Error("Couldn't store captcha cache for %q: %v", id, err)
+ }
+}
+
+func (c *imageCaptchaStore) Get(id string, clear bool) (digits []byte) {
+ val, ok := c.c.Get(imageCachePrefix + id).(string)
+ if !ok {
+ return digits
+ }
+
+ if clear {
+ if err := c.c.Delete(imageCachePrefix + id); err != nil {
+ log.Error("Couldn't delete captcha cache for %q: %v", id, err)
+ }
+ }
+
+ return []byte(val)
+}
+
+// GetImageCaptcha returns image captcha ID.
+func GetImageCaptcha() string {
+ imageCaptchaOnce.Do(func() {
+ captcha.SetCustomStore(&imageCaptchaStore{c: cache.GetCache()})
+ })
+ return captcha.New()
+}
+
+// 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 (
+ imgCaptchaIDField = "img-captcha-id"
+ imgCaptchaResponseField = "img-captcha-response"
+ 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 = captcha.VerifyString(ctx.Req.Form.Get(imgCaptchaIDField), ctx.Req.Form.Get(imgCaptchaResponseField))
+ 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("Captcha Verify failed: %v", err)
+ }
+
+ if !valid {
+ ctx.Data["Err_Captcha"] = true
+ ctx.RenderWithErr(ctx.Tr("form.captcha_incorrect"), tpl, form)
+ }
+}
diff --git a/services/context/context.go b/services/context/context.go
new file mode 100644
index 0000000..91e7b18
--- /dev/null
+++ b/services/context/context.go
@@ -0,0 +1,254 @@
+// 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"
+
+ "code.forgejo.org/go-chi/cache"
+ "code.forgejo.org/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,
+ Secure: setting.SessionConfig.Secure,
+ CookieHTTPOnly: setting.CSRFCookieHTTPOnly,
+ 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 = NewCSRFProtector(csrfOpts)
+
+ // 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()
+
+ // 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["DisableForks"] = setting.Repository.DisableForks
+ 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))
+ }
+}
diff --git a/services/context/context_cookie.go b/services/context/context_cookie.go
new file mode 100644
index 0000000..3699f81
--- /dev/null
+++ b/services/context/context_cookie.go
@@ -0,0 +1,56 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package context
+
+import (
+ "net/http"
+ "strings"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "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)
+}
+
+// SetLTACookie will generate a LTA token and add it as an cookie.
+func (ctx *Context) SetLTACookie(u *user_model.User) error {
+ days := 86400 * setting.LogInRememberDays
+ lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days)), auth_model.LongTermAuthorization)
+ if err != nil {
+ return err
+ }
+ ctx.SetSiteCookie(setting.CookieRememberName, lookup+":"+validator, days)
+ return nil
+}
diff --git a/services/context/context_model.go b/services/context/context_model.go
new file mode 100644
index 0000000..4f70aac
--- /dev/null
+++ b/services/context/context_model.go
@@ -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
+}
diff --git a/services/context/context_request.go b/services/context/context_request.go
new file mode 100644
index 0000000..984b9ac
--- /dev/null
+++ b/services/context/context_request.go
@@ -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
+}
diff --git a/services/context/context_response.go b/services/context/context_response.go
new file mode 100644
index 0000000..f36b834
--- /dev/null
+++ b/services/context/context_response.go
@@ -0,0 +1,194 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package context
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "net"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+ "syscall"
+ "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 which likely belongs to current site.
+// If no suitable redirection is found, it redirects to the home.
+// It returns the location it redirected to.
+func (ctx *Context) RedirectToFirst(location ...string) string {
+ for _, loc := range location {
+ if len(loc) == 0 {
+ continue
+ }
+
+ if httplib.IsRiskyRedirectURL(loc) {
+ continue
+ }
+
+ ctx.Redirect(loc)
+ return loc
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/")
+ return 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 || errors.Is(err, syscall.EPIPE) {
+ 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)
+ }
+}
+
+// RenderToHTML renders the template content to a HTML string
+func (ctx *Context) RenderToHTML(name base.TplName, data map[string]any) (template.HTML, error) {
+ var buf strings.Builder
+ err := ctx.Render.HTML(&buf, 0, string(name), data, ctx.TemplateContext)
+ return template.HTML(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)
+}
diff --git a/services/context/context_template.go b/services/context/context_template.go
new file mode 100644
index 0000000..7878d40
--- /dev/null
+++ b/services/context/context_template.go
@@ -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)
+}
diff --git a/services/context/context_test.go b/services/context/context_test.go
new file mode 100644
index 0000000..033ce2e
--- /dev/null
+++ b/services/context/context_test.go
@@ -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"))
+}
diff --git a/services/context/csrf.go b/services/context/csrf.go
new file mode 100644
index 0000000..e0518a4
--- /dev/null
+++ b/services/context/csrf.go
@@ -0,0 +1,171 @@
+// 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 (
+ "html/template"
+ "net/http"
+ "strconv"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+const (
+ CsrfHeaderName = "X-Csrf-Token"
+ CsrfFormName = "_csrf"
+ CsrfErrorString = "Invalid CSRF token."
+)
+
+// CSRFProtector represents a CSRF protector and is used to get the current token and validate the token.
+type CSRFProtector interface {
+ // PrepareForSessionUser prepares the csrf protector for the current session user.
+ PrepareForSessionUser(ctx *Context)
+ // Validate validates the csrf token in http context.
+ Validate(ctx *Context)
+ // DeleteCookie deletes the csrf cookie
+ DeleteCookie(ctx *Context)
+}
+
+type csrfProtector struct {
+ opt CsrfOptions
+ // id must be unique per user.
+ id string
+ // token is the valid one which wil be used by end user and passed via header, cookie, or hidden form value.
+ token string
+}
+
+// CsrfOptions maintains options to manage behavior of Generate.
+type CsrfOptions struct {
+ // The global secret value used to generate Tokens.
+ Secret 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
+ // Set the Secure flag to true on the cookie.
+ Secure bool
+ // sessionKey is the key used for getting the unique ID per user.
+ sessionKey string
+ // oldSessionKey saves old value corresponding to sessionKey.
+ oldSessionKey string
+}
+
+func newCsrfCookie(opt *CsrfOptions, value string) *http.Cookie {
+ return &http.Cookie{
+ Name: opt.Cookie,
+ Value: value,
+ Path: opt.CookiePath,
+ Domain: opt.CookieDomain,
+ MaxAge: int(CsrfTokenTimeout.Seconds()),
+ Secure: opt.Secure,
+ HttpOnly: opt.CookieHTTPOnly,
+ SameSite: opt.SameSite,
+ }
+}
+
+func NewCSRFProtector(opt CsrfOptions) CSRFProtector {
+ if opt.Secret == "" {
+ panic("CSRF secret is empty but it must be set") // it shouldn't happen because it is always set in code
+ }
+ opt.Cookie = util.IfZero(opt.Cookie, "_csrf")
+ opt.CookiePath = util.IfZero(opt.CookiePath, "/")
+ opt.sessionKey = "uid"
+ opt.oldSessionKey = "_old_" + opt.sessionKey
+ return &csrfProtector{opt: opt}
+}
+
+func (c *csrfProtector) PrepareForSessionUser(ctx *Context) {
+ c.id = "0"
+ if uidAny := ctx.Session.Get(c.opt.sessionKey); uidAny != nil {
+ switch uidVal := uidAny.(type) {
+ case string:
+ c.id = uidVal
+ case int64:
+ c.id = strconv.FormatInt(uidVal, 10)
+ default:
+ log.Error("invalid uid type in session: %T", uidAny)
+ }
+ }
+
+ oldUID := ctx.Session.Get(c.opt.oldSessionKey)
+ uidChanged := oldUID == nil || oldUID.(string) != c.id
+ cookieToken := ctx.GetSiteCookie(c.opt.Cookie)
+
+ needsNew := true
+ if uidChanged {
+ _ = ctx.Session.Set(c.opt.oldSessionKey, c.id)
+ } else if cookieToken != "" {
+ // If cookie token presents, reuse 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 {
+ c.token = cookieToken
+ needsNew = false
+ }
+ }
+ }
+
+ if needsNew {
+ // FIXME: actionId.
+ c.token = GenerateCsrfToken(c.opt.Secret, c.id, "POST", time.Now())
+ cookie := newCsrfCookie(&c.opt, c.token)
+ ctx.Resp.Header().Add("Set-Cookie", cookie.String())
+ }
+
+ ctx.Data["CsrfToken"] = c.token
+ ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(c.token) + `">`)
+}
+
+func (c *csrfProtector) validateToken(ctx *Context, token string) {
+ if !ValidCsrfToken(token, c.opt.Secret, c.id, "POST", time.Now()) {
+ c.DeleteCookie(ctx)
+ // currently, there should be no access to the APIPath with CSRF token. because templates shouldn't use the `/api/` endpoints.
+ // FIXME: distinguish what the response is for: HTML (web page) or JSON (fetch)
+ http.Error(ctx.Resp, CsrfErrorString, http.StatusBadRequest)
+ }
+}
+
+// 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, http.StatusBadRequest is sent.
+func (c *csrfProtector) Validate(ctx *Context) {
+ if token := ctx.Req.Header.Get(CsrfHeaderName); token != "" {
+ c.validateToken(ctx, token)
+ return
+ }
+ if token := ctx.Req.FormValue(CsrfFormName); 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) {
+ cookie := newCsrfCookie(&c.opt, "")
+ cookie.MaxAge = -1
+ ctx.Resp.Header().Add("Set-Cookie", cookie.String())
+}
diff --git a/services/context/org.go b/services/context/org.go
new file mode 100644
index 0000000..018b76d
--- /dev/null
+++ b/services/context/org.go
@@ -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...)
+ }
+}
diff --git a/services/context/package.go b/services/context/package.go
new file mode 100644
index 0000000..c452c65
--- /dev/null
+++ b/services/context/package.go
@@ -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)
+ })
+ }
+}
diff --git a/services/context/pagination.go b/services/context/pagination.go
new file mode 100644
index 0000000..655a278
--- /dev/null
+++ b/services/context/pagination.go
@@ -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, "fuzzy", "IsFuzzy")
+}
diff --git a/services/context/permission.go b/services/context/permission.go
new file mode 100644
index 0000000..14a9801
--- /dev/null
+++ b/services/context/permission.go
@@ -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
+ }
+ }
+}
diff --git a/services/context/private.go b/services/context/private.go
new file mode 100644
index 0000000..8b41949
--- /dev/null
+++ b/services/context/private.go
@@ -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
+}
diff --git a/services/context/quota.go b/services/context/quota.go
new file mode 100644
index 0000000..94e8847
--- /dev/null
+++ b/services/context/quota.go
@@ -0,0 +1,200 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package context
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ quota_model "code.gitea.io/gitea/models/quota"
+ "code.gitea.io/gitea/modules/base"
+)
+
+type QuotaTargetType int
+
+const (
+ QuotaTargetUser QuotaTargetType = iota
+ QuotaTargetRepo
+ QuotaTargetOrg
+)
+
+// QuotaExceeded
+// swagger:response quotaExceeded
+type APIQuotaExceeded struct {
+ Message string `json:"message"`
+ UserID int64 `json:"user_id"`
+ UserName string `json:"username,omitempty"`
+}
+
+// QuotaGroupAssignmentAPI returns a middleware to handle context-quota-group assignment for api routes
+func QuotaGroupAssignmentAPI() func(ctx *APIContext) {
+ return func(ctx *APIContext) {
+ groupName := ctx.Params("quotagroup")
+ group, err := quota_model.GetGroupByName(ctx, groupName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "quota_model.GetGroupByName", err)
+ return
+ }
+ if group == nil {
+ ctx.NotFound()
+ return
+ }
+ ctx.QuotaGroup = group
+ }
+}
+
+// QuotaRuleAssignmentAPI returns a middleware to handle context-quota-rule assignment for api routes
+func QuotaRuleAssignmentAPI() func(ctx *APIContext) {
+ return func(ctx *APIContext) {
+ ruleName := ctx.Params("quotarule")
+ rule, err := quota_model.GetRuleByName(ctx, ruleName)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "quota_model.GetRuleByName", err)
+ return
+ }
+ if rule == nil {
+ ctx.NotFound()
+ return
+ }
+ ctx.QuotaRule = rule
+ }
+}
+
+// ctx.CheckQuota checks whether the user in question is within quota limits (web context)
+func (ctx *Context) CheckQuota(subject quota_model.LimitSubject, userID int64, username string) bool {
+ ok, err := checkQuota(ctx.Base.originCtx, subject, userID, username, func(userID int64, username string) {
+ showHTML := false
+ for _, part := range ctx.Req.Header["Accept"] {
+ if strings.Contains(part, "text/html") {
+ showHTML = true
+ break
+ }
+ }
+ if !showHTML {
+ ctx.plainTextInternal(3, http.StatusRequestEntityTooLarge, []byte("Quota exceeded.\n"))
+ return
+ }
+
+ ctx.Data["IsRepo"] = ctx.Repo.Repository != nil
+ ctx.Data["Title"] = "Quota Exceeded"
+ ctx.HTML(http.StatusRequestEntityTooLarge, base.TplName("status/413"))
+ }, func(err error) {
+ ctx.Error(http.StatusInternalServerError, "quota_model.EvaluateForUser")
+ })
+ if err != nil {
+ return false
+ }
+ return ok
+}
+
+// ctx.CheckQuota checks whether the user in question is within quota limits (API context)
+func (ctx *APIContext) CheckQuota(subject quota_model.LimitSubject, userID int64, username string) bool {
+ ok, err := checkQuota(ctx.Base.originCtx, subject, userID, username, func(userID int64, username string) {
+ ctx.JSON(http.StatusRequestEntityTooLarge, APIQuotaExceeded{
+ Message: "quota exceeded",
+ UserID: userID,
+ UserName: username,
+ })
+ }, func(err error) {
+ ctx.InternalServerError(err)
+ })
+ if err != nil {
+ return false
+ }
+ return ok
+}
+
+// EnforceQuotaWeb returns a middleware that enforces quota limits on the given web route.
+func EnforceQuotaWeb(subject quota_model.LimitSubject, target QuotaTargetType) func(ctx *Context) {
+ return func(ctx *Context) {
+ ctx.CheckQuota(subject, target.UserID(ctx), target.UserName(ctx))
+ }
+}
+
+// EnforceQuotaWeb returns a middleware that enforces quota limits on the given API route.
+func EnforceQuotaAPI(subject quota_model.LimitSubject, target QuotaTargetType) func(ctx *APIContext) {
+ return func(ctx *APIContext) {
+ ctx.CheckQuota(subject, target.UserID(ctx), target.UserName(ctx))
+ }
+}
+
+// checkQuota wraps quota checking into a single function
+func checkQuota(ctx context.Context, subject quota_model.LimitSubject, userID int64, username string, quotaExceededHandler func(userID int64, username string), errorHandler func(err error)) (bool, error) {
+ ok, err := quota_model.EvaluateForUser(ctx, userID, subject)
+ if err != nil {
+ errorHandler(err)
+ return false, err
+ }
+ if !ok {
+ quotaExceededHandler(userID, username)
+ return false, nil
+ }
+ return true, nil
+}
+
+type QuotaContext interface {
+ GetQuotaTargetUserID(target QuotaTargetType) int64
+ GetQuotaTargetUserName(target QuotaTargetType) string
+}
+
+func (ctx *Context) GetQuotaTargetUserID(target QuotaTargetType) int64 {
+ switch target {
+ case QuotaTargetUser:
+ return ctx.Doer.ID
+ case QuotaTargetRepo:
+ return ctx.Repo.Repository.OwnerID
+ case QuotaTargetOrg:
+ return ctx.Org.Organization.ID
+ default:
+ return 0
+ }
+}
+
+func (ctx *Context) GetQuotaTargetUserName(target QuotaTargetType) string {
+ switch target {
+ case QuotaTargetUser:
+ return ctx.Doer.Name
+ case QuotaTargetRepo:
+ return ctx.Repo.Repository.Owner.Name
+ case QuotaTargetOrg:
+ return ctx.Org.Organization.Name
+ default:
+ return ""
+ }
+}
+
+func (ctx *APIContext) GetQuotaTargetUserID(target QuotaTargetType) int64 {
+ switch target {
+ case QuotaTargetUser:
+ return ctx.Doer.ID
+ case QuotaTargetRepo:
+ return ctx.Repo.Repository.OwnerID
+ case QuotaTargetOrg:
+ return ctx.Org.Organization.ID
+ default:
+ return 0
+ }
+}
+
+func (ctx *APIContext) GetQuotaTargetUserName(target QuotaTargetType) string {
+ switch target {
+ case QuotaTargetUser:
+ return ctx.Doer.Name
+ case QuotaTargetRepo:
+ return ctx.Repo.Repository.Owner.Name
+ case QuotaTargetOrg:
+ return ctx.Org.Organization.Name
+ default:
+ return ""
+ }
+}
+
+func (target QuotaTargetType) UserID(ctx QuotaContext) int64 {
+ return ctx.GetQuotaTargetUserID(target)
+}
+
+func (target QuotaTargetType) UserName(ctx QuotaContext) string {
+ return ctx.GetQuotaTargetUserName(target)
+}
diff --git a/services/context/repo.go b/services/context/repo.go
new file mode 100644
index 0000000..d2cee08
--- /dev/null
+++ b/services/context/repo.go
@@ -0,0 +1,1112 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package context
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ packages_model "code.gitea.io/gitea/models/packages"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ code_indexer "code.gitea.io/gitea/modules/indexer/code"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ asymkey_service "code.gitea.io/gitea/services/asymkey"
+
+ "github.com/editorconfig/editorconfig-core-go/v2"
+)
+
+// PullRequest contains information to make a pull request
+type PullRequest struct {
+ BaseRepo *repo_model.Repository
+ Allowed bool
+ SameRepo bool
+ HeadInfoSubURL string // [<user>:]<branch> url segment
+}
+
+// Repository contains information to operate a repository
+type Repository struct {
+ access_model.Permission
+ IsWatching bool
+ IsViewBranch bool
+ IsViewTag bool
+ IsViewCommit bool
+ Repository *repo_model.Repository
+ Owner *user_model.User
+ Commit *git.Commit
+ Tag *git.Tag
+ GitRepo *git.Repository
+ RefName string
+ BranchName string
+ TagName string
+ TreePath string
+ CommitID string
+ RepoLink string
+ CloneLink repo_model.CloneLink
+ CommitsCount int64
+
+ PullRequest *PullRequest
+}
+
+// CanWriteToBranch checks if the branch is writable by the user
+func (r *Repository) CanWriteToBranch(ctx context.Context, user *user_model.User, branch string) bool {
+ return issues_model.CanMaintainerWriteToBranch(ctx, r.Permission, branch, user)
+}
+
+// CanEnableEditor returns true if repository is editable and user has proper access level.
+func (r *Repository) CanEnableEditor(ctx context.Context, user *user_model.User) bool {
+ return r.IsViewBranch && r.CanWriteToBranch(ctx, user, r.BranchName) && r.Repository.CanEnableEditor() && !r.Repository.IsArchived
+}
+
+// CanCreateBranch returns true if repository is editable and user has proper access level.
+func (r *Repository) CanCreateBranch() bool {
+ return r.Permission.CanWrite(unit_model.TypeCode) && r.Repository.CanCreateBranch()
+}
+
+func (r *Repository) GetObjectFormat() git.ObjectFormat {
+ return git.ObjectFormatFromName(r.Repository.ObjectFormatName)
+}
+
+// RepoMustNotBeArchived checks if a repo is archived
+func RepoMustNotBeArchived() func(ctx *Context) {
+ return func(ctx *Context) {
+ if ctx.Repo.Repository.IsArchived {
+ ctx.NotFound("IsArchived", errors.New(ctx.Locale.TrString("repo.archive.title")))
+ }
+ }
+}
+
+// CanCommitToBranchResults represents the results of CanCommitToBranch
+type CanCommitToBranchResults struct {
+ CanCommitToBranch bool
+ EditorEnabled bool
+ UserCanPush bool
+ RequireSigned bool
+ WillSign bool
+ SigningKey string
+ WontSignReason string
+}
+
+// CanCommitToBranch returns true if repository is editable and user has proper access level
+//
+// and branch is not protected for push
+func (r *Repository) CanCommitToBranch(ctx context.Context, doer *user_model.User) (CanCommitToBranchResults, error) {
+ protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, r.Repository.ID, r.BranchName)
+ if err != nil {
+ return CanCommitToBranchResults{}, err
+ }
+ userCanPush := true
+ requireSigned := false
+ if protectedBranch != nil {
+ protectedBranch.Repo = r.Repository
+ userCanPush = protectedBranch.CanUserPush(ctx, doer)
+ requireSigned = protectedBranch.RequireSignedCommits
+ }
+
+ sign, keyID, _, err := asymkey_service.SignCRUDAction(ctx, r.Repository.RepoPath(), doer, r.Repository.RepoPath(), git.BranchPrefix+r.BranchName)
+
+ canCommit := r.CanEnableEditor(ctx, doer) && userCanPush
+ if requireSigned {
+ canCommit = canCommit && sign
+ }
+ wontSignReason := ""
+ if err != nil {
+ if asymkey_service.IsErrWontSign(err) {
+ wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason)
+ err = nil
+ } else {
+ wontSignReason = "error"
+ }
+ }
+
+ return CanCommitToBranchResults{
+ CanCommitToBranch: canCommit,
+ EditorEnabled: r.CanEnableEditor(ctx, doer),
+ UserCanPush: userCanPush,
+ RequireSigned: requireSigned,
+ WillSign: sign,
+ SigningKey: keyID,
+ WontSignReason: wontSignReason,
+ }, err
+}
+
+// CanUseTimetracker returns whether or not a user can use the timetracker.
+func (r *Repository) CanUseTimetracker(ctx context.Context, issue *issues_model.Issue, user *user_model.User) bool {
+ // Checking for following:
+ // 1. Is timetracker enabled
+ // 2. Is the user a contributor, admin, poster or assignee and do the repository policies require this?
+ isAssigned, _ := issues_model.IsUserAssignedToIssue(ctx, issue, user)
+ return r.Repository.IsTimetrackerEnabled(ctx) && (!r.Repository.AllowOnlyContributorsToTrackTime(ctx) ||
+ r.Permission.CanWriteIssuesOrPulls(issue.IsPull) || issue.IsPoster(user.ID) || isAssigned)
+}
+
+// CanCreateIssueDependencies returns whether or not a user can create dependencies.
+func (r *Repository) CanCreateIssueDependencies(ctx context.Context, user *user_model.User, isPull bool) bool {
+ return r.Repository.IsDependenciesEnabled(ctx) && r.Permission.CanWriteIssuesOrPulls(isPull)
+}
+
+// GetCommitsCount returns cached commit count for current view
+func (r *Repository) GetCommitsCount() (int64, error) {
+ if r.Commit == nil {
+ return 0, nil
+ }
+ var contextName string
+ if r.IsViewBranch {
+ contextName = r.BranchName
+ } else if r.IsViewTag {
+ contextName = r.TagName
+ } else {
+ contextName = r.CommitID
+ }
+ return cache.GetInt64(r.Repository.GetCommitsCountCacheKey(contextName, r.IsViewBranch || r.IsViewTag), func() (int64, error) {
+ return r.Commit.CommitsCount()
+ })
+}
+
+// GetCommitGraphsCount returns cached commit count for current view
+func (r *Repository) GetCommitGraphsCount(ctx context.Context, hidePRRefs bool, branches, files []string) (int64, error) {
+ cacheKey := fmt.Sprintf("commits-count-%d-graph-%t-%s-%s", r.Repository.ID, hidePRRefs, branches, files)
+
+ return cache.GetInt64(cacheKey, func() (int64, error) {
+ if len(branches) == 0 {
+ return git.AllCommitsCount(ctx, r.Repository.RepoPath(), hidePRRefs, files...)
+ }
+ return git.CommitsCount(ctx,
+ git.CommitsCountOptions{
+ RepoPath: r.Repository.RepoPath(),
+ Revision: branches,
+ RelPath: files,
+ })
+ })
+}
+
+// BranchNameSubURL sub-URL for the BranchName field
+func (r *Repository) BranchNameSubURL() string {
+ switch {
+ case r.IsViewBranch:
+ return "branch/" + util.PathEscapeSegments(r.BranchName)
+ case r.IsViewTag:
+ return "tag/" + util.PathEscapeSegments(r.TagName)
+ case r.IsViewCommit:
+ return "commit/" + util.PathEscapeSegments(r.CommitID)
+ }
+ log.Error("Unknown view type for repo: %v", r)
+ return ""
+}
+
+// FileExists returns true if a file exists in the given repo branch
+func (r *Repository) FileExists(path, branch string) (bool, error) {
+ if branch == "" {
+ branch = r.Repository.DefaultBranch
+ }
+ commit, err := r.GitRepo.GetBranchCommit(branch)
+ if err != nil {
+ return false, err
+ }
+ if _, err := commit.GetTreeEntryByPath(path); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+// GetEditorconfig returns the .editorconfig definition if found in the
+// HEAD of the default repo branch.
+func (r *Repository) GetEditorconfig(optCommit ...*git.Commit) (cfg *editorconfig.Editorconfig, warning, err error) {
+ if r.GitRepo == nil {
+ return nil, nil, nil
+ }
+
+ var commit *git.Commit
+
+ if len(optCommit) != 0 {
+ commit = optCommit[0]
+ } else {
+ commit, err = r.GitRepo.GetBranchCommit(r.Repository.DefaultBranch)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+ treeEntry, err := commit.GetTreeEntryByPath(".editorconfig")
+ if err != nil {
+ return nil, nil, err
+ }
+ if treeEntry.Blob().Size() >= setting.UI.MaxDisplayFileSize {
+ return nil, nil, git.ErrNotExist{ID: "", RelPath: ".editorconfig"}
+ }
+ reader, err := treeEntry.Blob().DataAsync()
+ if err != nil {
+ return nil, nil, err
+ }
+ defer reader.Close()
+ return editorconfig.ParseGraceful(reader)
+}
+
+// RetrieveBaseRepo retrieves base repository
+func RetrieveBaseRepo(ctx *Context, repo *repo_model.Repository) {
+ // Non-fork repository will not return error in this method.
+ if err := repo.GetBaseRepo(ctx); err != nil {
+ if repo_model.IsErrRepoNotExist(err) {
+ repo.IsFork = false
+ repo.ForkID = 0
+ return
+ }
+ ctx.ServerError("GetBaseRepo", err)
+ return
+ } else if err = repo.BaseRepo.LoadOwner(ctx); err != nil {
+ ctx.ServerError("BaseRepo.LoadOwner", err)
+ return
+ }
+}
+
+// RetrieveTemplateRepo retrieves template repository used to generate this repository
+func RetrieveTemplateRepo(ctx *Context, repo *repo_model.Repository) {
+ // Non-generated repository will not return error in this method.
+ templateRepo, err := repo_model.GetTemplateRepo(ctx, repo)
+ if err != nil {
+ if repo_model.IsErrRepoNotExist(err) {
+ repo.TemplateID = 0
+ return
+ }
+ ctx.ServerError("GetTemplateRepo", err)
+ return
+ } else if err = templateRepo.LoadOwner(ctx); err != nil {
+ ctx.ServerError("TemplateRepo.LoadOwner", err)
+ return
+ }
+
+ perm, err := access_model.GetUserRepoPermission(ctx, templateRepo, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("GetUserRepoPermission", err)
+ return
+ }
+
+ if !perm.CanRead(unit_model.TypeCode) {
+ repo.TemplateID = 0
+ }
+}
+
+// ComposeGoGetImport returns go-get-import meta content.
+func ComposeGoGetImport(owner, repo string) string {
+ /// setting.AppUrl is guaranteed to be parse as url
+ appURL, _ := url.Parse(setting.AppURL)
+
+ return path.Join(appURL.Host, setting.AppSubURL, url.PathEscape(owner), url.PathEscape(repo))
+}
+
+// EarlyResponseForGoGetMeta responses appropriate go-get meta with status 200
+// if user does not have actual access to the requested repository,
+// or the owner or repository does not exist at all.
+// This is particular a workaround for "go get" command which does not respect
+// .netrc file.
+func EarlyResponseForGoGetMeta(ctx *Context) {
+ username := ctx.Params(":username")
+ reponame := strings.TrimSuffix(ctx.Params(":reponame"), ".git")
+ if username == "" || reponame == "" {
+ ctx.PlainText(http.StatusBadRequest, "invalid repository path")
+ return
+ }
+
+ var cloneURL string
+ if setting.Repository.GoGetCloneURLProtocol == "ssh" {
+ cloneURL = repo_model.ComposeSSHCloneURL(username, reponame)
+ } else {
+ cloneURL = repo_model.ComposeHTTPSCloneURL(username, reponame)
+ }
+ goImportContent := fmt.Sprintf("%s git %s", ComposeGoGetImport(username, reponame), cloneURL)
+ htmlMeta := fmt.Sprintf(`<meta name="go-import" content="%s">`, html.EscapeString(goImportContent))
+ ctx.PlainText(http.StatusOK, htmlMeta)
+}
+
+// RedirectToRepo redirect to a differently-named repository
+func RedirectToRepo(ctx *Base, redirectRepoID int64) {
+ ownerName := ctx.Params(":username")
+ previousRepoName := ctx.Params(":reponame")
+
+ repo, err := repo_model.GetRepositoryByID(ctx, redirectRepoID)
+ if err != nil {
+ log.Error("GetRepositoryByID: %v", err)
+ ctx.Error(http.StatusInternalServerError, "GetRepositoryByID")
+ return
+ }
+
+ redirectPath := strings.Replace(
+ ctx.Req.URL.EscapedPath(),
+ url.PathEscape(ownerName)+"/"+url.PathEscape(previousRepoName),
+ url.PathEscape(repo.OwnerName)+"/"+url.PathEscape(repo.Name),
+ 1,
+ )
+ if ctx.Req.URL.RawQuery != "" {
+ redirectPath += "?" + ctx.Req.URL.RawQuery
+ }
+ ctx.Redirect(path.Join(setting.AppSubURL, redirectPath), http.StatusTemporaryRedirect)
+}
+
+func repoAssignment(ctx *Context, repo *repo_model.Repository) {
+ var err error
+ if err = repo.LoadOwner(ctx); err != nil {
+ ctx.ServerError("LoadOwner", err)
+ return
+ }
+
+ ctx.Repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
+ if err != nil {
+ ctx.ServerError("GetUserRepoPermission", err)
+ return
+ }
+
+ // Check access.
+ if !ctx.Repo.Permission.HasAccess() {
+ if ctx.FormString("go-get") == "1" {
+ EarlyResponseForGoGetMeta(ctx)
+ return
+ }
+ ctx.NotFound("no access right", nil)
+ return
+ }
+ ctx.Data["HasAccess"] = true
+ ctx.Data["Permission"] = &ctx.Repo.Permission
+
+ followingRepoList, err := repo_model.FindFollowingReposByRepoID(ctx, repo.ID)
+ if err == nil {
+ followingRepoString := ""
+ for idx, followingRepo := range followingRepoList {
+ if idx > 0 {
+ followingRepoString += ";"
+ }
+ followingRepoString += followingRepo.URI
+ }
+ ctx.Data["FollowingRepos"] = followingRepoString
+ } else if err != repo_model.ErrMirrorNotExist {
+ ctx.ServerError("FindFollowingRepoByRepoID", err)
+ return
+ }
+
+ if repo.IsMirror {
+ pullMirror, err := repo_model.GetMirrorByRepoID(ctx, repo.ID)
+ if err == nil {
+ ctx.Data["PullMirror"] = pullMirror
+ } else if err != repo_model.ErrMirrorNotExist {
+ ctx.ServerError("GetMirrorByRepoID", err)
+ return
+ }
+ }
+
+ pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, db.ListOptions{})
+ if err != nil {
+ ctx.ServerError("GetPushMirrorsByRepoID", err)
+ return
+ }
+
+ ctx.Repo.Repository = repo
+ ctx.Data["PushMirrors"] = pushMirrors
+ ctx.Data["RepoName"] = ctx.Repo.Repository.Name
+ ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty
+ ctx.Data["DefaultWikiBranchName"] = setting.Repository.DefaultBranch
+}
+
+// RepoIDAssignment returns a handler which assigns the repo to the context.
+func RepoIDAssignment() func(ctx *Context) {
+ return func(ctx *Context) {
+ repoID := ctx.ParamsInt64(":repoid")
+
+ // Get repository.
+ repo, err := repo_model.GetRepositoryByID(ctx, repoID)
+ if err != nil {
+ if repo_model.IsErrRepoNotExist(err) {
+ ctx.NotFound("GetRepositoryByID", nil)
+ } else {
+ ctx.ServerError("GetRepositoryByID", err)
+ }
+ return
+ }
+
+ repoAssignment(ctx, repo)
+ }
+}
+
+// RepoAssignment returns a middleware to handle repository assignment
+func RepoAssignment(ctx *Context) context.CancelFunc {
+ if _, repoAssignmentOnce := ctx.Data["repoAssignmentExecuted"]; repoAssignmentOnce {
+ log.Trace("RepoAssignment was exec already, skipping second call ...")
+ return nil
+ }
+ ctx.Data["repoAssignmentExecuted"] = true
+
+ var (
+ owner *user_model.User
+ err error
+ )
+
+ userName := ctx.Params(":username")
+ repoName := ctx.Params(":reponame")
+ repoName = strings.TrimSuffix(repoName, ".git")
+ if setting.Other.EnableFeed {
+ repoName = strings.TrimSuffix(repoName, ".rss")
+ repoName = strings.TrimSuffix(repoName, ".atom")
+ }
+
+ // Check if the user is the same as the repository owner
+ if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(userName) {
+ owner = ctx.Doer
+ } else {
+ owner, err = user_model.GetUserByName(ctx, userName)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ // go-get does not support redirects
+ // https://github.com/golang/go/issues/19760
+ if ctx.FormString("go-get") == "1" {
+ EarlyResponseForGoGetMeta(ctx)
+ return nil
+ }
+
+ if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil {
+ RedirectToUser(ctx.Base, userName, redirectUserID)
+ } else if user_model.IsErrUserRedirectNotExist(err) {
+ ctx.NotFound("GetUserByName", nil)
+ } else {
+ ctx.ServerError("LookupUserRedirect", err)
+ }
+ } else {
+ ctx.ServerError("GetUserByName", err)
+ }
+ return nil
+ }
+ }
+ ctx.Repo.Owner = owner
+ ctx.ContextUser = owner
+ ctx.Data["ContextUser"] = ctx.ContextUser
+ ctx.Data["Username"] = ctx.Repo.Owner.Name
+
+ // redirect link to wiki
+ if strings.HasSuffix(repoName, ".wiki") {
+ // ctx.Req.URL.Path does not have the preceding appSubURL - any redirect must have this added
+ // Now we happen to know that all of our paths are: /:username/:reponame/whatever_else
+ originalRepoName := ctx.Params(":reponame")
+ redirectRepoName := strings.TrimSuffix(repoName, ".wiki")
+ redirectRepoName += originalRepoName[len(redirectRepoName)+5:]
+ redirectPath := strings.Replace(
+ ctx.Req.URL.EscapedPath(),
+ url.PathEscape(userName)+"/"+url.PathEscape(originalRepoName),
+ url.PathEscape(userName)+"/"+url.PathEscape(redirectRepoName)+"/wiki",
+ 1,
+ )
+ if ctx.Req.URL.RawQuery != "" {
+ redirectPath += "?" + ctx.Req.URL.RawQuery
+ }
+ ctx.Redirect(path.Join(setting.AppSubURL, redirectPath))
+ return nil
+ }
+
+ // Get repository.
+ repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName)
+ if err != nil {
+ if repo_model.IsErrRepoNotExist(err) {
+ redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName)
+ if err == nil {
+ RedirectToRepo(ctx.Base, redirectRepoID)
+ } else if repo_model.IsErrRedirectNotExist(err) {
+ if ctx.FormString("go-get") == "1" {
+ EarlyResponseForGoGetMeta(ctx)
+ return nil
+ }
+ ctx.NotFound("GetRepositoryByName", nil)
+ } else {
+ ctx.ServerError("LookupRepoRedirect", err)
+ }
+ } else {
+ ctx.ServerError("GetRepositoryByName", err)
+ }
+ return nil
+ }
+ repo.Owner = owner
+
+ repoAssignment(ctx, repo)
+ if ctx.Written() {
+ return nil
+ }
+
+ ctx.Repo.RepoLink = repo.Link()
+ ctx.Data["RepoLink"] = ctx.Repo.RepoLink
+ ctx.Data["RepoRelPath"] = ctx.Repo.Owner.Name + "/" + ctx.Repo.Repository.Name
+
+ if setting.Other.EnableFeed {
+ ctx.Data["EnableFeed"] = true
+ ctx.Data["FeedURL"] = ctx.Repo.RepoLink
+ }
+
+ unit, err := ctx.Repo.Repository.GetUnit(ctx, unit_model.TypeExternalTracker)
+ if err == nil {
+ ctx.Data["RepoExternalIssuesLink"] = unit.ExternalTrackerConfig().ExternalTrackerURL
+ }
+
+ ctx.Data["NumTags"], err = db.Count[repo_model.Release](ctx, repo_model.FindReleasesOptions{
+ IncludeDrafts: true,
+ IncludeTags: true,
+ HasSha1: optional.Some(true), // only draft releases which are created with existing tags
+ RepoID: ctx.Repo.Repository.ID,
+ })
+ if err != nil {
+ ctx.ServerError("GetReleaseCountByRepoID", err)
+ return nil
+ }
+ ctx.Data["NumReleases"], err = db.Count[repo_model.Release](ctx, repo_model.FindReleasesOptions{
+ // only show draft releases for users who can write, read-only users shouldn't see draft releases.
+ IncludeDrafts: ctx.Repo.CanWrite(unit_model.TypeReleases),
+ RepoID: ctx.Repo.Repository.ID,
+ })
+ if err != nil {
+ ctx.ServerError("GetReleaseCountByRepoID", err)
+ return nil
+ }
+ ctx.Data["NumPackages"], err = packages_model.CountRepositoryPackages(ctx, ctx.Repo.Repository.ID)
+ if err != nil {
+ ctx.ServerError("GetPackageCountByRepoID", err)
+ return nil
+ }
+
+ ctx.Data["Title"] = owner.Name + "/" + repo.Name
+ ctx.Data["Repository"] = repo
+ ctx.Data["RepositoryAPActorID"] = repo.APActorID()
+ ctx.Data["Owner"] = ctx.Repo.Repository.Owner
+ ctx.Data["IsRepositoryOwner"] = ctx.Repo.IsOwner()
+ ctx.Data["IsRepositoryAdmin"] = ctx.Repo.IsAdmin()
+ ctx.Data["RepoOwnerIsOrganization"] = repo.Owner.IsOrganization()
+ ctx.Data["CanWriteCode"] = ctx.Repo.CanWrite(unit_model.TypeCode)
+ ctx.Data["CanWriteIssues"] = ctx.Repo.CanWrite(unit_model.TypeIssues)
+ ctx.Data["CanWritePulls"] = ctx.Repo.CanWrite(unit_model.TypePullRequests)
+ ctx.Data["CanWriteActions"] = ctx.Repo.CanWrite(unit_model.TypeActions)
+
+ canSignedUserFork, err := repo_module.CanUserForkRepo(ctx, ctx.Doer, ctx.Repo.Repository)
+ if err != nil {
+ ctx.ServerError("CanUserForkRepo", err)
+ return nil
+ }
+ ctx.Data["CanSignedUserFork"] = canSignedUserFork
+
+ userAndOrgForks, err := repo_model.GetForksByUserAndOrgs(ctx, ctx.Doer, ctx.Repo.Repository)
+ if err != nil {
+ ctx.ServerError("GetForksByUserAndOrgs", err)
+ return nil
+ }
+ ctx.Data["UserAndOrgForks"] = userAndOrgForks
+
+ // canSignedUserFork is true if the current user doesn't have a fork of this repo yet or
+ // if he owns an org that doesn't have a fork of this repo yet
+ // If multiple forks are available or if the user can fork to another account, but there is already a fork: open selection dialog
+ ctx.Data["ShowForkModal"] = len(userAndOrgForks) > 1 || (canSignedUserFork && len(userAndOrgForks) > 0)
+
+ ctx.Data["RepoCloneLink"] = repo.CloneLink()
+
+ cloneButtonShowHTTPS := !setting.Repository.DisableHTTPGit
+ cloneButtonShowSSH := !setting.SSH.Disabled && (ctx.IsSigned || setting.SSH.ExposeAnonymous)
+ if !cloneButtonShowHTTPS && !cloneButtonShowSSH {
+ // We have to show at least one link, so we just show the HTTPS
+ cloneButtonShowHTTPS = true
+ }
+ ctx.Data["CloneButtonShowHTTPS"] = cloneButtonShowHTTPS
+ ctx.Data["CloneButtonShowSSH"] = cloneButtonShowSSH
+ ctx.Data["CloneButtonOriginLink"] = ctx.Data["RepoCloneLink"] // it may be rewritten to the WikiCloneLink by the router middleware
+
+ ctx.Data["RepoSearchEnabled"] = setting.Indexer.RepoIndexerEnabled
+ if setting.Indexer.RepoIndexerEnabled {
+ ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx)
+ }
+
+ if ctx.IsSigned {
+ ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, repo.ID)
+ ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID)
+ }
+
+ if repo.IsFork {
+ RetrieveBaseRepo(ctx, repo)
+ if ctx.Written() {
+ return nil
+ }
+ }
+
+ if repo.IsGenerated() {
+ RetrieveTemplateRepo(ctx, repo)
+ if ctx.Written() {
+ return nil
+ }
+ }
+
+ isHomeOrSettings := ctx.Link == ctx.Repo.RepoLink || ctx.Link == ctx.Repo.RepoLink+"/settings" || strings.HasPrefix(ctx.Link, ctx.Repo.RepoLink+"/settings/")
+
+ // Disable everything when the repo is being created
+ if ctx.Repo.Repository.IsBeingCreated() || ctx.Repo.Repository.IsBroken() {
+ ctx.Data["BranchName"] = ctx.Repo.Repository.DefaultBranch
+ if !isHomeOrSettings {
+ ctx.Redirect(ctx.Repo.RepoLink)
+ }
+ return nil
+ }
+
+ gitRepo, err := gitrepo.OpenRepository(ctx, repo)
+ if err != nil {
+ if strings.Contains(err.Error(), "repository does not exist") || strings.Contains(err.Error(), "no such file or directory") {
+ log.Error("Repository %-v has a broken repository on the file system: %s Error: %v", ctx.Repo.Repository, ctx.Repo.Repository.RepoPath(), err)
+ ctx.Repo.Repository.MarkAsBrokenEmpty()
+ ctx.Data["BranchName"] = ctx.Repo.Repository.DefaultBranch
+ // Only allow access to base of repo or settings
+ if !isHomeOrSettings {
+ ctx.Redirect(ctx.Repo.RepoLink)
+ }
+ return nil
+ }
+ ctx.ServerError("RepoAssignment Invalid repo "+repo.FullName(), err)
+ return nil
+ }
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ }
+ ctx.Repo.GitRepo = gitRepo
+
+ // We opened it, we should close it
+ cancel := func() {
+ // If it's been set to nil then assume someone else has closed it.
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ }
+ }
+
+ // Stop at this point when the repo is empty.
+ if ctx.Repo.Repository.IsEmpty {
+ ctx.Data["BranchName"] = ctx.Repo.Repository.DefaultBranch
+ return cancel
+ }
+
+ branchOpts := git_model.FindBranchOptions{
+ RepoID: ctx.Repo.Repository.ID,
+ IsDeletedBranch: optional.Some(false),
+ ListOptions: db.ListOptionsAll,
+ }
+ branchesTotal, err := db.Count[git_model.Branch](ctx, branchOpts)
+ if err != nil {
+ ctx.ServerError("CountBranches", err)
+ return cancel
+ }
+
+ // non-empty repo should have at least 1 branch, so this repository's branches haven't been synced yet
+ if branchesTotal == 0 { // fallback to do a sync immediately
+ branchesTotal, err = repo_module.SyncRepoBranches(ctx, ctx.Repo.Repository.ID, 0)
+ if err != nil {
+ ctx.ServerError("SyncRepoBranches", err)
+ return cancel
+ }
+ }
+
+ ctx.Data["BranchesCount"] = branchesTotal
+
+ // If no branch is set in the request URL, try to guess a default one.
+ if len(ctx.Repo.BranchName) == 0 {
+ if len(ctx.Repo.Repository.DefaultBranch) > 0 && gitRepo.IsBranchExist(ctx.Repo.Repository.DefaultBranch) {
+ ctx.Repo.BranchName = ctx.Repo.Repository.DefaultBranch
+ } else {
+ ctx.Repo.BranchName, _ = gitrepo.GetDefaultBranch(ctx, ctx.Repo.Repository)
+ if ctx.Repo.BranchName == "" {
+ // If it still can't get a default branch, fall back to default branch from setting.
+ // Something might be wrong. Either site admin should fix the repo sync or Gitea should fix a potential bug.
+ ctx.Repo.BranchName = setting.Repository.DefaultBranch
+ }
+ }
+ ctx.Repo.RefName = ctx.Repo.BranchName
+ }
+ ctx.Data["BranchName"] = ctx.Repo.BranchName
+
+ // People who have push access or have forked repository can propose a new pull request.
+ canPush := ctx.Repo.CanWrite(unit_model.TypeCode) ||
+ (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID))
+ canCompare := false
+
+ // Pull request is allowed if this is a fork repository
+ // and base repository accepts pull requests.
+ if repo.BaseRepo != nil && repo.BaseRepo.AllowsPulls(ctx) {
+ canCompare = true
+ ctx.Data["BaseRepo"] = repo.BaseRepo
+ ctx.Repo.PullRequest.BaseRepo = repo.BaseRepo
+ ctx.Repo.PullRequest.Allowed = canPush
+ ctx.Repo.PullRequest.HeadInfoSubURL = url.PathEscape(ctx.Repo.Owner.Name) + ":" + util.PathEscapeSegments(ctx.Repo.BranchName)
+ } else if repo.AllowsPulls(ctx) {
+ // Or, this is repository accepts pull requests between branches.
+ canCompare = true
+ ctx.Data["BaseRepo"] = repo
+ ctx.Repo.PullRequest.BaseRepo = repo
+ ctx.Repo.PullRequest.Allowed = canPush
+ ctx.Repo.PullRequest.SameRepo = true
+ ctx.Repo.PullRequest.HeadInfoSubURL = util.PathEscapeSegments(ctx.Repo.BranchName)
+ }
+ ctx.Data["CanCompareOrPull"] = canCompare
+ ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequest
+
+ if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
+ repoTransfer, err := models.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.ServerError("GetPendingRepositoryTransfer", err)
+ return cancel
+ }
+
+ if err := repoTransfer.LoadAttributes(ctx); err != nil {
+ ctx.ServerError("LoadRecipient", err)
+ return cancel
+ }
+
+ ctx.Data["RepoTransfer"] = repoTransfer
+ if ctx.Doer != nil {
+ ctx.Data["CanUserAcceptTransfer"] = repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer)
+ }
+ }
+
+ if ctx.FormString("go-get") == "1" {
+ ctx.Data["GoGetImport"] = ComposeGoGetImport(owner.Name, repo.Name)
+ fullURLPrefix := repo.HTMLURL() + "/src/branch/" + util.PathEscapeSegments(ctx.Repo.BranchName)
+ ctx.Data["GoDocDirectory"] = fullURLPrefix + "{/dir}"
+ ctx.Data["GoDocFile"] = fullURLPrefix + "{/dir}/{file}#L{line}"
+ }
+ return cancel
+}
+
+// RepoRefType type of repo reference
+type RepoRefType int
+
+const (
+ // RepoRefLegacy unknown type, make educated guess and redirect.
+ // for backward compatibility with previous URL scheme
+ RepoRefLegacy RepoRefType = iota
+ // RepoRefAny is for usage where educated guess is needed
+ // but redirect can not be made
+ RepoRefAny
+ // RepoRefBranch branch
+ RepoRefBranch
+ // RepoRefTag tag
+ RepoRefTag
+ // RepoRefCommit commit
+ RepoRefCommit
+ // RepoRefBlob blob
+ RepoRefBlob
+)
+
+const headRefName = "HEAD"
+
+// RepoRef handles repository reference names when the ref name is not
+// explicitly given
+func RepoRef() func(*Context) context.CancelFunc {
+ // since no ref name is explicitly specified, ok to just use branch
+ return RepoRefByType(RepoRefBranch)
+}
+
+// RefTypeIncludesBranches returns true if ref type can be a branch
+func (rt RepoRefType) RefTypeIncludesBranches() bool {
+ if rt == RepoRefLegacy || rt == RepoRefAny || rt == RepoRefBranch {
+ return true
+ }
+ return false
+}
+
+// RefTypeIncludesTags returns true if ref type can be a tag
+func (rt RepoRefType) RefTypeIncludesTags() bool {
+ if rt == RepoRefLegacy || rt == RepoRefAny || rt == RepoRefTag {
+ return true
+ }
+ return false
+}
+
+func getRefNameFromPath(repo *Repository, path string, isExist func(string) bool) string {
+ refName := ""
+ parts := strings.Split(path, "/")
+ for i, part := range parts {
+ refName = strings.TrimPrefix(refName+"/"+part, "/")
+ if isExist(refName) {
+ repo.TreePath = strings.Join(parts[i+1:], "/")
+ return refName
+ }
+ }
+ return ""
+}
+
+func getRefName(ctx *Base, repo *Repository, pathType RepoRefType) string {
+ path := ctx.Params("*")
+ switch pathType {
+ case RepoRefLegacy, RepoRefAny:
+ if refName := getRefName(ctx, repo, RepoRefBranch); len(refName) > 0 {
+ return refName
+ }
+ if refName := getRefName(ctx, repo, RepoRefTag); len(refName) > 0 {
+ return refName
+ }
+ // For legacy and API support only full commit sha
+ parts := strings.Split(path, "/")
+
+ if len(parts) > 0 && len(parts[0]) == git.ObjectFormatFromName(repo.Repository.ObjectFormatName).FullLength() {
+ repo.TreePath = strings.Join(parts[1:], "/")
+ return parts[0]
+ }
+ if refName := getRefName(ctx, repo, RepoRefBlob); len(refName) > 0 {
+ return refName
+ }
+ repo.TreePath = path
+ return repo.Repository.DefaultBranch
+ case RepoRefBranch:
+ ref := getRefNameFromPath(repo, path, repo.GitRepo.IsBranchExist)
+ if len(ref) == 0 {
+ // check if ref is HEAD
+ parts := strings.Split(path, "/")
+ if parts[0] == headRefName {
+ repo.TreePath = strings.Join(parts[1:], "/")
+ return repo.Repository.DefaultBranch
+ }
+
+ // maybe it's a renamed branch
+ return getRefNameFromPath(repo, path, func(s string) bool {
+ b, exist, err := git_model.FindRenamedBranch(ctx, repo.Repository.ID, s)
+ if err != nil {
+ log.Error("FindRenamedBranch: %v", err)
+ return false
+ }
+
+ if !exist {
+ return false
+ }
+
+ ctx.Data["IsRenamedBranch"] = true
+ ctx.Data["RenamedBranchName"] = b.To
+
+ return true
+ })
+ }
+
+ return ref
+ case RepoRefTag:
+ return getRefNameFromPath(repo, path, repo.GitRepo.IsTagExist)
+ case RepoRefCommit:
+ parts := strings.Split(path, "/")
+
+ if len(parts) > 0 && len(parts[0]) >= 4 && len(parts[0]) <= repo.GetObjectFormat().FullLength() {
+ repo.TreePath = strings.Join(parts[1:], "/")
+ return parts[0]
+ }
+
+ if len(parts) > 0 && parts[0] == headRefName {
+ // HEAD ref points to last default branch commit
+ commit, err := repo.GitRepo.GetBranchCommit(repo.Repository.DefaultBranch)
+ if err != nil {
+ return ""
+ }
+ repo.TreePath = strings.Join(parts[1:], "/")
+ return commit.ID.String()
+ }
+ case RepoRefBlob:
+ _, err := repo.GitRepo.GetBlob(path)
+ if err != nil {
+ return ""
+ }
+ return path
+ default:
+ log.Error("Unrecognized path type: %v", path)
+ }
+ return ""
+}
+
+// RepoRefByType handles repository reference name for a specific type
+// of repository reference
+func RepoRefByType(refType RepoRefType, ignoreNotExistErr ...bool) func(*Context) context.CancelFunc {
+ return func(ctx *Context) (cancel context.CancelFunc) {
+ // Empty repository does not have reference information.
+ if ctx.Repo.Repository.IsEmpty {
+ // assume the user is viewing the (non-existent) default branch
+ ctx.Repo.IsViewBranch = true
+ ctx.Repo.BranchName = ctx.Repo.Repository.DefaultBranch
+ ctx.Data["TreePath"] = ""
+ return nil
+ }
+
+ var (
+ refName string
+ err error
+ )
+
+ if ctx.Repo.GitRepo == nil {
+ ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository)
+ if err != nil {
+ ctx.ServerError(fmt.Sprintf("Open Repository %v failed", ctx.Repo.Repository.FullName()), err)
+ return nil
+ }
+ // We opened it, we should close it
+ cancel = func() {
+ // If it's been set to nil then assume someone else has closed it.
+ if ctx.Repo.GitRepo != nil {
+ ctx.Repo.GitRepo.Close()
+ }
+ }
+ }
+
+ // Get default branch.
+ if len(ctx.Params("*")) == 0 {
+ refName = ctx.Repo.Repository.DefaultBranch
+ if !ctx.Repo.GitRepo.IsBranchExist(refName) {
+ brs, _, err := ctx.Repo.GitRepo.GetBranches(0, 1)
+ if err == nil && len(brs) != 0 {
+ refName = brs[0].Name
+ } else if len(brs) == 0 {
+ log.Error("No branches in non-empty repository %s", ctx.Repo.GitRepo.Path)
+ ctx.Repo.Repository.MarkAsBrokenEmpty()
+ } else {
+ log.Error("GetBranches error: %v", err)
+ ctx.Repo.Repository.MarkAsBrokenEmpty()
+ }
+ }
+ ctx.Repo.RefName = refName
+ ctx.Repo.BranchName = refName
+ ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName)
+ if err == nil {
+ ctx.Repo.CommitID = ctx.Repo.Commit.ID.String()
+ } else if strings.Contains(err.Error(), "fatal: not a git repository") || strings.Contains(err.Error(), "object does not exist") {
+ // if the repository is broken, we can continue to the handler code, to show "Settings -> Delete Repository" for end users
+ log.Error("GetBranchCommit: %v", err)
+ ctx.Repo.Repository.MarkAsBrokenEmpty()
+ } else {
+ ctx.ServerError("GetBranchCommit", err)
+ return cancel
+ }
+ ctx.Repo.IsViewBranch = true
+ } else {
+ refName = getRefName(ctx.Base, ctx.Repo, refType)
+ ctx.Repo.RefName = refName
+ isRenamedBranch, has := ctx.Data["IsRenamedBranch"].(bool)
+ if isRenamedBranch && has {
+ renamedBranchName := ctx.Data["RenamedBranchName"].(string)
+ ctx.Flash.Info(ctx.Tr("repo.branch.renamed", refName, renamedBranchName))
+ link := setting.AppSubURL + strings.Replace(ctx.Req.URL.EscapedPath(), util.PathEscapeSegments(refName), util.PathEscapeSegments(renamedBranchName), 1)
+ ctx.Redirect(link)
+ return cancel
+ }
+
+ if refType.RefTypeIncludesBranches() && ctx.Repo.GitRepo.IsBranchExist(refName) {
+ ctx.Repo.IsViewBranch = true
+ ctx.Repo.BranchName = refName
+
+ ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetBranchCommit(refName)
+ if err != nil {
+ ctx.ServerError("GetBranchCommit", err)
+ return cancel
+ }
+ ctx.Repo.CommitID = ctx.Repo.Commit.ID.String()
+ } else if refType.RefTypeIncludesTags() && ctx.Repo.GitRepo.IsTagExist(refName) {
+ ctx.Repo.IsViewTag = true
+ ctx.Repo.TagName = refName
+
+ ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetTagCommit(refName)
+ if err != nil {
+ if git.IsErrNotExist(err) {
+ ctx.NotFound("GetTagCommit", err)
+ return cancel
+ }
+ ctx.ServerError("GetTagCommit", err)
+ return cancel
+ }
+ ctx.Repo.CommitID = ctx.Repo.Commit.ID.String()
+ } else if len(refName) >= 4 && len(refName) <= ctx.Repo.GetObjectFormat().FullLength() {
+ ctx.Repo.IsViewCommit = true
+ ctx.Repo.CommitID = refName
+
+ ctx.Repo.Commit, err = ctx.Repo.GitRepo.GetCommit(refName)
+ if err != nil {
+ ctx.NotFound("GetCommit", err)
+ return cancel
+ }
+ // If short commit ID add canonical link header
+ if len(refName) < ctx.Repo.GetObjectFormat().FullLength() {
+ ctx.RespHeader().Set("Link", fmt.Sprintf("<%s>; rel=\"canonical\"",
+ util.URLJoin(setting.AppURL, strings.Replace(ctx.Req.URL.RequestURI(), util.PathEscapeSegments(refName), url.PathEscape(ctx.Repo.Commit.ID.String()), 1))))
+ }
+ } else {
+ if len(ignoreNotExistErr) > 0 && ignoreNotExistErr[0] {
+ return cancel
+ }
+ ctx.NotFound("RepoRef invalid repo", fmt.Errorf("branch or tag not exist: %s", refName))
+ return cancel
+ }
+
+ if refType == RepoRefLegacy {
+ // redirect from old URL scheme to new URL scheme
+ prefix := strings.TrimPrefix(setting.AppSubURL+strings.ToLower(strings.TrimSuffix(ctx.Req.URL.Path, ctx.Params("*"))), strings.ToLower(ctx.Repo.RepoLink))
+
+ ctx.Redirect(path.Join(
+ ctx.Repo.RepoLink,
+ util.PathEscapeSegments(prefix),
+ ctx.Repo.BranchNameSubURL(),
+ util.PathEscapeSegments(ctx.Repo.TreePath)))
+ return cancel
+ }
+ }
+
+ ctx.Data["BranchName"] = ctx.Repo.BranchName
+ ctx.Data["RefName"] = ctx.Repo.RefName
+ ctx.Data["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL()
+ ctx.Data["TagName"] = ctx.Repo.TagName
+ ctx.Data["CommitID"] = ctx.Repo.CommitID
+ ctx.Data["TreePath"] = ctx.Repo.TreePath
+ ctx.Data["IsViewBranch"] = ctx.Repo.IsViewBranch
+ ctx.Data["IsViewTag"] = ctx.Repo.IsViewTag
+ ctx.Data["IsViewCommit"] = ctx.Repo.IsViewCommit
+ ctx.Data["CanCreateBranch"] = ctx.Repo.CanCreateBranch()
+
+ ctx.Repo.CommitsCount, err = ctx.Repo.GetCommitsCount()
+ if err != nil {
+ ctx.ServerError("GetCommitsCount", err)
+ return cancel
+ }
+ ctx.Data["CommitsCount"] = ctx.Repo.CommitsCount
+ ctx.Repo.GitRepo.LastCommitCache = git.NewLastCommitCache(ctx.Repo.CommitsCount, ctx.Repo.Repository.FullName(), ctx.Repo.GitRepo, cache.GetCache())
+
+ return cancel
+ }
+}
+
+// GitHookService checks if repository Git hooks service has been enabled.
+func GitHookService() func(ctx *Context) {
+ return func(ctx *Context) {
+ if !ctx.Doer.CanEditGitHook() {
+ ctx.NotFound("GitHookService", nil)
+ return
+ }
+ }
+}
+
+// UnitTypes returns a middleware to set unit types to context variables.
+func UnitTypes() func(ctx *Context) {
+ return func(ctx *Context) {
+ ctx.Data["UnitTypeCode"] = unit_model.TypeCode
+ ctx.Data["UnitTypeIssues"] = unit_model.TypeIssues
+ ctx.Data["UnitTypePullRequests"] = unit_model.TypePullRequests
+ ctx.Data["UnitTypeReleases"] = unit_model.TypeReleases
+ ctx.Data["UnitTypeWiki"] = unit_model.TypeWiki
+ ctx.Data["UnitTypeExternalWiki"] = unit_model.TypeExternalWiki
+ ctx.Data["UnitTypeExternalTracker"] = unit_model.TypeExternalTracker
+ ctx.Data["UnitTypeProjects"] = unit_model.TypeProjects
+ ctx.Data["UnitTypePackages"] = unit_model.TypePackages
+ ctx.Data["UnitTypeActions"] = unit_model.TypeActions
+ }
+}
diff --git a/services/context/repository.go b/services/context/repository.go
new file mode 100644
index 0000000..422ac3f
--- /dev/null
+++ b/services/context/repository.go
@@ -0,0 +1,25 @@
+// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package context
+
+import (
+ "net/http"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+)
+
+// RepositoryIDAssignmentAPI returns a middleware to handle context-repo assignment for api routes
+func RepositoryIDAssignmentAPI() func(ctx *APIContext) {
+ return func(ctx *APIContext) {
+ repositoryID := ctx.ParamsInt64(":repository-id")
+
+ var err error
+ repository := new(Repository)
+ repository.Repository, err = repo_model.GetRepositoryByID(ctx, repositoryID)
+ if err != nil {
+ ctx.Error(http.StatusNotFound, "GetRepositoryByID", err)
+ }
+ ctx.Repo = repository
+ }
+}
diff --git a/services/context/response.go b/services/context/response.go
new file mode 100644
index 0000000..2f271f2
--- /dev/null
+++ b/services/context/response.go
@@ -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),
+ }
+}
diff --git a/services/context/upload/upload.go b/services/context/upload/upload.go
new file mode 100644
index 0000000..77a7eb9
--- /dev/null
+++ b/services/context/upload/upload.go
@@ -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
+ }
+}
diff --git a/services/context/upload/upload_test.go b/services/context/upload/upload_test.go
new file mode 100644
index 0000000..f2c3242
--- /dev/null
+++ b/services/context/upload/upload_test.go
@@ -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))
+ }
+}
diff --git a/services/context/user.go b/services/context/user.go
new file mode 100644
index 0000000..4c9cd29
--- /dev/null
+++ b/services/context/user.go
@@ -0,0 +1,84 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package context
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+// UserAssignmentWeb returns a middleware to handle context-user assignment for web routes
+func UserAssignmentWeb() 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)
+ }
+ }
+ ctx.ContextUser = userAssignment(ctx.Base, ctx.Doer, errorFn)
+ ctx.Data["ContextUser"] = ctx.ContextUser
+ }
+}
+
+// UserIDAssignmentAPI returns a middleware to handle context-user assignment for api routes
+func UserIDAssignmentAPI() func(ctx *APIContext) {
+ return func(ctx *APIContext) {
+ userID := ctx.ParamsInt64(":user-id")
+
+ if ctx.IsSigned && ctx.Doer.ID == userID {
+ ctx.ContextUser = ctx.Doer
+ } else {
+ var err error
+ ctx.ContextUser, err = user_model.GetUserByID(ctx, userID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ ctx.Error(http.StatusNotFound, "GetUserByID", err)
+ } else {
+ ctx.Error(http.StatusInternalServerError, "GetUserByID", err)
+ }
+ }
+ }
+ }
+}
+
+// UserAssignmentAPI returns a middleware to handle context-user assignment for api routes
+func UserAssignmentAPI() func(ctx *APIContext) {
+ return func(ctx *APIContext) {
+ ctx.ContextUser = userAssignment(ctx.Base, ctx.Doer, ctx.Error)
+ }
+}
+
+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) {
+ contextUser = doer
+ } else {
+ var err error
+ contextUser, err = user_model.GetUserByName(ctx, username)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ if redirectUserID, err := user_model.LookupUserRedirect(ctx, username); err == nil {
+ RedirectToUser(ctx, username, redirectUserID)
+ } else if user_model.IsErrUserRedirectNotExist(err) {
+ errCb(http.StatusNotFound, "GetUserByName", err)
+ } else {
+ errCb(http.StatusInternalServerError, "LookupUserRedirect", err)
+ }
+ } else {
+ errCb(http.StatusInternalServerError, "GetUserByName", err)
+ }
+ }
+ }
+ return contextUser
+}
diff --git a/services/context/utils.go b/services/context/utils.go
new file mode 100644
index 0000000..293750f
--- /dev/null
+++ b/services/context/utils.go
@@ -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
+}
diff --git a/services/context/xsrf.go b/services/context/xsrf.go
new file mode 100644
index 0000000..15e36d1
--- /dev/null
+++ b/services/context/xsrf.go
@@ -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
+}
diff --git a/services/context/xsrf_test.go b/services/context/xsrf_test.go
new file mode 100644
index 0000000..21cda5d
--- /dev/null
+++ b/services/context/xsrf_test.go
@@ -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))
+ }
+ })
+}
diff --git a/services/contexttest/context_tests.go b/services/contexttest/context_tests.go
new file mode 100644
index 0000000..7c829f3
--- /dev/null
+++ b/services/contexttest/context_tests.go
@@ -0,0 +1,208 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// Package contexttest provides utilities for testing Web/API contexts with models.
+package contexttest
+
+import (
+ gocontext "context"
+ "io"
+ "maps"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ org_model "code.gitea.io/gitea/models/organization"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/modules/web/middleware"
+ "code.gitea.io/gitea/services/context"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func mockRequest(t *testing.T, reqPath string) *http.Request {
+ method, path, found := strings.Cut(reqPath, " ")
+ if !found {
+ method = "GET"
+ path = reqPath
+ }
+ requestURL, err := url.Parse(path)
+ require.NoError(t, err)
+ req := &http.Request{Method: method, URL: requestURL, Form: maps.Clone(requestURL.Query()), Header: http.Header{}}
+ req = req.WithContext(middleware.WithContextData(req.Context()))
+ return req
+}
+
+type MockContextOption struct {
+ Render context.Render
+}
+
+// MockContext mock context for unit tests
+func MockContext(t *testing.T, reqPath string, opts ...MockContextOption) (*context.Context, *httptest.ResponseRecorder) {
+ var opt MockContextOption
+ if len(opts) > 0 {
+ opt = opts[0]
+ }
+ if opt.Render == nil {
+ opt.Render = &MockRender{}
+ }
+ resp := httptest.NewRecorder()
+ req := mockRequest(t, reqPath)
+ base, baseCleanUp := context.NewBaseContext(resp, req)
+ _ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later
+ base.Data = middleware.GetContextData(req.Context())
+ base.Locale = &translation.MockLocale{}
+
+ ctx := context.NewWebContext(base, opt.Render, nil)
+ ctx.PageData = map[string]any{}
+ ctx.Data["PageStartTime"] = time.Now()
+ chiCtx := chi.NewRouteContext()
+ ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx)
+ return ctx, resp
+}
+
+// MockAPIContext mock context for unit tests
+func MockAPIContext(t *testing.T, reqPath string) (*context.APIContext, *httptest.ResponseRecorder) {
+ resp := httptest.NewRecorder()
+ req := mockRequest(t, reqPath)
+ base, baseCleanUp := context.NewBaseContext(resp, req)
+ base.Data = middleware.GetContextData(req.Context())
+ base.Locale = &translation.MockLocale{}
+ ctx := &context.APIContext{Base: base}
+ _ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later
+
+ chiCtx := chi.NewRouteContext()
+ ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx)
+ return ctx, resp
+}
+
+func MockPrivateContext(t *testing.T, reqPath string) (*context.PrivateContext, *httptest.ResponseRecorder) {
+ resp := httptest.NewRecorder()
+ req := mockRequest(t, reqPath)
+ base, baseCleanUp := context.NewBaseContext(resp, req)
+ base.Data = middleware.GetContextData(req.Context())
+ base.Locale = &translation.MockLocale{}
+ ctx := &context.PrivateContext{Base: base}
+ _ = baseCleanUp // during test, it doesn't need to do clean up. TODO: this can be improved later
+ chiCtx := chi.NewRouteContext()
+ ctx.Base.AppendContextValue(chi.RouteCtxKey, chiCtx)
+ return ctx, resp
+}
+
+// LoadRepo load a repo into a test context.
+func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) {
+ var doer *user_model.User
+ repo := &context.Repository{}
+ switch ctx := ctx.(type) {
+ case *context.Context:
+ ctx.Repo = repo
+ doer = ctx.Doer
+ case *context.APIContext:
+ ctx.Repo = repo
+ doer = ctx.Doer
+ default:
+ assert.FailNow(t, "context is not *context.Context or *context.APIContext")
+ }
+
+ repo.Repository = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
+ var err error
+ repo.Owner, err = user_model.GetUserByID(ctx, repo.Repository.OwnerID)
+ require.NoError(t, err)
+ repo.RepoLink = repo.Repository.Link()
+ repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo.Repository, doer)
+ require.NoError(t, err)
+}
+
+// LoadRepoCommit loads a repo's commit into a test context.
+func LoadRepoCommit(t *testing.T, ctx gocontext.Context) {
+ var repo *context.Repository
+ switch ctx := ctx.(type) {
+ case *context.Context:
+ repo = ctx.Repo
+ case *context.APIContext:
+ repo = ctx.Repo
+ default:
+ assert.FailNow(t, "context is not *context.Context or *context.APIContext")
+ }
+
+ if repo.GitRepo == nil {
+ assert.FailNow(t, "must call LoadGitRepo")
+ }
+
+ branch, err := repo.GitRepo.GetHEADBranch()
+ require.NoError(t, err)
+ assert.NotNil(t, branch)
+ if branch != nil {
+ repo.Commit, err = repo.GitRepo.GetBranchCommit(branch.Name)
+ require.NoError(t, err)
+ }
+}
+
+// LoadUser load a user into a test context
+func LoadUser(t *testing.T, ctx gocontext.Context, userID int64) {
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+ switch ctx := ctx.(type) {
+ case *context.Context:
+ ctx.Doer = doer
+ case *context.APIContext:
+ ctx.Doer = doer
+ default:
+ assert.FailNow(t, "context is not *context.Context or *context.APIContext")
+ }
+}
+
+// LoadOrganization load an org into a test context
+func LoadOrganization(t *testing.T, ctx gocontext.Context, orgID int64) {
+ org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: orgID})
+ switch ctx := ctx.(type) {
+ case *context.Context:
+ ctx.Org.Organization = org
+ case *context.APIContext:
+ ctx.Org.Organization = org
+ default:
+ assert.FailNow(t, "context is not *context.Context or *context.APIContext")
+ }
+}
+
+// LoadGitRepo load a git repo into a test context. Requires that ctx.Repo has
+// already been populated.
+func LoadGitRepo(t *testing.T, ctx gocontext.Context) {
+ var repo *context.Repository
+ switch ctx := ctx.(type) {
+ case *context.Context:
+ repo = ctx.Repo
+ case *context.APIContext:
+ repo = ctx.Repo
+ default:
+ assert.FailNow(t, "context is not *context.Context or *context.APIContext")
+ }
+
+ require.NoError(t, repo.Repository.LoadOwner(ctx))
+ var err error
+ repo.GitRepo, err = gitrepo.OpenRepository(ctx, repo.Repository)
+ require.NoError(t, err)
+}
+
+type MockRender struct{}
+
+func (tr *MockRender) TemplateLookup(tmpl string, _ gocontext.Context) (templates.TemplateExecutor, error) {
+ return nil, nil
+}
+
+func (tr *MockRender) HTML(w io.Writer, status int, _ string, _ any, _ gocontext.Context) error {
+ if resp, ok := w.(http.ResponseWriter); ok {
+ resp.WriteHeader(status)
+ }
+ return nil
+}