diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /services/context | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
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 +} |