From f4652d9f3e03ff58b13629d4d5ffc87f6ea9cc0c Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Wed, 2 Jun 2021 21:27:12 +0800 Subject: [PATCH 01/26] Split UI API routes from API v1 routes --- modules/context/api_ui.go | 72 ++++ routers/api/ui/api.go | 373 ++++++++++++++++++ routers/api/v1/api.go | 3 - routers/web/web.go | 1 + .../repo/issue/view_content/sidebar.tmpl | 2 +- web_src/js/components/DashboardRepoList.js | 4 +- web_src/js/features/notification.js | 2 +- web_src/js/features/stopwatch.js | 2 +- 8 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 modules/context/api_ui.go create mode 100644 routers/api/ui/api.go diff --git a/modules/context/api_ui.go b/modules/context/api_ui.go new file mode 100644 index 0000000000000..ddd7579c0e479 --- /dev/null +++ b/modules/context/api_ui.go @@ -0,0 +1,72 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package context + +import ( + "html" + "net/http" + "strings" + + "code.gitea.io/gitea/modules/auth/sso" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web/middleware" + + "gitea.com/go-chi/session" +) + +// APIUIContexter returns apicontext as middleware +func APIUIContexter() func(http.Handler) http.Handler { + var csrfOpts = getCsrfOpts() + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + var locale = middleware.Locale(w, req) + var ctx = APIContext{ + Context: &Context{ + Resp: NewResponse(w), + Data: map[string]interface{}{}, + Locale: locale, + Session: session.GetSession(req), + Repo: &Repository{ + PullRequest: &PullRequest{}, + }, + Org: &Organization{}, + }, + Org: &APIOrganization{}, + } + + ctx.Req = WithAPIContext(WithContext(req, ctx.Context), &ctx) + ctx.csrf = Csrfer(csrfOpts, ctx.Context) + + // 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 + } + } + + // Get user from session if logged in. + ctx.User, ctx.IsBasicAuth = sso.SignedInUser(ctx.Req, ctx.Resp, &ctx, ctx.Session) + if ctx.User != nil { + ctx.IsSigned = true + ctx.Data["IsSigned"] = ctx.IsSigned + ctx.Data["SignedUser"] = ctx.User + ctx.Data["SignedUserID"] = ctx.User.ID + ctx.Data["SignedUserName"] = ctx.User.Name + ctx.Data["IsAdmin"] = ctx.User.IsAdmin + } else { + ctx.Data["SignedUserID"] = int64(0) + ctx.Data["SignedUserName"] = "" + } + + ctx.Resp.Header().Set(`X-Frame-Options`, `SAMEORIGIN`) + + ctx.Data["CsrfToken"] = html.EscapeString(ctx.csrf.GetToken()) + + next.ServeHTTP(ctx.Resp, ctx.Req) + }) + } +} diff --git a/routers/api/ui/api.go b/routers/api/ui/api.go new file mode 100644 index 0000000000000..b56f7afccf979 --- /dev/null +++ b/routers/api/ui/api.go @@ -0,0 +1,373 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package ui + +import ( + "net/http" + "reflect" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/notify" + "code.gitea.io/gitea/routers/api/v1/org" + "code.gitea.io/gitea/routers/api/v1/repo" + _ "code.gitea.io/gitea/routers/api/v1/swagger" // for swagger generation + "code.gitea.io/gitea/routers/api/v1/user" + + "gitea.com/go-chi/binding" + "gitea.com/go-chi/session" + "github.com/go-chi/cors" +) + +func repoAssignment() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + userName := ctx.Params("username") + repoName := ctx.Params("reponame") + + var ( + owner *models.User + err error + ) + + // Check if the user is the same as the repository owner. + if ctx.IsSigned && ctx.User.LowerName == strings.ToLower(userName) { + owner = ctx.User + } else { + owner, err = models.GetUserByName(userName) + if err != nil { + if models.IsErrUserNotExist(err) { + if redirectUserID, err := models.LookupUserRedirect(userName); err == nil { + context.RedirectToUser(ctx.Context, userName, redirectUserID) + } else if models.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetUserByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + } + ctx.Repo.Owner = owner + + // Get repository. + repo, err := models.GetRepositoryByName(owner.ID, repoName) + if err != nil { + if models.IsErrRepoNotExist(err) { + redirectRepoID, err := models.LookupRepoRedirect(owner.ID, repoName) + if err == nil { + context.RedirectToRepo(ctx.Context, redirectRepoID) + } else if models.IsErrRepoRedirectNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "LookupRepoRedirect", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByName", err) + } + return + } + + repo.Owner = owner + ctx.Repo.Repository = repo + + ctx.Repo.Permission, err = models.GetUserRepoPermission(repo, ctx.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } + + if !ctx.Repo.HasAccess() { + ctx.NotFound() + return + } + } +} + +// Contexter middleware already checks token for user sign in process. +func reqToken() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if true == ctx.Data["IsApiToken"] { + return + } + if ctx.Context.IsBasicAuth { + ctx.CheckForOTP() + return + } + if ctx.IsSigned { + ctx.RequireCSRF() + return + } + ctx.Error(http.StatusUnauthorized, "reqToken", "token is required") + } +} + +func reqExploreSignIn() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if setting.Service.Explore.RequireSigninView && !ctx.IsSigned { + ctx.Error(http.StatusUnauthorized, "reqExploreSignIn", "you must be signed in to search for users") + } + } +} + +// reqOrgOwnership user should be an organization owner, or a site admin +func reqOrgOwnership() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.Context.IsUserSiteAdmin() { + return + } + + var orgID int64 + if ctx.Org.Organization != nil { + orgID = ctx.Org.Organization.ID + } else if ctx.Org.Team != nil { + orgID = ctx.Org.Team.OrgID + } else { + ctx.Error(http.StatusInternalServerError, "", "reqOrgOwnership: unprepared context") + return + } + + isOwner, err := models.IsOrganizationOwner(orgID, ctx.User.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrganizationOwner", err) + return + } else if !isOwner { + if ctx.Org.Organization != nil { + ctx.Error(http.StatusForbidden, "", "Must be an organization owner") + } else { + ctx.NotFound() + } + return + } + } +} + +// reqOrgMembership user should be an organization member, or a site admin +func reqOrgMembership() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.Context.IsUserSiteAdmin() { + return + } + + var orgID int64 + if ctx.Org.Organization != nil { + orgID = ctx.Org.Organization.ID + } else if ctx.Org.Team != nil { + orgID = ctx.Org.Team.OrgID + } else { + ctx.Error(http.StatusInternalServerError, "", "reqOrgMembership: unprepared context") + return + } + + if isMember, err := models.IsOrganizationMember(orgID, ctx.User.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrganizationMember", err) + return + } else if !isMember { + if ctx.Org.Organization != nil { + ctx.Error(http.StatusForbidden, "", "Must be an organization member") + } else { + ctx.NotFound() + } + return + } + } +} + +func orgAssignment(args ...bool) func(ctx *context.APIContext) { + var ( + assignOrg bool + assignTeam bool + ) + if len(args) > 0 { + assignOrg = args[0] + } + if len(args) > 1 { + assignTeam = args[1] + } + return func(ctx *context.APIContext) { + ctx.Org = new(context.APIOrganization) + + var err error + if assignOrg { + ctx.Org.Organization, err = models.GetOrgByName(ctx.Params(":org")) + if err != nil { + if models.IsErrOrgNotExist(err) { + redirectUserID, err := models.LookupUserRedirect(ctx.Params(":org")) + if err == nil { + context.RedirectToUser(ctx.Context, ctx.Params(":org"), redirectUserID) + } else if models.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetOrgByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetOrgByName", err) + } + return + } + } + + if assignTeam { + ctx.Org.Team, err = models.GetTeamByID(ctx.ParamsInt64(":teamid")) + if err != nil { + if models.IsErrTeamNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetTeamById", err) + } + return + } + } + } +} + +func mustEnableIssuesOrPulls(ctx *context.APIContext) { + if !ctx.Repo.CanRead(models.UnitTypeIssues) && + !(ctx.Repo.Repository.CanEnablePulls() && ctx.Repo.CanRead(models.UnitTypePullRequests)) { + if ctx.Repo.Repository.CanEnablePulls() && log.IsTrace() { + if ctx.IsSigned { + log.Trace("Permission Denied: User %-v cannot read %-v and %-v in Repo %-v\n"+ + "User in Repo has Permissions: %-+v", + ctx.User, + models.UnitTypeIssues, + models.UnitTypePullRequests, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Anonymous user cannot read %-v and %-v in Repo %-v\n"+ + "Anonymous user in Repo has Permissions: %-+v", + models.UnitTypeIssues, + models.UnitTypePullRequests, + ctx.Repo.Repository, + ctx.Repo.Permission) + } + } + ctx.NotFound() + return + } +} + +func mustNotBeArchived(ctx *context.APIContext) { + if ctx.Repo.Repository.IsArchived { + ctx.NotFound() + return + } +} + +// bind binding an obj to a func(ctx *context.APIContext) +func bind(obj interface{}) http.HandlerFunc { + var tp = reflect.TypeOf(obj) + for tp.Kind() == reflect.Ptr { + tp = tp.Elem() + } + return web.Wrap(func(ctx *context.APIContext) { + var theObj = reflect.New(tp).Interface() // create a new form obj for every request but not use obj directly + errs := binding.Bind(ctx.Req, theObj) + if len(errs) > 0 { + ctx.Error(http.StatusUnprocessableEntity, "validationError", errs[0].Error()) + return + } + web.SetForm(ctx, theObj) + }) +} + +// Routes registers all v1 APIs routes to web application. +func Routes() *web.Route { + var m = web.NewRoute() + + m.Use(session.Sessioner(session.Options{ + Provider: setting.SessionConfig.Provider, + ProviderConfig: setting.SessionConfig.ProviderConfig, + CookieName: setting.SessionConfig.CookieName, + CookiePath: setting.SessionConfig.CookiePath, + Gclifetime: setting.SessionConfig.Gclifetime, + Maxlifetime: setting.SessionConfig.Maxlifetime, + Secure: setting.SessionConfig.Secure, + SameSite: setting.SessionConfig.SameSite, + Domain: setting.SessionConfig.Domain, + })) + m.Use(securityHeaders()) + if setting.CORSConfig.Enabled { + m.Use(cors.Handler(cors.Options{ + //Scheme: setting.CORSConfig.Scheme, // FIXME: the cors middleware needs scheme option + AllowedOrigins: setting.CORSConfig.AllowDomain, + //setting.CORSConfig.AllowSubdomain // FIXME: the cors middleware needs allowSubdomain option + AllowedMethods: setting.CORSConfig.Methods, + AllowCredentials: setting.CORSConfig.AllowCredentials, + MaxAge: int(setting.CORSConfig.MaxAge.Seconds()), + })) + } + m.Use(context.APIContexter()) + + m.Use(context.ToggleAPI(&context.ToggleOptions{ + SignInRequired: setting.Service.RequireSignInView, + })) + + m.Group("", func() { + // Notifications + m.Group("/notifications", func() { + m.Get("/new", notify.NewAvailable) + }, reqToken()) + + // Users + m.Group("/users", func() { + m.Get("/search", reqExploreSignIn(), user.Search) + }) + + m.Group("/user", func() { + m.Get("/stopwatches", repo.GetStopwatches) + }, reqToken()) + + // Repositories + m.Group("/repos", func() { + m.Get("/search", repo.Search) + + m.Get("/issues/search", repo.SearchIssues) + + m.Group("/{username}/{reponame}", func() { + m.Group("/issues", func() { + m.Combo("").Get(repo.ListIssues). + Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue) + m.Group("/{index}", func() { + m.Combo("").Get(repo.GetIssue). + Patch(reqToken(), bind(api.EditIssueOption{}), repo.EditIssue) + }) + }, mustEnableIssuesOrPulls) + }, repoAssignment()) + }) + + // Organizations + m.Group("/orgs/{org}", func() { + m.Group("/teams", func() { + m.Combo("", reqToken()).Get(org.ListTeams). + Post(reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam) + m.Get("/search", org.SearchTeam) + }, reqOrgMembership()) + }, orgAssignment(true)) + + m.Group("/topics", func() { + m.Get("/search", repo.TopicSearch) + }) + }) + + return m +} + +func securityHeaders() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + // CORB: https://www.chromium.org/Home/chromium-security/corb-for-developers + // http://stackoverflow.com/a/3146618/244009 + resp.Header().Set("x-content-type-options", "nosniff") + next.ServeHTTP(resp, req) + }) + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 4b30164026745..8998804966ef5 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -563,9 +563,6 @@ func bind(obj interface{}) http.HandlerFunc { // Routes registers all v1 APIs routes to web application. func Routes(sessioner func(http.Handler) http.Handler) *web.Route { m := web.NewRoute() - - m.Use(sessioner) - m.Use(securityHeaders()) if setting.CORSConfig.Enabled { m.Use(cors.Handler(cors.Options{ diff --git a/routers/web/web.go b/routers/web/web.go index b40a43058d423..7b0e76b38fb55 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/routing" + "code.gitea.io/gitea/routers/admin" "code.gitea.io/gitea/routers/api/v1/misc" "code.gitea.io/gitea/routers/web/admin" "code.gitea.io/gitea/routers/web/auth" diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index aed155fdbfb43..e0880f1952f66 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -429,7 +429,7 @@ {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}