diff options
Diffstat (limited to 'routers/web')
164 files changed, 5669 insertions, 5105 deletions
diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go index 3902a1efb1..0cd13acf60 100644 --- a/routers/web/admin/admin.go +++ b/routers/web/admin/admin.go @@ -37,6 +37,7 @@ const ( tplSelfCheck templates.TplName = "admin/self_check" tplCron templates.TplName = "admin/cron" tplQueue templates.TplName = "admin/queue" + tplPerfTrace templates.TplName = "admin/perftrace" tplStacktrace templates.TplName = "admin/stacktrace" tplQueueManage templates.TplName = "admin/queue_manage" tplStats templates.TplName = "admin/stats" diff --git a/routers/web/admin/admin_test.go b/routers/web/admin/admin_test.go index 6c38f0b509..a568c7c5c8 100644 --- a/routers/web/admin/admin_test.go +++ b/routers/web/admin/admin_test.go @@ -69,7 +69,7 @@ func TestShadowPassword(t *testing.T) { } for _, k := range kases { - assert.EqualValues(t, k.Result, shadowPassword(k.Provider, k.CfgItem)) + assert.Equal(t, k.Result, shadowPassword(k.Provider, k.CfgItem)) } } @@ -79,7 +79,7 @@ func TestSelfCheckPost(t *testing.T) { ctx, resp := contexttest.MockContext(t, "GET http://host/sub/admin/self_check?location_origin=http://frontend") SelfCheckPost(ctx) - assert.EqualValues(t, http.StatusOK, resp.Code) + assert.Equal(t, http.StatusOK, resp.Code) data := struct { Problems []string `json:"problems"` diff --git a/routers/web/admin/applications.go b/routers/web/admin/applications.go index aec6349f21..79c3a08808 100644 --- a/routers/web/admin/applications.go +++ b/routers/web/admin/applications.go @@ -4,7 +4,6 @@ package admin import ( - "fmt" "net/http" "code.gitea.io/gitea/models/auth" @@ -23,8 +22,8 @@ var ( func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers { return &user_setting.OAuth2CommonHandlers{ OwnerID: 0, - BasePathList: fmt.Sprintf("%s/-/admin/applications", setting.AppSubURL), - BasePathEditPrefix: fmt.Sprintf("%s/-/admin/applications/oauth2", setting.AppSubURL), + BasePathList: setting.AppSubURL + "/-/admin/applications", + BasePathEditPrefix: setting.AppSubURL + "/-/admin/applications/oauth2", TplAppEdit: tplSettingsOauth2ApplicationEdit, } } diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index 249347e835..0f6f31b884 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -28,8 +28,6 @@ import ( "code.gitea.io/gitea/services/auth/source/sspi" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" - - "xorm.io/xorm/convert" ) const ( @@ -149,7 +147,6 @@ func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source { RestrictedFilter: form.RestrictedFilter, AllowDeactivateAll: form.AllowDeactivateAll, Enabled: true, - SkipLocalTwoFA: form.SkipLocalTwoFA, } } @@ -163,7 +160,6 @@ func parseSMTPConfig(form forms.AuthenticationForm) *smtp.Source { SkipVerify: form.SkipVerify, HeloHostname: form.HeloHostname, DisableHelo: form.DisableHelo, - SkipLocalTwoFA: form.SkipLocalTwoFA, } } @@ -181,7 +177,7 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { customURLMapping = nil } var scopes []string - for _, s := range strings.Split(form.Oauth2Scopes, ",") { + for s := range strings.SplitSeq(form.Oauth2Scopes, ",") { s = strings.TrimSpace(s) if s != "" { scopes = append(scopes, s) @@ -198,7 +194,6 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { Scopes: scopes, RequiredClaimName: form.Oauth2RequiredClaimName, RequiredClaimValue: form.Oauth2RequiredClaimValue, - SkipLocalTwoFA: form.SkipLocalTwoFA, GroupClaimName: form.Oauth2GroupClaimName, RestrictedGroup: form.Oauth2RestrictedGroup, AdminGroup: form.Oauth2AdminGroup, @@ -252,7 +247,7 @@ func NewAuthSourcePost(ctx *context.Context) { ctx.Data["SSPIDefaultLanguage"] = "" hasTLS := false - var config convert.Conversion + var config auth.Config switch auth.Type(form.Type) { case auth.LDAP, auth.DLDAP: config = parseLDAPConfig(form) @@ -262,9 +257,8 @@ func NewAuthSourcePost(ctx *context.Context) { hasTLS = true case auth.PAM: config = &pam_service.Source{ - ServiceName: form.PAMServiceName, - EmailDomain: form.PAMEmailDomain, - SkipLocalTwoFA: form.SkipLocalTwoFA, + ServiceName: form.PAMServiceName, + EmailDomain: form.PAMEmailDomain, } case auth.OAuth2: config = parseOAuth2Config(form) @@ -291,7 +285,7 @@ func NewAuthSourcePost(ctx *context.Context) { return } default: - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } ctx.Data["HasTLS"] = hasTLS @@ -302,11 +296,12 @@ func NewAuthSourcePost(ctx *context.Context) { } if err := auth.CreateSource(ctx, &auth.Source{ - Type: auth.Type(form.Type), - Name: form.Name, - IsActive: form.IsActive, - IsSyncEnabled: form.IsSyncEnabled, - Cfg: config, + Type: auth.Type(form.Type), + Name: form.Name, + IsActive: form.IsActive, + IsSyncEnabled: form.IsSyncEnabled, + TwoFactorPolicy: form.TwoFactorPolicy, + Cfg: config, }); err != nil { if auth.IsErrSourceAlreadyExist(err) { ctx.Data["Err_Name"] = true @@ -384,7 +379,7 @@ func EditAuthSourcePost(ctx *context.Context) { return } - var config convert.Conversion + var config auth.Config switch auth.Type(form.Type) { case auth.LDAP, auth.DLDAP: config = parseLDAPConfig(form) @@ -413,7 +408,7 @@ func EditAuthSourcePost(ctx *context.Context) { return } default: - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } @@ -421,6 +416,7 @@ func EditAuthSourcePost(ctx *context.Context) { source.IsActive = form.IsActive source.IsSyncEnabled = form.IsSyncEnabled source.Cfg = config + source.TwoFactorPolicy = form.TwoFactorPolicy if err := auth.UpdateSource(ctx, source); err != nil { if auth.IsErrSourceAlreadyExist(err) { ctx.Data["Err_Name"] = true diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 520f14e89f..0e5b23db6d 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -61,7 +61,7 @@ func TestCache(ctx *context.Context) { func shadowPasswordKV(cfgItem, splitter string) string { fields := strings.Split(cfgItem, splitter) - for i := 0; i < len(fields); i++ { + for i := range fields { if strings.HasPrefix(fields[i], "password=") { fields[i] = "password=******" break @@ -200,7 +200,7 @@ func ChangeConfig(ctx *context.Context) { value := ctx.FormString("value") cfg := setting.Config() - marshalBool := func(v string) (string, error) { //nolint:unparam + marshalBool := func(v string) (string, error) { //nolint:unparam // error is always nil if b, _ := strconv.ParseBool(v); b { return "true", nil } diff --git a/routers/web/admin/diagnosis.go b/routers/web/admin/diagnosis.go index 020554a35a..5395529d66 100644 --- a/routers/web/admin/diagnosis.go +++ b/routers/web/admin/diagnosis.go @@ -10,17 +10,13 @@ import ( "time" "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/tailmsg" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" ) func MonitorDiagnosis(ctx *context.Context) { - seconds := ctx.FormInt64("seconds") - if seconds <= 5 { - seconds = 5 - } - if seconds > 300 { - seconds = 300 - } + seconds := min(max(ctx.FormInt64("seconds"), 1), 300) httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{ ContentType: "application/zip", @@ -65,4 +61,16 @@ func MonitorDiagnosis(ctx *context.Context) { return } _ = pprof.Lookup("heap").WriteTo(f, 0) + + f, err = zipWriter.CreateHeader(&zip.FileHeader{Name: "perftrace.txt", Method: zip.Deflate, Modified: time.Now()}) + if err != nil { + ctx.ServerError("Failed to create zip file", err) + return + } + for _, record := range tailmsg.GetManager().GetTraceRecorder().GetRecords() { + _, _ = f.Write(util.UnsafeStringToBytes(record.Time.Format(time.RFC3339))) + _, _ = f.Write([]byte(" ")) + _, _ = f.Write(util.UnsafeStringToBytes((record.Content))) + _, _ = f.Write([]byte("\n\n")) + } } diff --git a/routers/web/admin/emails.go b/routers/web/admin/emails.go index 23ddfa583a..51b3d584f4 100644 --- a/routers/web/admin/emails.go +++ b/routers/web/admin/emails.go @@ -116,7 +116,7 @@ func ActivateEmail(ctx *context.Context) { activate, oka := truefalse[ctx.FormString("activate")] if uid == 0 || len(email) == 0 || !okp || !oka { - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } diff --git a/routers/web/admin/notice.go b/routers/web/admin/notice.go index 21a8ab0d17..e9d6abbe92 100644 --- a/routers/web/admin/notice.go +++ b/routers/web/admin/notice.go @@ -26,10 +26,7 @@ func Notices(ctx *context.Context) { ctx.Data["PageIsAdminNotices"] = true total := system_model.CountNotices(ctx) - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) notices, err := system_model.Notices(ctx, page, setting.UI.Admin.NoticePagingNum) if err != nil { diff --git a/routers/web/admin/orgs.go b/routers/web/admin/orgs.go index 35e61efa17..e34f203aaf 100644 --- a/routers/web/admin/orgs.go +++ b/routers/web/admin/orgs.go @@ -27,7 +27,7 @@ func Organizations(ctx *context.Context) { ctx.SetFormString("sort", UserSearchDefaultAdminSort) } - explore.RenderUserSearch(ctx, &user_model.SearchUserOptions{ + explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeOrganization, IncludeReserved: true, // administrator needs to list all accounts include reserved diff --git a/routers/web/admin/packages.go b/routers/web/admin/packages.go index 5122342259..1904bfee11 100644 --- a/routers/web/admin/packages.go +++ b/routers/web/admin/packages.go @@ -24,10 +24,7 @@ const ( // Packages shows all packages func Packages(ctx *context.Context) { - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) query := ctx.FormTrim("q") packageType := ctx.FormTrim("type") sort := ctx.FormTrim("sort") diff --git a/routers/web/admin/perftrace.go b/routers/web/admin/perftrace.go new file mode 100644 index 0000000000..51ee57da10 --- /dev/null +++ b/routers/web/admin/perftrace.go @@ -0,0 +1,18 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + "code.gitea.io/gitea/modules/tailmsg" + "code.gitea.io/gitea/services/context" +) + +func PerfTrace(ctx *context.Context) { + monitorTraceCommon(ctx) + ctx.Data["PageIsAdminMonitorPerfTrace"] = true + ctx.Data["PerfTraceRecords"] = tailmsg.GetManager().GetTraceRecorder().GetRecords() + ctx.HTML(http.StatusOK, tplPerfTrace) +} diff --git a/routers/web/admin/stacktrace.go b/routers/web/admin/stacktrace.go index ff751be621..2b8c2fb4af 100644 --- a/routers/web/admin/stacktrace.go +++ b/routers/web/admin/stacktrace.go @@ -12,10 +12,17 @@ import ( "code.gitea.io/gitea/services/context" ) +func monitorTraceCommon(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor") + ctx.Data["PageIsAdminMonitorTrace"] = true + // Hide the performance trace tab in production, because it shows a lot of SQLs and is not that useful for end users. + // To avoid confusing end users, do not let them know this tab. End users should "download diagnosis report" instead. + ctx.Data["ShowAdminPerformanceTraceTab"] = !setting.IsProd +} + // Stacktrace show admin monitor goroutines page func Stacktrace(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("admin.monitor") - ctx.Data["PageIsAdminMonitorStacktrace"] = true + monitorTraceCommon(ctx) ctx.Data["GoroutineCount"] = runtime.NumGoroutine() diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index f6a3af1c86..27577cd35b 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -21,8 +21,8 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/explore" user_setting "code.gitea.io/gitea/routers/web/user/setting" @@ -65,18 +65,18 @@ func Users(ctx *context.Context) { "SortType": sortType, } - explore.RenderUserSearch(ctx, &user_model.SearchUserOptions{ + explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeIndividual, ListOptions: db.ListOptions{ PageSize: setting.UI.Admin.UserPagingNum, }, SearchByEmail: true, - IsActive: util.OptionalBoolParse(statusFilterMap["is_active"]), - IsAdmin: util.OptionalBoolParse(statusFilterMap["is_admin"]), - IsRestricted: util.OptionalBoolParse(statusFilterMap["is_restricted"]), - IsTwoFactorEnabled: util.OptionalBoolParse(statusFilterMap["is_2fa_enabled"]), - IsProhibitLogin: util.OptionalBoolParse(statusFilterMap["is_prohibit_login"]), + IsActive: optional.ParseBool(statusFilterMap["is_active"]), + IsAdmin: optional.ParseBool(statusFilterMap["is_admin"]), + IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]), + IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]), + IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]), IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones }, tplUsers) } @@ -269,7 +269,7 @@ func ViewUser(ctx *context.Context) { return } - repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ ListOptions: db.ListOptionsAll, OwnerID: u.ID, OrderBy: db.SearchOrderByAlphabetically, @@ -293,9 +293,9 @@ func ViewUser(ctx *context.Context) { ctx.Data["EmailsTotal"] = len(emails) orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{ - ListOptions: db.ListOptionsAll, - UserID: u.ID, - IncludePrivate: true, + ListOptions: db.ListOptionsAll, + UserID: u.ID, + IncludeVisibility: structs.VisibleTypePrivate, }) if err != nil { ctx.ServerError("FindOrgs", err) @@ -432,7 +432,7 @@ func EditUserPost(ctx *context.Context) { Website: optional.Some(form.Website), Location: optional.Some(form.Location), IsActive: optional.Some(form.Active), - IsAdmin: optional.Some(form.Admin), + IsAdmin: user_service.UpdateOptionFieldFromValue(form.Admin), AllowGitHook: optional.Some(form.AllowGitHook), AllowImportLocal: optional.Some(form.AllowImportLocal), MaxRepoCreation: optional.Some(form.MaxRepoCreation), diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go index fe363fe90a..d15d33dfd4 100644 --- a/routers/web/auth/2fa.go +++ b/routers/web/auth/2fa.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web" @@ -87,6 +88,7 @@ func TwoFactorPost(ctx *context.Context) { return } + _ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true) handleSignIn(ctx, u, remember) return } diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 3fe1d5970e..94f75f69ff 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -76,6 +76,10 @@ func autoSignIn(ctx *context.Context) (bool, error) { } return false, nil } + userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID) + if err != nil { + return false, fmt.Errorf("HasTwoFactorOrWebAuthn: %w", err) + } isSucceed = true @@ -87,9 +91,9 @@ func autoSignIn(ctx *context.Context) (bool, error) { ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) if err := updateSession(ctx, nil, map[string]any{ - // Set session IDs - "uid": u.ID, - "uname": u.Name, + session.KeyUID: u.ID, + session.KeyUname: u.Name, + session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth, }); err != nil { return false, fmt.Errorf("unable to updateSession: %w", err) } @@ -169,6 +173,7 @@ func prepareSignInPageData(ctx *context.Context) { ctx.Data["PageIsLogin"] = true ctx.Data["EnableSSPI"] = auth.IsSSPIEnabled(ctx) ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm + ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin { context.SetCaptchaData(ctx) @@ -191,7 +196,7 @@ func SignIn(ctx *context.Context) { // SignInPost response for sign in request func SignInPost(ctx *context.Context) { if !setting.Service.EnablePasswordSignInForm { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } @@ -238,9 +243,8 @@ func SignInPost(ctx *context.Context) { } // Now handle 2FA: - // First of all if the source can skip local two fa we're done - if skipper, ok := source.Cfg.(auth_service.LocalTwoFASkipper); ok && skipper.IsSkipLocalTwoFA() { + if source.TwoFactorShouldSkip() { handleSignIn(ctx, u, form.Remember) return } @@ -261,7 +265,7 @@ func SignInPost(ctx *context.Context) { } if !hasTOTPtwofa && !hasWebAuthnTwofa { - // No two factor auth configured we can sign in the user + // No two-factor auth configured we can sign in the user handleSignIn(ctx, u, form.Remember) return } @@ -310,8 +314,14 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) } + userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID) + if err != nil { + ctx.ServerError("HasTwoFactorOrWebAuthn", err) + return setting.AppSubURL + "/" + } + if err := updateSession(ctx, []string{ - // Delete the openid, 2fa and linkaccount data + // Delete the openid, 2fa and link_account data "openid_verified_uri", "openid_signin_remember", "openid_determined_email", @@ -320,8 +330,9 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe "twofaRemember", "linkAccount", }, map[string]any{ - "uid": u.ID, - "uname": u.Name, + session.KeyUID: u.ID, + session.KeyUname: u.Name, + session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth, }); err != nil { ctx.ServerError("RegenerateSession", err) return setting.AppSubURL + "/" @@ -410,9 +421,11 @@ func SignOut(ctx *context.Context) { // SignUp render the register page func SignUp(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("sign_up") - ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/sign_up" + hasUsers, _ := user_model.HasUsers(ctx) + ctx.Data["IsFirstTimeRegistration"] = !hasUsers.HasAnyUser + oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true)) if err != nil { ctx.ServerError("UserSignUp", err) @@ -455,7 +468,7 @@ func SignUpPost(ctx *context.Context) { // Permission denied if DisableRegistration or AllowOnlyExternalRegistration options are true if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } @@ -533,7 +546,8 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, } if err := user_model.CreateUser(ctx, u, meta, overwrites); err != nil { if allowLink && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) { - if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingAuto { + switch setting.OAuth2Client.AccountLinking { + case setting.OAuth2AccountLinkingAuto: var user *user_model.User user = &user_model.User{Name: u.Name} hasUser, err := user_model.GetUser(ctx, user) @@ -549,7 +563,7 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, // TODO: probably we should respect 'remember' user's choice... linkAccount(ctx, user, *gothUser, true) return false // user is already created here, all redirects are handled - } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin { + case setting.OAuth2AccountLinkingLogin: showLinkingLogin(ctx, *gothUser) return false // user will be created only after linking login } @@ -598,10 +612,16 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, // sends a confirmation email if required. func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User) (ok bool) { // Auto-set admin for the only user. - if user_model.CountUsers(ctx, nil) == 1 { + hasUsers, err := user_model.HasUsers(ctx) + if err != nil { + ctx.ServerError("HasUsers", err) + return false + } + if hasUsers.HasOnlyOneUser { + // the only user is the one just created, will set it as admin opts := &user_service.UpdateOptions{ IsActive: optional.Some(true), - IsAdmin: optional.Some(true), + IsAdmin: user_service.UpdateOptionFieldFromValue(true), SetLastLogin: true, } if err := user_service.UpdateUser(ctx, u, opts); err != nil { @@ -766,7 +786,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { } if err := user_model.UpdateUserCols(ctx, user, "is_active", "rands"); err != nil { if user_model.IsErrUserNotExist(err) { - ctx.NotFound("UpdateUserCols", err) + ctx.NotFound(err) } else { ctx.ServerError("UpdateUser", err) } diff --git a/routers/web/auth/auth_test.go b/routers/web/auth/auth_test.go index cbcb2a5222..e238125407 100644 --- a/routers/web/auth/auth_test.go +++ b/routers/web/auth/auth_test.go @@ -61,23 +61,35 @@ func TestUserLogin(t *testing.T) { assert.Equal(t, "/", test.RedirectURL(resp)) } -func TestSignUpOAuth2ButMissingFields(t *testing.T) { +func TestSignUpOAuth2Login(t *testing.T) { defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)() - defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) { - return goth.User{Provider: "dummy-auth-source", UserID: "dummy-user"}, nil - })() addOAuth2Source(t, "dummy-auth-source", oauth2.Source{}) - mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockStore("dummy-sid")} - ctx, resp := contexttest.MockContext(t, "/user/oauth2/dummy-auth-source/callback?code=dummy-code", mockOpt) - ctx.SetPathParam("provider", "dummy-auth-source") - SignInOAuthCallback(ctx) - assert.Equal(t, http.StatusSeeOther, resp.Code) - assert.Equal(t, "/user/link_account", test.RedirectURL(resp)) + t.Run("OAuth2MissingField", func(t *testing.T) { + defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{Provider: "dummy-auth-source", UserID: "dummy-user"}, nil + })() + mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockStore("dummy-sid")} + ctx, resp := contexttest.MockContext(t, "/user/oauth2/dummy-auth-source/callback?code=dummy-code", mockOpt) + ctx.SetPathParam("provider", "dummy-auth-source") + SignInOAuthCallback(ctx) + assert.Equal(t, http.StatusSeeOther, resp.Code) + assert.Equal(t, "/user/link_account", test.RedirectURL(resp)) + + // then the user will be redirected to the link account page, and see a message about the missing fields + ctx, _ = contexttest.MockContext(t, "/user/link_account", mockOpt) + LinkAccount(ctx) + assert.EqualValues(t, "auth.oauth_callback_unable_auto_reg:dummy-auth-source,email", ctx.Data["AutoRegistrationFailedPrompt"]) + }) - // then the user will be redirected to the link account page, and see a message about the missing fields - ctx, _ = contexttest.MockContext(t, "/user/link_account", mockOpt) - LinkAccount(ctx) - assert.EqualValues(t, "auth.oauth_callback_unable_auto_reg:dummy-auth-source,email", ctx.Data["AutoRegistrationFailedPrompt"]) + t.Run("OAuth2CallbackError", func(t *testing.T) { + mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockStore("dummy-sid")} + ctx, resp := contexttest.MockContext(t, "/user/oauth2/dummy-auth-source/callback", mockOpt) + ctx.SetPathParam("provider", "dummy-auth-source") + SignInOAuthCallback(ctx) + assert.Equal(t, http.StatusSeeOther, resp.Code) + assert.Equal(t, "/user/login", test.RedirectURL(resp)) + assert.Contains(t, ctx.Flash.ErrorMsg, "auth.oauth.signin.error.general") + }) } diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go index 147d8d3802..b3c61946b9 100644 --- a/routers/web/auth/linkaccount.go +++ b/routers/web/auth/linkaccount.go @@ -29,6 +29,7 @@ var tplLinkAccount templates.TplName = "user/auth/link_account" // LinkAccount shows the page where the user can decide to login or create a new account func LinkAccount(ctx *context.Context) { + // FIXME: these common template variables should be prepared in one common function, but not just copy-paste again and again. ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration ctx.Data["Title"] = ctx.Tr("link_account") ctx.Data["LinkAccountMode"] = true @@ -43,13 +44,20 @@ func LinkAccount(ctx *context.Context) { ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm ctx.Data["ShowRegistrationButton"] = false + ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth // use this to set the right link into the signIn and signUp templates in the link_account template ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" gothUser, ok := ctx.Session.Get("linkAccountGothUser").(goth.User) + + // If you'd like to quickly debug the "link account" page layout, just uncomment the blow line + // Don't worry, when the below line exists, the lint won't pass: ineffectual assignment to gothUser (ineffassign) + // gothUser, ok = goth.User{Email: "invalid-email", Name: "."}, true // intentionally use invalid data to avoid pass the registration check + if !ok { // no account in session, so just redirect to the login page, then the user could restart the process ctx.Redirect(setting.AppSubURL + "/user/login") @@ -135,7 +143,10 @@ func LinkAccountPostSignIn(ctx *context.Context) { ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm ctx.Data["ShowRegistrationButton"] = false + ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth // use this to set the right link into the signIn and signUp templates in the link_account template ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" @@ -223,7 +234,10 @@ func LinkAccountPostRegister(ctx *context.Context) { ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration + ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration + ctx.Data["EnablePasswordSignInForm"] = setting.Service.EnablePasswordSignInForm ctx.Data["ShowRegistrationButton"] = false + ctx.Data["EnablePasskeyAuth"] = setting.Service.EnablePasskeyAuth // use this to set the right link into the signIn and signUp templates in the link_account template ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" @@ -246,7 +260,7 @@ func LinkAccountPostRegister(ctx *context.Context) { } if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 7a9721cf56..a13b987aab 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web/middleware" @@ -115,7 +116,7 @@ func SignInOAuthCallback(ctx *context.Context) { case "temporarily_unavailable": ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable")) default: - ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error")) + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.general", callbackErr.Description)) } ctx.Redirect(setting.AppSubURL + "/user/login") return @@ -155,9 +156,10 @@ func SignInOAuthCallback(ctx *context.Context) { return } if uname == "" { - if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname { + switch setting.OAuth2Client.Username { + case setting.OAuth2UsernameNickname: missingFields = append(missingFields, "nickname") - } else if setting.OAuth2Client.Username == setting.OAuth2UsernamePreferredUsername { + case setting.OAuth2UsernamePreferredUsername: missingFields = append(missingFields, "preferred_username") } // else: "UserID" and "Email" have been handled above separately } @@ -191,8 +193,8 @@ func SignInOAuthCallback(ctx *context.Context) { source := authSource.Cfg.(*oauth2.Source) isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser) - u.IsAdmin = isAdmin.ValueOrDefault(false) - u.IsRestricted = isRestricted.ValueOrDefault(false) + u.IsAdmin = isAdmin.ValueOrDefault(user_service.UpdateOptionField[bool]{FieldValue: false}).FieldValue + u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted) if !createAndHandleCreatedUser(ctx, templates.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { // error already handled @@ -256,11 +258,11 @@ func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[ return claimValueToStringSet(groupClaims) } -func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) { +func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin optional.Option[user_service.UpdateOptionField[bool]], isRestricted optional.Option[bool]) { groups := getClaimedGroups(source, gothUser) if source.AdminGroup != "" { - isAdmin = optional.Some(groups.Contains(source.AdminGroup)) + isAdmin = user_service.UpdateOptionFieldFromSync(groups.Contains(source.AdminGroup)) } if source.RestrictedGroup != "" { isRestricted = optional.Some(groups.Contains(source.RestrictedGroup)) @@ -301,7 +303,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) needs2FA := false - if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA { + if !source.TwoFactorShouldSkip() { _, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { ctx.ServerError("UserSignIn", err) @@ -351,10 +353,16 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model ctx.ServerError("UpdateUser", err) return } + userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID) + if err != nil { + ctx.ServerError("UpdateUser", err) + return + } if err := updateSession(ctx, nil, map[string]any{ - "uid": u.ID, - "uname": u.Name, + session.KeyUID: u.ID, + session.KeyUname: u.Name, + session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth, }); err != nil { ctx.ServerError("updateSession", err) return @@ -431,8 +439,10 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ gothUser, err := oauth2Source.Callback(request, response) if err != nil { if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") { - log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) + log.Error("oauth2Source.Callback failed: %v", err) + } else { + err = errCallback{Code: "internal", Description: err.Error()} } return nil, goth.User{}, err } diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go index 6262ad8a6d..dc9f34fd44 100644 --- a/routers/web/auth/oauth2_provider.go +++ b/routers/web/auth/oauth2_provider.go @@ -4,17 +4,16 @@ package auth import ( - "errors" "fmt" "html" "html/template" "net/http" "net/url" - "strings" + "strconv" "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/auth/httpauth" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -98,7 +97,7 @@ func InfoOAuth(ctx *context.Context) { } response := &userInfoResponse{ - Sub: fmt.Sprint(ctx.Doer.ID), + Sub: strconv.FormatInt(ctx.Doer.ID, 10), Name: ctx.Doer.DisplayName(), PreferredUsername: ctx.Doer.Name, Email: ctx.Doer.Email, @@ -107,9 +106,8 @@ func InfoOAuth(ctx *context.Context) { var accessTokenScope auth.AccessTokenScope if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" { - auths := strings.Fields(auHead) - if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") { - accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, auths[1]) + if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil { + accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token) } } @@ -126,23 +124,17 @@ func InfoOAuth(ctx *context.Context) { ctx.JSON(http.StatusOK, response) } -func parseBasicAuth(ctx *context.Context) (username, password string, err error) { - authHeader := ctx.Req.Header.Get("Authorization") - if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { - return base.BasicAuthDecode(authData) - } - return "", "", errors.New("invalid basic authentication") -} - // IntrospectOAuth introspects an oauth token func IntrospectOAuth(ctx *context.Context) { clientIDValid := false - if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil { + authHeader := ctx.Req.Header.Get("Authorization") + if parsed, ok := httpauth.ParseAuthorizationHeader(authHeader); ok && parsed.BasicAuth != nil { + clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID) if err != nil && !auth.IsErrOauthClientIDInvalid(err) { // this is likely a database error; log it and respond without details log.Error("Error retrieving client_id: %v", err) - ctx.Error(http.StatusInternalServerError) + ctx.HTTPError(http.StatusInternalServerError) return } clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret)) @@ -171,7 +163,7 @@ func IntrospectOAuth(ctx *context.Context) { response.Scope = grant.Scope response.Issuer = setting.AppURL response.Audience = []string{app.ClientID} - response.Subject = fmt.Sprint(grant.UserID) + response.Subject = strconv.FormatInt(grant.UserID, 10) } if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil { response.Username = user.Name @@ -249,7 +241,7 @@ func AuthorizeOAuth(ctx *context.Context) { }, form.RedirectURI) return } - if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil { + if err := ctx.Session.Set("CodeChallenge", form.CodeChallenge); err != nil { handleAuthorizeError(ctx, AuthorizeError{ ErrorCode: ErrorCodeServerError, ErrorDescription: "cannot set code challenge", @@ -363,7 +355,7 @@ func GrantApplicationOAuth(ctx *context.Context) { form := web.GetForm(ctx).(*forms.GrantApplicationForm) if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State || ctx.Session.Get("redirect_uri") != form.RedirectURI { - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } @@ -440,7 +432,7 @@ func OIDCKeys(ctx *context.Context) { jwk, err := oauth2_provider.DefaultSigningKey.ToJWK() if err != nil { log.Error("Error converting signing key to JWK: %v", err) - ctx.Error(http.StatusInternalServerError) + ctx.HTTPError(http.StatusInternalServerError) return } @@ -464,16 +456,16 @@ func AccessTokenOAuth(ctx *context.Context) { form := *web.GetForm(ctx).(*forms.AccessTokenForm) // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header if form.ClientID == "" || form.ClientSecret == "" { - authHeader := ctx.Req.Header.Get("Authorization") - if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { - clientID, clientSecret, err := base.BasicAuthDecode(authData) - if err != nil { + if authHeader := ctx.Req.Header.Get("Authorization"); authHeader != "" { + parsed, ok := httpauth.ParseAuthorizationHeader(authHeader) + if !ok || parsed.BasicAuth == nil { handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ ErrorCode: oauth2_provider.AccessTokenErrorCodeInvalidRequest, ErrorDescription: "cannot parse basic auth header", }) return } + clientID, clientSecret := parsed.BasicAuth.Username, parsed.BasicAuth.Password // validate that any fields present in the form match the Basic auth header if form.ClientID != "" && form.ClientID != clientID { handleAccessTokenError(ctx, oauth2_provider.AccessTokenError{ diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go index 41d37ecb8b..2ef4a86022 100644 --- a/routers/web/auth/openid.go +++ b/routers/web/auth/openid.go @@ -4,7 +4,7 @@ package auth import ( - "fmt" + "errors" "net/http" "net/url" @@ -55,13 +55,13 @@ func allowedOpenIDURI(uri string) (err error) { } } // must match one of this or be refused - return fmt.Errorf("URI not allowed by whitelist") + return errors.New("URI not allowed by whitelist") } // A blacklist match expliclty forbids for _, pat := range setting.Service.OpenIDBlacklist { if pat.MatchString(uri) { - return fmt.Errorf("URI forbidden by blacklist") + return errors.New("URI forbidden by blacklist") } } @@ -99,7 +99,7 @@ func SignInOpenIDPost(ctx *context.Context) { url, err := openid.RedirectURL(id, redirectTo, setting.AppURL) if err != nil { log.Error("Error in OpenID redirect URL: %s, %v", redirectTo, err.Error()) - ctx.RenderWithErr(fmt.Sprintf("Unable to find OpenID provider in %s", redirectTo), tplSignInOpenID, &form) + ctx.RenderWithErr("Unable to find OpenID provider in "+redirectTo, tplSignInOpenID, &form) return } @@ -337,7 +337,7 @@ func RegisterOpenIDPost(ctx *context.Context) { ctx.Data["OpenID"] = oid if setting.Service.AllowOnlyInternalRegistration { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } @@ -349,10 +349,7 @@ func RegisterOpenIDPost(ctx *context.Context) { context.VerifyCaptcha(ctx, tplSignUpOID, form) } - length := setting.MinPasswordLength - if length < 256 { - length = 256 - } + length := max(setting.MinPasswordLength, 256) password, err := util.CryptoRandomString(int64(length)) if err != nil { ctx.RenderWithErr(err.Error(), tplSignUpOID, form) diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index 614e086f77..537ad4b994 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -5,7 +5,6 @@ package auth import ( "errors" - "fmt" "net/http" "code.gitea.io/gitea/models/auth" @@ -53,7 +52,7 @@ func ForgotPasswdPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("auth.forgot_password_title") if setting.MailService == nil { - ctx.NotFound("ForgotPasswdPost", nil) + ctx.NotFound(nil) return } ctx.Data["IsResetRequest"] = true @@ -108,21 +107,21 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto } if len(code) == 0 { - ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) + ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", setting.AppSubURL+"/user/forgot_password"), true) return nil, nil } // Fail early, don't frustrate the user u := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}, code) if u == nil { - ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) + ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", setting.AppSubURL+"/user/forgot_password"), true) return nil, nil } twofa, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil { if !auth.IsErrTwoFactorNotEnrolled(err) { - ctx.Error(http.StatusInternalServerError, "CommonResetPassword", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "CommonResetPassword", err.Error()) return nil, nil } } else { @@ -181,7 +180,7 @@ func ResetPasswdPost(ctx *context.Context) { passcode := ctx.FormString("passcode") ok, err := twofa.ValidateTOTP(passcode) if err != nil { - ctx.Error(http.StatusInternalServerError, "ValidateTOTP", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "ValidateTOTP", err.Error()) return } if !ok || twofa.LastUsedPasscode == passcode { diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go index 69031adeaa..78f6c3b58e 100644 --- a/routers/web/auth/webauthn.go +++ b/routers/web/auth/webauthn.go @@ -50,6 +50,11 @@ func WebAuthn(ctx *context.Context) { // WebAuthnPasskeyAssertion submits a WebAuthn challenge for the passkey login to the browser func WebAuthnPasskeyAssertion(ctx *context.Context) { + if !setting.Service.EnablePasskeyAuth { + ctx.HTTPError(http.StatusForbidden) + return + } + assertion, sessionData, err := wa.WebAuthn.BeginDiscoverableLogin() if err != nil { ctx.ServerError("webauthn.BeginDiscoverableLogin", err) @@ -66,6 +71,11 @@ func WebAuthnPasskeyAssertion(ctx *context.Context) { // WebAuthnPasskeyLogin handles the WebAuthn login process using a Passkey func WebAuthnPasskeyLogin(ctx *context.Context) { + if !setting.Service.EnablePasskeyAuth { + ctx.HTTPError(http.StatusForbidden) + return + } + sessionData, okData := ctx.Session.Get("webauthnPasskeyAssertion").(*webauthn.SessionData) if !okData || sessionData == nil { ctx.ServerError("ctx.Session.Get", errors.New("not in WebAuthn session")) diff --git a/routers/web/base.go b/routers/web/base.go index abe11593f7..e43f36a97b 100644 --- a/routers/web/base.go +++ b/routers/web/base.go @@ -19,13 +19,13 @@ import ( "code.gitea.io/gitea/modules/web/routing" ) -func storageHandler(storageSetting *setting.Storage, prefix string, objStore storage.ObjectStorage) http.HandlerFunc { +func avatarStorageHandler(storageSetting *setting.Storage, prefix string, objStore storage.ObjectStorage) http.HandlerFunc { prefix = strings.Trim(prefix, "/") - funcInfo := routing.GetFuncInfo(storageHandler, prefix) + funcInfo := routing.GetFuncInfo(avatarStorageHandler, prefix) if storageSetting.ServeDirect() { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.Method != "GET" && req.Method != "HEAD" { + return func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodHead { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } @@ -52,11 +52,11 @@ func storageHandler(storageSetting *setting.Storage, prefix string, objStore sto } http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect) - }) + } } - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.Method != "GET" && req.Method != "HEAD" { + return func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodHead { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } @@ -93,6 +93,8 @@ func storageHandler(storageSetting *setting.Storage, prefix string, objStore sto return } defer fr.Close() - httpcache.ServeContentWithCacheControl(w, req, path.Base(rPath), fi.ModTime(), fr) - }) + + httpcache.SetCacheControlInHeader(w.Header(), httpcache.CacheControlForPublicStatic()) + http.ServeContent(w, req, path.Base(rPath), fi.ModTime(), fr) + } } diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go index 1ea1398173..a22d376579 100644 --- a/routers/web/devtest/devtest.go +++ b/routers/web/devtest/devtest.go @@ -4,16 +4,22 @@ package devtest import ( + "fmt" + "html/template" "net/http" "path" + "strconv" "strings" "time" + "unicode" "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/badge" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" ) @@ -45,86 +51,135 @@ func FetchActionTest(ctx *context.Context) { ctx.JSONRedirect("") } -func prepareMockData(ctx *context.Context) { - if ctx.Req.URL.Path == "/devtest/gitea-ui" { - now := time.Now() - ctx.Data["TimeNow"] = now - ctx.Data["TimePast5s"] = now.Add(-5 * time.Second) - ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second) - ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute) - ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute) - ctx.Data["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second) - ctx.Data["TimeFuture1y"] = now.Add(1 * 366 * 86400 * time.Second) +func prepareMockDataGiteaUI(ctx *context.Context) { + now := time.Now() + ctx.Data["TimeNow"] = now + ctx.Data["TimePast5s"] = now.Add(-5 * time.Second) + ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second) + ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute) + ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute) + ctx.Data["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second) + ctx.Data["TimeFuture1y"] = now.Add(1 * 366 * 86400 * time.Second) +} + +func prepareMockDataBadgeCommitSign(ctx *context.Context) { + var commits []*asymkey.SignCommit + mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 1}}) + mockUser := mockUsers[0] + commits = append(commits, &asymkey.SignCommit{ + Verification: &asymkey.CommitVerification{}, + UserCommit: &user_model.UserCommit{ + Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + }, + }) + commits = append(commits, &asymkey.SignCommit{ + Verification: &asymkey.CommitVerification{ + Verified: true, + Reason: "name / key-id", + SigningUser: mockUser, + SigningKey: &asymkey.GPGKey{KeyID: "12345678"}, + TrustStatus: "trusted", + }, + UserCommit: &user_model.UserCommit{ + User: mockUser, + Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + }, + }) + commits = append(commits, &asymkey.SignCommit{ + Verification: &asymkey.CommitVerification{ + Verified: true, + Reason: "name / key-id", + SigningUser: mockUser, + SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"}, + TrustStatus: "untrusted", + }, + UserCommit: &user_model.UserCommit{ + User: mockUser, + Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + }, + }) + commits = append(commits, &asymkey.SignCommit{ + Verification: &asymkey.CommitVerification{ + Verified: true, + Reason: "name / key-id", + SigningUser: mockUser, + SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"}, + TrustStatus: "other(unmatch)", + }, + UserCommit: &user_model.UserCommit{ + User: mockUser, + Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + }, + }) + commits = append(commits, &asymkey.SignCommit{ + Verification: &asymkey.CommitVerification{ + Warning: true, + Reason: "gpg.error", + SigningEmail: "test@example.com", + }, + UserCommit: &user_model.UserCommit{ + User: mockUser, + Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, + }, + }) + + ctx.Data["MockCommits"] = commits +} + +func prepareMockDataBadgeActionsSvg(ctx *context.Context) { + fontFamilyNames := strings.Split(badge.DefaultFontFamily, ",") + selectedFontFamilyName := ctx.FormString("font", fontFamilyNames[0]) + selectedStyle := ctx.FormString("style", badge.DefaultStyle) + var badges []badge.Badge + badges = append(badges, badge.GenerateBadge("啊啊啊啊啊啊啊啊啊啊啊啊", "🌞🌞🌞🌞🌞", "green")) + for r := range rune(256) { + if unicode.IsPrint(r) { + s := strings.Repeat(string(r), 15) + badges = append(badges, badge.GenerateBadge(s, util.TruncateRunes(s, 7), "green")) + } } - if ctx.Req.URL.Path == "/devtest/commit-sign-badge" { - var commits []*asymkey.SignCommit - mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 1}}) - mockUser := mockUsers[0] - commits = append(commits, &asymkey.SignCommit{ - Verification: &asymkey.CommitVerification{}, - UserCommit: &user_model.UserCommit{ - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, - }, - }) - commits = append(commits, &asymkey.SignCommit{ - Verification: &asymkey.CommitVerification{ - Verified: true, - Reason: "name / key-id", - SigningUser: mockUser, - SigningKey: &asymkey.GPGKey{KeyID: "12345678"}, - TrustStatus: "trusted", - }, - UserCommit: &user_model.UserCommit{ - User: mockUser, - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, - }, - }) - commits = append(commits, &asymkey.SignCommit{ - Verification: &asymkey.CommitVerification{ - Verified: true, - Reason: "name / key-id", - SigningUser: mockUser, - SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"}, - TrustStatus: "untrusted", - }, - UserCommit: &user_model.UserCommit{ - User: mockUser, - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, - }, - }) - commits = append(commits, &asymkey.SignCommit{ - Verification: &asymkey.CommitVerification{ - Verified: true, - Reason: "name / key-id", - SigningUser: mockUser, - SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"}, - TrustStatus: "other(unmatch)", - }, - UserCommit: &user_model.UserCommit{ - User: mockUser, - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, - }, - }) - commits = append(commits, &asymkey.SignCommit{ - Verification: &asymkey.CommitVerification{ - Warning: true, - Reason: "gpg.error", - SigningEmail: "test@example.com", - }, - UserCommit: &user_model.UserCommit{ - User: mockUser, - Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, - }, - }) + var badgeSVGs []template.HTML + for i, b := range badges { + b.IDPrefix = "devtest-" + strconv.FormatInt(int64(i), 10) + "-" + b.FontFamily = selectedFontFamilyName + var h template.HTML + var err error + switch selectedStyle { + case badge.StyleFlat: + h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat", map[string]any{"Badge": b}) + case badge.StyleFlatSquare: + h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat-square", map[string]any{"Badge": b}) + default: + err = fmt.Errorf("unknown badge style: %s", selectedStyle) + } + if err != nil { + ctx.ServerError("RenderToHTML", err) + return + } + badgeSVGs = append(badgeSVGs, h) + } + ctx.Data["BadgeSVGs"] = badgeSVGs + ctx.Data["BadgeFontFamilyNames"] = fontFamilyNames + ctx.Data["SelectedFontFamilyName"] = selectedFontFamilyName + ctx.Data["BadgeStyles"] = badge.GlobalVars().AllStyles + ctx.Data["SelectedStyle"] = selectedStyle +} - ctx.Data["MockCommits"] = commits +func prepareMockData(ctx *context.Context) { + switch ctx.Req.URL.Path { + case "/devtest/gitea-ui": + prepareMockDataGiteaUI(ctx) + case "/devtest/badge-commit-sign": + prepareMockDataBadgeCommitSign(ctx) + case "/devtest/badge-actions-svg": + prepareMockDataBadgeActionsSvg(ctx) } } -func Tmpl(ctx *context.Context) { +func TmplCommon(ctx *context.Context) { prepareMockData(ctx) - if ctx.Req.Method == "POST" { + if ctx.Req.Method == http.MethodPost { _ = ctx.Req.ParseForm() ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"<br>"+ "Form: "+ctx.Req.Form.Encode()+"<br>"+ diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index f29b8e4046..bc741ecd11 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -4,9 +4,9 @@ package devtest import ( - "fmt" mathRand "math/rand/v2" "net/http" + "strconv" "strings" "time" @@ -38,8 +38,8 @@ func generateMockStepsLog(logCur actions.LogCursor) (stepsLog []*actions.ViewSte for i := 0; i < mockCount; i++ { logStr := mockedLogs[int(cur)%len(mockedLogs)] cur++ - logStr = strings.ReplaceAll(logStr, "{step}", fmt.Sprintf("%d", logCur.Step)) - logStr = strings.ReplaceAll(logStr, "{cursor}", fmt.Sprintf("%d", cur)) + logStr = strings.ReplaceAll(logStr, "{step}", strconv.Itoa(logCur.Step)) + logStr = strings.ReplaceAll(logStr, "{cursor}", strconv.FormatInt(cur, 10)) stepsLog = append(stepsLog, &actions.ViewStepLog{ Step: logCur.Step, Cursor: cur, @@ -52,13 +52,22 @@ func generateMockStepsLog(logCur actions.LogCursor) (stepsLog []*actions.ViewSte return stepsLog } +func MockActionsView(ctx *context.Context) { + ctx.Data["RunID"] = ctx.PathParam("run") + ctx.Data["JobID"] = ctx.PathParam("job") + ctx.HTML(http.StatusOK, "devtest/repo-action-view") +} + func MockActionsRunsJobs(ctx *context.Context) { - req := web.GetForm(ctx).(*actions.ViewRequest) + runID := ctx.PathParamInt64("run") + req := web.GetForm(ctx).(*actions.ViewRequest) resp := &actions.ViewResponse{} resp.State.Run.TitleHTML = `mock run title <a href="/">link</a>` resp.State.Run.Status = actions_model.StatusRunning.String() - resp.State.Run.CanCancel = true + resp.State.Run.CanCancel = runID == 10 + resp.State.Run.CanApprove = runID == 20 + resp.State.Run.CanRerun = runID == 30 resp.State.Run.CanDeleteArtifact = true resp.State.Run.WorkflowID = "workflow-id" resp.State.Run.WorkflowLink = "./workflow-link" @@ -85,6 +94,39 @@ func MockActionsRunsJobs(ctx *context.Context) { Size: 1024 * 1024, Status: "completed", }) + resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ + Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + Size: 100 * 1024, + Status: "expired", + }) + resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ + Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + Size: 1024 * 1024, + Status: "completed", + }) + + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ + ID: runID * 10, + Name: "job 100", + Status: actions_model.StatusRunning.String(), + CanRerun: true, + Duration: "1h", + }) + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ + ID: runID*10 + 1, + Name: "job 101", + Status: actions_model.StatusWaiting.String(), + CanRerun: false, + Duration: "2h", + }) + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ + ID: runID*10 + 2, + Name: "job 102", + Status: actions_model.StatusFailure.String(), + CanRerun: false, + Duration: "3h", + }) + resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ Summary: "step 0 (mock slow)", Duration: time.Hour.String(), @@ -118,7 +160,7 @@ func MockActionsRunsJobs(ctx *context.Context) { } if doErrorResponse { if mathRand.Float64() > 0.5 { - ctx.Error(http.StatusInternalServerError, "devtest mock error response") + ctx.HTTPError(http.StatusInternalServerError, "devtest mock error response") return } } diff --git a/routers/web/explore/code.go b/routers/web/explore/code.go index ae5ff3db76..3bb50ef397 100644 --- a/routers/web/explore/code.go +++ b/routers/web/explore/code.go @@ -5,6 +5,7 @@ package explore import ( "net/http" + "slices" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" @@ -72,10 +73,10 @@ func Code(ctx *context.Context) { if (len(repoIDs) > 0) || isAdmin { total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ - RepoIDs: repoIDs, - Keyword: prepareSearch.Keyword, - IsKeywordFuzzy: prepareSearch.IsFuzzy, - Language: prepareSearch.Language, + RepoIDs: repoIDs, + Keyword: prepareSearch.Keyword, + SearchMode: prepareSearch.SearchMode, + Language: prepareSearch.Language, Paginator: &db.ListOptions{ Page: page, PageSize: setting.UI.RepoSearchPagingNum, @@ -93,14 +94,7 @@ func Code(ctx *context.Context) { loadRepoIDs := make([]int64, 0, len(searchResults)) for _, result := range searchResults { - var find bool - for _, id := range loadRepoIDs { - if id == result.RepoID { - find = true - break - } - } - if !find { + if !slices.Contains(loadRepoIDs, result.RepoID) { loadRepoIDs = append(loadRepoIDs, result.RepoID) } } diff --git a/routers/web/explore/org.go b/routers/web/explore/org.go index 7bb71acfd7..f8f7f5c18c 100644 --- a/routers/web/explore/org.go +++ b/routers/web/explore/org.go @@ -44,7 +44,7 @@ func Organizations(ctx *context.Context) { ctx.SetFormString("sort", sortOrder) } - RenderUserSearch(ctx, &user_model.SearchUserOptions{ + RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeOrganization, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, diff --git a/routers/web/explore/repo.go b/routers/web/explore/repo.go index cf3128314b..f0d7d0ce7d 100644 --- a/routers/web/explore/repo.go +++ b/routers/web/explore/repo.go @@ -94,7 +94,7 @@ func RenderRepoSearch(ctx *context.Context, opts *RepoSearchOptions) { private := ctx.FormOptionalBool("private") ctx.Data["IsPrivate"] = private - repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + repos, count, err = repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ Page: page, PageSize: opts.PageSize, @@ -151,6 +151,7 @@ func Repos(ctx *context.Context) { ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage ctx.Data["Title"] = ctx.Tr("explore") ctx.Data["PageIsExplore"] = true + ctx.Data["ShowRepoOwnerOnList"] = true ctx.Data["PageIsExploreRepositories"] = true ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled diff --git a/routers/web/explore/topic.go b/routers/web/explore/topic.go index b4507ba28d..be457df587 100644 --- a/routers/web/explore/topic.go +++ b/routers/web/explore/topic.go @@ -25,7 +25,7 @@ func TopicSearch(ctx *context.Context) { topics, total, err := db.FindAndCount[repo_model.Topic](ctx, opts) if err != nil { - ctx.Error(http.StatusInternalServerError) + ctx.HTTPError(http.StatusInternalServerError) return } diff --git a/routers/web/explore/user.go b/routers/web/explore/user.go index b14272c76a..af48e6fb79 100644 --- a/routers/web/explore/user.go +++ b/routers/web/explore/user.go @@ -32,7 +32,7 @@ func isKeywordValid(keyword string) bool { } // RenderUserSearch render user search page -func RenderUserSearch(ctx *context.Context, opts *user_model.SearchUserOptions, tplName templates.TplName) { +func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, tplName templates.TplName) { // Sitemap index for sitemap paths opts.Page = int(ctx.PathParamInt64("idx")) isSitemap := ctx.PathParam("idx") != "" @@ -87,7 +87,7 @@ func RenderUserSearch(ctx *context.Context, opts *user_model.SearchUserOptions, } if opts.SupportedSortOrders != nil && !opts.SupportedSortOrders.Contains(sortOrder) { - ctx.NotFound("unsupported sort order", nil) + ctx.NotFound(nil) return } @@ -151,7 +151,7 @@ func Users(ctx *context.Context) { ctx.SetFormString("sort", sortOrder) } - RenderUserSearch(ctx, &user_model.SearchUserOptions{ + RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeIndividual, ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, diff --git a/routers/web/feed/branch.go b/routers/web/feed/branch.go index 6c4cc11ca0..094fd987ac 100644 --- a/routers/web/feed/branch.go +++ b/routers/web/feed/branch.go @@ -4,7 +4,6 @@ package feed import ( - "fmt" "strings" "time" @@ -16,13 +15,13 @@ import ( // ShowBranchFeed shows tags and/or releases on the repo as RSS / Atom feed func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType string) { - commits, err := ctx.Repo.Commit.CommitsByRange(0, 10, "") + commits, err := ctx.Repo.Commit.CommitsByRange(0, 10, "", "", "") if err != nil { ctx.ServerError("ShowBranchFeed", err) return } - title := fmt.Sprintf("Latest commits for branch %s", ctx.Repo.BranchName) + title := "Latest commits for branch " + ctx.Repo.BranchName link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.RefTypeNameSubURL()} feed := &feeds.Feed{ @@ -43,6 +42,7 @@ func ShowBranchFeed(ctx *context.Context, repo *repo.Repository, formatType stri }, Description: commit.Message(), Content: commit.Message(), + Created: commit.Committer.When, }) } diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go index b7f849dc65..7c59132841 100644 --- a/routers/web/feed/convert.go +++ b/routers/web/feed/convert.go @@ -50,7 +50,7 @@ func toReleaseLink(ctx *context.Context, act *activities_model.Action) string { // renderCommentMarkdown renders the comment markdown to html func renderCommentMarkdown(ctx *context.Context, act *activities_model.Action, content string) template.HTML { - act.LoadRepo(ctx) + _ = act.LoadRepo(ctx) if act.Repo == nil { return "" } @@ -201,7 +201,7 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio switch act.OpType { case activities_model.ActionCommitRepo, activities_model.ActionMirrorSyncPush: push := templates.ActionContent2Commits(act) - + _ = act.LoadRepo(ctx) for _, commit := range push.Commits { if len(desc) != 0 { desc += "\n\n" @@ -209,7 +209,7 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio desc += fmt.Sprintf("<a href=\"%s\">%s</a>\n%s", html.EscapeString(fmt.Sprintf("%s/commit/%s", act.GetRepoAbsoluteLink(ctx), commit.Sha1)), commit.Sha1, - renderUtils.RenderCommitMessage(commit.Message, nil), + renderUtils.RenderCommitMessage(commit.Message, act.Repo), ) } @@ -258,18 +258,18 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio } // GetFeedType return if it is a feed request and altered name and feed type. -func GetFeedType(name string, req *http.Request) (bool, string, string) { +func GetFeedType(name string, req *http.Request) (showFeed bool, feedType string) { if strings.HasSuffix(name, ".rss") || strings.Contains(req.Header.Get("Accept"), "application/rss+xml") { - return true, strings.TrimSuffix(name, ".rss"), "rss" + return true, "rss" } if strings.HasSuffix(name, ".atom") || strings.Contains(req.Header.Get("Accept"), "application/atom+xml") { - return true, strings.TrimSuffix(name, ".atom"), "atom" + return true, "atom" } - return false, name, "" + return false, "" } // feedActionsToFeedItems convert gitea's Repo's Releases to feeds Item diff --git a/routers/web/feed/file.go b/routers/web/feed/file.go index 518d995ccb..026c15c43a 100644 --- a/routers/web/feed/file.go +++ b/routers/web/feed/file.go @@ -4,7 +4,6 @@ package feed import ( - "fmt" "strings" "time" @@ -33,7 +32,7 @@ func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string return } - title := fmt.Sprintf("Latest commits for file %s", ctx.Repo.TreePath) + title := "Latest commits for file " + ctx.Repo.TreePath link := &feeds.Link{Href: repo.HTMLURL() + "/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)} @@ -55,6 +54,7 @@ func ShowFileFeed(ctx *context.Context, repo *repo.Repository, formatType string }, Description: commit.Message(), Content: commit.Message(), + Created: commit.Committer.When, }) } diff --git a/routers/web/feed/profile.go b/routers/web/feed/profile.go index 4ec46e302a..cecf9d409a 100644 --- a/routers/web/feed/profile.go +++ b/routers/web/feed/profile.go @@ -7,6 +7,7 @@ import ( "time" activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/renderhelper" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/services/context" @@ -28,12 +29,23 @@ func ShowUserFeedAtom(ctx *context.Context) { // showUserFeed show user activity as RSS / Atom feed func showUserFeed(ctx *context.Context, formatType string) { includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + isOrganisation := ctx.ContextUser.IsOrganization() + if ctx.IsSigned && isOrganisation && !includePrivate { + // When feed is requested by a member of the organization, + // include the private repo's the member has access to. + isOrgMember, err := organization.IsOrganizationMember(ctx, ctx.ContextUser.ID, ctx.Doer.ID) + if err != nil { + ctx.ServerError("IsOrganizationMember", err) + return + } + includePrivate = isOrgMember + } actions, _, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{ RequestedUser: ctx.ContextUser, Actor: ctx.Doer, IncludePrivate: includePrivate, - OnlyPerformedBy: !ctx.ContextUser.IsOrganization(), + OnlyPerformedBy: !isOrganisation, IncludeDeleted: false, Date: ctx.FormString("date"), }) diff --git a/routers/web/feed/profile_test.go b/routers/web/feed/profile_test.go new file mode 100644 index 0000000000..a0f1509269 --- /dev/null +++ b/routers/web/feed/profile_test.go @@ -0,0 +1,38 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package feed_test + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/routers/web/feed" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} + +func TestCheckGetOrgFeedsAsOrgMember(t *testing.T) { + unittest.PrepareTestEnv(t) + t.Run("OrgMember", func(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "org3.atom") + ctx.ContextUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + contexttest.LoadUser(t, ctx, 2) + ctx.IsSigned = true + feed.ShowUserFeedAtom(ctx) + assert.Contains(t, resp.Body.String(), "<entry>") // Should contain 1 private entry + }) + t.Run("NonOrgMember", func(t *testing.T) { + ctx, resp := contexttest.MockContext(t, "org3.atom") + ctx.ContextUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + contexttest.LoadUser(t, ctx, 5) + ctx.IsSigned = true + feed.ShowUserFeedAtom(ctx) + assert.NotContains(t, resp.Body.String(), "<entry>") // Should not contain any entries + }) +} diff --git a/routers/web/feed/render.go b/routers/web/feed/render.go index 462ebb97b5..d1a229e5b2 100644 --- a/routers/web/feed/render.go +++ b/routers/web/feed/render.go @@ -9,7 +9,7 @@ import ( // RenderBranchFeed render format for branch or file func RenderBranchFeed(ctx *context.Context) { - _, _, showFeedType := GetFeedType(ctx.PathParam("reponame"), ctx.Req) + _, showFeedType := GetFeedType(ctx.PathParam("reponame"), ctx.Req) if ctx.Repo.TreePath == "" { ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType) } else { diff --git a/routers/web/githttp.go b/routers/web/githttp.go index 5831e6f523..06de811f16 100644 --- a/routers/web/githttp.go +++ b/routers/web/githttp.go @@ -4,26 +4,12 @@ package web import ( - "net/http" - - "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/repo" "code.gitea.io/gitea/services/context" ) func addOwnerRepoGitHTTPRouters(m *web.Router) { - reqGitSignIn := func(ctx *context.Context) { - if !setting.Service.RequireSignInView { - return - } - // rely on the results of Contexter - if !ctx.IsSigned { - // TODO: support digit auth - which would be Authorization header with digit - ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`) - ctx.Error(http.StatusUnauthorized) - } - } m.Group("/{username}/{reponame}", func() { m.Methods("POST,OPTIONS", "/git-upload-pack", repo.ServiceUploadPack) m.Methods("POST,OPTIONS", "/git-receive-pack", repo.ServiceReceivePack) @@ -36,5 +22,5 @@ func addOwnerRepoGitHTTPRouters(m *web.Router) { m.Methods("GET,OPTIONS", "/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38,62}}", repo.GetLooseObject) m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.pack", repo.GetPackFile) m.Methods("GET,OPTIONS", "/objects/pack/pack-{file:[0-9a-f]{40,64}}.idx", repo.GetIdxFile) - }, optSignInIgnoreCsrf, reqGitSignIn, repo.HTTPGitEnabledHandler, repo.CorsHandler(), context.UserAssignmentWeb()) + }, optSignInIgnoreCsrf, repo.HTTPGitEnabledHandler, repo.CorsHandler(), context.UserAssignmentWeb()) } diff --git a/routers/web/goget.go b/routers/web/goget.go index 79d5c2b207..67e0bee866 100644 --- a/routers/web/goget.go +++ b/routers/web/goget.go @@ -18,7 +18,7 @@ import ( ) func goGet(ctx *context.Context) { - if ctx.Req.Method != "GET" || len(ctx.Req.URL.RawQuery) < 8 || ctx.FormString("go-get") != "1" { + if ctx.Req.Method != http.MethodGet || len(ctx.Req.URL.RawQuery) < 8 || ctx.FormString("go-get") != "1" { return } diff --git a/routers/web/home.go b/routers/web/home.go index 9ad495d54f..4b15ee83c2 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -68,7 +68,7 @@ func Home(ctx *context.Context) { func HomeSitemap(ctx *context.Context) { m := sitemap.NewSitemapIndex() if !setting.Service.Explore.DisableUsersPage { - _, cnt, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + _, cnt, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Type: user_model.UserTypeIndividual, ListOptions: db.ListOptions{PageSize: 1}, IsActive: optional.Some(true), @@ -86,7 +86,7 @@ func HomeSitemap(ctx *context.Context) { } } - _, cnt, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + _, cnt, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ PageSize: 1, }, @@ -113,5 +113,5 @@ func HomeSitemap(ctx *context.Context) { // NotFound render 404 page func NotFound(ctx *context.Context) { ctx.Data["Title"] = "Page Not Found" - ctx.NotFound("home.NotFound", nil) + ctx.NotFound(nil) } diff --git a/routers/web/misc/markup.go b/routers/web/misc/markup.go index 0c7ec6c2eb..f90cf3ffed 100644 --- a/routers/web/misc/markup.go +++ b/routers/web/misc/markup.go @@ -15,6 +15,6 @@ import ( // Markup render markup document to HTML func Markup(ctx *context.Context) { form := web.GetForm(ctx).(*api.MarkupOption) - mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck + mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck // form.Wiki is deprecated common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, form.FilePath) } diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go index caaca7f521..59b97c1717 100644 --- a/routers/web/misc/misc.go +++ b/routers/web/misc/misc.go @@ -20,7 +20,7 @@ func SSHInfo(rw http.ResponseWriter, req *http.Request) { return } rw.Header().Set("content-type", "text/json;charset=UTF-8") - _, err := rw.Write([]byte(`{"type":"gitea","version":1}`)) + _, err := rw.Write([]byte(`{"type":"agit","version":1}`)) if err != nil { log.Error("fail to write result: err: %v", err) rw.WriteHeader(http.StatusInternalServerError) @@ -38,7 +38,7 @@ func RobotsTxt(w http.ResponseWriter, req *http.Request) { if ok, _ := util.IsExist(robotsTxt); !ok { robotsTxt = util.FilePathJoinAbs(setting.CustomPath, "robots.txt") // the legacy "robots.txt" } - httpcache.SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) + httpcache.SetCacheControlInHeader(w.Header(), httpcache.CacheControlForPublicStatic()) http.ServeFile(w, req, robotsTxt) } diff --git a/routers/web/nodeinfo.go b/routers/web/nodeinfo.go index f1cc7bf530..47856bf98b 100644 --- a/routers/web/nodeinfo.go +++ b/routers/web/nodeinfo.go @@ -4,7 +4,6 @@ package web import ( - "fmt" "net/http" "code.gitea.io/gitea/modules/setting" @@ -24,7 +23,7 @@ type nodeInfoLink struct { func NodeInfoLinks(ctx *context.Context) { nodeinfolinks := &nodeInfoLinks{ Links: []nodeInfoLink{{ - fmt.Sprintf("%sapi/v1/nodeinfo", setting.AppURL), + setting.AppURL + "api/v1/nodeinfo", "http://nodeinfo.diaspora.software/ns/schema/2.1", }}, } diff --git a/routers/web/org/block.go b/routers/web/org/block.go index aeb4bd51a8..60f722dd39 100644 --- a/routers/web/org/block.go +++ b/routers/web/org/block.go @@ -20,6 +20,11 @@ func BlockedUsers(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsBlockedUsers"] = true + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + shared_user.BlockedUsers(ctx, ctx.ContextUser) if ctx.Written() { return @@ -29,6 +34,11 @@ func BlockedUsers(ctx *context.Context) { } func BlockedUsersPost(ctx *context.Context) { + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + shared_user.BlockedUsersPost(ctx, ctx.ContextUser) if ctx.Written() { return diff --git a/routers/web/org/home.go b/routers/web/org/home.go index 277adb60ca..63ae6c683b 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -29,12 +29,12 @@ func Home(ctx *context.Context) { uname := ctx.PathParam("username") if strings.HasSuffix(uname, ".keys") || strings.HasSuffix(uname, ".gpg") { - ctx.NotFound("", nil) + ctx.NotFound(nil) return } ctx.SetPathParam("org", uname) - context.HandleOrgAssignment(ctx) + context.OrgAssignment(context.OrgAssignmentOptions{})(ctx) if ctx.Written() { return } @@ -86,12 +86,6 @@ func home(ctx *context.Context, viewRepositories bool) { private := ctx.FormOptionalBool("private") ctx.Data["IsPrivate"] = private - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) - return - } - opts := &organization.FindOrgMembersOpts{ Doer: ctx.Doer, OrgID: org.ID, @@ -109,9 +103,9 @@ func home(ctx *context.Context, viewRepositories bool) { ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0 - prepareResult, err := shared_user.PrepareOrgHeader(ctx) + prepareResult, err := shared_user.RenderUserOrgHeader(ctx) if err != nil { - ctx.ServerError("PrepareOrgHeader", err) + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -121,7 +115,7 @@ func home(ctx *context.Context, viewRepositories bool) { ctx.Data["PageIsViewOverview"] = isViewOverview ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil - repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ PageSize: setting.UI.User.RepoPagingNum, Page: page, @@ -154,7 +148,7 @@ func home(ctx *context.Context, viewRepositories bool) { ctx.HTML(http.StatusOK, tplOrgHome) } -func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.PrepareOrgHeaderResult) bool { +func prepareOrgProfileReadme(ctx *context.Context, prepareResult *shared_user.PrepareOwnerHeaderResult) bool { viewAs := ctx.FormString("view_as", util.Iif(ctx.Org.IsMember, "member", "public")) viewAsMember := viewAs == "member" diff --git a/routers/web/org/members.go b/routers/web/org/members.go index 1665a12302..61022d3f09 100644 --- a/routers/web/org/members.go +++ b/routers/web/org/members.go @@ -28,10 +28,7 @@ func Members(ctx *context.Context) { ctx.Data["Title"] = org.FullName ctx.Data["PageIsOrgMembers"] = true - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) opts := &organization.FindOrgMembersOpts{ Doer: ctx.Doer, @@ -41,7 +38,7 @@ func Members(ctx *context.Context) { if ctx.Doer != nil { isMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID) if err != nil { - ctx.Error(http.StatusInternalServerError, "IsOrgMember") + ctx.HTTPError(http.StatusInternalServerError, "IsOrgMember") return } opts.IsDoerMember = isMember @@ -50,13 +47,12 @@ func Members(ctx *context.Context) { total, err := organization.CountOrgMembers(ctx, opts) if err != nil { - ctx.Error(http.StatusInternalServerError, "CountOrgMembers") + ctx.HTTPError(http.StatusInternalServerError, "CountOrgMembers") return } - _, err = shared_user.PrepareOrgHeader(ctx) - if err != nil { - ctx.ServerError("PrepareOrgHeader", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -93,19 +89,19 @@ func MembersAction(ctx *context.Context) { switch ctx.PathParam("action") { case "private": if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, false) case "public": if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, true) case "remove": if !ctx.Org.IsOwner { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } err = org_service.RemoveOrgUser(ctx, org, member) diff --git a/routers/web/org/org.go b/routers/web/org/org.go index 856a605764..0540d5c591 100644 --- a/routers/web/org/org.go +++ b/routers/web/org/org.go @@ -27,11 +27,14 @@ const ( // Create render the page for create organization func Create(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("new_org") - ctx.Data["DefaultOrgVisibilityMode"] = setting.Service.DefaultOrgVisibilityMode if !ctx.Doer.CanCreateOrganization() { ctx.ServerError("Not allowed", errors.New(ctx.Locale.TrString("org.form.create_org_not_allowed"))) return } + + ctx.Data["visibility"] = setting.Service.DefaultOrgVisibilityMode + ctx.Data["repo_admin_change_team_access"] = true + ctx.HTML(http.StatusOK, tplCreateOrg) } diff --git a/routers/web/org/org_labels.go b/routers/web/org/org_labels.go index 02eae8052e..2a4aa7f557 100644 --- a/routers/web/org/org_labels.go +++ b/routers/web/org/org_labels.go @@ -4,13 +4,15 @@ package org import ( - "net/http" + "errors" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/label" repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + shared_label "code.gitea.io/gitea/routers/web/shared/label" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -32,47 +34,45 @@ func RetrieveLabels(ctx *context.Context) { // NewLabel create new label for organization func NewLabel(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.CreateLabelForm) - ctx.Data["Title"] = ctx.Tr("repo.labels") - ctx.Data["PageIsLabels"] = true - ctx.Data["PageIsOrgSettings"] = true - - if ctx.HasError() { - ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) - ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") + form := shared_label.GetLabelEditForm(ctx) + if ctx.Written() { return } l := &issues_model.Label{ - OrgID: ctx.Org.Organization.ID, - Name: form.Title, - Exclusive: form.Exclusive, - Description: form.Description, - Color: form.Color, + OrgID: ctx.Org.Organization.ID, + Name: form.Title, + Exclusive: form.Exclusive, + Description: form.Description, + Color: form.Color, + ExclusiveOrder: form.ExclusiveOrder, } if err := issues_model.NewLabel(ctx, l); err != nil { ctx.ServerError("NewLabel", err) return } - ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") + ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/labels") } // UpdateLabel update a label's name and color func UpdateLabel(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.CreateLabelForm) + form := shared_label.GetLabelEditForm(ctx) + if ctx.Written() { + return + } + l, err := issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, form.ID) - if err != nil { - switch { - case issues_model.IsErrOrgLabelNotExist(err): - ctx.Error(http.StatusNotFound) - default: - ctx.ServerError("UpdateLabel", err) - } + if errors.Is(err, util.ErrNotExist) { + ctx.JSONErrorNotFound() + return + } else if err != nil { + ctx.ServerError("GetLabelInOrgByID", err) return } l.Name = form.Title l.Exclusive = form.Exclusive + l.ExclusiveOrder = form.ExclusiveOrder l.Description = form.Description l.Color = form.Color l.SetArchived(form.IsArchived) @@ -80,7 +80,7 @@ func UpdateLabel(ctx *context.Context) { ctx.ServerError("UpdateLabel", err) return } - ctx.Redirect(ctx.Org.OrgLink + "/settings/labels") + ctx.JSONRedirect(ctx.Org.OrgLink + "/settings/labels") } // DeleteLabel delete a label diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 32da1b41d1..059cce8281 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -36,24 +36,24 @@ const ( // MustEnableProjects check if projects are enabled in settings func MustEnableProjects(ctx *context.Context) { if unit.TypeProjects.UnitGlobalDisabled() { - ctx.NotFound("EnableProjects", nil) + ctx.NotFound(nil) return } } // Projects renders the home page of projects func Projects(ctx *context.Context) { - shared_user.PrepareContextForProfileBigAvatar(ctx) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } ctx.Data["Title"] = ctx.Tr("repo.projects") sortType := ctx.FormTrim("sort") isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" keyword := ctx.FormTrim("q") - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) var projectType project_model.Type if ctx.ContextUser.IsOrganization() { @@ -77,6 +77,11 @@ func Projects(ctx *context.Context) { return } + if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil { + ctx.ServerError("LoadIssueNumbersForProjects", err) + return + } + opTotal, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{ OwnerID: ctx.ContextUser.ID, IsClosed: optional.Some(!isShowClosed), @@ -96,7 +101,6 @@ func Projects(ctx *context.Context) { } ctx.Data["Projects"] = projects - shared_user.RenderUserHeader(ctx) if isShowClosed { ctx.Data["State"] = "closed" @@ -108,12 +112,6 @@ func Projects(ctx *context.Context) { project.RenderedContent = renderUtils.MarkdownToHtml(project.Description) } - err = shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) - return - } - numPages := 0 if total > 0 { numPages = (int(total) - 1/setting.UI.IssuePagingNum) @@ -147,11 +145,8 @@ func RenderNewProject(ctx *context.Context) { ctx.Data["PageIsViewProjects"] = true ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() ctx.Data["CancelLink"] = ctx.ContextUser.HomeLink() + "/-/projects" - shared_user.RenderUserHeader(ctx) - - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -162,7 +157,10 @@ func RenderNewProject(ctx *context.Context) { func NewProjectPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.CreateProjectForm) ctx.Data["Title"] = ctx.Tr("repo.projects.new") - shared_user.RenderUserHeader(ctx) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } if ctx.HasError() { RenderNewProject(ctx) @@ -222,7 +220,7 @@ func DeleteProject(ctx *context.Context) { return } if p.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("", nil) + ctx.NotFound(nil) return } @@ -243,7 +241,10 @@ func RenderEditProject(ctx *context.Context) { ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) ctx.Data["CardTypes"] = project_model.GetCardConfig() - shared_user.RenderUserHeader(ctx) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) if err != nil { @@ -251,7 +252,7 @@ func RenderEditProject(ctx *context.Context) { return } if p.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("", nil) + ctx.NotFound(nil) return } @@ -277,11 +278,8 @@ func EditProjectPost(ctx *context.Context) { ctx.Data["CardTypes"] = project_model.GetCardConfig() ctx.Data["CancelLink"] = project_model.ProjectLinkForOrg(ctx.ContextUser, projectID) - shared_user.RenderUserHeader(ctx) - - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -296,7 +294,7 @@ func EditProjectPost(ctx *context.Context) { return } if p.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("", nil) + ctx.NotFound(nil) return } @@ -324,7 +322,11 @@ func ViewProject(ctx *context.Context) { return } if project.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("", nil) + ctx.NotFound(nil) + return + } + if err := project.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) return } @@ -334,20 +336,27 @@ func ViewProject(ctx *context.Context) { return } - labelIDs := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner) + preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, project.RepoID, project.Owner) if ctx.Written() { return } - assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future + assigneeID := ctx.FormString("assignee") - issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{ - LabelIDs: labelIDs, - AssigneeID: optional.Some(assigneeID), - }) + opts := issues_model.IssuesOptions{ + LabelIDs: preparedLabelFilter.SelectedLabelIDs, + AssigneeID: assigneeID, + Owner: project.Owner, + Doer: ctx.Doer, + } + + issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &opts) if err != nil { ctx.ServerError("LoadIssuesOfColumns", err) return } + for _, column := range columns { + column.NumIssues = int64(len(issuesMap[column.ID])) + } if project.CardType != project_model.CardTypeTextOnly { issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) @@ -390,8 +399,8 @@ func ViewProject(ctx *context.Context) { } // Get the exclusive scope for every label ID - labelExclusiveScopes := make([]string, 0, len(labelIDs)) - for _, labelID := range labelIDs { + labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs)) + for _, labelID := range preparedLabelFilter.SelectedLabelIDs { foundExclusiveScope := false for _, label := range labels { if label.ID == labelID || label.ID == -labelID { @@ -406,7 +415,7 @@ func ViewProject(ctx *context.Context) { } for _, l := range labels { - l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) + l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes) } ctx.Data["Labels"] = labels ctx.Data["NumLabels"] = len(labels) @@ -427,11 +436,9 @@ func ViewProject(ctx *context.Context) { ctx.Data["Project"] = project ctx.Data["IssuesMap"] = issuesMap ctx.Data["Columns"] = columns - shared_user.RenderUserHeader(ctx) - err = shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -593,7 +600,7 @@ func MoveIssues(ctx *context.Context) { return } if project.OwnerID != ctx.ContextUser.ID { - ctx.NotFound("InvalidRepoID", nil) + ctx.NotFound(nil) return } @@ -604,7 +611,7 @@ func MoveIssues(ctx *context.Context) { } if column.ProjectID != project.ID { - ctx.NotFound("ColumnNotInProject", nil) + ctx.NotFound(nil) return } diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index cb1c4213c9..2bc1e8bc43 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -18,6 +18,7 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" user_setting "code.gitea.io/gitea/routers/web/user/setting" @@ -31,8 +32,6 @@ import ( const ( // tplSettingsOptions template path for render settings tplSettingsOptions templates.TplName = "org/settings/options" - // tplSettingsDelete template path for render delete repository - tplSettingsDelete templates.TplName = "org/settings/delete" // tplSettingsHooks template path for render hook settings tplSettingsHooks templates.TplName = "org/settings/hooks" // tplSettingsLabels template path for render labels settings @@ -48,9 +47,8 @@ func Settings(ctx *context.Context) { ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess ctx.Data["ContextUser"] = ctx.ContextUser - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -72,26 +70,6 @@ func SettingsPost(ctx *context.Context) { org := ctx.Org.Organization - if org.Name != form.Name { - if err := user_service.RenameUser(ctx, org.AsUser(), form.Name); err != nil { - if user_model.IsErrUserAlreadyExist(err) { - ctx.Data["Err_Name"] = true - ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSettingsOptions, &form) - } else if db.IsErrNameReserved(err) { - ctx.Data["Err_Name"] = true - ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) - } else if db.IsErrNamePatternNotAllowed(err) { - ctx.Data["Err_Name"] = true - ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) - } else { - ctx.ServerError("RenameUser", err) - } - return - } - - ctx.Org.OrgLink = setting.AppSubURL + "/org/" + url.PathEscape(org.Name) - } - if form.Email != "" { if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), form.Email); err != nil { ctx.Data["Err_Email"] = true @@ -121,7 +99,7 @@ func SettingsPost(ctx *context.Context) { // update forks visibility if visibilityChanged { - repos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + repos, _, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{ Actor: org.AsUser(), Private: true, ListOptions: db.ListOptions{Page: 1, PageSize: org.NumRepos}, }) if err != nil { @@ -164,43 +142,27 @@ func SettingsDeleteAvatar(ctx *context.Context) { ctx.JSONRedirect(ctx.Org.OrgLink + "/settings") } -// SettingsDelete response for deleting an organization -func SettingsDelete(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("org.settings") - ctx.Data["PageIsOrgSettings"] = true - ctx.Data["PageIsSettingsDelete"] = true - - if ctx.Req.Method == "POST" { - if ctx.Org.Organization.Name != ctx.FormString("org_name") { - ctx.Data["Err_OrgName"] = true - ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_org_name"), tplSettingsDelete, nil) - return - } - - if err := org_service.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil { - if repo_model.IsErrUserOwnRepos(err) { - ctx.Flash.Error(ctx.Tr("form.org_still_own_repo")) - ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") - } else if packages_model.IsErrUserOwnPackages(err) { - ctx.Flash.Error(ctx.Tr("form.org_still_own_packages")) - ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") - } else { - ctx.ServerError("DeleteOrganization", err) - } - } else { - log.Trace("Organization deleted: %s", ctx.Org.Organization.Name) - ctx.Redirect(setting.AppSubURL + "/") - } +// SettingsDeleteOrgPost response for deleting an organization +func SettingsDeleteOrgPost(ctx *context.Context) { + if ctx.Org.Organization.Name != ctx.FormString("org_name") { + ctx.JSONError(ctx.Tr("form.enterred_invalid_org_name")) return } - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if err := org_service.DeleteOrganization(ctx, ctx.Org.Organization, false /* no purge */); err != nil { + if repo_model.IsErrUserOwnRepos(err) { + ctx.JSONError(ctx.Tr("form.org_still_own_repo")) + } else if packages_model.IsErrUserOwnPackages(err) { + ctx.JSONError(ctx.Tr("form.org_still_own_packages")) + } else { + log.Error("DeleteOrganization: %v", err) + ctx.JSONError(util.Iif(ctx.Doer.IsAdmin, err.Error(), string(ctx.Tr("org.settings.delete_failed")))) + } return } - ctx.HTML(http.StatusOK, tplSettingsDelete) + ctx.Flash.Success(ctx.Tr("org.settings.delete_successful", ctx.Org.Organization.Name)) + ctx.JSONRedirect(setting.AppSubURL + "/") } // Webhooks render webhook list page @@ -218,9 +180,8 @@ func Webhooks(ctx *context.Context) { return } - err = shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -246,11 +207,47 @@ func Labels(ctx *context.Context) { ctx.Data["PageIsOrgSettingsLabels"] = true ctx.Data["LabelTemplateFiles"] = repo_module.LabelTemplateFiles - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } ctx.HTML(http.StatusOK, tplSettingsLabels) } + +// SettingsRenamePost response for renaming organization +func SettingsRenamePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RenameOrgForm) + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + oldOrgName, newOrgName := ctx.Org.Organization.Name, form.NewOrgName + + if form.OrgName != oldOrgName { + ctx.JSONError(ctx.Tr("form.enterred_invalid_org_name")) + return + } + if newOrgName == oldOrgName { + ctx.JSONError(ctx.Tr("org.settings.rename_no_change")) + return + } + + if err := user_service.RenameUser(ctx, ctx.Org.Organization.AsUser(), newOrgName); err != nil { + if user_model.IsErrUserAlreadyExist(err) { + ctx.JSONError(ctx.Tr("org.form.name_been_taken", newOrgName)) + } else if db.IsErrNameReserved(err) { + ctx.JSONError(ctx.Tr("org.form.name_reserved", newOrgName)) + } else if db.IsErrNamePatternNotAllowed(err) { + ctx.JSONError(ctx.Tr("org.form.name_pattern_not_allowed", newOrgName)) + } else { + log.Error("RenameOrganization: %v", err) + ctx.JSONError(util.Iif(ctx.Doer.IsAdmin, err.Error(), string(ctx.Tr("org.settings.rename_failed")))) + } + return + } + + ctx.Flash.Success(ctx.Tr("org.settings.rename_success", oldOrgName, newOrgName)) + ctx.JSONRedirect(setting.AppSubURL + "/org/" + url.PathEscape(newOrgName) + "/settings") +} diff --git a/routers/web/org/setting_oauth2.go b/routers/web/org/setting_oauth2.go index c93058477e..47f653bf88 100644 --- a/routers/web/org/setting_oauth2.go +++ b/routers/web/org/setting_oauth2.go @@ -45,9 +45,8 @@ func Applications(ctx *context.Context) { } ctx.Data["Applications"] = apps - err = shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } diff --git a/routers/web/org/setting_packages.go b/routers/web/org/setting_packages.go index 0912a9e0fd..ec80e2867c 100644 --- a/routers/web/org/setting_packages.go +++ b/routers/web/org/setting_packages.go @@ -25,9 +25,8 @@ func Packages(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsPackages"] = true - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -41,9 +40,8 @@ func PackagesRuleAdd(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsPackages"] = true - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -57,9 +55,8 @@ func PackagesRuleEdit(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsPackages"] = true - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -99,9 +96,8 @@ func PackagesRulePreview(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsPackages"] = true - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 26031029d6..0ec7cfddc5 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -46,6 +46,10 @@ const ( // Teams render teams list page func Teams(ctx *context.Context) { + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } org := ctx.Org.Organization ctx.Data["Title"] = org.FullName ctx.Data["PageIsOrgTeams"] = true @@ -58,12 +62,6 @@ func Teams(ctx *context.Context) { } ctx.Data["Teams"] = ctx.Org.Teams - _, err := shared_user.PrepareOrgHeader(ctx) - if err != nil { - ctx.ServerError("PrepareOrgHeader", err) - return - } - ctx.HTML(http.StatusOK, tplTeams) } @@ -74,7 +72,7 @@ func TeamsAction(ctx *context.Context) { switch ctx.PathParam("action") { case "join": if !ctx.Org.IsOwner { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } err = org_service.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer) @@ -96,7 +94,7 @@ func TeamsAction(ctx *context.Context) { return case "remove": if !ctx.Org.IsOwner { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -123,7 +121,7 @@ func TeamsAction(ctx *context.Context) { return case "add": if !ctx.Org.IsOwner { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } uname := strings.ToLower(ctx.FormString("uname")) @@ -167,7 +165,7 @@ func TeamsAction(ctx *context.Context) { page = "team" case "remove_invite": if !ctx.Org.IsOwner { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -228,7 +226,7 @@ func checkIsOrgMemberAndRedirect(ctx *context.Context, defaultRedirect string) { // TeamsRepoAction operate team's repository func TeamsRepoAction(ctx *context.Context) { if !ctx.Org.IsOwner { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -272,22 +270,35 @@ func TeamsRepoAction(ctx *context.Context) { // NewTeam render create new team page func NewTeam(ctx *context.Context) { + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } ctx.Data["Title"] = ctx.Org.Organization.FullName ctx.Data["PageIsOrgTeams"] = true ctx.Data["PageIsOrgTeamsNew"] = true ctx.Data["Team"] = &org_model.Team{} ctx.Data["Units"] = unit_model.Units - if err := shared_user.LoadHeaderCount(ctx); err != nil { - ctx.ServerError("LoadHeaderCount", err) - return - } ctx.HTML(http.StatusOK, tplTeamNew) } +// FIXME: TEAM-UNIT-PERMISSION: this design is not right, when a new unit is added in the future, +// The existing teams won't inherit the correct admin permission for the new unit. +// The full history is like this: +// 1. There was only "team", no "team unit", so "team.authorize" was used to determine the team permission. +// 2. Later, "team unit" was introduced, then the usage of "team.authorize" became inconsistent, and causes various bugs. +// - Sometimes, "team.authorize" is used to determine the team permission, e.g. admin, owner +// - Sometimes, "team unit" is used not really used and "team unit" is used. +// - Some functions like `GetTeamsWithAccessToAnyRepoUnit` use both. +// +// 3. After introducing "team unit" and more unclear changes, it becomes difficult to maintain team permissions. +// - Org owner need to click the permission for each unit, but can't just set a common "write" permission for all units. +// +// Ideally, "team.authorize=write" should mean the team has write access to all units including newly (future) added ones. func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_model.Type]perm.AccessMode { unitPerms := make(map[unit_model.Type]perm.AccessMode) for _, ut := range unit_model.AllRepoUnitTypes { - // Default accessmode is none + // Default access mode is none unitPerms[ut] = perm.AccessModeNone v, ok := forms[fmt.Sprintf("unit_%d", ut)] @@ -314,19 +325,14 @@ func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_mod func NewTeamPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.CreateTeamForm) includesAllRepositories := form.RepoAccess == "all" - p := perm.ParseAccessMode(form.Permission) - unitPerms := getUnitPerms(ctx.Req.Form, p) - if p < perm.AccessModeAdmin { - // if p is less than admin accessmode, then it should be general accessmode, - // so we should calculate the minial accessmode from units accessmodes. - p = unit_model.MinUnitAccessMode(unitPerms) - } + teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin) + unitPerms := getUnitPerms(ctx.Req.Form, teamPermission) t := &org_model.Team{ OrgID: ctx.Org.Organization.ID, Name: form.TeamName, Description: form.Description, - AccessMode: p, + AccessMode: teamPermission, IncludesAllRepositories: includesAllRepositories, CanCreateOrgRepo: form.CanCreateOrgRepo, } @@ -373,15 +379,15 @@ func NewTeamPost(ctx *context.Context) { // TeamMembers render team members page func TeamMembers(ctx *context.Context) { + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + ctx.Data["Title"] = ctx.Org.Team.Name ctx.Data["PageIsOrgTeams"] = true ctx.Data["PageIsOrgTeamMembers"] = true - if err := shared_user.LoadHeaderCount(ctx); err != nil { - ctx.ServerError("LoadHeaderCount", err) - return - } - if err := ctx.Org.Team.LoadMembers(ctx); err != nil { ctx.ServerError("GetMembers", err) return @@ -401,15 +407,15 @@ func TeamMembers(ctx *context.Context) { // TeamRepositories show the repositories of team func TeamRepositories(ctx *context.Context) { + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + ctx.Data["Title"] = ctx.Org.Team.Name ctx.Data["PageIsOrgTeams"] = true ctx.Data["PageIsOrgTeamRepos"] = true - if err := shared_user.LoadHeaderCount(ctx); err != nil { - ctx.ServerError("LoadHeaderCount", err) - return - } - repos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{ TeamID: ctx.Org.Team.ID, }) @@ -466,16 +472,16 @@ func SearchTeam(ctx *context.Context) { // EditTeam render team edit page func EditTeam(ctx *context.Context) { + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } ctx.Data["Title"] = ctx.Org.Organization.FullName ctx.Data["PageIsOrgTeams"] = true if err := ctx.Org.Team.LoadUnits(ctx); err != nil { ctx.ServerError("LoadUnits", err) return } - if err := shared_user.LoadHeaderCount(ctx); err != nil { - ctx.ServerError("LoadHeaderCount", err) - return - } ctx.Data["Team"] = ctx.Org.Team ctx.Data["Units"] = unit_model.Units ctx.HTML(http.StatusOK, tplTeamNew) @@ -485,13 +491,8 @@ func EditTeam(ctx *context.Context) { func EditTeamPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.CreateTeamForm) t := ctx.Org.Team - newAccessMode := perm.ParseAccessMode(form.Permission) - unitPerms := getUnitPerms(ctx.Req.Form, newAccessMode) - if newAccessMode < perm.AccessModeAdmin { - // if newAccessMode is less than admin accessmode, then it should be general accessmode, - // so we should calculate the minial accessmode from units accessmodes. - newAccessMode = unit_model.MinUnitAccessMode(unitPerms) - } + teamPermission := perm.ParseAccessMode(form.Permission, perm.AccessModeNone, perm.AccessModeAdmin) + unitPerms := getUnitPerms(ctx.Req.Form, teamPermission) isAuthChanged := false isIncludeAllChanged := false includesAllRepositories := form.RepoAccess == "all" @@ -503,9 +504,9 @@ func EditTeamPost(ctx *context.Context) { if !t.IsOwnerTeam() { t.Name = form.TeamName - if t.AccessMode != newAccessMode { + if t.AccessMode != teamPermission { isAuthChanged = true - t.AccessMode = newAccessMode + t.AccessMode = teamPermission } if t.IncludesAllRepositories != includesAllRepositories { @@ -568,7 +569,7 @@ func TeamInvite(ctx *context.Context) { invite, org, team, inviter, err := getTeamInviteFromContext(ctx) if err != nil { if org_model.IsErrTeamInviteNotFound(err) { - ctx.NotFound("ErrTeamInviteNotFound", err) + ctx.NotFound(err) } else { ctx.ServerError("getTeamInviteFromContext", err) } @@ -589,7 +590,7 @@ func TeamInvitePost(ctx *context.Context) { invite, org, team, _, err := getTeamInviteFromContext(ctx) if err != nil { if org_model.IsErrTeamInviteNotFound(err) { - ctx.NotFound("ErrTeamInviteNotFound", err) + ctx.NotFound(err) } else { ctx.ServerError("getTeamInviteFromContext", err) } diff --git a/routers/web/org/worktime.go b/routers/web/org/worktime.go new file mode 100644 index 0000000000..c7b44baf7b --- /dev/null +++ b/routers/web/org/worktime.go @@ -0,0 +1,82 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/modules/templates" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const tplByRepos templates.TplName = "org/worktime" + +// parseOrgTimes contains functionality that is required in all these functions, +// like parsing the date from the request, setting default dates, etc. +func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) { + rangeFrom := ctx.FormString("from") + rangeTo := ctx.FormString("to") + if rangeFrom == "" { + rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month + } + if rangeTo == "" { + rangeTo = time.Now().Format("2006-01-02") // defaults to today + } + + ctx.Data["RangeFrom"] = rangeFrom + ctx.Data["RangeTo"] = rangeTo + + timeFrom, err := time.Parse("2006-01-02", rangeFrom) + if err != nil { + ctx.ServerError("time.Parse", err) + } + timeTo, err := time.Parse("2006-01-02", rangeTo) + if err != nil { + ctx.ServerError("time.Parse", err) + } + unixFrom = timeFrom.Unix() + unixTo = timeTo.Add(1440*time.Minute - 1*time.Second).Unix() // humans expect that we include the ending day too + return unixFrom, unixTo +} + +func Worktime(ctx *context.Context) { + ctx.Data["PageIsOrgTimes"] = true + + unixFrom, unixTo := parseOrgTimes(ctx) + if ctx.Written() { + return + } + + worktimeBy := ctx.FormString("by") + ctx.Data["WorktimeBy"] = worktimeBy + + var worktimeSumResult any + var err error + switch worktimeBy { + case "milestones": + worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx.Org.Organization, unixFrom, unixTo) + ctx.Data["WorktimeByMilestones"] = true + case "members": + worktimeSumResult, err = organization.GetWorktimeByMembers(ctx.Org.Organization, unixFrom, unixTo) + ctx.Data["WorktimeByMembers"] = true + default: /* by repos */ + worktimeSumResult, err = organization.GetWorktimeByRepos(ctx.Org.Organization, unixFrom, unixTo) + ctx.Data["WorktimeByRepos"] = true + } + if err != nil { + ctx.ServerError("GetWorktime", err) + return + } + + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + + ctx.Data["WorktimeSumResult"] = worktimeSumResult + ctx.HTML(http.StatusOK, tplByRepos) +} diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 539c4b6ed0..202da407d2 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -6,6 +6,7 @@ package actions import ( "bytes" stdCtx "context" + "errors" "net/http" "slices" "strings" @@ -45,18 +46,18 @@ type Workflow struct { // MustEnableActions check if actions are enabled in settings func MustEnableActions(ctx *context.Context) { if !setting.Actions.Enabled { - ctx.NotFound("MustEnableActions", nil) + ctx.NotFound(nil) return } if unit.TypeActions.UnitGlobalDisabled() { - ctx.NotFound("MustEnableActions", nil) + ctx.NotFound(nil) return } if ctx.Repo.Repository != nil { if !ctx.Repo.CanRead(unit.TypeActions) { - ctx.NotFound("MustEnableActions", nil) + ctx.NotFound(nil) return } } @@ -67,7 +68,11 @@ func List(ctx *context.Context) { ctx.Data["PageIsActions"] = true commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) - if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch) + ctx.NotFound(nil) + return + } else if err != nil { ctx.ServerError("GetBranchCommit", err) return } @@ -88,7 +93,7 @@ func List(ctx *context.Context) { func WorkflowDispatchInputs(ctx *context.Context) { ref := ctx.FormString("ref") if ref == "" { - ctx.NotFound("WorkflowDispatchInputs: no ref", nil) + ctx.NotFound(nil) return } // get target commit of run from specified ref @@ -121,7 +126,7 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, commit *git.Commit) ( var curWorkflow *model.Workflow - entries, err := actions.ListWorkflows(commit) + _, entries, err := actions.ListWorkflows(commit) if err != nil { ctx.ServerError("ListWorkflows", err) return nil @@ -306,12 +311,14 @@ func prepareWorkflowList(ctx *context.Context, workflows []Workflow) { } ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors) - ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx) + ctx.Data["StatusInfoList"] = actions_model.GetStatusInfoList(ctx, ctx.Locale) pager := context.NewPagination(int(total), opts.PageSize, opts.Page, 5) pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0 + + ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.CanWrite(unit.TypeActions) } // loadIsRefDeleted loads the IsRefDeleted field for each run in the list. @@ -370,10 +377,8 @@ func workflowDispatchConfig(w *model.Workflow) *WorkflowDispatch { if !decodeNode(w.RawOn, &val) { return nil } - for _, v := range val { - if v == "workflow_dispatch" { - return &WorkflowDispatch{} - } + if slices.Contains(val, "workflow_dispatch") { + return &WorkflowDispatch{} } case yaml.MappingNode: var val map[string]yaml.Node diff --git a/routers/web/repo/actions/badge.go b/routers/web/repo/actions/badge.go index e920ecaf58..d268a8df8a 100644 --- a/routers/web/repo/actions/badge.go +++ b/routers/web/repo/actions/badge.go @@ -5,35 +5,38 @@ package actions import ( "errors" - "fmt" "net/http" "path/filepath" "strings" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/modules/badge" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" ) func GetWorkflowBadge(ctx *context.Context) { workflowFile := ctx.PathParam("workflow_name") - branch := ctx.Req.URL.Query().Get("branch") - if branch == "" { - branch = ctx.Repo.Repository.DefaultBranch - } - branchRef := fmt.Sprintf("refs/heads/%s", branch) - event := ctx.Req.URL.Query().Get("event") + branch := ctx.FormString("branch", ctx.Repo.Repository.DefaultBranch) + event := ctx.FormString("event") + style := ctx.FormString("style") - badge, err := getWorkflowBadge(ctx, workflowFile, branchRef, event) + branchRef := git.RefNameFromBranch(branch) + b, err := getWorkflowBadge(ctx, workflowFile, branchRef.String(), event) if err != nil { ctx.ServerError("GetWorkflowBadge", err) return } - ctx.Data["Badge"] = badge + ctx.Data["Badge"] = b ctx.RespHeader().Set("Content-Type", "image/svg+xml") - ctx.HTML(http.StatusOK, "shared/actions/runner_badge") + switch style { + case badge.StyleFlatSquare: + ctx.HTML(http.StatusOK, "shared/actions/runner_badge_flat-square") + default: // defaults to badge.StyleFlat + ctx.HTML(http.StatusOK, "shared/actions/runner_badge_flat") + } } func getWorkflowBadge(ctx *context.Context, workflowFile, branchName, event string) (badge.Badge, error) { @@ -48,7 +51,7 @@ func getWorkflowBadge(ctx *context.Context, workflowFile, branchName, event stri return badge.Badge{}, err } - color, ok := badge.StatusColorMap[run.Status] + color, ok := badge.GlobalVars().StatusColorMap[run.Status] if !ok { return badge.GenerateBadge(workflowName, "unknown status", badge.DefaultColor), nil } diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index e5d83960b8..7e1b923fa4 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -14,32 +14,27 @@ import ( "net/http" "net/url" "strconv" - "strings" "time" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" - "code.gitea.io/gitea/models/perm" - access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" - api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" actions_service "code.gitea.io/gitea/services/actions" context_module "code.gitea.io/gitea/services/context" - "code.gitea.io/gitea/services/convert" + notify_service "code.gitea.io/gitea/services/notify" - "github.com/nektos/act/pkg/jobparser" "github.com/nektos/act/pkg/model" "xorm.io/builder" ) @@ -69,6 +64,36 @@ func View(ctx *context_module.Context) { ctx.HTML(http.StatusOK, tplViewActions) } +func ViewWorkflowFile(ctx *context_module.Context) { + runIndex := getRunIndex(ctx) + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) + if err != nil { + ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + commit, err := ctx.Repo.GitRepo.GetCommit(run.CommitSHA) + if err != nil { + ctx.NotFoundOrServerError("GetCommit", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + rpath, entries, err := actions.ListWorkflows(commit) + if err != nil { + ctx.ServerError("ListWorkflows", err) + return + } + for _, entry := range entries { + if entry.Name() == run.WorkflowID { + ctx.Redirect(fmt.Sprintf("%s/src/commit/%s/%s/%s", ctx.Repo.RepoLink, url.PathEscape(run.CommitSHA), util.PathEscapeSegments(rpath), util.PathEscapeSegments(run.WorkflowID))) + return + } + } + ctx.NotFound(nil) +} + type LogCursor struct { Step int `json:"step"` Cursor int64 `json:"cursor"` @@ -205,13 +230,9 @@ func ViewPost(ctx *context_module.Context) { } } - // TODO: "ComposeMetas" (usually for comment) is not quite right, but it is still the same as what template "RenderCommitMessage" does. - // need to be refactored together in the future - metas := ctx.Repo.Repository.ComposeMetas(ctx) - // the title for the "run" is from the commit message resp.State.Run.Title = run.Title - resp.State.Run.TitleHTML = templates.NewRenderUtils(ctx).RenderCommitMessage(run.Title, metas) + resp.State.Run.TitleHTML = templates.NewRenderUtils(ctx).RenderCommitMessage(run.Title, ctx.Repo.Repository) resp.State.Run.Link = run.Link() resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) @@ -281,84 +302,98 @@ func ViewPost(ctx *context_module.Context) { resp.State.CurrentJob.Steps = make([]*ViewJobStep, 0) // marshal to '[]' instead fo 'null' in json resp.Logs.StepsLog = make([]*ViewStepLog, 0) // marshal to '[]' instead fo 'null' in json if task != nil { - steps := actions.FullSteps(task) - - for _, v := range steps { - resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &ViewJobStep{ - Summary: v.Name, - Duration: v.Duration().String(), - Status: v.Status.String(), - }) + steps, logs, err := convertToViewModel(ctx, req.LogCursors, task) + if err != nil { + ctx.ServerError("convertToViewModel", err) + return } + resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...) + resp.Logs.StepsLog = append(resp.Logs.StepsLog, logs...) + } - for _, cursor := range req.LogCursors { - if !cursor.Expanded { - continue - } + ctx.JSON(http.StatusOK, resp) +} - step := steps[cursor.Step] - - // if task log is expired, return a consistent log line - if task.LogExpired { - if cursor.Cursor == 0 { - resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ - Step: cursor.Step, - Cursor: 1, - Lines: []*ViewStepLogLine{ - { - Index: 1, - Message: ctx.Locale.TrString("actions.runs.expire_log_message"), - // Timestamp doesn't mean anything when the log is expired. - // Set it to the task's updated time since it's probably the time when the log has expired. - Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second), - }, +func convertToViewModel(ctx *context_module.Context, cursors []LogCursor, task *actions_model.ActionTask) ([]*ViewJobStep, []*ViewStepLog, error) { + var viewJobs []*ViewJobStep + var logs []*ViewStepLog + + steps := actions.FullSteps(task) + + for _, v := range steps { + viewJobs = append(viewJobs, &ViewJobStep{ + Summary: v.Name, + Duration: v.Duration().String(), + Status: v.Status.String(), + }) + } + + for _, cursor := range cursors { + if !cursor.Expanded { + continue + } + + step := steps[cursor.Step] + + // if task log is expired, return a consistent log line + if task.LogExpired { + if cursor.Cursor == 0 { + logs = append(logs, &ViewStepLog{ + Step: cursor.Step, + Cursor: 1, + Lines: []*ViewStepLogLine{ + { + Index: 1, + Message: ctx.Locale.TrString("actions.runs.expire_log_message"), + // Timestamp doesn't mean anything when the log is expired. + // Set it to the task's updated time since it's probably the time when the log has expired. + Timestamp: float64(task.Updated.AsTime().UnixNano()) / float64(time.Second), }, - Started: int64(step.Started), - }) - } - continue + }, + Started: int64(step.Started), + }) } + continue + } - logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead fo 'null' in json - - index := step.LogIndex + cursor.Cursor - validCursor := cursor.Cursor >= 0 && - // !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready. - // So return the same cursor and empty lines to let the frontend retry. - cursor.Cursor < step.LogLength && - // !(index < task.LogIndexes[index]) when task data is older than step data. - // It can be fixed by making sure write/read tasks and steps in the same transaction, - // but it's easier to just treat it as fetching the next line before it's ready. - index < int64(len(task.LogIndexes)) - - if validCursor { - length := step.LogLength - cursor.Cursor - offset := task.LogIndexes[index] - logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length) - if err != nil { - ctx.ServerError("actions.ReadLogs", err) - return - } - - for i, row := range logRows { - logLines = append(logLines, &ViewStepLogLine{ - Index: cursor.Cursor + int64(i) + 1, // start at 1 - Message: row.Content, - Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), - }) - } + logLines := make([]*ViewStepLogLine, 0) // marshal to '[]' instead fo 'null' in json + + index := step.LogIndex + cursor.Cursor + validCursor := cursor.Cursor >= 0 && + // !(cursor.Cursor < step.LogLength) when the frontend tries to fetch next line before it's ready. + // So return the same cursor and empty lines to let the frontend retry. + cursor.Cursor < step.LogLength && + // !(index < task.LogIndexes[index]) when task data is older than step data. + // It can be fixed by making sure write/read tasks and steps in the same transaction, + // but it's easier to just treat it as fetching the next line before it's ready. + index < int64(len(task.LogIndexes)) + + if validCursor { + length := step.LogLength - cursor.Cursor + offset := task.LogIndexes[index] + logRows, err := actions.ReadLogs(ctx, task.LogInStorage, task.LogFilename, offset, length) + if err != nil { + return nil, nil, fmt.Errorf("actions.ReadLogs: %w", err) } - resp.Logs.StepsLog = append(resp.Logs.StepsLog, &ViewStepLog{ - Step: cursor.Step, - Cursor: cursor.Cursor + int64(len(logLines)), - Lines: logLines, - Started: int64(step.Started), - }) + for i, row := range logRows { + logLines = append(logLines, &ViewStepLogLine{ + Index: cursor.Cursor + int64(i) + 1, // start at 1 + Message: row.Content, + Timestamp: float64(row.Time.AsTime().UnixNano()) / float64(time.Second), + }) + } } + + logs = append(logs, &ViewStepLog{ + Step: cursor.Step, + Cursor: cursor.Cursor + int64(len(logLines)), + Lines: logLines, + Started: int64(step.Started), + }) } - ctx.JSON(http.StatusOK, resp) + return viewJobs, logs, nil } // Rerun will rerun jobs in the given run @@ -373,7 +408,7 @@ func Rerun(ctx *context_module.Context) { run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetRunByIndex", err) return } @@ -391,7 +426,7 @@ func Rerun(ctx *context_module.Context) { run.Started = 0 run.Stopped = 0 if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration"); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("UpdateRun", err) return } } @@ -406,7 +441,7 @@ func Rerun(ctx *context_module.Context) { // if the job has needs, it should be set to "blocked" status to wait for other jobs shouldBlock := len(j.Needs) > 0 if err := rerunJob(ctx, j, shouldBlock); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("RerunJob", err) return } } @@ -420,7 +455,7 @@ func Rerun(ctx *context_module.Context) { // jobs other than the specified one should be set to "blocked" status shouldBlock := j.JobID != job.JobID if err := rerunJob(ctx, j, shouldBlock); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("RerunJob", err) return } } @@ -450,6 +485,9 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou } actions_service.CreateCommitStatus(ctx, job) + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + return nil } @@ -457,49 +495,19 @@ func Logs(ctx *context_module.Context) { runIndex := getRunIndex(ctx) jobIndex := ctx.PathParamInt64("job") - job, _ := getRunJobs(ctx, runIndex, jobIndex) - if ctx.Written() { - return - } - if job.TaskID == 0 { - ctx.Error(http.StatusNotFound, "job is not started") - return - } - - err := job.LoadRun(ctx) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - - task, err := actions_model.GetTaskByID(ctx, job.TaskID) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - if task.LogExpired { - ctx.Error(http.StatusNotFound, "logs have been cleaned up") - return - } - - reader, err := actions.OpenLogs(ctx, task.LogInStorage, task.LogFilename) + run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.NotFoundOrServerError("GetRunByIndex", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) return } - defer reader.Close() - workflowName := job.Run.WorkflowID - if p := strings.Index(workflowName, "."); p > 0 { - workflowName = workflowName[0:p] + if err = common.DownloadActionsRunJobLogsWithIndex(ctx.Base, ctx.Repo.Repository, run.ID, jobIndex); err != nil { + ctx.NotFoundOrServerError("DownloadActionsRunJobLogsWithIndex", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) } - ctx.ServeContent(reader, &context_module.ServeHeaderOptions{ - Filename: fmt.Sprintf("%v-%v-%v.log", workflowName, job.Name, task.ID), - ContentLength: &task.LogSize, - ContentType: "text/plain", - ContentTypeCharset: "utf-8", - Disposition: "attachment", - }) } func Cancel(ctx *context_module.Context) { @@ -510,6 +518,8 @@ func Cancel(ctx *context_module.Context) { return } + var updatedjobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { for _, job := range jobs { status := job.Status @@ -524,7 +534,10 @@ func Cancel(ctx *context_module.Context) { return err } if n == 0 { - return fmt.Errorf("job has changed, try again") + return errors.New("job has changed, try again") + } + if n > 0 { + updatedjobs = append(updatedjobs, job) } continue } @@ -534,12 +547,21 @@ func Cancel(ctx *context_module.Context) { } return nil }); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("StopTask", err) return } actions_service.CreateCommitStatus(ctx, jobs...) + for _, job := range updatedjobs { + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } + if len(updatedjobs) > 0 { + job := updatedjobs[0] + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } ctx.JSON(http.StatusOK, struct{}{}) } @@ -553,6 +575,8 @@ func Approve(ctx *context_module.Context) { run := current.Run doer := ctx.Doer + var updatedjobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { run.NeedApproval = false run.ApprovedBy = doer.ID @@ -562,23 +586,64 @@ func Approve(ctx *context_module.Context) { for _, job := range jobs { if len(job.Needs) == 0 && job.Status.IsBlocked() { job.Status = actions_model.StatusWaiting - _, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") if err != nil { return err } + if n > 0 { + updatedjobs = append(updatedjobs, job) + } } } return nil }); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("UpdateRunJob", err) return } actions_service.CreateCommitStatus(ctx, jobs...) + if len(updatedjobs) > 0 { + job := updatedjobs[0] + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + } + + for _, job := range updatedjobs { + _ = job.LoadAttributes(ctx) + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } + ctx.JSON(http.StatusOK, struct{}{}) } +func Delete(ctx *context_module.Context) { + runIndex := getRunIndex(ctx) + repoID := ctx.Repo.Repository.ID + + run, err := actions_model.GetRunByIndex(ctx, repoID, runIndex) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.JSONErrorNotFound() + return + } + ctx.ServerError("GetRunByIndex", err) + return + } + + if !run.Status.IsDone() { + ctx.JSONError(ctx.Tr("actions.runs.not_done")) + return + } + + if err := actions_service.DeleteRun(ctx, run); err != nil { + ctx.ServerError("DeleteRun", err) + return + } + + ctx.JSONOK() +} + // getRunJobs gets the jobs of runIndex, and returns jobs[jobIndex], jobs. // Any error will be written to the ctx. // It never returns a nil job of an empty jobs, if the jobIndex is out of range, it will be treated as 0. @@ -586,20 +651,20 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) if err != nil { if errors.Is(err, util.ErrNotExist) { - ctx.Error(http.StatusNotFound, err.Error()) + ctx.NotFound(nil) return nil, nil } - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetRunByIndex", err) return nil, nil } run.Repo = ctx.Repo.Repository jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetRunJobsByRunID", err) return nil, nil } if len(jobs) == 0 { - ctx.Error(http.StatusNotFound) + ctx.NotFound(nil) return nil, nil } @@ -614,11 +679,6 @@ func getRunJobs(ctx *context_module.Context, runIndex, jobIndex int64) (*actions } func ArtifactsDeleteView(ctx *context_module.Context) { - if !ctx.Repo.CanWrite(unit.TypeActions) { - ctx.Error(http.StatusForbidden, "no permission") - return - } - runIndex := getRunIndex(ctx) artifactName := ctx.PathParam("artifact_name") @@ -630,7 +690,7 @@ func ArtifactsDeleteView(ctx *context_module.Context) { return } if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("SetArtifactNeedDelete", err) return } ctx.JSON(http.StatusOK, struct{}{}) @@ -643,10 +703,10 @@ func ArtifactsDownloadView(ctx *context_module.Context) { run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) if err != nil { if errors.Is(err, util.ErrNotExist) { - ctx.Error(http.StatusNotFound, err.Error()) + ctx.HTTPError(http.StatusNotFound, err.Error()) return } - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetRunByIndex", err) return } @@ -655,41 +715,30 @@ func ArtifactsDownloadView(ctx *context_module.Context) { ArtifactName: artifactName, }) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("FindArtifacts", err) return } if len(artifacts) == 0 { - ctx.Error(http.StatusNotFound, "artifact not found") + ctx.HTTPError(http.StatusNotFound, "artifact not found") return } // if artifacts status is not uploaded-confirmed, treat it as not found for _, art := range artifacts { - if art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) { - ctx.Error(http.StatusNotFound, "artifact not found") + if art.Status != actions_model.ArtifactStatusUploadConfirmed { + ctx.HTTPError(http.StatusNotFound, "artifact not found") return } } ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) - // Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend - // The v4 backend enshures ContentEncoding is set to "application/zip", which is not the case for the old backend - if len(artifacts) == 1 && artifacts[0].ArtifactName+".zip" == artifacts[0].ArtifactPath && artifacts[0].ContentEncoding == "application/zip" { - art := artifacts[0] - if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, nil) - if u != nil && err == nil { - ctx.Redirect(u.String()) - return - } - } - f, err := storage.ActionsArtifacts.Open(art.StoragePath) + if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { + err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("DownloadArtifactV4", err) return } - _, _ = io.Copy(ctx.Resp, f) return } @@ -700,7 +749,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { for _, art := range artifacts { f, err := storage.ActionsArtifacts.Open(art.StoragePath) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("ActionsArtifacts.Open", err) return } @@ -708,7 +757,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { if art.ContentEncoding == "gzip" { r, err = gzip.NewReader(f) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("gzip.NewReader", err) return } } else { @@ -718,11 +767,11 @@ func ArtifactsDownloadView(ctx *context_module.Context) { w, err := writer.Create(art.ArtifactPath) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("writer.Create", err) return } if _, err := io.Copy(w, r); err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.ServerError("io.Copy", err) return } } @@ -783,142 +832,28 @@ func Run(ctx *context_module.Context) { ctx.ServerError("ref", nil) return } - - // can not rerun job when workflow is disabled - cfgUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions) - cfg := cfgUnit.ActionsConfig() - if cfg.IsWorkflowDisabled(workflowID) { - ctx.Flash.Error(ctx.Tr("actions.workflow.disabled")) - ctx.Redirect(redirectURL) - return - } - - // get target commit of run from specified ref - refName := git.RefName(ref) - var runTargetCommit *git.Commit - var err error - if refName.IsTag() { - runTargetCommit, err = ctx.Repo.GitRepo.GetTagCommit(refName.TagName()) - } else if refName.IsBranch() { - runTargetCommit, err = ctx.Repo.GitRepo.GetBranchCommit(refName.BranchName()) - } else { - ctx.Flash.Error(ctx.Tr("form.git_ref_name_error", ref)) - ctx.Redirect(redirectURL) - return - } - if err != nil { - ctx.Flash.Error(ctx.Tr("form.target_ref_not_exist", ref)) - ctx.Redirect(redirectURL) - return - } - - // get workflow entry from runTargetCommit - entries, err := actions.ListWorkflows(runTargetCommit) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - - // find workflow from commit - var workflows []*jobparser.SingleWorkflow - for _, entry := range entries { - if entry.Name() == workflowID { - content, err := actions.GetContentFromEntry(entry) - if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) - return - } - workflows, err = jobparser.Parse(content) - if err != nil { - ctx.ServerError("workflow", err) - return - } - break - } - } - - if len(workflows) == 0 { - ctx.Flash.Error(ctx.Tr("actions.workflow.not_found", workflowID)) - ctx.Redirect(redirectURL) - return - } - - // get inputs from post - workflow := &model.Workflow{ - RawOn: workflows[0].RawOn, - } - inputs := make(map[string]any) - if workflowDispatch := workflow.WorkflowDispatchConfig(); workflowDispatch != nil { + err := actions_service.DispatchActionWorkflow(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, workflowID, ref, func(workflowDispatch *model.WorkflowDispatch, inputs map[string]any) error { for name, config := range workflowDispatch.Inputs { value := ctx.Req.PostFormValue(name) if config.Type == "boolean" { - // https://www.w3.org/TR/html401/interact/forms.html - // https://stackoverflow.com/questions/11424037/do-checkbox-inputs-only-post-data-if-theyre-checked - // Checkboxes (and radio buttons) are on/off switches that may be toggled by the user. - // A switch is "on" when the control element's checked attribute is set. - // When a form is submitted, only "on" checkbox controls can become successful. - inputs[name] = strconv.FormatBool(value == "on") + inputs[name] = strconv.FormatBool(ctx.FormBool(name)) } else if value != "" { inputs[name] = value } else { inputs[name] = config.Default } } - } - - // ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event - // https://docs.github.com/en/actions/learn-github-actions/contexts#github-context - // https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_dispatch - workflowDispatchPayload := &api.WorkflowDispatchPayload{ - Workflow: workflowID, - Ref: ref, - Repository: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}), - Inputs: inputs, - Sender: convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone), - } - var eventPayload []byte - if eventPayload, err = workflowDispatchPayload.JSONPayload(); err != nil { - ctx.ServerError("JSONPayload", err) - return - } - - run := &actions_model.ActionRun{ - Title: strings.SplitN(runTargetCommit.CommitMessage, "\n", 2)[0], - RepoID: ctx.Repo.Repository.ID, - OwnerID: ctx.Repo.Repository.OwnerID, - WorkflowID: workflowID, - TriggerUserID: ctx.Doer.ID, - Ref: ref, - CommitSHA: runTargetCommit.ID.String(), - IsForkPullRequest: false, - Event: "workflow_dispatch", - TriggerEvent: "workflow_dispatch", - EventPayload: string(eventPayload), - Status: actions_model.StatusWaiting, - } - - // cancel running jobs of the same workflow - if err := actions_model.CancelPreviousJobs( - ctx, - run.RepoID, - run.Ref, - run.WorkflowID, - run.Event, - ); err != nil { - log.Error("CancelRunningJobs: %v", err) - } - - // Insert the action run and its associated jobs into the database - if err := actions_model.InsertRun(ctx, run, workflows); err != nil { - ctx.ServerError("workflow", err) - return - } - - alljobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) + return nil + }) if err != nil { - log.Error("FindRunJobs: %v", err) + if errLocale := util.ErrorAsLocale(err); errLocale != nil { + ctx.Flash.Error(ctx.Tr(errLocale.TrKey, errLocale.TrArgs...)) + ctx.Redirect(redirectURL) + } else { + ctx.ServerError("DispatchActionWorkflow", err) + } + return } - actions_service.CreateCommitStatus(ctx, alljobs...) ctx.Flash.Success(ctx.Tr("actions.workflow.run_success", workflowID)) ctx.Redirect(redirectURL) diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go index 1d809ad8e9..8232f0cc04 100644 --- a/routers/web/repo/activity.go +++ b/routers/web/repo/activity.go @@ -8,6 +8,7 @@ import ( "time" activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/services/context" @@ -52,12 +53,26 @@ func Activity(ctx *context.Context) { ctx.Data["DateUntil"] = timeUntil ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + ctx.Data["Period"].(string)) + canReadCode := ctx.Repo.CanRead(unit.TypeCode) + if canReadCode { + // GetActivityStats needs to read the default branch to get some information + branchExist, _ := git.IsBranchExist(ctx, ctx.Repo.Repository.ID, ctx.Repo.Repository.DefaultBranch) + if !branchExist { + ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch) + ctx.NotFound(nil) + return + } + } + var err error - if ctx.Data["Activity"], err = activities_model.GetActivityStats(ctx, ctx.Repo.Repository, timeFrom, + // TODO: refactor these arguments to a struct + ctx.Data["Activity"], err = activities_model.GetActivityStats(ctx, ctx.Repo.Repository, timeFrom, ctx.Repo.CanRead(unit.TypeReleases), ctx.Repo.CanRead(unit.TypeIssues), ctx.Repo.CanRead(unit.TypePullRequests), - ctx.Repo.CanRead(unit.TypeCode)); err != nil { + canReadCode, + ) + if err != nil { ctx.ServerError("GetActivityStats", err) return } diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index f8e51521be..9eda926dad 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -34,13 +34,13 @@ func UploadReleaseAttachment(ctx *context.Context) { // UploadAttachment response for uploading attachments func uploadAttachment(ctx *context.Context, repoID int64, allowedTypes string) { if !setting.Attachment.Enabled { - ctx.Error(http.StatusNotFound, "attachment is not enabled") + ctx.HTTPError(http.StatusNotFound, "attachment is not enabled") return } file, header, err := ctx.Req.FormFile("file") if err != nil { - ctx.Error(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) + ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) return } defer file.Close() @@ -52,10 +52,10 @@ func uploadAttachment(ctx *context.Context, repoID int64, allowedTypes string) { }) if err != nil { if upload.IsErrFileTypeForbidden(err) { - ctx.Error(http.StatusBadRequest, err.Error()) + ctx.HTTPError(http.StatusBadRequest, err.Error()) return } - ctx.Error(http.StatusInternalServerError, fmt.Sprintf("NewAttachment: %v", err)) + ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("NewAttachment: %v", err)) return } @@ -70,16 +70,16 @@ func DeleteAttachment(ctx *context.Context) { file := ctx.FormString("file") attach, err := repo_model.GetAttachmentByUUID(ctx, file) if err != nil { - ctx.Error(http.StatusBadRequest, err.Error()) + ctx.HTTPError(http.StatusBadRequest, err.Error()) return } if !ctx.IsSigned || (ctx.Doer.ID != attach.UploaderID) { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } err = repo_model.DeleteAttachment(ctx, attach, true) if err != nil { - ctx.Error(http.StatusInternalServerError, fmt.Sprintf("DeleteAttachment: %v", err)) + ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("DeleteAttachment: %v", err)) return } ctx.JSON(http.StatusOK, map[string]string{ @@ -92,7 +92,7 @@ func ServeAttachment(ctx *context.Context, uuid string) { attach, err := repo_model.GetAttachmentByUUID(ctx, uuid) if err != nil { if repo_model.IsErrAttachmentNotExist(err) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) } else { ctx.ServerError("GetAttachmentByUUID", err) } @@ -107,17 +107,17 @@ func ServeAttachment(ctx *context.Context, uuid string) { if repository == nil { // If not linked if !(ctx.IsSigned && attach.UploaderID == ctx.Doer.ID) { // We block if not the uploader - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } } else { // If we have the repository we check access perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "GetUserRepoPermission", err.Error()) return } if !perm.CanRead(unitType) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } } diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 1022c5e6d6..e304633f95 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -8,19 +8,21 @@ import ( gotemplate "html/template" "net/http" "net/url" + "path" "strconv" "strings" + repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/languagestats" "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" - files_service "code.gitea.io/gitea/services/repository/files" ) type blameRow struct { @@ -40,74 +42,58 @@ type blameRow struct { // RefBlame render blame page func RefBlame(ctx *context.Context) { - fileName := ctx.Repo.TreePath - if len(fileName) == 0 { - ctx.NotFound("Blame FileName", nil) - return - } - - branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - treeLink := branchLink - rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() - - if len(ctx.Repo.TreePath) > 0 { - treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - } - - var treeNames []string - paths := make([]string, 0, 5) - if len(ctx.Repo.TreePath) > 0 { - treeNames = strings.Split(ctx.Repo.TreePath, "/") - for i := range treeNames { - paths = append(paths, strings.Join(treeNames[:i+1], "/")) - } - - ctx.Data["HasParentPath"] = true - if len(paths)-2 >= 0 { - ctx.Data["ParentPath"] = "/" + paths[len(paths)-1] - } - } + ctx.Data["PageIsViewCode"] = true + ctx.Data["IsBlame"] = true // Get current entry user currently looking at. + if ctx.Repo.TreePath == "" { + ctx.NotFound(nil) + return + } entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) if err != nil { HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) return } - blob := entry.Blob() + treeNames := strings.Split(ctx.Repo.TreePath, "/") + var paths []string + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } ctx.Data["Paths"] = paths - ctx.Data["TreeLink"] = treeLink ctx.Data["TreeNames"] = treeNames - ctx.Data["BranchLink"] = branchLink - - ctx.Data["RawFileLink"] = rawLink + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - ctx.Data["PageIsViewCode"] = true - - ctx.Data["IsBlame"] = true + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + blob := entry.Blob() fileSize := blob.Size() ctx.Data["FileSize"] = fileSize - ctx.Data["FileName"] = blob.Name() + ctx.Data["FileTreePath"] = ctx.Repo.TreePath + + tplName := tplRepoViewContent + if !ctx.FormBool("only_content") { + prepareHomeTreeSideBarSwitch(ctx) + tplName = tplRepoView + } if fileSize >= setting.UI.MaxDisplayFileSize { ctx.Data["IsFileTooLarge"] = true - ctx.HTML(http.StatusOK, tplRepoHome) + ctx.HTML(http.StatusOK, tplName) return } - ctx.Data["NumLines"], err = blob.GetBlobLineCount() + ctx.Data["NumLines"], err = blob.GetBlobLineCount(nil) if err != nil { - ctx.NotFound("GetBlobLineCount", err) + ctx.NotFound(err) return } bypassBlameIgnore, _ := strconv.ParseBool(ctx.FormString("bypass-blame-ignore")) - - result, err := performBlame(ctx, ctx.Repo.Repository.RepoPath(), ctx.Repo.Commit, fileName, bypassBlameIgnore) + result, err := performBlame(ctx, ctx.Repo.Repository, ctx.Repo.Commit, ctx.Repo.TreePath, bypassBlameIgnore) if err != nil { - ctx.NotFound("CreateBlameReader", err) + ctx.NotFound(err) return } @@ -121,7 +107,7 @@ func RefBlame(ctx *context.Context) { renderBlame(ctx, result.Parts, commitNames) - ctx.HTML(http.StatusOK, tplRepoHome) + ctx.HTML(http.StatusOK, tplName) } type blameResult struct { @@ -130,10 +116,10 @@ type blameResult struct { FaultyIgnoreRevsFile bool } -func performBlame(ctx *context.Context, repoPath string, commit *git.Commit, file string, bypassBlameIgnore bool) (*blameResult, error) { +func performBlame(ctx *context.Context, repo *repo_model.Repository, commit *git.Commit, file string, bypassBlameIgnore bool) (*blameResult, error) { objectFormat := ctx.Repo.GetObjectFormat() - blameReader, err := git.CreateBlameReader(ctx, objectFormat, repoPath, commit, file, bypassBlameIgnore) + blameReader, err := git.CreateBlameReader(ctx, objectFormat, repo.RepoPath(), commit, file, bypassBlameIgnore) if err != nil { return nil, err } @@ -149,7 +135,7 @@ func performBlame(ctx *context.Context, repoPath string, commit *git.Commit, fil if len(r.Parts) == 0 && r.UsesIgnoreRevs { // try again without ignored revs - blameReader, err = git.CreateBlameReader(ctx, objectFormat, repoPath, commit, file, true) + blameReader, err = git.CreateBlameReader(ctx, objectFormat, repo.RepoPath(), commit, file, true) if err != nil { return nil, err } @@ -221,7 +207,7 @@ func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[st commit, err = ctx.Repo.GitRepo.GetCommit(sha) if err != nil { if git.IsErrNotExist(err) { - ctx.NotFound("Repo.GitRepo.GetCommit", err) + ctx.NotFound(err) } else { ctx.ServerError("Repo.GitRepo.GetCommit", err) } @@ -234,7 +220,12 @@ func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[st } // populate commit email addresses to later look up avatars. - for _, c := range user_model.ValidateCommitsWithEmails(ctx, commits) { + validatedCommits, err := user_model.ValidateCommitsWithEmails(ctx, commits) + if err != nil { + ctx.ServerError("ValidateCommitsWithEmails", err) + return nil + } + for _, c := range validatedCommits { commitNames[c.ID.String()] = c } @@ -244,7 +235,7 @@ func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[st func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) { repoLink := ctx.Repo.RepoLink - language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) + language, err := languagestats.GetFileLanguage(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) if err != nil { log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) } @@ -295,8 +286,7 @@ func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames if i != len(lines)-1 { line += "\n" } - fileName := fmt.Sprintf("%v", ctx.Data["FileName"]) - line, lexerNameForLine := highlight.Code(fileName, language, line) + line, lexerNameForLine := highlight.Code(path.Base(ctx.Repo.TreePath), language, line) // set lexer name to the first detected lexer. this is certainly suboptimal and // we should instead highlight the whole file at once diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go index 8747526f72..96d1d87836 100644 --- a/routers/web/repo/branch.go +++ b/routers/web/repo/branch.go @@ -45,10 +45,7 @@ func Branches(ctx *context.Context) { ctx.Data["PageIsViewCode"] = true ctx.Data["PageIsBranches"] = true - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) pageSize := setting.Git.BranchesRangeSize kw := ctx.FormString("q") @@ -93,7 +90,7 @@ func Branches(ctx *context.Context) { // DeleteBranchPost responses for delete merged branch func DeleteBranchPost(ctx *context.Context) { - defer redirect(ctx) + defer jsonRedirectBranches(ctx) branchName := ctx.FormString("name") if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName, nil); err != nil { @@ -120,7 +117,7 @@ func DeleteBranchPost(ctx *context.Context) { // RestoreBranchPost responses for delete merged branch func RestoreBranchPost(ctx *context.Context) { - defer redirect(ctx) + defer jsonRedirectBranches(ctx) branchID := ctx.FormInt64("branch_id") branchName := ctx.FormString("name") @@ -170,7 +167,7 @@ func RestoreBranchPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.branch.restore_success", deletedBranch.Name)) } -func redirect(ctx *context.Context) { +func jsonRedirectBranches(ctx *context.Context) { ctx.JSONRedirect(ctx.Repo.RepoLink + "/branches?page=" + url.QueryEscape(ctx.FormString("page"))) } @@ -178,7 +175,7 @@ func redirect(ctx *context.Context) { func CreateBranch(ctx *context.Context) { form := web.GetForm(ctx).(*forms.NewBranchForm) if !ctx.Repo.CanCreateBranch() { - ctx.NotFound("CreateBranch", nil) + ctx.NotFound(nil) return } @@ -261,10 +258,10 @@ func CreateBranch(ctx *context.Context) { func MergeUpstream(ctx *context.Context) { branchName := ctx.FormString("branch") - _, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName) + _, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, branchName, false) if err != nil { if errors.Is(err, util.ErrNotExist) { - ctx.JSONError(ctx.Tr("error.not_found")) + ctx.JSONErrorNotFound() return } else if pull_service.IsErrMergeConflicts(err) { ctx.JSONError(ctx.Tr("repo.pulls.merge_conflict")) diff --git a/routers/web/repo/cherry_pick.go b/routers/web/repo/cherry_pick.go deleted file mode 100644 index 33d941c9d8..0000000000 --- a/routers/web/repo/cherry_pick.go +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repo - -import ( - "bytes" - "errors" - "strings" - - git_model "code.gitea.io/gitea/models/git" - "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" - "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/services/context" - "code.gitea.io/gitea/services/forms" - "code.gitea.io/gitea/services/repository/files" -) - -var tplCherryPick templates.TplName = "repo/editor/cherry_pick" - -// CherryPick handles cherrypick GETs -func CherryPick(ctx *context.Context) { - ctx.Data["SHA"] = ctx.PathParam("sha") - cherryPickCommit, err := ctx.Repo.GitRepo.GetCommit(ctx.PathParam("sha")) - if err != nil { - if git.IsErrNotExist(err) { - ctx.NotFound("Missing Commit", err) - return - } - ctx.ServerError("GetCommit", err) - return - } - - if ctx.FormString("cherry-pick-type") == "revert" { - ctx.Data["CherryPickType"] = "revert" - ctx.Data["commit_summary"] = "revert " + ctx.PathParam("sha") - ctx.Data["commit_message"] = "revert " + cherryPickCommit.Message() - } else { - ctx.Data["CherryPickType"] = "cherry-pick" - splits := strings.SplitN(cherryPickCommit.Message(), "\n", 2) - ctx.Data["commit_summary"] = splits[0] - ctx.Data["commit_message"] = splits[1] - } - - canCommit := renderCommitRights(ctx) - ctx.Data["TreePath"] = "" - - if canCommit { - ctx.Data["commit_choice"] = frmCommitChoiceDirect - } else { - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - } - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) - ctx.Data["last_commit"] = ctx.Repo.CommitID - ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - - ctx.HTML(200, tplCherryPick) -} - -// CherryPickPost handles cherrypick POSTs -func CherryPickPost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.CherryPickForm) - - sha := ctx.PathParam("sha") - ctx.Data["SHA"] = sha - if form.Revert { - ctx.Data["CherryPickType"] = "revert" - } else { - ctx.Data["CherryPickType"] = "cherry-pick" - } - - canCommit := renderCommitRights(ctx) - branchName := ctx.Repo.BranchName - if form.CommitChoice == frmCommitChoiceNewBranch { - branchName = form.NewBranchName - } - ctx.Data["commit_summary"] = form.CommitSummary - ctx.Data["commit_message"] = form.CommitMessage - ctx.Data["commit_choice"] = form.CommitChoice - ctx.Data["new_branch_name"] = form.NewBranchName - ctx.Data["last_commit"] = ctx.Repo.CommitID - ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - - if ctx.HasError() { - ctx.HTML(200, tplCherryPick) - return - } - - // Cannot commit to a an existing branch if user doesn't have rights - if branchName == ctx.Repo.BranchName && !canCommit { - ctx.Data["Err_NewBranchName"] = true - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplCherryPick, &form) - return - } - - message := strings.TrimSpace(form.CommitSummary) - if message == "" { - if form.Revert { - message = ctx.Locale.TrString("repo.commit.revert-header", sha) - } else { - message = ctx.Locale.TrString("repo.commit.cherry-pick-header", sha) - } - } - - form.CommitMessage = strings.TrimSpace(form.CommitMessage) - if len(form.CommitMessage) > 0 { - message += "\n\n" + form.CommitMessage - } - - opts := &files.ApplyDiffPatchOptions{ - LastCommitID: form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: branchName, - Message: message, - } - - // First lets try the simple plain read-tree -m approach - opts.Content = sha - if _, err := files.CherryPick(ctx, ctx.Repo.Repository, ctx.Doer, form.Revert, opts); err != nil { - if git_model.IsErrBranchAlreadyExists(err) { - // User has specified a branch that already exists - branchErr := err.(git_model.ErrBranchAlreadyExists) - ctx.Data["Err_NewBranchName"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form) - return - } else if files.IsErrCommitIDDoesNotMatch(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) - return - } - // Drop through to the apply technique - - buf := &bytes.Buffer{} - if form.Revert { - if err := git.GetReverseRawDiff(ctx, ctx.Repo.Repository.RepoPath(), sha, buf); err != nil { - if git.IsErrNotExist(err) { - ctx.NotFound("GetRawDiff", errors.New("commit "+ctx.PathParam("sha")+" does not exist.")) - return - } - ctx.ServerError("GetRawDiff", err) - return - } - } else { - if err := git.GetRawDiff(ctx.Repo.GitRepo, sha, git.RawDiffType("patch"), buf); err != nil { - if git.IsErrNotExist(err) { - ctx.NotFound("GetRawDiff", errors.New("commit "+ctx.PathParam("sha")+" does not exist.")) - return - } - ctx.ServerError("GetRawDiff", err) - return - } - } - - opts.Content = buf.String() - ctx.Data["FileContent"] = opts.Content - - if _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil { - if git_model.IsErrBranchAlreadyExists(err) { - // User has specified a branch that already exists - branchErr := err.(git_model.ErrBranchAlreadyExists) - ctx.Data["Err_NewBranchName"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplCherryPick, &form) - return - } else if files.IsErrCommitIDDoesNotMatch(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) - return - } - ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) - return - } - } - - if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) { - ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) - } else { - ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName)) - } -} diff --git a/routers/web/repo/code_frequency.go b/routers/web/repo/code_frequency.go index 6572adce74..2b2dd5744a 100644 --- a/routers/web/repo/code_frequency.go +++ b/routers/web/repo/code_frequency.go @@ -29,12 +29,12 @@ func CodeFrequency(ctx *context.Context) { // CodeFrequencyData returns JSON of code frequency data func CodeFrequencyData(ctx *context.Context) { - if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch); err != nil { if errors.Is(err, contributors_service.ErrAwaitGeneration) { ctx.Status(http.StatusAccepted) return } - ctx.ServerError("GetCodeFrequencyData", err) + ctx.ServerError("GetContributorStats", err) } else { ctx.JSON(http.StatusOK, contributorStats["total"].Weeks) } diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 8ffda8ae0a..b3af138461 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -21,17 +21,20 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/gitgraph" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" + asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" + git_service "code.gitea.io/gitea/services/git" "code.gitea.io/gitea/services/gitdiff" repo_service "code.gitea.io/gitea/services/repository" + "code.gitea.io/gitea/services/repository/gitgraph" ) const ( @@ -57,17 +60,14 @@ func RefCommits(ctx *context.Context) { func Commits(ctx *context.Context) { ctx.Data["PageIsCommits"] = true if ctx.Repo.Commit == nil { - ctx.NotFound("Commit not found", nil) + ctx.NotFound(nil) return } ctx.Data["PageIsViewCode"] = true commitsCount := ctx.Repo.CommitsCount - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) pageSize := ctx.FormInt("limit") if pageSize <= 0 { @@ -75,12 +75,16 @@ func Commits(ctx *context.Context) { } // Both `git log branchName` and `git log commitId` work. - commits, err := ctx.Repo.Commit.CommitsByRange(page, pageSize, "") + commits, err := ctx.Repo.Commit.CommitsByRange(page, pageSize, "", "", "") if err != nil { ctx.ServerError("CommitsByRange", err) return } - ctx.Data["Commits"] = processGitCommits(ctx, commits) + ctx.Data["Commits"], err = processGitCommits(ctx, commits) + if err != nil { + ctx.ServerError("processGitCommits", err) + return + } commitIDs := make([]string, 0, len(commits)) for _, c := range commits { commitIDs = append(commitIDs, c.ID.String()) @@ -192,7 +196,11 @@ func SearchCommits(ctx *context.Context) { return } ctx.Data["CommitCount"] = len(commits) - ctx.Data["Commits"] = processGitCommits(ctx, commits) + ctx.Data["Commits"], err = processGitCommits(ctx, commits) + if err != nil { + ctx.ServerError("processGitCommits", err) + return + } ctx.Data["Keyword"] = query if all { @@ -205,41 +213,41 @@ func SearchCommits(ctx *context.Context) { // FileHistory show a file's reversions func FileHistory(ctx *context.Context) { - fileName := ctx.Repo.TreePath - if len(fileName) == 0 { + if ctx.Repo.TreePath == "" { Commits(ctx) return } - commitsCount, err := ctx.Repo.GitRepo.FileCommitsCount(ctx.Repo.RefFullName.ShortName(), fileName) // FIXME: legacy code used ShortName + commitsCount, err := ctx.Repo.GitRepo.FileCommitsCount(ctx.Repo.RefFullName.ShortName(), ctx.Repo.TreePath) if err != nil { ctx.ServerError("FileCommitsCount", err) return } else if commitsCount == 0 { - ctx.NotFound("FileCommitsCount", nil) + ctx.NotFound(nil) return } - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) commits, err := ctx.Repo.GitRepo.CommitsByFileAndRange( git.CommitsByFileAndRangeOptions{ Revision: ctx.Repo.RefFullName.ShortName(), // FIXME: legacy code used ShortName - File: fileName, + File: ctx.Repo.TreePath, Page: page, }) if err != nil { ctx.ServerError("CommitsByFileAndRange", err) return } - ctx.Data["Commits"] = processGitCommits(ctx, commits) + ctx.Data["Commits"], err = processGitCommits(ctx, commits) + if err != nil { + ctx.ServerError("processGitCommits", err) + return + } ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name - ctx.Data["FileName"] = fileName + ctx.Data["FileTreePath"] = ctx.Repo.TreePath ctx.Data["CommitCount"] = commitsCount pager := context.NewPagination(int(commitsCount), setting.Git.CommitsRangeSize, page, 5) @@ -270,7 +278,7 @@ func Diff(ctx *context.Context) { ) if ctx.Data["PageIsWiki"] != nil { - gitRepo, err = gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + gitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo()) if err != nil { ctx.ServerError("Repo.GitRepo.GetCommit", err) return @@ -283,7 +291,7 @@ func Diff(ctx *context.Context) { commit, err := gitRepo.GetCommit(commitID) if err != nil { if git.IsErrNotExist(err) { - ctx.NotFound("Repo.GitRepo.GetCommit", err) + ctx.NotFound(err) } else { ctx.ServerError("Repo.GitRepo.GetCommit", err) } @@ -300,25 +308,30 @@ func Diff(ctx *context.Context) { maxLines, maxFiles = -1, -1 } - diff, err := gitdiff.GetDiff(ctx, gitRepo, &gitdiff.DiffOptions{ + diff, err := gitdiff.GetDiffForRender(ctx, gitRepo, &gitdiff.DiffOptions{ AfterCommitID: commitID, SkipTo: ctx.FormString("skip-to"), MaxLines: maxLines, MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, MaxFiles: maxFiles, WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), - FileOnly: fileOnly, }, files...) if err != nil { - ctx.NotFound("GetDiff", err) + ctx.NotFound(err) + return + } + diffShortStat, err := gitdiff.GetDiffShortStat(gitRepo, "", commitID) + if err != nil { + ctx.ServerError("GetDiffShortStat", err) return } + ctx.Data["DiffShortStat"] = diffShortStat parents := make([]string, commit.ParentCount()) for i := 0; i < commit.ParentCount(); i++ { sha, err := commit.ParentID(i) if err != nil { - ctx.NotFound("repo.Diff", err) + ctx.NotFound(err) return } parents[i] = sha.String() @@ -330,19 +343,35 @@ func Diff(ctx *context.Context) { ctx.Data["Reponame"] = repoName var parentCommit *git.Commit + var parentCommitID string if commit.ParentCount() > 0 { parentCommit, err = gitRepo.GetCommit(parents[0]) if err != nil { - ctx.NotFound("GetParentCommit", err) + ctx.NotFound(err) return } + parentCommitID = parentCommit.ID.String() } setCompareContext(ctx, parentCommit, commit, userName, repoName) ctx.Data["Title"] = commit.Summary() + " · " + base.ShortSha(commitID) ctx.Data["Commit"] = commit ctx.Data["Diff"] = diff - statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll) + if !fileOnly { + diffTree, err := gitdiff.GetDiffTree(ctx, gitRepo, false, parentCommitID, commitID) + if err != nil { + ctx.ServerError("GetDiffTree", err) + return + } + + renderedIconPool := fileicon.NewRenderedIconPool() + ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil) + ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder()) + ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen()) + ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML() + } + + statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll) if err != nil { log.Error("GetLatestCommitStatus: %v", err) } @@ -353,11 +382,11 @@ func Diff(ctx *context.Context) { ctx.Data["CommitStatus"] = git_model.CalcCommitStatus(statuses) ctx.Data["CommitStatuses"] = statuses - verification := asymkey_model.ParseCommitWithSignature(ctx, commit) + verification := asymkey_service.ParseCommitWithSignature(ctx, commit) ctx.Data["Verification"] = verification ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit) ctx.Data["Parents"] = parents - ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) { return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID) @@ -386,7 +415,7 @@ func Diff(ctx *context.Context) { func RawDiff(ctx *context.Context) { var gitRepo *git.Repository if ctx.Data["PageIsWiki"] != nil { - wikiRepo, err := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + wikiRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo()) if err != nil { ctx.ServerError("OpenRepository", err) return @@ -407,8 +436,7 @@ func RawDiff(ctx *context.Context) { ctx.Resp, ); err != nil { if git.IsErrNotExist(err) { - ctx.NotFound("GetRawDiff", - errors.New("commit "+ctx.PathParam("sha")+" does not exist.")) + ctx.NotFound(errors.New("commit " + ctx.PathParam("sha") + " does not exist.")) return } ctx.ServerError("GetRawDiff", err) @@ -416,13 +444,19 @@ func RawDiff(ctx *context.Context) { } } -func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) []*git_model.SignCommitWithStatuses { - commits := git_model.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository) +func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_model.SignCommitWithStatuses, error) { + commits, err := git_service.ConvertFromGitCommit(ctx, gitCommits, ctx.Repo.Repository) + if err != nil { + return nil, err + } if !ctx.Repo.CanRead(unit_model.TypeActions) { for _, commit := range commits { + if commit.Status == nil { + continue + } commit.Status.HideActionsURL(ctx) git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses) } } - return commits + return commits, nil } diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index b3c1eb7cb0..de34a9375c 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" csv_module "code.gitea.io/gitea/modules/csv" + "code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" @@ -258,7 +259,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { ci.HeadUser, err = user_model.GetUserByName(ctx, headInfos[0]) if err != nil { if user_model.IsErrUserNotExist(err) { - ctx.NotFound("GetUserByName", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetUserByName", err) } @@ -273,7 +274,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { ci.HeadRepo, err = repo_model.GetRepositoryByOwnerAndName(ctx, headInfosSplit[0], headInfosSplit[1]) if err != nil { if repo_model.IsErrRepoNotExist(err) { - ctx.NotFound("GetRepositoryByOwnerAndName", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetRepositoryByOwnerAndName", err) } @@ -281,7 +282,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { } if err := ci.HeadRepo.LoadOwner(ctx); err != nil { if user_model.IsErrUserNotExist(err) { - ctx.NotFound("GetUserByName", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetUserByName", err) } @@ -292,7 +293,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { isSameRepo = ci.HeadRepo.ID == ctx.Repo.Repository.ID } } else { - ctx.NotFound("CompareAndPullRequest", nil) + ctx.NotFound(nil) return nil } ctx.Data["HeadUser"] = ci.HeadUser @@ -301,8 +302,8 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { // Check if base branch is valid. baseIsCommit := ctx.Repo.GitRepo.IsCommitExist(ci.BaseBranch) - baseIsBranch := ctx.Repo.GitRepo.IsBranchExist(ci.BaseBranch) - baseIsTag := ctx.Repo.GitRepo.IsTagExist(ci.BaseBranch) + baseIsBranch := gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, ci.BaseBranch) + baseIsTag := gitrepo.IsTagExist(ctx, ctx.Repo.Repository, ci.BaseBranch) if !baseIsCommit && !baseIsBranch && !baseIsTag { // Check if baseBranch is short sha commit hash @@ -318,7 +319,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { } return nil } else { - ctx.NotFound("IsRefExist", nil) + ctx.NotFound(nil) return nil } } @@ -401,14 +402,13 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { ci.HeadRepo = ctx.Repo.Repository ci.HeadGitRepo = ctx.Repo.GitRepo } else if has { - ci.HeadGitRepo, err = gitrepo.OpenRepository(ctx, ci.HeadRepo) + ci.HeadGitRepo, err = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ci.HeadRepo) if err != nil { - ctx.ServerError("OpenRepository", err) + ctx.ServerError("RepositoryFromRequestContextOrOpen", err) return nil } - defer ci.HeadGitRepo.Close() } else { - ctx.NotFound("ParseCompareInfo", nil) + ctx.NotFound(nil) return nil } @@ -430,7 +430,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { baseRepo, permBase) } - ctx.NotFound("ParseCompareInfo", nil) + ctx.NotFound(nil) return nil } @@ -449,7 +449,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { ci.HeadRepo, permHead) } - ctx.NotFound("ParseCompareInfo", nil) + ctx.NotFound(nil) return nil } ctx.Data["CanWriteToHeadRepo"] = permHead.CanWrite(unit.TypeCode) @@ -504,8 +504,8 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { // Check if head branch is valid. headIsCommit := ci.HeadGitRepo.IsCommitExist(ci.HeadBranch) - headIsBranch := ci.HeadGitRepo.IsBranchExist(ci.HeadBranch) - headIsTag := ci.HeadGitRepo.IsTagExist(ci.HeadBranch) + headIsBranch := gitrepo.IsBranchExist(ctx, ci.HeadRepo, ci.HeadBranch) + headIsTag := gitrepo.IsTagExist(ctx, ci.HeadRepo, ci.HeadBranch) if !headIsCommit && !headIsBranch && !headIsTag { // Check if headBranch is short sha commit hash if headCommit, _ := ci.HeadGitRepo.GetCommit(ci.HeadBranch); headCommit != nil { @@ -513,7 +513,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { ctx.Data["HeadBranch"] = ci.HeadBranch headIsCommit = true } else { - ctx.NotFound("IsRefExist", nil) + ctx.NotFound(nil) return nil } } @@ -533,7 +533,7 @@ func ParseCompareInfo(ctx *context.Context) *common.CompareInfo { baseRepo, permBase) } - ctx.NotFound("ParseCompareInfo", nil) + ctx.NotFound(nil) return nil } @@ -569,20 +569,20 @@ func PrepareCompareDiff( ctx *context.Context, ci *common.CompareInfo, whitespaceBehavior git.TrustedCmdArgs, -) bool { - var ( - repo = ctx.Repo.Repository - err error - title string - ) - - // Get diff information. - ctx.Data["CommitRepoLink"] = ci.HeadRepo.Link() - +) (nothingToCompare bool) { + repo := ctx.Repo.Repository headCommitID := ci.CompareInfo.HeadCommitID + ctx.Data["CommitRepoLink"] = ci.HeadRepo.Link() ctx.Data["AfterCommitID"] = headCommitID + // follow GitHub's behavior: autofill the form and expand + newPrFormTitle := ctx.FormTrim("title") + newPrFormBody := ctx.FormTrim("body") + ctx.Data["ExpandNewPrForm"] = ctx.FormBool("expand") || ctx.FormBool("quick_pull") || newPrFormTitle != "" || newPrFormBody != "" + ctx.Data["TitleQuery"] = newPrFormTitle + ctx.Data["BodyQuery"] = newPrFormBody + if (headCommitID == ci.CompareInfo.MergeBase && !ci.DirectComparison) || headCommitID == ci.CompareInfo.BaseCommitID { ctx.Data["IsNothingToCompare"] = true @@ -614,7 +614,7 @@ func PrepareCompareDiff( fileOnly := ctx.FormBool("file-only") - diff, err := gitdiff.GetDiff(ctx, ci.HeadGitRepo, + diff, err := gitdiff.GetDiffForRender(ctx, ci.HeadGitRepo, &gitdiff.DiffOptions{ BeforeCommitID: beforeCommitID, AfterCommitID: headCommitID, @@ -624,14 +624,33 @@ func PrepareCompareDiff( MaxFiles: maxFiles, WhitespaceBehavior: whitespaceBehavior, DirectComparison: ci.DirectComparison, - FileOnly: fileOnly, }, ctx.FormStrings("files")...) if err != nil { - ctx.ServerError("GetDiffRangeWithWhitespaceBehavior", err) + ctx.ServerError("GetDiff", err) + return false + } + diffShortStat, err := gitdiff.GetDiffShortStat(ci.HeadGitRepo, beforeCommitID, headCommitID) + if err != nil { + ctx.ServerError("GetDiffShortStat", err) return false } + ctx.Data["DiffShortStat"] = diffShortStat ctx.Data["Diff"] = diff - ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 + + if !fileOnly { + diffTree, err := gitdiff.GetDiffTree(ctx, ci.HeadGitRepo, false, beforeCommitID, headCommitID) + if err != nil { + ctx.ServerError("GetDiffTree", err) + return false + } + + renderedIconPool := fileicon.NewRenderedIconPool() + ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, nil) + ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder()) + ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen()) + ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML() + } headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID) if err != nil { @@ -647,10 +666,15 @@ func PrepareCompareDiff( return false } - commits := processGitCommits(ctx, ci.CompareInfo.Commits) + commits, err := processGitCommits(ctx, ci.CompareInfo.Commits) + if err != nil { + ctx.ServerError("processGitCommits", err) + return false + } ctx.Data["Commits"] = commits ctx.Data["CommitCount"] = len(commits) + title := ci.HeadBranch if len(commits) == 1 { c := commits[0] title = strings.TrimSpace(c.UserCommit.Summary()) @@ -659,9 +683,8 @@ func PrepareCompareDiff( if len(body) > 1 { ctx.Data["content"] = strings.Join(body[1:], "\n") } - } else { - title = ci.HeadBranch } + if len(title) > 255 { var trailer string title, trailer = util.EllipsisDisplayStringX(title, 255) @@ -708,11 +731,6 @@ func getBranchesAndTagsForRepo(ctx gocontext.Context, repo *repo_model.Repositor // CompareDiff show different from one commit to another commit func CompareDiff(ctx *context.Context) { ci := ParseCompareInfo(ctx) - defer func() { - if ci != nil && ci.HeadGitRepo != nil { - ci.HeadGitRepo.Close() - } - }() if ctx.Written() { return } @@ -726,8 +744,7 @@ func CompareDiff(ctx *context.Context) { ctx.Data["OtherCompareSeparator"] = "..." } - nothingToCompare := PrepareCompareDiff(ctx, ci, - gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string))) + nothingToCompare := PrepareCompareDiff(ctx, ci, gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string))) if ctx.Written() { return } @@ -866,7 +883,7 @@ func ExcerptBlob(ctx *context.Context) { gitRepo := ctx.Repo.GitRepo if ctx.Data["PageIsWiki"] == true { var err error - gitRepo, err = gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + gitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo()) if err != nil { ctx.ServerError("OpenRepository", err) return @@ -876,12 +893,11 @@ func ExcerptBlob(ctx *context.Context) { chunkSize := gitdiff.BlobExcerptChunkSize commit, err := gitRepo.GetCommit(commitID) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetCommit") + ctx.HTTPError(http.StatusInternalServerError, "GetCommit") return } section := &gitdiff.DiffSection{ FileName: filePath, - Name: filePath, } if direction == "up" && (idxLeft-lastLeft) > chunkSize { idxLeft -= chunkSize @@ -905,7 +921,7 @@ func ExcerptBlob(ctx *context.Context) { idxRight = lastRight } if err != nil { - ctx.Error(http.StatusInternalServerError, "getExcerptLines") + ctx.HTTPError(http.StatusInternalServerError, "getExcerptLines") return } if idxRight > lastRight { @@ -927,9 +943,10 @@ func ExcerptBlob(ctx *context.Context) { RightHunkSize: rightHunkSize, }, } - if direction == "up" { + switch direction { + case "up": section.Lines = append([]*gitdiff.DiffLine{lineSection}, section.Lines...) - } else if direction == "down" { + case "down": section.Lines = append(section.Lines, lineSection) } } diff --git a/routers/web/repo/contributors.go b/routers/web/repo/contributors.go index e9c0919955..93dec1f350 100644 --- a/routers/web/repo/contributors.go +++ b/routers/web/repo/contributors.go @@ -26,7 +26,7 @@ func Contributors(ctx *context.Context) { // ContributorsData renders JSON of contributors along with their weekly commit statistics func ContributorsData(ctx *context.Context) { - if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { + if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch); err != nil { if errors.Is(err, contributors_service.ErrAwaitGeneration) { ctx.Status(http.StatusAccepted) return diff --git a/routers/web/repo/download.go b/routers/web/repo/download.go index cb1163c70b..020cebf196 100644 --- a/routers/web/repo/download.go +++ b/routers/web/repo/download.go @@ -46,7 +46,7 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Tim log.Error("ServeBlobOrLFS: Close: %v", err) } closed = true - return common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified) + return common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified) } if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) { return nil @@ -78,14 +78,14 @@ func ServeBlobOrLFS(ctx *context.Context, blob *git.Blob, lastModified *time.Tim } closed = true - return common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified) + return common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified) } func getBlobForEntry(ctx *context.Context) (*git.Blob, *time.Time) { entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) if err != nil { if git.IsErrNotExist(err) { - ctx.NotFound("GetTreeEntryByPath", err) + ctx.NotFound(err) } else { ctx.ServerError("GetTreeEntryByPath", err) } @@ -93,7 +93,7 @@ func getBlobForEntry(ctx *context.Context) (*git.Blob, *time.Time) { } if entry.IsDir() || entry.IsSubModule() { - ctx.NotFound("getBlobForEntry", nil) + ctx.NotFound(nil) return nil, nil } @@ -114,7 +114,7 @@ func SingleDownload(ctx *context.Context) { return } - if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil { + if err := common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified); err != nil { ctx.ServerError("ServeBlob", err) } } @@ -136,13 +136,13 @@ func DownloadByID(ctx *context.Context) { blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha")) if err != nil { if git.IsErrNotExist(err) { - ctx.NotFound("GetBlob", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetBlob", err) } return } - if err = common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, nil); err != nil { + if err = common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, nil); err != nil { ctx.ServerError("ServeBlob", err) } } @@ -152,7 +152,7 @@ func DownloadByIDOrLFS(ctx *context.Context) { blob, err := ctx.Repo.GitRepo.GetBlob(ctx.PathParam("sha")) if err != nil { if git.IsErrNotExist(err) { - ctx.NotFound("GetBlob", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetBlob", err) } diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go index 85f407ab8d..2a5ac10282 100644 --- a/routers/web/repo/editor.go +++ b/routers/web/repo/editor.go @@ -4,6 +4,7 @@ package repo import ( + "bytes" "fmt" "io" "net/http" @@ -11,19 +12,17 @@ import ( "strings" git_model "code.gitea.io/gitea/models/git" - repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" - "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/routers/utils" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/forms" @@ -35,848 +34,422 @@ const ( tplEditDiffPreview templates.TplName = "repo/editor/diff_preview" tplDeleteFile templates.TplName = "repo/editor/delete" tplUploadFile templates.TplName = "repo/editor/upload" + tplPatchFile templates.TplName = "repo/editor/patch" + tplCherryPick templates.TplName = "repo/editor/cherry_pick" - frmCommitChoiceDirect string = "direct" - frmCommitChoiceNewBranch string = "commit-to-new-branch" + editorCommitChoiceDirect string = "direct" + editorCommitChoiceNewBranch string = "commit-to-new-branch" ) -func canCreateBasePullRequest(ctx *context.Context) bool { - baseRepo := ctx.Repo.Repository.BaseRepo - return baseRepo != nil && baseRepo.UnitEnabled(ctx, unit.TypePullRequests) -} - -func renderCommitRights(ctx *context.Context) bool { - canCommitToBranch, err := ctx.Repo.CanCommitToBranch(ctx, ctx.Doer) - if err != nil { - log.Error("CanCommitToBranch: %v", err) - } - ctx.Data["CanCommitToBranch"] = canCommitToBranch - ctx.Data["CanCreatePullRequest"] = ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) || canCreateBasePullRequest(ctx) - - return canCommitToBranch.CanCommitToBranch -} - -// redirectForCommitChoice redirects after committing the edit to a branch -func redirectForCommitChoice(ctx *context.Context, commitChoice, newBranchName, treePath string) { - if commitChoice == frmCommitChoiceNewBranch { - // Redirect to a pull request when possible - redirectToPullRequest := false - repo := ctx.Repo.Repository - baseBranch := ctx.Repo.BranchName - headBranch := newBranchName - if repo.UnitEnabled(ctx, unit.TypePullRequests) { - redirectToPullRequest = true - } else if canCreateBasePullRequest(ctx) { - redirectToPullRequest = true - baseBranch = repo.BaseRepo.DefaultBranch - headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch - repo = repo.BaseRepo - } - - if redirectToPullRequest { - ctx.Redirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch)) - return +func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions { + cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath) + if cleanedTreePath != ctx.Repo.TreePath { + redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath)) + if ctx.Req.URL.RawQuery != "" { + redirectTo += "?" + ctx.Req.URL.RawQuery } + ctx.Redirect(redirectTo) + return nil } - returnURI := ctx.FormString("return_uri") - - ctx.RedirectToCurrentSite( - returnURI, - ctx.Repo.RepoLink+"/src/branch/"+util.PathEscapeSegments(newBranchName)+"/"+util.PathEscapeSegments(treePath), - ) -} - -// getParentTreeFields returns list of parent tree names and corresponding tree paths -// based on given tree path. -func getParentTreeFields(treePath string) (treeNames, treePaths []string) { - if len(treePath) == 0 { - return treeNames, treePaths - } - - treeNames = strings.Split(treePath, "/") - treePaths = make([]string, len(treeNames)) - for i := range treeNames { - treePaths[i] = strings.Join(treeNames[:i+1], "/") + commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName) + if err != nil { + ctx.ServerError("PrepareCommitFormOptions", err) + return nil } - return treeNames, treePaths -} - -func editFile(ctx *context.Context, isNewFile bool) { - ctx.Data["PageIsViewCode"] = true - ctx.Data["PageIsEdit"] = true - ctx.Data["IsNewFile"] = isNewFile - canCommit := renderCommitRights(ctx) - treePath := cleanUploadFileName(ctx.Repo.TreePath) - if treePath != ctx.Repo.TreePath { - if isNewFile { - ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_new", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) - } else { - ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_edit", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) - } - return + if commitFormOptions.NeedFork { + ForkToEdit(ctx) + return nil } - // Check if the filename (and additional path) is specified in the querystring - // (filename is a misnomer, but kept for compatibility with GitHub) - filePath, fileName := path.Split(ctx.Req.URL.Query().Get("filename")) - filePath = strings.Trim(filePath, "/") - treeNames, treePaths := getParentTreeFields(path.Join(ctx.Repo.TreePath, filePath)) - - if !isNewFile { - entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) - if err != nil { - HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) - return - } - - // No way to edit a directory online. - if entry.IsDir() { - ctx.NotFound("entry.IsDir", nil) - return - } - - blob := entry.Blob() - if blob.Size() >= setting.UI.MaxDisplayFileSize { - ctx.NotFound("blob.Size", err) - return - } - - dataRc, err := blob.DataAsync() - if err != nil { - ctx.NotFound("blob.Data", err) - return - } - - defer dataRc.Close() - - ctx.Data["FileSize"] = blob.Size() - ctx.Data["FileName"] = blob.Name() - - buf := make([]byte, 1024) - n, _ := util.ReadAtMost(dataRc, buf) - buf = buf[:n] - - // Only some file types are editable online as text. - if !typesniffer.DetectContentType(buf).IsRepresentableAsText() { - ctx.NotFound("typesniffer.IsRepresentableAsText", nil) - return - } - - d, _ := io.ReadAll(dataRc) - - buf = append(buf, d...) - if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil { - log.Error("ToUTF8: %v", err) - ctx.Data["FileContent"] = string(buf) - } else { - ctx.Data["FileContent"] = content - } - } else { - // Append filename from query, or empty string to allow user name the new file. - treeNames = append(treeNames, fileName) + if commitFormOptions.WillSubmitToFork && !commitFormOptions.TargetRepo.CanEnableEditor() { + ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.editor.fork_not_editable") + ctx.NotFound(nil) } - ctx.Data["TreeNames"] = treeNames - ctx.Data["TreePaths"] = treePaths ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - ctx.Data["commit_summary"] = "" - ctx.Data["commit_message"] = "" - if canCommit { - ctx.Data["commit_choice"] = frmCommitChoiceDirect - } else { - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - } - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) - ctx.Data["last_commit"] = ctx.Repo.CommitID + ctx.Data["TreePath"] = ctx.Repo.TreePath + ctx.Data["CommitFormOptions"] = commitFormOptions + + // for online editor ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",") ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") - ctx.Data["EditorconfigJson"] = GetEditorConfig(ctx, treePath) - ctx.Data["IsEditingFileOnly"] = ctx.FormString("return_uri") != "" ctx.Data["ReturnURI"] = ctx.FormString("return_uri") - ctx.HTML(http.StatusOK, tplEditFile) + // form fields + ctx.Data["commit_summary"] = "" + ctx.Data["commit_message"] = "" + ctx.Data["commit_choice"] = util.Iif(commitFormOptions.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch) + ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, commitFormOptions.TargetRepo) + ctx.Data["last_commit"] = ctx.Repo.CommitID + return commitFormOptions } -// GetEditorConfig returns a editorconfig JSON string for given treePath or "null" -func GetEditorConfig(ctx *context.Context, treePath string) string { - ec, _, err := ctx.Repo.GetEditorconfig() - if err == nil { - def, err := ec.GetDefinitionForFilename(treePath) - if err == nil { - jsonStr, _ := json.Marshal(def) - return string(jsonStr) - } - } - return "null" +func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) { + // show the tree path fields in the "breadcrumb" and help users to edit the target tree path + ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(strings.TrimPrefix(treePath, "/")) } -// EditFile render edit file page -func EditFile(ctx *context.Context) { - editFile(ctx, false) +type preparedEditorCommitForm[T any] struct { + form T + commonForm *forms.CommitCommonForm + CommitFormOptions *context.CommitFormOptions + OldBranchName string + NewBranchName string + GitCommitter *files_service.IdentityOptions } -// NewFile render create file page -func NewFile(ctx *context.Context) { - editFile(ctx, true) +func (f *preparedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string { + commitMessage := util.IfZero(strings.TrimSpace(f.commonForm.CommitSummary), defaultCommitMessage) + if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" { + commitMessage += "\n\n" + body + } + return commitMessage } -func editFilePost(ctx *context.Context, form forms.EditRepoFileForm, isNewFile bool) { - canCommit := renderCommitRights(ctx) - treeNames, treePaths := getParentTreeFields(form.TreePath) - branchName := ctx.Repo.BranchName - if form.CommitChoice == frmCommitChoiceNewBranch { - branchName = form.NewBranchName +func prepareEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *preparedEditorCommitForm[T] { + form := web.GetForm(ctx).(T) + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return nil } - ctx.Data["PageIsEdit"] = true - ctx.Data["PageHasPosted"] = true - ctx.Data["IsNewFile"] = isNewFile - ctx.Data["TreePath"] = form.TreePath - ctx.Data["TreeNames"] = treeNames - ctx.Data["TreePaths"] = treePaths - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(ctx.Repo.BranchName) - ctx.Data["FileContent"] = form.Content - ctx.Data["commit_summary"] = form.CommitSummary - ctx.Data["commit_message"] = form.CommitMessage - ctx.Data["commit_choice"] = form.CommitChoice - ctx.Data["new_branch_name"] = form.NewBranchName - ctx.Data["last_commit"] = ctx.Repo.CommitID - ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",") - ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") - ctx.Data["EditorconfigJson"] = GetEditorConfig(ctx, form.TreePath) + commonForm := form.GetCommitCommonForm() + commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath) - if ctx.HasError() { - ctx.HTML(http.StatusOK, tplEditFile) - return + commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName) + if err != nil { + ctx.ServerError("PrepareCommitFormOptions", err) + return nil } - - // Cannot commit to a an existing branch if user doesn't have rights - if branchName == ctx.Repo.BranchName && !canCommit { - ctx.Data["Err_NewBranchName"] = true - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form) - return + if commitFormOptions.NeedFork { + // It shouldn't happen, because we should have done the checks in the "GET" request. But just in case. + ctx.JSONError(ctx.Locale.TrString("error.not_found")) + return nil } - // CommitSummary is optional in the web form, if empty, give it a default message based on add or update - // `message` will be both the summary and message combined - message := strings.TrimSpace(form.CommitSummary) - if len(message) == 0 { - if isNewFile { - message = ctx.Locale.TrString("repo.editor.add", form.TreePath) - } else { - message = ctx.Locale.TrString("repo.editor.update", form.TreePath) - } + // check commit behavior + fromBaseBranch := ctx.FormString("from_base_branch") + commitToNewBranch := commonForm.CommitChoice == editorCommitChoiceNewBranch || fromBaseBranch != "" + targetBranchName := util.Iif(commitToNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName) + if targetBranchName == ctx.Repo.BranchName && !commitFormOptions.CanCommitToBranch { + ctx.JSONError(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", targetBranchName)) + return nil } - form.CommitMessage = strings.TrimSpace(form.CommitMessage) - if len(form.CommitMessage) > 0 { - message += "\n\n" + form.CommitMessage + + if !issues.CanMaintainerWriteToBranch(ctx, ctx.Repo.Permission, targetBranchName, ctx.Doer) { + ctx.NotFound(nil) + return nil } - operation := "update" - if isNewFile { - operation = "create" + // Committer user info + gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, commonForm.CommitEmail) + if !valid { + ctx.JSONError(ctx.Tr("repo.editor.invalid_commit_email")) + return nil } - if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ - LastCommitID: form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: branchName, - Message: message, - Files: []*files_service.ChangeRepoFile{ - { - Operation: operation, - FromTreePath: ctx.Repo.TreePath, - TreePath: form.TreePath, - ContentReader: strings.NewReader(strings.ReplaceAll(form.Content, "\r", "")), - }, - }, - Signoff: form.Signoff, - }); err != nil { - // This is where we handle all the errors thrown by files_service.ChangeRepoFiles - if git.IsErrNotExist(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_editing_no_longer_exists", ctx.Repo.TreePath), tplEditFile, &form) - } else if git_model.IsErrLFSFileLocked(err) { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.upload_file_is_locked", err.(git_model.ErrLFSFileLocked).Path, err.(git_model.ErrLFSFileLocked).UserName), tplEditFile, &form) - } else if files_service.IsErrFilenameInvalid(err) { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", form.TreePath), tplEditFile, &form) - } else if files_service.IsErrFilePathInvalid(err) { - ctx.Data["Err_TreePath"] = true - if fileErr, ok := err.(files_service.ErrFilePathInvalid); ok { - switch fileErr.Type { - case git.EntryModeSymlink: - ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplEditFile, &form) - case git.EntryModeTree: - ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplEditFile, &form) - case git.EntryModeBlob: - ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplEditFile, &form) - default: - ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", fileErr.Path), tplEditFile, &form) - } - } else { - ctx.Error(http.StatusInternalServerError, err.Error()) - } - } else if files_service.IsErrRepoFileAlreadyExists(err) { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.file_already_exists", form.TreePath), tplEditFile, &form) - } else if git.IsErrBranchNotExist(err) { - // For when a user adds/updates a file to a branch that no longer exists - if branchErr, ok := err.(git.ErrBranchNotExist); ok { - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplEditFile, &form) - } else { - ctx.Error(http.StatusInternalServerError, err.Error()) - } - } else if git_model.IsErrBranchAlreadyExists(err) { - // For when a user specifies a new branch that already exists - ctx.Data["Err_NewBranchName"] = true - if branchErr, ok := err.(git_model.ErrBranchAlreadyExists); ok { - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form) - } else { - ctx.Error(http.StatusInternalServerError, err.Error()) - } - } else if files_service.IsErrCommitIDDoesNotMatch(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.commit_id_not_matching"), tplEditFile, &form) - } else if git.IsErrPushOutOfDate(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.push_out_of_date"), tplEditFile, &form) - } else if git.IsErrPushRejected(err) { - errPushRej := err.(*git.ErrPushRejected) - if len(errPushRej.Message) == 0 { - ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplEditFile, &form) + if commitToNewBranch { + // if target branch exists, we should stop + targetBranchExists, err := git_model.IsBranchExist(ctx, commitFormOptions.TargetRepo.ID, targetBranchName) + if err != nil { + ctx.ServerError("IsBranchExist", err) + return nil + } else if targetBranchExists { + if fromBaseBranch != "" { + ctx.JSONError(ctx.Tr("repo.editor.fork_branch_exists", targetBranchName)) } else { - flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ - "Message": ctx.Tr("repo.editor.push_rejected"), - "Summary": ctx.Tr("repo.editor.push_rejected_summary"), - "Details": utils.SanitizeFlashErrorString(errPushRej.Message), - }) - if err != nil { - ctx.ServerError("editFilePost.HTMLString", err) - return - } - ctx.RenderWithErr(flashError, tplEditFile, &form) - } - } else { - flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ - "Message": ctx.Tr("repo.editor.fail_to_update_file", form.TreePath), - "Summary": ctx.Tr("repo.editor.fail_to_update_file_summary"), - "Details": utils.SanitizeFlashErrorString(err.Error()), - }) - if err != nil { - ctx.ServerError("editFilePost.HTMLString", err) - return + ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", targetBranchName)) } - ctx.RenderWithErr(flashError, tplEditFile, &form) + return nil } } - if ctx.Repo.Repository.IsEmpty { - if isEmpty, err := ctx.Repo.GitRepo.IsEmpty(); err == nil && !isEmpty { - _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, IsEmpty: false}, "is_empty") + oldBranchName := ctx.Repo.BranchName + if fromBaseBranch != "" { + err = editorPushBranchToForkedRepository(ctx, ctx.Doer, ctx.Repo.Repository.BaseRepo, fromBaseBranch, commitFormOptions.TargetRepo, targetBranchName) + if err != nil { + log.Error("Unable to editorPushBranchToForkedRepository: %v", err) + ctx.JSONError(ctx.Tr("repo.editor.fork_failed_to_push_branch", targetBranchName)) + return nil } + // we have pushed the base branch as the new branch, now we need to commit the changes directly to the new branch + oldBranchName = targetBranchName } - redirectForCommitChoice(ctx, form.CommitChoice, branchName, form.TreePath) -} - -// EditFilePost response for editing file -func EditFilePost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.EditRepoFileForm) - editFilePost(ctx, *form, false) -} - -// NewFilePost response for creating file -func NewFilePost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.EditRepoFileForm) - editFilePost(ctx, *form, true) -} - -// DiffPreviewPost render preview diff page -func DiffPreviewPost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.EditPreviewDiffForm) - treePath := cleanUploadFileName(ctx.Repo.TreePath) - if len(treePath) == 0 { - ctx.Error(http.StatusInternalServerError, "file name to diff is invalid") - return - } - - entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath) - if err != nil { - ctx.Error(http.StatusInternalServerError, "GetTreeEntryByPath: "+err.Error()) - return - } else if entry.IsDir() { - ctx.Error(http.StatusUnprocessableEntity) - return - } - - diff, err := files_service.GetDiffPreview(ctx, ctx.Repo.Repository, ctx.Repo.BranchName, treePath, form.Content) - if err != nil { - ctx.Error(http.StatusInternalServerError, "GetDiffPreview: "+err.Error()) - return - } - - if diff.NumFiles != 0 { - ctx.Data["File"] = diff.Files[0] + return &preparedEditorCommitForm[T]{ + form: form, + commonForm: commonForm, + CommitFormOptions: commitFormOptions, + OldBranchName: oldBranchName, + NewBranchName: targetBranchName, + GitCommitter: gitCommitter, } - - ctx.HTML(http.StatusOK, tplEditDiffPreview) } -// DeleteFile render delete file page -func DeleteFile(ctx *context.Context) { - ctx.Data["PageIsDelete"] = true - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - treePath := cleanUploadFileName(ctx.Repo.TreePath) - - if treePath != ctx.Repo.TreePath { - ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_delete", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) +// redirectForCommitChoice redirects after committing the edit to a branch +func redirectForCommitChoice[T any](ctx *context.Context, parsed *preparedEditorCommitForm[T], treePath string) { + // when editing a file in a PR, it should return to the origin location + if returnURI := ctx.FormString("return_uri"); returnURI != "" && httplib.IsCurrentGiteaSiteURL(ctx, returnURI) { + ctx.JSONRedirect(returnURI) return } - ctx.Data["TreePath"] = treePath - canCommit := renderCommitRights(ctx) - - ctx.Data["commit_summary"] = "" - ctx.Data["commit_message"] = "" - ctx.Data["last_commit"] = ctx.Repo.CommitID - if canCommit { - ctx.Data["commit_choice"] = frmCommitChoiceDirect - } else { - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch + if parsed.commonForm.CommitChoice == editorCommitChoiceNewBranch { + // Redirect to a pull request when possible + redirectToPullRequest := false + repo, baseBranch, headBranch := ctx.Repo.Repository, parsed.OldBranchName, parsed.NewBranchName + if ctx.Repo.Repository.IsFork && parsed.CommitFormOptions.CanCreateBasePullRequest { + redirectToPullRequest = true + baseBranch = repo.BaseRepo.DefaultBranch + headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch + repo = repo.BaseRepo + } else if repo.UnitEnabled(ctx, unit.TypePullRequests) { + redirectToPullRequest = true + } + if redirectToPullRequest { + ctx.JSONRedirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch)) + return + } } - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) - ctx.HTML(http.StatusOK, tplDeleteFile) + // redirect to the newly updated file + redirectTo := util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.NewBranchName), util.PathEscapeSegments(treePath)) + ctx.JSONRedirect(redirectTo) } -// DeleteFilePost response for deleting file -func DeleteFilePost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.DeleteRepoFileForm) - canCommit := renderCommitRights(ctx) - branchName := ctx.Repo.BranchName - if form.CommitChoice == frmCommitChoiceNewBranch { - branchName = form.NewBranchName - } - - ctx.Data["PageIsDelete"] = true - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - ctx.Data["TreePath"] = ctx.Repo.TreePath - ctx.Data["commit_summary"] = form.CommitSummary - ctx.Data["commit_message"] = form.CommitMessage - ctx.Data["commit_choice"] = form.CommitChoice - ctx.Data["new_branch_name"] = form.NewBranchName - ctx.Data["last_commit"] = ctx.Repo.CommitID - - if ctx.HasError() { - ctx.HTML(http.StatusOK, tplDeleteFile) - return - } - - if branchName == ctx.Repo.BranchName && !canCommit { - ctx.Data["Err_NewBranchName"] = true - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplDeleteFile, &form) - return +func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.ReadCloser, fInfo *fileInfo) { + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + HandleGitError(ctx, "GetTreeEntryByPath", err) + return nil, nil, nil } - message := strings.TrimSpace(form.CommitSummary) - if len(message) == 0 { - message = ctx.Locale.TrString("repo.editor.delete", ctx.Repo.TreePath) - } - form.CommitMessage = strings.TrimSpace(form.CommitMessage) - if len(form.CommitMessage) > 0 { - message += "\n\n" + form.CommitMessage + // No way to edit a directory online. + if entry.IsDir() { + ctx.NotFound(nil) + return nil, nil, nil } - if _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ - LastCommitID: form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: branchName, - Files: []*files_service.ChangeRepoFile{ - { - Operation: "delete", - TreePath: ctx.Repo.TreePath, - }, - }, - Message: message, - Signoff: form.Signoff, - }); err != nil { - // This is where we handle all the errors thrown by repofiles.DeleteRepoFile - if git.IsErrNotExist(err) || files_service.IsErrRepoFileDoesNotExist(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_deleting_no_longer_exists", ctx.Repo.TreePath), tplDeleteFile, &form) - } else if files_service.IsErrFilenameInvalid(err) { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", ctx.Repo.TreePath), tplDeleteFile, &form) - } else if files_service.IsErrFilePathInvalid(err) { - ctx.Data["Err_TreePath"] = true - if fileErr, ok := err.(files_service.ErrFilePathInvalid); ok { - switch fileErr.Type { - case git.EntryModeSymlink: - ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplDeleteFile, &form) - case git.EntryModeTree: - ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplDeleteFile, &form) - case git.EntryModeBlob: - ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplDeleteFile, &form) - default: - ctx.ServerError("DeleteRepoFile", err) - } - } else { - ctx.ServerError("DeleteRepoFile", err) - } - } else if git.IsErrBranchNotExist(err) { - // For when a user deletes a file to a branch that no longer exists - if branchErr, ok := err.(git.ErrBranchNotExist); ok { - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplDeleteFile, &form) - } else { - ctx.Error(http.StatusInternalServerError, err.Error()) - } - } else if git_model.IsErrBranchAlreadyExists(err) { - // For when a user specifies a new branch that already exists - if branchErr, ok := err.(git_model.ErrBranchAlreadyExists); ok { - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplDeleteFile, &form) - } else { - ctx.Error(http.StatusInternalServerError, err.Error()) - } - } else if files_service.IsErrCommitIDDoesNotMatch(err) || git.IsErrPushOutOfDate(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_deleting", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(form.LastCommit)+"..."+util.PathEscapeSegments(ctx.Repo.CommitID)), tplDeleteFile, &form) - } else if git.IsErrPushRejected(err) { - errPushRej := err.(*git.ErrPushRejected) - if len(errPushRej.Message) == 0 { - ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplDeleteFile, &form) - } else { - flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ - "Message": ctx.Tr("repo.editor.push_rejected"), - "Summary": ctx.Tr("repo.editor.push_rejected_summary"), - "Details": utils.SanitizeFlashErrorString(errPushRej.Message), - }) - if err != nil { - ctx.ServerError("DeleteFilePost.HTMLString", err) - return - } - ctx.RenderWithErr(flashError, tplDeleteFile, &form) - } + blob := entry.Blob() + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(err) } else { - ctx.ServerError("DeleteRepoFile", err) + ctx.ServerError("getFileReader", err) } - return + return nil, nil, nil } - ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", ctx.Repo.TreePath)) - treePath := path.Dir(ctx.Repo.TreePath) - if treePath == "." { - treePath = "" // the file deleted was in the root, so we return the user to the root directory - } - if len(treePath) > 0 { - // Need to get the latest commit since it changed - commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName) - if err == nil && commit != nil { - // We have the comment, now find what directory we can return the user to - // (must have entries) - treePath = GetClosestParentWithFiles(treePath, commit) - } else { - treePath = "" // otherwise return them to the root of the repo + if fInfo.isLFSFile() { + lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) + if err != nil { + _ = dataRc.Close() + ctx.ServerError("GetTreePathLock", err) + return nil, nil, nil + } else if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { + _ = dataRc.Close() + ctx.NotFound(nil) + return nil, nil, nil } } - redirectForCommitChoice(ctx, form.CommitChoice, branchName, treePath) -} - -// UploadFile render upload file page -func UploadFile(ctx *context.Context) { - ctx.Data["PageIsUpload"] = true - upload.AddUploadContext(ctx, "repo") - canCommit := renderCommitRights(ctx) - treePath := cleanUploadFileName(ctx.Repo.TreePath) - if treePath != ctx.Repo.TreePath { - ctx.Redirect(path.Join(ctx.Repo.RepoLink, "_upload", util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(treePath))) - return - } - ctx.Repo.TreePath = treePath - - treeNames, treePaths := getParentTreeFields(ctx.Repo.TreePath) - if len(treeNames) == 0 { - // We must at least have one element for user to input. - treeNames = []string{""} - } - - ctx.Data["TreeNames"] = treeNames - ctx.Data["TreePaths"] = treePaths - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - ctx.Data["commit_summary"] = "" - ctx.Data["commit_message"] = "" - if canCommit { - ctx.Data["commit_choice"] = frmCommitChoiceDirect - } else { - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - } - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) - - ctx.HTML(http.StatusOK, tplUploadFile) + return buf, dataRc, fInfo } -// UploadFilePost response for uploading file -func UploadFilePost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.UploadRepoFileForm) - ctx.Data["PageIsUpload"] = true - upload.AddUploadContext(ctx, "repo") - canCommit := renderCommitRights(ctx) - - oldBranchName := ctx.Repo.BranchName - branchName := oldBranchName - - if form.CommitChoice == frmCommitChoiceNewBranch { - branchName = form.NewBranchName - } - - form.TreePath = cleanUploadFileName(form.TreePath) +func EditFile(ctx *context.Context) { + editorAction := ctx.PathParam("editor_action") + isNewFile := editorAction == "_new" + ctx.Data["IsNewFile"] = isNewFile - treeNames, treePaths := getParentTreeFields(form.TreePath) - if len(treeNames) == 0 { - // We must at least have one element for user to input. - treeNames = []string{""} + // Check if the filename (and additional path) is specified in the querystring + // (filename is a misnomer, but kept for compatibility with GitHub) + urlQuery := ctx.Req.URL.Query() + queryFilename := urlQuery.Get("filename") + if queryFilename != "" { + newTreePath := path.Join(ctx.Repo.TreePath, queryFilename) + redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(newTreePath)) + urlQuery.Del("filename") + if newQueryParams := urlQuery.Encode(); newQueryParams != "" { + redirectTo += "?" + newQueryParams + } + ctx.Redirect(redirectTo) + return } - ctx.Data["TreePath"] = form.TreePath - ctx.Data["TreeNames"] = treeNames - ctx.Data["TreePaths"] = treePaths - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName) - ctx.Data["commit_summary"] = form.CommitSummary - ctx.Data["commit_message"] = form.CommitMessage - ctx.Data["commit_choice"] = form.CommitChoice - ctx.Data["new_branch_name"] = branchName + // on the "New File" page, we should add an empty path field to make end users could input a new name + prepareTreePathFieldsAndPaths(ctx, util.Iif(isNewFile, ctx.Repo.TreePath+"/", ctx.Repo.TreePath)) - if ctx.HasError() { - ctx.HTML(http.StatusOK, tplUploadFile) + prepareEditorCommitFormOptions(ctx, editorAction) + if ctx.Written() { return } - if oldBranchName != branchName { - if _, err := ctx.Repo.GitRepo.GetBranch(branchName); err == nil { - ctx.Data["Err_NewBranchName"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchName), tplUploadFile, &form) + if !isNewFile { + prefetch, dataRc, fInfo := editFileOpenExisting(ctx) + if ctx.Written() { return } - } else if !canCommit { - ctx.Data["Err_NewBranchName"] = true - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplUploadFile, &form) - return - } + defer dataRc.Close() - if !ctx.Repo.Repository.IsEmpty { - var newTreePath string - for _, part := range treeNames { - newTreePath = path.Join(newTreePath, part) - entry, err := ctx.Repo.Commit.GetTreeEntryByPath(newTreePath) - if err != nil { - if git.IsErrNotExist(err) { - break // Means there is no item with that name, so we're good - } - ctx.ServerError("Repo.Commit.GetTreeEntryByPath", err) - return - } + ctx.Data["FileSize"] = fInfo.fileSize - // User can only upload files to a directory, the directory name shouldn't be an existing file. - if !entry.IsDir() { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", part), tplUploadFile, &form) - return - } + // Only some file types are editable online as text. + if fInfo.isLFSFile() { + ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") + } else if !fInfo.st.IsRepresentableAsText() { + ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") + } else if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { + ctx.Data["NotEditableReason"] = ctx.Tr("repo.editor.cannot_edit_too_large_file") } - } - message := strings.TrimSpace(form.CommitSummary) - if len(message) == 0 { - dir := form.TreePath - if dir == "" { - dir = "/" - } - message = ctx.Locale.TrString("repo.editor.upload_files_to_dir", dir) - } - - form.CommitMessage = strings.TrimSpace(form.CommitMessage) - if len(form.CommitMessage) > 0 { - message += "\n\n" + form.CommitMessage - } - - if err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{ - LastCommitID: ctx.Repo.CommitID, - OldBranch: oldBranchName, - NewBranch: branchName, - TreePath: form.TreePath, - Message: message, - Files: form.Files, - Signoff: form.Signoff, - }); err != nil { - if git_model.IsErrLFSFileLocked(err) { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.upload_file_is_locked", err.(git_model.ErrLFSFileLocked).Path, err.(git_model.ErrLFSFileLocked).UserName), tplUploadFile, &form) - } else if files_service.IsErrFilenameInvalid(err) { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", form.TreePath), tplUploadFile, &form) - } else if files_service.IsErrFilePathInvalid(err) { - ctx.Data["Err_TreePath"] = true - fileErr := err.(files_service.ErrFilePathInvalid) - switch fileErr.Type { - case git.EntryModeSymlink: - ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplUploadFile, &form) - case git.EntryModeTree: - ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplUploadFile, &form) - case git.EntryModeBlob: - ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplUploadFile, &form) - default: - ctx.Error(http.StatusInternalServerError, err.Error()) + if ctx.Data["NotEditableReason"] == nil { + buf, err := io.ReadAll(io.MultiReader(bytes.NewReader(prefetch), dataRc)) + if err != nil { + ctx.ServerError("ReadAll", err) + return } - } else if files_service.IsErrRepoFileAlreadyExists(err) { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.file_already_exists", form.TreePath), tplUploadFile, &form) - } else if git.IsErrBranchNotExist(err) { - branchErr := err.(git.ErrBranchNotExist) - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplUploadFile, &form) - } else if git_model.IsErrBranchAlreadyExists(err) { - // For when a user specifies a new branch that already exists - ctx.Data["Err_NewBranchName"] = true - branchErr := err.(git_model.ErrBranchAlreadyExists) - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplUploadFile, &form) - } else if git.IsErrPushOutOfDate(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(ctx.Repo.CommitID)+"..."+util.PathEscapeSegments(form.NewBranchName)), tplUploadFile, &form) - } else if git.IsErrPushRejected(err) { - errPushRej := err.(*git.ErrPushRejected) - if len(errPushRej.Message) == 0 { - ctx.RenderWithErr(ctx.Tr("repo.editor.push_rejected_no_message"), tplUploadFile, &form) + if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil { + ctx.Data["FileContent"] = string(buf) } else { - flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ - "Message": ctx.Tr("repo.editor.push_rejected"), - "Summary": ctx.Tr("repo.editor.push_rejected_summary"), - "Details": utils.SanitizeFlashErrorString(errPushRej.Message), - }) - if err != nil { - ctx.ServerError("UploadFilePost.HTMLString", err) - return - } - ctx.RenderWithErr(flashError, tplUploadFile, &form) + ctx.Data["FileContent"] = content } - } else { - // os.ErrNotExist - upload file missing in the intervening time?! - log.Error("Error during upload to repo: %-v to filepath: %s on %s from %s: %v", ctx.Repo.Repository, form.TreePath, oldBranchName, form.NewBranchName, err) - ctx.RenderWithErr(ctx.Tr("repo.editor.unable_to_upload_files", form.TreePath, err), tplUploadFile, &form) } - return } - if ctx.Repo.Repository.IsEmpty { - if isEmpty, err := ctx.Repo.GitRepo.IsEmpty(); err == nil && !isEmpty { - _ = repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, IsEmpty: false}, "is_empty") - } + ctx.Data["EditorconfigJson"] = getContextRepoEditorConfig(ctx, ctx.Repo.TreePath) + ctx.HTML(http.StatusOK, tplEditFile) +} + +func EditFilePost(ctx *context.Context) { + editorAction := ctx.PathParam("editor_action") + isNewFile := editorAction == "_new" + parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) + if ctx.Written() { + return } - redirectForCommitChoice(ctx, form.CommitChoice, branchName, form.TreePath) -} + defaultCommitMessage := util.Iif(isNewFile, ctx.Locale.TrString("repo.editor.add", parsed.form.TreePath), ctx.Locale.TrString("repo.editor.update", parsed.form.TreePath)) -func cleanUploadFileName(name string) string { - // Rebase the filename - name = util.PathJoinRel(name) - // Git disallows any filenames to have a .git directory in them. - for _, part := range strings.Split(name, "/") { - if strings.ToLower(part) == ".git" { - return "" - } + var operation string + if isNewFile { + operation = "create" + } else if parsed.form.Content.Has() { + // The form content only has data if the file is representable as text, is not too large and not in lfs. + operation = "update" + } else if ctx.Repo.TreePath != parsed.form.TreePath { + // If it doesn't have data, the only possible operation is a "rename" + operation = "rename" + } else { + // It should never happen, just in case + ctx.JSONError(ctx.Tr("error.occurred")) + return } - return name -} -// UploadFileToServer upload file to server file dir not git -func UploadFileToServer(ctx *context.Context) { - file, header, err := ctx.Req.FormFile("file") + _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ + LastCommitID: parsed.form.LastCommit, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, + Message: parsed.GetCommitMessage(defaultCommitMessage), + Files: []*files_service.ChangeRepoFile{ + { + Operation: operation, + FromTreePath: ctx.Repo.TreePath, + TreePath: parsed.form.TreePath, + ContentReader: strings.NewReader(strings.ReplaceAll(parsed.form.Content.Value(), "\r", "")), + }, + }, + Signoff: parsed.form.Signoff, + Author: parsed.GitCommitter, + Committer: parsed.GitCommitter, + }) if err != nil { - ctx.Error(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) return } - defer file.Close() - buf := make([]byte, 1024) - n, _ := util.ReadAtMost(file, buf) - if n > 0 { - buf = buf[:n] - } + redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) +} - err = upload.Verify(buf, header.Filename, setting.Repository.Upload.AllowedTypes) - if err != nil { - ctx.Error(http.StatusBadRequest, err.Error()) +// DeleteFile render delete file page +func DeleteFile(ctx *context.Context) { + prepareEditorCommitFormOptions(ctx, "_delete") + if ctx.Written() { return } + ctx.Data["PageIsDelete"] = true + ctx.HTML(http.StatusOK, tplDeleteFile) +} - name := cleanUploadFileName(header.Filename) - if len(name) == 0 { - ctx.Error(http.StatusInternalServerError, "Upload file name is invalid") +// DeleteFilePost response for deleting file +func DeleteFilePost(ctx *context.Context) { + parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx) + if ctx.Written() { return } - upload, err := repo_model.NewUpload(ctx, name, buf, file) + treePath := ctx.Repo.TreePath + _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ + LastCommitID: parsed.form.LastCommit, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, + Files: []*files_service.ChangeRepoFile{ + { + Operation: "delete", + TreePath: treePath, + }, + }, + Message: parsed.GetCommitMessage(ctx.Locale.TrString("repo.editor.delete", treePath)), + Signoff: parsed.form.Signoff, + Author: parsed.GitCommitter, + Committer: parsed.GitCommitter, + }) if err != nil { - ctx.Error(http.StatusInternalServerError, fmt.Sprintf("NewUpload: %v", err)) + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) return } - log.Trace("New file uploaded: %s", upload.UUID) - ctx.JSON(http.StatusOK, map[string]string{ - "uuid": upload.UUID, - }) + ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath)) + redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath) + redirectForCommitChoice(ctx, parsed, redirectTreePath) } -// RemoveUploadFileFromServer remove file from server file dir -func RemoveUploadFileFromServer(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.RemoveUploadFileForm) - if len(form.File) == 0 { - ctx.Status(http.StatusNoContent) - return - } - - if err := repo_model.DeleteUploadByUUID(ctx, form.File); err != nil { - ctx.Error(http.StatusInternalServerError, fmt.Sprintf("DeleteUploadByUUID: %v", err)) +func UploadFile(ctx *context.Context) { + ctx.Data["PageIsUpload"] = true + prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath) + opts := prepareEditorCommitFormOptions(ctx, "_upload") + if ctx.Written() { return } + upload.AddUploadContextForRepo(ctx, opts.TargetRepo) - log.Trace("Upload file removed: %s", form.File) - ctx.Status(http.StatusNoContent) + ctx.HTML(http.StatusOK, tplUploadFile) } -// GetUniquePatchBranchName Gets a unique branch name for a new patch branch -// It will be in the form of <username>-patch-<num> where <num> is the first branch of this format -// that doesn't already exist. If we exceed 1000 tries or an error is thrown, we just return "" so the user has to -// type in the branch name themselves (will be an empty field) -func GetUniquePatchBranchName(ctx *context.Context) string { - prefix := ctx.Doer.LowerName + "-patch-" - for i := 1; i <= 1000; i++ { - branchName := fmt.Sprintf("%s%d", prefix, i) - if _, err := ctx.Repo.GitRepo.GetBranch(branchName); err != nil { - if git.IsErrBranchNotExist(err) { - return branchName - } - log.Error("GetUniquePatchBranchName: %v", err) - return "" - } +func UploadFilePost(ctx *context.Context) { + parsed := prepareEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx) + if ctx.Written() { + return } - return "" -} -// GetClosestParentWithFiles Recursively gets the path of parent in a tree that has files (used when file in a tree is -// deleted). Returns "" for the root if no parents other than the root have files. If the given treePath isn't a -// SubTree or it has no entries, we go up one dir and see if we can return the user to that listing. -func GetClosestParentWithFiles(treePath string, commit *git.Commit) string { - if len(treePath) == 0 || treePath == "." { - return "" - } - // see if the tree has entries - if tree, err := commit.SubTree(treePath); err != nil { - // failed to get tree, going up a dir - return GetClosestParentWithFiles(path.Dir(treePath), commit) - } else if entries, err := tree.ListEntries(); err != nil || len(entries) == 0 { - // no files in this dir, going up a dir - return GetClosestParentWithFiles(path.Dir(treePath), commit) - } - return treePath + defaultCommitMessage := ctx.Locale.TrString("repo.editor.upload_files_to_dir", util.IfZero(parsed.form.TreePath, "/")) + err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{ + LastCommitID: parsed.form.LastCommit, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, + TreePath: parsed.form.TreePath, + Message: parsed.GetCommitMessage(defaultCommitMessage), + Files: parsed.form.Files, + Signoff: parsed.form.Signoff, + Author: parsed.GitCommitter, + Committer: parsed.GitCommitter, + }) + if err != nil { + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) + return + } + redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) } diff --git a/routers/web/repo/editor_apply_patch.go b/routers/web/repo/editor_apply_patch.go new file mode 100644 index 0000000000..bd2811cc5f --- /dev/null +++ b/routers/web/repo/editor_apply_patch.go @@ -0,0 +1,51 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/repository/files" +) + +func NewDiffPatch(ctx *context.Context) { + prepareEditorCommitFormOptions(ctx, "_diffpatch") + if ctx.Written() { + return + } + + ctx.Data["PageIsPatch"] = true + ctx.HTML(http.StatusOK, tplPatchFile) +} + +// NewDiffPatchPost response for sending patch page +func NewDiffPatchPost(ctx *context.Context) { + parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) + if ctx.Written() { + return + } + + defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch") + _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{ + LastCommitID: parsed.form.LastCommit, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, + Message: parsed.GetCommitMessage(defaultCommitMessage), + Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"), + Author: parsed.GitCommitter, + Committer: parsed.GitCommitter, + }) + if err != nil { + err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch") + } + if err != nil { + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) + return + } + redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) +} diff --git a/routers/web/repo/editor_cherry_pick.go b/routers/web/repo/editor_cherry_pick.go new file mode 100644 index 0000000000..10c2741b1c --- /dev/null +++ b/routers/web/repo/editor_cherry_pick.go @@ -0,0 +1,86 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "net/http" + "strings" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/repository/files" +) + +func CherryPick(ctx *context.Context) { + prepareEditorCommitFormOptions(ctx, "_cherrypick") + if ctx.Written() { + return + } + + fromCommitID := ctx.PathParam("sha") + ctx.Data["FromCommitID"] = fromCommitID + cherryPickCommit, err := ctx.Repo.GitRepo.GetCommit(fromCommitID) + if err != nil { + HandleGitError(ctx, "GetCommit", err) + return + } + + if ctx.FormString("cherry-pick-type") == "revert" { + ctx.Data["CherryPickType"] = "revert" + ctx.Data["commit_summary"] = "revert " + ctx.PathParam("sha") + ctx.Data["commit_message"] = "revert " + cherryPickCommit.Message() + } else { + ctx.Data["CherryPickType"] = "cherry-pick" + splits := strings.SplitN(cherryPickCommit.Message(), "\n", 2) + ctx.Data["commit_summary"] = splits[0] + ctx.Data["commit_message"] = splits[1] + } + + ctx.HTML(http.StatusOK, tplCherryPick) +} + +func CherryPickPost(ctx *context.Context) { + fromCommitID := ctx.PathParam("sha") + parsed := prepareEditorCommitSubmittedForm[*forms.CherryPickForm](ctx) + if ctx.Written() { + return + } + + defaultCommitMessage := util.Iif(parsed.form.Revert, ctx.Locale.TrString("repo.commit.revert-header", fromCommitID), ctx.Locale.TrString("repo.commit.cherry-pick-header", fromCommitID)) + opts := &files.ApplyDiffPatchOptions{ + LastCommitID: parsed.form.LastCommit, + OldBranch: parsed.OldBranchName, + NewBranch: parsed.NewBranchName, + Message: parsed.GetCommitMessage(defaultCommitMessage), + Author: parsed.GitCommitter, + Committer: parsed.GitCommitter, + } + + // First try the simple plain read-tree -m approach + opts.Content = fromCommitID + if _, err := files.CherryPick(ctx, ctx.Repo.Repository, ctx.Doer, parsed.form.Revert, opts); err != nil { + // Drop through to the "apply" method + buf := &bytes.Buffer{} + if parsed.form.Revert { + err = git.GetReverseRawDiff(ctx, ctx.Repo.Repository.RepoPath(), fromCommitID, buf) + } else { + err = git.GetRawDiff(ctx.Repo.GitRepo, fromCommitID, "patch", buf) + } + if err == nil { + opts.Content = buf.String() + _, err = files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts) + if err != nil { + err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch") + } + } + if err != nil { + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) + return + } + } + redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) +} diff --git a/routers/web/repo/editor_error.go b/routers/web/repo/editor_error.go new file mode 100644 index 0000000000..245226a039 --- /dev/null +++ b/routers/web/repo/editor_error.go @@ -0,0 +1,82 @@ +// Copyright 2025 Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/utils" + context_service "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" +) + +func errorAs[T error](v error) (e T, ok bool) { + if errors.As(v, &e) { + return e, true + } + return e, false +} + +func editorHandleFileOperationErrorRender(ctx *context_service.Context, message, summary, details string) { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": message, + "Summary": summary, + "Details": utils.SanitizeFlashErrorString(details), + }) + if err == nil { + ctx.JSONError(flashError) + } else { + log.Error("RenderToHTML: %v", err) + ctx.JSONError(message + "\n" + summary + "\n" + utils.SanitizeFlashErrorString(details)) + } +} + +func editorHandleFileOperationError(ctx *context_service.Context, targetBranchName string, err error) { + if errAs := util.ErrorAsLocale(err); errAs != nil { + ctx.JSONError(ctx.Tr(errAs.TrKey, errAs.TrArgs...)) + } else if errAs, ok := errorAs[git.ErrNotExist](err); ok { + ctx.JSONError(ctx.Tr("repo.editor.file_modifying_no_longer_exists", errAs.RelPath)) + } else if errAs, ok := errorAs[git_model.ErrLFSFileLocked](err); ok { + ctx.JSONError(ctx.Tr("repo.editor.upload_file_is_locked", errAs.Path, errAs.UserName)) + } else if errAs, ok := errorAs[files_service.ErrFilenameInvalid](err); ok { + ctx.JSONError(ctx.Tr("repo.editor.filename_is_invalid", errAs.Path)) + } else if errAs, ok := errorAs[files_service.ErrFilePathInvalid](err); ok { + switch errAs.Type { + case git.EntryModeSymlink: + ctx.JSONError(ctx.Tr("repo.editor.file_is_a_symlink", errAs.Path)) + case git.EntryModeTree: + ctx.JSONError(ctx.Tr("repo.editor.filename_is_a_directory", errAs.Path)) + case git.EntryModeBlob: + ctx.JSONError(ctx.Tr("repo.editor.directory_is_a_file", errAs.Path)) + default: + ctx.JSONError(ctx.Tr("repo.editor.filename_is_invalid", errAs.Path)) + } + } else if errAs, ok := errorAs[files_service.ErrRepoFileAlreadyExists](err); ok { + ctx.JSONError(ctx.Tr("repo.editor.file_already_exists", errAs.Path)) + } else if errAs, ok := errorAs[git.ErrBranchNotExist](err); ok { + ctx.JSONError(ctx.Tr("repo.editor.branch_does_not_exist", errAs.Name)) + } else if errAs, ok := errorAs[git_model.ErrBranchAlreadyExists](err); ok { + ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", errAs.BranchName)) + } else if files_service.IsErrCommitIDDoesNotMatch(err) { + ctx.JSONError(ctx.Tr("repo.editor.commit_id_not_matching")) + } else if files_service.IsErrCommitIDDoesNotMatch(err) || git.IsErrPushOutOfDate(err) { + ctx.JSONError(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+util.PathEscapeSegments(ctx.Repo.CommitID)+"..."+util.PathEscapeSegments(targetBranchName))) + } else if errAs, ok := errorAs[*git.ErrPushRejected](err); ok { + if errAs.Message == "" { + ctx.JSONError(ctx.Tr("repo.editor.push_rejected_no_message")) + } else { + editorHandleFileOperationErrorRender(ctx, ctx.Locale.TrString("repo.editor.push_rejected"), ctx.Locale.TrString("repo.editor.push_rejected_summary"), errAs.Message) + } + } else if errors.Is(err, util.ErrNotExist) { + ctx.JSONError(ctx.Tr("error.not_found")) + } else { + setting.PanicInDevOrTesting("unclear err %T: %v", err, err) + editorHandleFileOperationErrorRender(ctx, ctx.Locale.TrString("repo.editor.failed_to_commit"), ctx.Locale.TrString("repo.editor.failed_to_commit_summary"), err.Error()) + } +} diff --git a/routers/web/repo/editor_fork.go b/routers/web/repo/editor_fork.go new file mode 100644 index 0000000000..b78a634c00 --- /dev/null +++ b/routers/web/repo/editor_fork.go @@ -0,0 +1,31 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +const tplEditorFork templates.TplName = "repo/editor/fork" + +func ForkToEdit(ctx *context.Context) { + ctx.HTML(http.StatusOK, tplEditorFork) +} + +func ForkToEditPost(ctx *context.Context) { + ForkRepoTo(ctx, ctx.Doer, repo_service.ForkRepoOptions{ + BaseRepo: ctx.Repo.Repository, + Name: getUniqueRepositoryName(ctx, ctx.Doer.ID, ctx.Repo.Repository.Name), + Description: ctx.Repo.Repository.Description, + SingleBranch: ctx.Repo.Repository.DefaultBranch, // maybe we only need the default branch in the fork? + }) + if ctx.Written() { + return + } + ctx.JSONRedirect("") // reload the page, the new fork should be editable now +} diff --git a/routers/web/repo/editor_preview.go b/routers/web/repo/editor_preview.go new file mode 100644 index 0000000000..14be5b72b6 --- /dev/null +++ b/routers/web/repo/editor_preview.go @@ -0,0 +1,41 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" +) + +func DiffPreviewPost(ctx *context.Context) { + content := ctx.FormString("content") + treePath := files_service.CleanGitTreePath(ctx.Repo.TreePath) + if treePath == "" { + ctx.HTTPError(http.StatusBadRequest, "file name to diff is invalid") + return + } + + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath) + if err != nil { + ctx.ServerError("GetTreeEntryByPath", err) + return + } else if entry.IsDir() { + ctx.HTTPError(http.StatusUnprocessableEntity) + return + } + + diff, err := files_service.GetDiffPreview(ctx, ctx.Repo.Repository, ctx.Repo.BranchName, treePath, content) + if err != nil { + ctx.ServerError("GetDiffPreview", err) + return + } + + if len(diff.Files) != 0 { + ctx.Data["File"] = diff.Files[0] + } + + ctx.HTML(http.StatusOK, tplEditDiffPreview) +} diff --git a/routers/web/repo/editor_test.go b/routers/web/repo/editor_test.go index 566db31693..6e2c1d6219 100644 --- a/routers/web/repo/editor_test.go +++ b/routers/web/repo/editor_test.go @@ -6,76 +6,27 @@ package repo import ( "testing" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" - "code.gitea.io/gitea/services/contexttest" "github.com/stretchr/testify/assert" ) -func TestCleanUploadName(t *testing.T) { +func TestEditorUtils(t *testing.T) { unittest.PrepareTestEnv(t) - - kases := map[string]string{ - ".git/refs/master": "", - "/root/abc": "root/abc", - "./../../abc": "abc", - "a/../.git": "", - "a/../../../abc": "abc", - "../../../acd": "acd", - "../../.git/abc": "", - "..\\..\\.git/abc": "..\\..\\.git/abc", - "..\\../.git/abc": "", - "..\\../.git": "", - "abc/../def": "def", - ".drone.yml": ".drone.yml", - ".abc/def/.drone.yml": ".abc/def/.drone.yml", - "..drone.yml.": "..drone.yml.", - "..a.dotty...name...": "..a.dotty...name...", - "..a.dotty../.folder../.name...": "..a.dotty../.folder../.name...", - } - for k, v := range kases { - assert.EqualValues(t, cleanUploadFileName(k), v) - } -} - -func TestGetUniquePatchBranchName(t *testing.T) { - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1") - ctx.SetPathParam("id", "1") - contexttest.LoadRepo(t, ctx, 1) - contexttest.LoadRepoCommit(t, ctx) - contexttest.LoadUser(t, ctx, 2) - contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - - expectedBranchName := "user2-patch-1" - branchName := GetUniquePatchBranchName(ctx) - assert.Equal(t, expectedBranchName, branchName) -} - -func TestGetClosestParentWithFiles(t *testing.T) { - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1") - ctx.SetPathParam("id", "1") - contexttest.LoadRepo(t, ctx, 1) - contexttest.LoadRepoCommit(t, ctx) - contexttest.LoadUser(t, ctx, 2) - contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - - repo := ctx.Repo.Repository - branch := repo.DefaultBranch - gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) - defer gitRepo.Close() - commit, _ := gitRepo.GetBranchCommit(branch) - var expectedTreePath string // Should return the root dir, empty string, since there are no subdirs in this repo - for _, deletedFile := range []string{ - "dir1/dir2/dir3/file.txt", - "file.txt", - } { - treePath := GetClosestParentWithFiles(deletedFile, commit) - assert.Equal(t, expectedTreePath, treePath) - } + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + t.Run("getUniquePatchBranchName", func(t *testing.T) { + branchName := getUniquePatchBranchName(t.Context(), "user2", repo) + assert.Equal(t, "user2-patch-1", branchName) + }) + t.Run("getClosestParentWithFiles", func(t *testing.T) { + gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo) + defer gitRepo.Close() + treePath := getClosestParentWithFiles(gitRepo, "sub-home-md-img-check", "docs/foo/bar") + assert.Equal(t, "docs", treePath) + treePath = getClosestParentWithFiles(gitRepo, "sub-home-md-img-check", "any/other") + assert.Empty(t, treePath) + }) } diff --git a/routers/web/repo/editor_uploader.go b/routers/web/repo/editor_uploader.go new file mode 100644 index 0000000000..1ce9a1aca4 --- /dev/null +++ b/routers/web/repo/editor_uploader.go @@ -0,0 +1,61 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + files_service "code.gitea.io/gitea/services/repository/files" +) + +// UploadFileToServer upload file to server file dir not git +func UploadFileToServer(ctx *context.Context) { + file, header, err := ctx.Req.FormFile("file") + if err != nil { + ctx.ServerError("FormFile", err) + return + } + defer file.Close() + + buf := make([]byte, 1024) + n, _ := util.ReadAtMost(file, buf) + if n > 0 { + buf = buf[:n] + } + + err = upload.Verify(buf, header.Filename, setting.Repository.Upload.AllowedTypes) + if err != nil { + ctx.HTTPError(http.StatusBadRequest, err.Error()) + return + } + + name := files_service.CleanGitTreePath(header.Filename) + if len(name) == 0 { + ctx.HTTPError(http.StatusBadRequest, "Upload file name is invalid") + return + } + + uploaded, err := repo_model.NewUpload(ctx, name, buf, file) + if err != nil { + ctx.ServerError("NewUpload", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"uuid": uploaded.UUID}) +} + +// RemoveUploadFileFromServer remove file from server file dir +func RemoveUploadFileFromServer(ctx *context.Context) { + fileUUID := ctx.FormString("file") + if err := repo_model.DeleteUploadByUUID(ctx, fileUUID); err != nil { + ctx.ServerError("DeleteUploadByUUID", err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/repo/editor_util.go b/routers/web/repo/editor_util.go new file mode 100644 index 0000000000..f910f0bd40 --- /dev/null +++ b/routers/web/repo/editor_util.go @@ -0,0 +1,110 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "context" + "fmt" + "path" + "strings" + + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + context_service "code.gitea.io/gitea/services/context" +) + +// getUniquePatchBranchName Gets a unique branch name for a new patch branch +// It will be in the form of <username>-patch-<num> where <num> is the first branch of this format +// that doesn't already exist. If we exceed 1000 tries or an error is thrown, we just return "" so the user has to +// type in the branch name themselves (will be an empty field) +func getUniquePatchBranchName(ctx context.Context, prefixName string, repo *repo_model.Repository) string { + prefix := prefixName + "-patch-" + for i := 1; i <= 1000; i++ { + branchName := fmt.Sprintf("%s%d", prefix, i) + if exist, err := git_model.IsBranchExist(ctx, repo.ID, branchName); err != nil { + log.Error("getUniquePatchBranchName: %v", err) + return "" + } else if !exist { + return branchName + } + } + return "" +} + +// getClosestParentWithFiles Recursively gets the closest path of parent in a tree that has files when a file in a tree is +// deleted. It returns "" for the tree root if no parents other than the root have files. +func getClosestParentWithFiles(gitRepo *git.Repository, branchName, originTreePath string) string { + var f func(treePath string, commit *git.Commit) string + f = func(treePath string, commit *git.Commit) string { + if treePath == "" || treePath == "." { + return "" + } + // see if the tree has entries + if tree, err := commit.SubTree(treePath); err != nil { + return f(path.Dir(treePath), commit) // failed to get the tree, going up a dir + } else if entries, err := tree.ListEntries(); err != nil || len(entries) == 0 { + return f(path.Dir(treePath), commit) // no files in this dir, going up a dir + } + return treePath + } + commit, err := gitRepo.GetBranchCommit(branchName) // must get the commit again to get the latest change + if err != nil { + log.Error("GetBranchCommit: %v", err) + return "" + } + return f(originTreePath, commit) +} + +// getContextRepoEditorConfig returns the editorconfig JSON string for given treePath or "null" +func getContextRepoEditorConfig(ctx *context_service.Context, treePath string) string { + ec, _, err := ctx.Repo.GetEditorconfig() + if err == nil { + def, err := ec.GetDefinitionForFilename(treePath) + if err == nil { + jsonStr, _ := json.Marshal(def) + return string(jsonStr) + } + } + return "null" +} + +// getParentTreeFields returns list of parent tree names and corresponding tree paths based on given treePath. +// eg: []{"a", "b", "c"}, []{"a", "a/b", "a/b/c"} +// or: []{""}, []{""} for the root treePath +func getParentTreeFields(treePath string) (treeNames, treePaths []string) { + treeNames = strings.Split(treePath, "/") + treePaths = make([]string, len(treeNames)) + for i := range treeNames { + treePaths[i] = strings.Join(treeNames[:i+1], "/") + } + return treeNames, treePaths +} + +// getUniqueRepositoryName Gets a unique repository name for a user +// It will append a -<num> postfix if the name is already taken +func getUniqueRepositoryName(ctx context.Context, ownerID int64, name string) string { + uniqueName := name + for i := 1; i < 1000; i++ { + _, err := repo_model.GetRepositoryByName(ctx, ownerID, uniqueName) + if err != nil || repo_model.IsErrRepoNotExist(err) { + return uniqueName + } + uniqueName = fmt.Sprintf("%s-%d", name, i) + i++ + } + return "" +} + +func editorPushBranchToForkedRepository(ctx context.Context, doer *user_model.User, baseRepo *repo_model.Repository, baseBranchName string, targetRepo *repo_model.Repository, targetBranchName string) error { + return git.Push(ctx, baseRepo.RepoPath(), git.PushOptions{ + Remote: targetRepo.RepoPath(), + Branch: baseBranchName + ":" + targetBranchName, + Env: repo_module.PushingEnvironment(doer, targetRepo), + }) +} diff --git a/routers/web/repo/fork.go b/routers/web/repo/fork.go index 786b5d7e43..c2694e540f 100644 --- a/routers/web/repo/fork.go +++ b/routers/web/repo/fork.go @@ -37,7 +37,7 @@ func getForkRepository(ctx *context.Context) *repo_model.Repository { if forkRepo.IsEmpty { log.Trace("Empty repository %-v", forkRepo) - ctx.NotFound("getForkRepository", nil) + ctx.NotFound(nil) return nil } @@ -91,12 +91,17 @@ func getForkRepository(ctx *context.Context) *repo_model.Repository { ctx.Data["CanForkToUser"] = canForkToUser ctx.Data["Orgs"] = orgs + // TODO: this message should only be shown for the "current doer" when it is selected, just like the "new repo" page. + // msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", ctx.Doer.MaxCreationLimit()) + if canForkToUser { ctx.Data["ContextUser"] = ctx.Doer + ctx.Data["CanForkRepoInNewOwner"] = true } else if len(orgs) > 0 { ctx.Data["ContextUser"] = orgs[0] + ctx.Data["CanForkRepoInNewOwner"] = true } else { - ctx.Data["CanForkRepo"] = false + ctx.Data["CanForkRepoInNewOwner"] = false ctx.Flash.Error(ctx.Tr("repo.fork_no_valid_owners"), true) return nil } @@ -120,15 +125,6 @@ func getForkRepository(ctx *context.Context) *repo_model.Repository { // Fork render repository fork page func Fork(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("new_fork") - - if ctx.Doer.CanForkRepo() { - ctx.Data["CanForkRepo"] = true - } else { - maxCreationLimit := ctx.Doer.MaxCreationLimit() - msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) - ctx.Flash.Error(msg, true) - } - getForkRepository(ctx) if ctx.Written() { return @@ -141,7 +137,6 @@ func Fork(ctx *context.Context) { func ForkPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.CreateRepoForm) ctx.Data["Title"] = ctx.Tr("new_fork") - ctx.Data["CanForkRepo"] = true ctxUser := checkContextUser(ctx, form.UID) if ctx.Written() { @@ -156,7 +151,7 @@ func ForkPost(ctx *context.Context) { ctx.Data["ContextUser"] = ctxUser if ctx.HasError() { - ctx.HTML(http.StatusOK, tplFork) + ctx.JSONError(ctx.GetErrMsg()) return } @@ -164,12 +159,12 @@ func ForkPost(ctx *context.Context) { traverseParentRepo := forkRepo for { if !repository.CanUserForkBetweenOwners(ctxUser.ID, traverseParentRepo.OwnerID) { - ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + ctx.JSONError(ctx.Tr("repo.settings.new_owner_has_same_repo")) return } repo := repo_model.GetForkedRepo(ctx, ctxUser.ID, traverseParentRepo.ID) if repo != nil { - ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) + ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) return } if !traverseParentRepo.IsFork { @@ -189,49 +184,55 @@ func ForkPost(ctx *context.Context) { ctx.ServerError("CanCreateOrgRepo", err) return } else if !isAllowedToFork { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } } - repo, err := repo_service.ForkRepository(ctx, ctx.Doer, ctxUser, repo_service.ForkRepoOptions{ + repo := ForkRepoTo(ctx, ctxUser, repo_service.ForkRepoOptions{ BaseRepo: forkRepo, Name: form.RepoName, Description: form.Description, SingleBranch: form.ForkSingleBranch, }) + if ctx.Written() { + return + } + ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) +} + +func ForkRepoTo(ctx *context.Context, owner *user_model.User, forkOpts repo_service.ForkRepoOptions) *repo_model.Repository { + repo, err := repo_service.ForkRepository(ctx, ctx.Doer, owner, forkOpts) if err != nil { ctx.Data["Err_RepoName"] = true switch { case repo_model.IsErrReachLimitOfRepo(err): - maxCreationLimit := ctxUser.MaxCreationLimit() + maxCreationLimit := owner.MaxCreationLimit() msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) - ctx.RenderWithErr(msg, tplFork, &form) + ctx.JSONError(msg) case repo_model.IsErrRepoAlreadyExist(err): - ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplFork, &form) + ctx.JSONError(ctx.Tr("repo.settings.new_owner_has_same_repo")) case repo_model.IsErrRepoFilesAlreadyExist(err): switch { case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplFork, form) + ctx.JSONError(ctx.Tr("form.repository_files_already_exist.adopt_or_delete")) case setting.Repository.AllowAdoptionOfUnadoptedRepositories: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplFork, form) + ctx.JSONError(ctx.Tr("form.repository_files_already_exist.adopt")) case setting.Repository.AllowDeleteOfUnadoptedRepositories: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplFork, form) + ctx.JSONError(ctx.Tr("form.repository_files_already_exist.delete")) default: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplFork, form) + ctx.JSONError(ctx.Tr("form.repository_files_already_exist")) } case db.IsErrNameReserved(err): - ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplFork, &form) + ctx.JSONError(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name)) case db.IsErrNamePatternNotAllowed(err): - ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplFork, &form) + ctx.JSONError(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern)) case errors.Is(err, user_model.ErrBlockedUser): - ctx.RenderWithErr(ctx.Tr("repo.fork.blocked_user"), tplFork, form) + ctx.JSONError(ctx.Tr("repo.fork.blocked_user")) default: ctx.ServerError("ForkPost", err) } - return + return nil } - - log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name) - ctx.Redirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name)) + return repo } diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go index 6b2a7fd076..deb3ae4f3a 100644 --- a/routers/web/repo/githttp.go +++ b/routers/web/repo/githttp.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strconv" "strings" "sync" @@ -29,7 +30,6 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" @@ -78,7 +78,7 @@ func httpBase(ctx *context.Context) *serviceHandler { strings.HasSuffix(ctx.Req.URL.Path, "git-upload-archive") { isPull = true } else { - isPull = ctx.Req.Method == "GET" + isPull = ctx.Req.Method == http.MethodHead || ctx.Req.Method == http.MethodGet } var accessMode perm.AccessMode @@ -127,7 +127,7 @@ func httpBase(ctx *context.Context) *serviceHandler { // Only public pull don't need auth. isPublicPull := repoExist && !repo.IsPrivate && isPull var ( - askAuth = !isPublicPull || setting.Service.RequireSignInView + askAuth = !isPublicPull || setting.Service.RequireSignInViewStrict environ []string ) @@ -147,7 +147,7 @@ func httpBase(ctx *context.Context) *serviceHandler { if !ctx.IsSigned { // TODO: support digit auth - which would be Authorization header with digit ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`) - ctx.Error(http.StatusUnauthorized) + ctx.HTTPError(http.StatusUnauthorized) return nil } @@ -303,24 +303,19 @@ var ( func dummyInfoRefs(ctx *context.Context) { infoRefsOnce.Do(func() { - tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-info-refs-cache") + tmpDir, cleanup, err := setting.AppDataTempDir("git-repo-content").MkdirTempRandom("gitea-info-refs-cache") if err != nil { log.Error("Failed to create temp dir for git-receive-pack cache: %v", err) return } - - defer func() { - if err := util.RemoveAll(tmpDir); err != nil { - log.Error("RemoveAll: %v", err) - } - }() + defer cleanup() if err := git.InitRepository(ctx, tmpDir, true, git.Sha1ObjectFormat.Name()); err != nil { log.Error("Failed to init bare repo for git-receive-pack cache: %v", err) return } - refs, _, err := git.NewCommand(ctx, "receive-pack", "--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Dir: tmpDir}) + refs, _, err := git.NewCommand("receive-pack", "--stateless-rpc", "--advertise-refs", ".").RunStdBytes(ctx, &git.RunOpts{Dir: tmpDir}) if err != nil { log.Error(fmt.Sprintf("%v - %s", err, string(refs))) } @@ -360,8 +355,8 @@ func setHeaderNoCache(ctx *context.Context) { func setHeaderCacheForever(ctx *context.Context) { now := time.Now().Unix() expires := now + 31536000 - ctx.Resp.Header().Set("Date", fmt.Sprintf("%d", now)) - ctx.Resp.Header().Set("Expires", fmt.Sprintf("%d", expires)) + ctx.Resp.Header().Set("Date", strconv.FormatInt(now, 10)) + ctx.Resp.Header().Set("Expires", strconv.FormatInt(expires, 10)) ctx.Resp.Header().Set("Cache-Control", "public, max-age=31536000") } @@ -369,12 +364,7 @@ func containsParentDirectorySeparator(v string) bool { if !strings.Contains(v, "..") { return false } - for _, ent := range strings.FieldsFunc(v, isSlashRune) { - if ent == ".." { - return true - } - } - return false + return slices.Contains(strings.FieldsFunc(v, isSlashRune), "..") } func isSlashRune(r rune) bool { return r == '/' || r == '\\' } @@ -394,7 +384,7 @@ func (h *serviceHandler) sendFile(ctx *context.Context, contentType, file string } ctx.Resp.Header().Set("Content-Type", contentType) - ctx.Resp.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) + ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10)) // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat ctx.Resp.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat)) http.ServeFile(ctx.Resp, ctx.Req, reqFile) @@ -403,12 +393,12 @@ func (h *serviceHandler) sendFile(ctx *context.Context, contentType, file string // one or more key=value pairs separated by colons var safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`) -func prepareGitCmdWithAllowedService(ctx *context.Context, service string) (*git.Command, error) { +func prepareGitCmdWithAllowedService(service string) (*git.Command, error) { if service == "receive-pack" { - return git.NewCommand(ctx, "receive-pack"), nil + return git.NewCommand("receive-pack"), nil } if service == "upload-pack" { - return git.NewCommand(ctx, "upload-pack"), nil + return git.NewCommand("upload-pack"), nil } return nil, fmt.Errorf("service %q is not allowed", service) @@ -428,7 +418,7 @@ func serviceRPC(ctx *context.Context, h *serviceHandler, service string) { return } - cmd, err := prepareGitCmdWithAllowedService(ctx, service) + cmd, err := prepareGitCmdWithAllowedService(service) if err != nil { log.Error("Failed to prepareGitCmdWithService: %v", err) ctx.Resp.WriteHeader(http.StatusUnauthorized) @@ -458,7 +448,7 @@ func serviceRPC(ctx *context.Context, h *serviceHandler, service string) { var stderr bytes.Buffer cmd.AddArguments("--stateless-rpc").AddDynamicArguments(h.getRepoDir()) - if err := cmd.Run(&git.RunOpts{ + if err := cmd.Run(ctx, &git.RunOpts{ Dir: h.getRepoDir(), Env: append(os.Environ(), h.environ...), Stdout: ctx.Resp, @@ -498,7 +488,7 @@ func getServiceType(ctx *context.Context) string { } func updateServerInfo(ctx gocontext.Context, dir string) []byte { - out, _, err := git.NewCommand(ctx, "update-server-info").RunStdBytes(&git.RunOpts{Dir: dir}) + out, _, err := git.NewCommand("update-server-info").RunStdBytes(ctx, &git.RunOpts{Dir: dir}) if err != nil { log.Error(fmt.Sprintf("%v - %s", err, string(out))) } @@ -521,14 +511,14 @@ func GetInfoRefs(ctx *context.Context) { } setHeaderNoCache(ctx) service := getServiceType(ctx) - cmd, err := prepareGitCmdWithAllowedService(ctx, service) + cmd, err := prepareGitCmdWithAllowedService(service) if err == nil { if protocol := ctx.Req.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) } h.environ = append(os.Environ(), h.environ...) - refs, _, err := cmd.AddArguments("--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Env: h.environ, Dir: h.getRepoDir()}) + refs, _, err := cmd.AddArguments("--stateless-rpc", "--advertise-refs", ".").RunStdBytes(ctx, &git.RunOpts{Env: h.environ, Dir: h.getRepoDir()}) if err != nil { log.Error(fmt.Sprintf("%v - %s", err, string(refs))) } diff --git a/routers/web/repo/githttp_test.go b/routers/web/repo/githttp_test.go index 5ba8de3d63..0164b11f66 100644 --- a/routers/web/repo/githttp_test.go +++ b/routers/web/repo/githttp_test.go @@ -37,6 +37,6 @@ func TestContainsParentDirectorySeparator(t *testing.T) { } for i := range tests { - assert.EqualValues(t, tests[i].b, containsParentDirectorySeparator(tests[i].v)) + assert.Equal(t, tests[i].b, containsParentDirectorySeparator(tests[i].v)) } } diff --git a/routers/web/repo/helper.go b/routers/web/repo/helper.go index 3bf064c1e3..7b92cba0fd 100644 --- a/routers/web/repo/helper.go +++ b/routers/web/repo/helper.go @@ -12,7 +12,7 @@ func HandleGitError(ctx *context.Context, msg string, err error) { if git.IsErrNotExist(err) { ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.tree_path_not_found", ctx.Repo.TreePath, ctx.Repo.RefTypeNameSubURL()) ctx.Data["NotFoundGoBackURL"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - ctx.NotFound(msg, err) + ctx.NotFound(err) } else { ctx.ServerError(msg, err) } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index f150897a2d..54b7e5df2a 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -43,7 +43,8 @@ const ( tplIssueChoose templates.TplName = "repo/issue/choose" tplIssueView templates.TplName = "repo/issue/view" - tplReactions templates.TplName = "repo/issue/view_content/reactions" + tplPullMergeBox templates.TplName = "repo/issue/view_content/pull_merge_box" + tplReactions templates.TplName = "repo/issue/view_content/reactions" issueTemplateKey = "IssueTemplate" issueTemplateTitleKey = "IssueTemplateTitle" @@ -91,7 +92,7 @@ func MustAllowUserComment(ctx *context.Context) { func MustEnableIssues(ctx *context.Context) { if !ctx.Repo.CanRead(unit.TypeIssues) && !ctx.Repo.CanRead(unit.TypeExternalTracker) { - ctx.NotFound("MustEnableIssues", nil) + ctx.NotFound(nil) return } @@ -105,7 +106,7 @@ func MustEnableIssues(ctx *context.Context) { // MustAllowPulls check if repository enable pull requests and user have right to do that func MustAllowPulls(ctx *context.Context) { if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { - ctx.NotFound("MustAllowPulls", nil) + ctx.NotFound(nil) return } @@ -201,7 +202,7 @@ func GetActionIssue(ctx *context.Context) *issues_model.Issue { func checkIssueRights(ctx *context.Context, issue *issues_model.Issue) { if issue.IsPull && !ctx.Repo.CanRead(unit.TypePullRequests) || !issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { - ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + ctx.NotFound(nil) } } @@ -211,7 +212,7 @@ func getActionIssues(ctx *context.Context) issues_model.IssueList { return nil } issueIDs := make([]int64, 0, 10) - for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") { + for stringIssueID := range strings.SplitSeq(commaSeparatedIssueIDs, ",") { issueID, err := strconv.ParseInt(stringIssueID, 10, 64) if err != nil { ctx.ServerError("ParseInt", err) @@ -229,11 +230,11 @@ func getActionIssues(ctx *context.Context) issues_model.IssueList { prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) for _, issue := range issues { if issue.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) + ctx.NotFound(errors.New("some issue's RepoID is incorrect")) return nil } if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { - ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + ctx.NotFound(nil) return nil } if err = issue.LoadAttributes(ctx); err != nil { @@ -249,9 +250,9 @@ func GetIssueInfo(ctx *context.Context) { issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) if err != nil { if issues_model.IsErrIssueNotExist(err) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) } else { - ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) } return } @@ -259,13 +260,13 @@ func GetIssueInfo(ctx *context.Context) { if issue.IsPull { // Need to check if Pulls are enabled and we can read Pulls if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } } else { // Need to check if Issues are enabled and we can read Issues if !ctx.Repo.CanRead(unit.TypeIssues) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } } @@ -284,13 +285,13 @@ func UpdateIssueTitle(ctx *context.Context) { } if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } title := ctx.FormTrim("title") if len(title) == 0 { - ctx.Error(http.StatusNoContent) + ctx.HTTPError(http.StatusNoContent) return } @@ -312,7 +313,7 @@ func UpdateIssueRef(ctx *context.Context) { } if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) || issue.IsPull { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } @@ -336,7 +337,7 @@ func UpdateIssueContent(ctx *context.Context) { } if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } @@ -363,7 +364,9 @@ func UpdateIssueContent(ctx *context.Context) { } } - rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository) + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{ + FootnoteContextID: "0", + }) content, err := markdown.RenderString(rctx, issue.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -382,21 +385,21 @@ func UpdateIssueDeadline(ctx *context.Context) { issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) if err != nil { if issues_model.IsErrIssueNotExist(err) { - ctx.NotFound("GetIssueByIndex", err) + ctx.NotFound(err) } else { - ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) } return } if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { - ctx.Error(http.StatusForbidden, "", "Not repo writer") + ctx.HTTPError(http.StatusForbidden, "", "Not repo writer") return } deadlineUnix, _ := common.ParseDeadlineDateToEndOfDay(ctx.FormString("deadline")) if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { - ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "UpdateIssueDeadline", err.Error()) return } @@ -417,6 +420,16 @@ func UpdateIssueMilestone(ctx *context.Context) { continue } issue.MilestoneID = milestoneID + if milestoneID > 0 { + var err error + issue.Milestone, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) + if err != nil { + ctx.ServerError("GetMilestoneByRepoID", err) + return + } + } else { + issue.Milestone = nil + } if err := issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { ctx.ServerError("ChangeMilestoneAssign", err) return @@ -497,7 +510,7 @@ func ChangeIssueReaction(ctx *context.Context) { } } - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } @@ -540,7 +553,7 @@ func ChangeIssueReaction(ctx *context.Context) { log.Trace("Reaction for issue removed: %d/%d", ctx.Repo.Repository.ID, issue.ID) default: - ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.PathParam("action")), nil) + ctx.NotFound(nil) return } diff --git a/routers/web/repo/issue_comment.go b/routers/web/repo/issue_comment.go index 8564c613de..c2a7f6b682 100644 --- a/routers/web/repo/issue_comment.go +++ b/routers/web/repo/issue_comment.go @@ -8,11 +8,13 @@ import ( "fmt" "html/template" "net/http" + "strconv" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/renderhelper" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" repo_module "code.gitea.io/gitea/modules/repository" @@ -53,7 +55,7 @@ func NewComment(ctx *context.Context) { } } - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } @@ -95,7 +97,7 @@ func NewComment(ctx *context.Context) { // Regenerate patch and test conflict. if pr == nil { issue.PullRequest.HeadCommitID = "" - pull_service.AddToTaskQueue(ctx, issue.PullRequest) + pull_service.StartPullRequestCheckImmediately(ctx, issue.PullRequest) } // check whether the ref of PR <refs/pulls/pr_index/head> in base repo is consistent with the head commit of head branch in the head repo @@ -117,7 +119,7 @@ func NewComment(ctx *context.Context) { ctx.ServerError("Unable to load head repo", err) return } - if ok := git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.BaseBranch); !ok { + if ok := gitrepo.IsBranchExist(ctx, pull.HeadRepo, pull.BaseBranch); !ok { // todo localize ctx.JSONError("The origin branch is delete, cannot reopen.") return @@ -224,35 +226,42 @@ func UpdateCommentContent(ctx *context.Context) { } if comment.Issue.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + ctx.NotFound(issues_model.ErrCommentNotExist{}) return } if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } if !comment.Type.HasContentSupport() { - ctx.Error(http.StatusNoContent) + ctx.HTTPError(http.StatusNoContent) return } - oldContent := comment.Content newContent := ctx.FormString("content") contentVersion := ctx.FormInt("content_version") + if contentVersion != comment.ContentVersion { + ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed")) + return + } - // allow to save empty content - comment.Content = newContent - if err = issue_service.UpdateComment(ctx, comment, contentVersion, ctx.Doer, oldContent); err != nil { - if errors.Is(err, user_model.ErrBlockedUser) { - ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user")) - } else if errors.Is(err, issues_model.ErrCommentAlreadyChanged) { - ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed")) - } else { - ctx.ServerError("UpdateComment", err) + if newContent != comment.Content { + // allow to save empty content + oldContent := comment.Content + comment.Content = newContent + + if err = issue_service.UpdateComment(ctx, comment, contentVersion, ctx.Doer, oldContent); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user")) + } else if errors.Is(err, issues_model.ErrCommentAlreadyChanged) { + ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed")) + } else { + ctx.ServerError("UpdateComment", err) + } + return } - return } if err := comment.LoadAttachments(ctx); err != nil { @@ -270,7 +279,9 @@ func UpdateCommentContent(ctx *context.Context) { var renderedContent template.HTML if comment.Content != "" { - rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository) + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(comment.ID, 10), + }) renderedContent, err = markdown.RenderString(rctx, comment.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -302,15 +313,15 @@ func DeleteComment(ctx *context.Context) { } if comment.Issue.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + ctx.NotFound(issues_model.ErrCommentNotExist{}) return } if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } else if !comment.Type.HasContentSupport() { - ctx.Error(http.StatusNoContent) + ctx.HTTPError(http.StatusNoContent) return } @@ -337,7 +348,7 @@ func ChangeCommentReaction(ctx *context.Context) { } if comment.Issue.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + ctx.NotFound(issues_model.ErrCommentNotExist{}) return } @@ -360,12 +371,12 @@ func ChangeCommentReaction(ctx *context.Context) { } } - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } if !comment.Type.HasContentSupport() { - ctx.Error(http.StatusNoContent) + ctx.HTTPError(http.StatusNoContent) return } @@ -403,7 +414,7 @@ func ChangeCommentReaction(ctx *context.Context) { log.Trace("Reaction for comment removed: %d/%d/%d", ctx.Repo.Repository.ID, comment.Issue.ID, comment.ID) default: - ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.PathParam("action")), nil) + ctx.NotFound(nil) return } @@ -442,12 +453,12 @@ func GetCommentAttachments(ctx *context.Context) { } if comment.Issue.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + ctx.NotFound(issues_model.ErrCommentNotExist{}) return } if !ctx.Repo.Permission.CanReadIssuesOrPulls(comment.Issue.IsPull) { - ctx.NotFound("CanReadIssuesOrPulls", issues_model.ErrCommentNotExist{}) + ctx.NotFound(issues_model.ErrCommentNotExist{}) return } diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go index 13c6d89b6e..3602f4ec8a 100644 --- a/routers/web/repo/issue_content_history.go +++ b/routers/web/repo/issue_content_history.go @@ -157,15 +157,16 @@ func GetContentHistoryDetail(ctx *context.Context) { diffHTMLBuf := bytes.Buffer{} diffHTMLBuf.WriteString("<pre class='chroma'>") for _, it := range diff { - if it.Type == diffmatchpatch.DiffInsert { + switch it.Type { + case diffmatchpatch.DiffInsert: diffHTMLBuf.WriteString("<span class='gi'>") diffHTMLBuf.WriteString(html.EscapeString(it.Text)) diffHTMLBuf.WriteString("</span>") - } else if it.Type == diffmatchpatch.DiffDelete { + case diffmatchpatch.DiffDelete: diffHTMLBuf.WriteString("<span class='gd'>") diffHTMLBuf.WriteString(html.EscapeString(it.Text)) diffHTMLBuf.WriteString("</span>") - } else { + default: diffHTMLBuf.WriteString(html.EscapeString(it.Text)) } } @@ -186,7 +187,7 @@ func SoftDeleteContentHistory(ctx *context.Context) { return } if ctx.Doer == nil { - ctx.NotFound("Require SignIn", nil) + ctx.NotFound(nil) return } @@ -202,12 +203,12 @@ func SoftDeleteContentHistory(ctx *context.Context) { return } if history.IssueID != issue.ID { - ctx.NotFound("CompareRepoID", issues_model.ErrCommentNotExist{}) + ctx.NotFound(issues_model.ErrCommentNotExist{}) return } if commentID != 0 { if history.CommentID != commentID { - ctx.NotFound("CompareCommentID", issues_model.ErrCommentNotExist{}) + ctx.NotFound(issues_model.ErrCommentNotExist{}) return } @@ -216,7 +217,7 @@ func SoftDeleteContentHistory(ctx *context.Context) { return } if comment.IssueID != issue.ID { - ctx.NotFound("CompareIssueID", issues_model.ErrCommentNotExist{}) + ctx.NotFound(issues_model.ErrCommentNotExist{}) return } } diff --git a/routers/web/repo/issue_dependency.go b/routers/web/repo/issue_dependency.go index 0f6787386d..73298958c0 100644 --- a/routers/web/repo/issue_dependency.go +++ b/routers/web/repo/issue_dependency.go @@ -23,7 +23,7 @@ func AddDependency(ctx *context.Context) { // Check if the Repo is allowed to have dependencies if !ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull) { - ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") + ctx.HTTPError(http.StatusForbidden, "CanCreateIssueDependencies") return } @@ -97,7 +97,7 @@ func RemoveDependency(ctx *context.Context) { // Check if the Repo is allowed to have dependencies if !ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, issue.IsPull) { - ctx.Error(http.StatusForbidden, "CanCreateIssueDependencies") + ctx.HTTPError(http.StatusForbidden, "CanCreateIssueDependencies") return } @@ -119,7 +119,7 @@ func RemoveDependency(ctx *context.Context) { case "blocking": depType = issues_model.DependencyTypeBlocking default: - ctx.Error(http.StatusBadRequest, "GetDependecyType") + ctx.HTTPError(http.StatusBadRequest, "GetDependecyType") return } diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go index 5ef6b09faa..72a316e98d 100644 --- a/routers/web/repo/issue_label.go +++ b/routers/web/repo/issue_label.go @@ -4,6 +4,7 @@ package repo import ( + "errors" "net/http" "code.gitea.io/gitea/models/db" @@ -13,7 +14,9 @@ import ( "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + shared_label "code.gitea.io/gitea/routers/web/shared/label" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" issue_service "code.gitea.io/gitea/services/issue" @@ -100,54 +103,53 @@ func RetrieveLabelsForList(ctx *context.Context) { // NewLabel create new label for repository func NewLabel(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.CreateLabelForm) - ctx.Data["Title"] = ctx.Tr("repo.labels") - ctx.Data["PageIsLabels"] = true - - if ctx.HasError() { - ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) - ctx.Redirect(ctx.Repo.RepoLink + "/labels") + form := shared_label.GetLabelEditForm(ctx) + if ctx.Written() { return } l := &issues_model.Label{ - RepoID: ctx.Repo.Repository.ID, - Name: form.Title, - Exclusive: form.Exclusive, - Description: form.Description, - Color: form.Color, + RepoID: ctx.Repo.Repository.ID, + Name: form.Title, + Exclusive: form.Exclusive, + ExclusiveOrder: form.ExclusiveOrder, + Description: form.Description, + Color: form.Color, } if err := issues_model.NewLabel(ctx, l); err != nil { ctx.ServerError("NewLabel", err) return } - ctx.Redirect(ctx.Repo.RepoLink + "/labels") + ctx.JSONRedirect(ctx.Repo.RepoLink + "/labels") } // UpdateLabel update a label's name and color func UpdateLabel(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.CreateLabelForm) + form := shared_label.GetLabelEditForm(ctx) + if ctx.Written() { + return + } + l, err := issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, form.ID) - if err != nil { - switch { - case issues_model.IsErrRepoLabelNotExist(err): - ctx.Error(http.StatusNotFound) - default: - ctx.ServerError("UpdateLabel", err) - } + if errors.Is(err, util.ErrNotExist) { + ctx.JSONErrorNotFound() + return + } else if err != nil { + ctx.ServerError("GetLabelInRepoByID", err) return } + l.Name = form.Title l.Exclusive = form.Exclusive + l.ExclusiveOrder = form.ExclusiveOrder l.Description = form.Description l.Color = form.Color - l.SetArchived(form.IsArchived) if err := issues_model.UpdateLabel(ctx, l); err != nil { ctx.ServerError("UpdateLabel", err) return } - ctx.Redirect(ctx.Repo.RepoLink + "/labels") + ctx.JSONRedirect(ctx.Repo.RepoLink + "/labels") } // DeleteLabel delete a label @@ -180,7 +182,7 @@ func UpdateIssueLabel(ctx *context.Context) { label, err := issues_model.GetLabelByID(ctx, ctx.FormInt64("id")) if err != nil { if issues_model.IsErrRepoLabelNotExist(err) { - ctx.Error(http.StatusNotFound, "GetLabelByID") + ctx.HTTPError(http.StatusNotFound, "GetLabelByID") } else { ctx.ServerError("GetLabelByID", err) } @@ -221,7 +223,7 @@ func UpdateIssueLabel(ctx *context.Context) { } default: log.Warn("Unrecognized action: %s", action) - ctx.Error(http.StatusInternalServerError) + ctx.HTTPError(http.StatusInternalServerError) return } diff --git a/routers/web/repo/issue_label_test.go b/routers/web/repo/issue_label_test.go index 8a613e2c7e..f4eca26f8e 100644 --- a/routers/web/repo/issue_label_test.go +++ b/routers/web/repo/issue_label_test.go @@ -6,10 +6,12 @@ package repo import ( "net/http" "strconv" + "strings" "testing" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/web" @@ -19,26 +21,27 @@ import ( "github.com/stretchr/testify/assert" ) -func int64SliceToCommaSeparated(a []int64) string { - s := "" - for i, n := range a { - if i > 0 { - s += "," - } - s += strconv.Itoa(int(n)) - } - return s +func TestIssueLabel(t *testing.T) { + unittest.PrepareTestEnv(t) + t.Run("RetrieveLabels", testRetrieveLabels) + t.Run("NewLabel", testNewLabel) + t.Run("NewLabelInvalidColor", testNewLabelInvalidColor) + t.Run("UpdateLabel", testUpdateLabel) + t.Run("UpdateLabelInvalidColor", testUpdateLabelInvalidColor) + t.Run("UpdateIssueLabelClear", testUpdateIssueLabelClear) + t.Run("UpdateIssueLabelToggle", testUpdateIssueLabelToggle) + t.Run("InitializeLabels", testInitializeLabels) + t.Run("DeleteLabel", testDeleteLabel) } -func TestInitializeLabels(t *testing.T) { - unittest.PrepareTestEnv(t) +func testInitializeLabels(t *testing.T) { assert.NoError(t, repository.LoadRepoConfig()) ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/initialize") contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 2) web.SetForm(ctx, &forms.InitializeLabelsForm{TemplateName: "Default"}) InitializeLabels(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ RepoID: 2, Name: "enhancement", @@ -47,8 +50,7 @@ func TestInitializeLabels(t *testing.T) { assert.Equal(t, "/user2/repo2/labels", test.RedirectURL(ctx.Resp)) } -func TestRetrieveLabels(t *testing.T) { - unittest.PrepareTestEnv(t) +func testRetrieveLabels(t *testing.T) { for _, testCase := range []struct { RepoID int64 Sort string @@ -68,15 +70,14 @@ func TestRetrieveLabels(t *testing.T) { assert.True(t, ok) if assert.Len(t, labels, len(testCase.ExpectedLabelIDs)) { for i, label := range labels { - assert.EqualValues(t, testCase.ExpectedLabelIDs[i], label.ID) + assert.Equal(t, testCase.ExpectedLabelIDs[i], label.ID) } } } } -func TestNewLabel(t *testing.T) { - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/edit") +func testNewLabel(t *testing.T) { + ctx, respWriter := contexttest.MockContext(t, "user2/repo1/labels/edit") contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 1) web.SetForm(ctx, &forms.CreateLabelForm{ @@ -84,17 +85,32 @@ func TestNewLabel(t *testing.T) { Color: "#abcdef", }) NewLabel(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ Name: "newlabel", Color: "#abcdef", }) - assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(ctx.Resp)) + assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(respWriter)) } -func TestUpdateLabel(t *testing.T) { - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/edit") +func testNewLabelInvalidColor(t *testing.T) { + ctx, respWriter := contexttest.MockContext(t, "user2/repo1/labels/edit") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.CreateLabelForm{ + Title: "newlabel-x", + Color: "bad-label-code", + }) + NewLabel(ctx) + assert.Equal(t, http.StatusBadRequest, ctx.Resp.WrittenStatus()) + assert.Equal(t, "repo.issues.label_color_invalid", test.ParseJSONError(respWriter.Body.Bytes()).ErrorMessage) + unittest.AssertNotExistsBean(t, &issues_model.Label{ + Name: "newlabel-x", + }) +} + +func testUpdateLabel(t *testing.T) { + ctx, respWriter := contexttest.MockContext(t, "user2/repo1/labels/edit") contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 1) web.SetForm(ctx, &forms.CreateLabelForm{ @@ -104,43 +120,62 @@ func TestUpdateLabel(t *testing.T) { IsArchived: true, }) UpdateLabel(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ ID: 2, Name: "newnameforlabel", Color: "#abcdef", }) - assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(ctx.Resp)) + assert.Equal(t, "/user2/repo1/labels", test.RedirectURL(respWriter)) } -func TestDeleteLabel(t *testing.T) { - unittest.PrepareTestEnv(t) +func testUpdateLabelInvalidColor(t *testing.T) { + ctx, respWriter := contexttest.MockContext(t, "user2/repo1/labels/edit") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + web.SetForm(ctx, &forms.CreateLabelForm{ + ID: 1, + Title: "label1", + Color: "bad-label-code", + }) + + UpdateLabel(ctx) + + assert.Equal(t, http.StatusBadRequest, ctx.Resp.WrittenStatus()) + assert.Equal(t, "repo.issues.label_color_invalid", test.ParseJSONError(respWriter.Body.Bytes()).ErrorMessage) + unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ + ID: 1, + Name: "label1", + Color: "#abcdef", + }) +} + +func testDeleteLabel(t *testing.T) { ctx, _ := contexttest.MockContext(t, "user2/repo1/labels/delete") contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 1) ctx.Req.Form.Set("id", "2") DeleteLabel(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) unittest.AssertNotExistsBean(t, &issues_model.Label{ID: 2}) unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{LabelID: 2}) assert.EqualValues(t, ctx.Tr("repo.issues.label_deletion_success"), ctx.Flash.SuccessMsg) } -func TestUpdateIssueLabel_Clear(t *testing.T) { - unittest.PrepareTestEnv(t) +func testUpdateIssueLabelClear(t *testing.T) { ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 1) ctx.Req.Form.Set("issue_ids", "1,3") ctx.Req.Form.Set("action", "clear") UpdateIssueLabel(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: 1}) unittest.AssertNotExistsBean(t, &issues_model.IssueLabel{IssueID: 3}) unittest.CheckConsistencyFor(t, &issues_model.Label{}) } -func TestUpdateIssueLabel_Toggle(t *testing.T) { +func testUpdateIssueLabelToggle(t *testing.T) { for _, testCase := range []struct { Action string IssueIDs []int64 @@ -156,11 +191,12 @@ func TestUpdateIssueLabel_Toggle(t *testing.T) { ctx, _ := contexttest.MockContext(t, "user2/repo1/issues/labels") contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 1) - ctx.Req.Form.Set("issue_ids", int64SliceToCommaSeparated(testCase.IssueIDs)) + + ctx.Req.Form.Set("issue_ids", strings.Join(base.Int64sToStrings(testCase.IssueIDs), ",")) ctx.Req.Form.Set("action", testCase.Action) ctx.Req.Form.Set("id", strconv.Itoa(int(testCase.LabelID))) UpdateIssueLabel(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) for _, issueID := range testCase.IssueIDs { if testCase.ExpectedAdd { unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issueID, LabelID: testCase.LabelID}) diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index 2f615a100e..b55f4bcc90 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -5,8 +5,10 @@ package repo import ( "bytes" - "fmt" + "maps" "net/http" + "slices" + "sort" "strconv" "strings" @@ -18,6 +20,7 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + db_indexer "code.gitea.io/gitea/modules/indexer/issues/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" @@ -30,14 +33,6 @@ import ( pull_service "code.gitea.io/gitea/services/pull" ) -func issueIDsFromSearch(ctx *context.Context, keyword string, opts *issues_model.IssuesOptions) ([]int64, error) { - ids, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts)) - if err != nil { - return nil, fmt.Errorf("SearchIssues: %w", err) - } - return ids, nil -} - func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Repository) { ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo) } @@ -46,7 +41,7 @@ func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Reposit func SearchIssues(ctx *context.Context) { before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { - ctx.Error(http.StatusUnprocessableEntity, err.Error()) + ctx.HTTPError(http.StatusUnprocessableEntity, err.Error()) return } @@ -66,7 +61,7 @@ func SearchIssues(ctx *context.Context) { ) { // find repos user can access (for issue search) - opts := &repo_model.SearchRepoOptions{ + opts := repo_model.SearchRepoOptions{ Private: false, AllPublic: true, TopicOnly: false, @@ -84,9 +79,9 @@ func SearchIssues(ctx *context.Context) { owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) if err != nil { if user_model.IsErrUserNotExist(err) { - ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) + ctx.HTTPError(http.StatusBadRequest, "Owner not found", err.Error()) } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "GetUserByName", err.Error()) } return } @@ -97,15 +92,15 @@ func SearchIssues(ctx *context.Context) { } if ctx.FormString("team") != "" { if ctx.FormString("owner") == "" { - ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") + ctx.HTTPError(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") return } team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) if err != nil { if organization.IsErrTeamNotExist(err) { - ctx.Error(http.StatusBadRequest, "Team not found", err.Error()) + ctx.HTTPError(http.StatusBadRequest, "Team not found", err.Error()) } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "GetUserByName", err.Error()) } return } @@ -118,7 +113,7 @@ func SearchIssues(ctx *context.Context) { } repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts) if err != nil { - ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error()) return } if len(repoIDs) == 0 { @@ -149,7 +144,7 @@ func SearchIssues(ctx *context.Context) { } includedAnyLabels, err = issues_model.GetLabelIDsByNames(ctx, includedLabelNames) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetLabelIDsByNames", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "GetLabelIDsByNames", err.Error()) return } } @@ -163,7 +158,7 @@ func SearchIssues(ctx *context.Context) { } includedMilestones, err = issues_model.GetMilestoneIDsByNames(ctx, includedMilestoneNames) if err != nil { - ctx.Error(http.StatusInternalServerError, "GetMilestoneIDsByNames", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "GetMilestoneIDsByNames", err.Error()) return } } @@ -208,10 +203,10 @@ func SearchIssues(ctx *context.Context) { if ctx.IsSigned { ctxUserID := ctx.Doer.ID if ctx.FormBool("created") { - searchOpt.PosterID = optional.Some(ctxUserID) + searchOpt.PosterID = strconv.FormatInt(ctxUserID, 10) } if ctx.FormBool("assigned") { - searchOpt.AssigneeID = optional.Some(ctxUserID) + searchOpt.AssigneeID = strconv.FormatInt(ctxUserID, 10) } if ctx.FormBool("mentioned") { searchOpt.MentionID = optional.Some(ctxUserID) @@ -230,12 +225,12 @@ func SearchIssues(ctx *context.Context) { ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) if err != nil { - ctx.Error(http.StatusInternalServerError, "SearchIssues", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "SearchIssues", err.Error()) return } issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) if err != nil { - ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "FindIssuesByIDs", err.Error()) return } @@ -251,12 +246,12 @@ func getUserIDForFilter(ctx *context.Context, queryName string) int64 { user, err := user_model.GetUserByName(ctx, userName) if user_model.IsErrUserNotExist(err) { - ctx.NotFound("", err) + ctx.NotFound(err) return 0 } if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.HTTPError(http.StatusInternalServerError, err.Error()) return 0 } @@ -269,7 +264,7 @@ func getUserIDForFilter(ctx *context.Context, queryName string) int64 { func SearchRepoIssuesJSON(ctx *context.Context) { before, since, err := context.GetQueryBeforeSince(ctx.Base) if err != nil { - ctx.Error(http.StatusUnprocessableEntity, err.Error()) + ctx.HTTPError(http.StatusUnprocessableEntity, err.Error()) return } @@ -299,7 +294,7 @@ func SearchRepoIssuesJSON(ctx *context.Context) { continue } if !issues_model.IsErrMilestoneNotExist(err) { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.HTTPError(http.StatusInternalServerError, err.Error()) return } id, err := strconv.ParseInt(part[i], 10, 64) @@ -314,7 +309,7 @@ func SearchRepoIssuesJSON(ctx *context.Context) { if issues_model.IsErrMilestoneNotExist(err) { continue } - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.HTTPError(http.StatusInternalServerError, err.Error()) } } @@ -373,10 +368,10 @@ func SearchRepoIssuesJSON(ctx *context.Context) { } if createdByID > 0 { - searchOpt.PosterID = optional.Some(createdByID) + searchOpt.PosterID = strconv.FormatInt(createdByID, 10) } if assignedByID > 0 { - searchOpt.AssigneeID = optional.Some(assignedByID) + searchOpt.AssigneeID = strconv.FormatInt(assignedByID, 10) } if mentionedByID > 0 { searchOpt.MentionID = optional.Some(mentionedByID) @@ -384,12 +379,12 @@ func SearchRepoIssuesJSON(ctx *context.Context) { ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) if err != nil { - ctx.Error(http.StatusInternalServerError, "SearchIssues", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "SearchIssues", err.Error()) return } issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) if err != nil { - ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err.Error()) + ctx.HTTPError(http.StatusInternalServerError, "FindIssuesByIDs", err.Error()) return } @@ -459,6 +454,19 @@ func UpdateIssueStatus(ctx *context.Context) { ctx.JSONOK() } +func prepareIssueFilterExclusiveOrderScopes(ctx *context.Context, allLabels []*issues_model.Label) { + scopeSet := make(map[string]bool) + for _, label := range allLabels { + scope := label.ExclusiveScope() + if len(scope) > 0 && label.ExclusiveOrder > 0 { + scopeSet[scope] = true + } + } + scopes := slices.Collect(maps.Keys(scopeSet)) + sort.Strings(scopes) + ctx.Data["ExclusiveLabelScopes"] = scopes +} + func renderMilestones(ctx *context.Context) { // Get milestones milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ @@ -481,7 +489,7 @@ func renderMilestones(ctx *context.Context) { ctx.Data["ClosedMilestones"] = closedMilestones } -func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) { +func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int64, isPullOption optional.Option[bool]) { var err error viewType := ctx.FormString("type") sortType := ctx.FormString("sort") @@ -490,7 +498,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt viewType = "all" } - assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future + assigneeID := ctx.FormString("assignee") posterUsername := ctx.FormString("poster") posterUserID := shared_user.GetFilterUserIDByName(ctx, posterUsername) var mentionedID, reviewRequestedID, reviewedID int64 @@ -498,11 +506,11 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt if ctx.IsSigned { switch viewType { case "created_by": - posterUserID = optional.Some(ctx.Doer.ID) + posterUserID = strconv.FormatInt(ctx.Doer.ID, 10) case "mentioned": mentionedID = ctx.Doer.ID case "assigned": - assigneeID = ctx.Doer.ID + assigneeID = strconv.FormatInt(ctx.Doer.ID, 10) case "review_requested": reviewRequestedID = ctx.Doer.ID case "reviewed_by": @@ -521,18 +529,21 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt mileIDs = []int64{milestoneID} } - labelIDs := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner) + preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, repo.ID, ctx.Repo.Owner) if ctx.Written() { return } + prepareIssueFilterExclusiveOrderScopes(ctx, preparedLabelFilter.AllLabels) + + var keywordMatchedIssueIDs []int64 var issueStats *issues_model.IssueStats statsOpts := &issues_model.IssuesOptions{ RepoIDs: []int64{repo.ID}, - LabelIDs: labelIDs, + LabelIDs: preparedLabelFilter.SelectedLabelIDs, MilestoneIDs: mileIDs, ProjectID: projectID, - AssigneeID: optional.Some(assigneeID), + AssigneeID: assigneeID, MentionedID: mentionedID, PosterID: posterUserID, ReviewRequestedID: reviewRequestedID, @@ -541,7 +552,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt IssueIDs: nil, } if keyword != "" { - allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts) + keywordMatchedIssueIDs, _, err = issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, statsOpts)) if err != nil { if issue_indexer.IsAvailable(ctx) { ctx.ServerError("issueIDsFromSearch", err) @@ -550,14 +561,17 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt ctx.Data["IssueIndexerUnavailable"] = true return } - statsOpts.IssueIDs = allIssueIDs + if len(keywordMatchedIssueIDs) == 0 { + // It did search with the keyword, but no issue found, just set issueStats to empty, then no need to do query again. + issueStats = &issues_model.IssueStats{} + // set keywordMatchedIssueIDs to empty slice, so we can distinguish it from "nil" + keywordMatchedIssueIDs = []int64{} + } + statsOpts.IssueIDs = keywordMatchedIssueIDs } - if keyword != "" && len(statsOpts.IssueIDs) == 0 { - // So it did search with the keyword, but no issue found. - // Just set issueStats to empty. - issueStats = &issues_model.IssueStats{} - } else { - // So it did search with the keyword, and found some issues. It needs to get issueStats of these issues. + + if issueStats == nil { + // Either it did search with the keyword, and found some issues, it needs to get issueStats of these issues. // Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts. issueStats, err = issues_model.GetIssueStats(ctx, statsOpts) if err != nil { @@ -589,31 +603,27 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt ctx.Data["TotalTrackedTime"] = totalTrackedTime } - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } - - var total int - switch { - case isShowClosed.Value(): - total = int(issueStats.ClosedCount) - case !isShowClosed.Has(): - total = int(issueStats.OpenCount + issueStats.ClosedCount) - default: - total = int(issueStats.OpenCount) + // prepare pager + total := int(issueStats.OpenCount + issueStats.ClosedCount) + if isShowClosed.Has() { + total = util.Iif(isShowClosed.Value(), int(issueStats.ClosedCount), int(issueStats.OpenCount)) } + page := max(ctx.FormInt("page"), 1) pager := context.NewPagination(total, setting.UI.IssuePagingNum, page, 5) + // prepare real issue list: var issues issues_model.IssueList - { - ids, err := issueIDsFromSearch(ctx, keyword, &issues_model.IssuesOptions{ + if keywordMatchedIssueIDs == nil || len(keywordMatchedIssueIDs) > 0 { + // Either it did search with the keyword, and found some issues, then keywordMatchedIssueIDs is not null, it needs to use db indexer. + // Or the keyword is empty, it also needs to usd db indexer. + // In either case, no need to use keyword anymore + searchResult, err := db_indexer.GetIndexer().FindWithIssueOptions(ctx, &issues_model.IssuesOptions{ Paginator: &db.ListOptions{ Page: pager.Paginater.Current(), PageSize: setting.UI.IssuePagingNum, }, RepoIDs: []int64{repo.ID}, - AssigneeID: optional.Some(assigneeID), + AssigneeID: assigneeID, PosterID: posterUserID, MentionedID: mentionedID, ReviewRequestedID: reviewRequestedID, @@ -622,18 +632,16 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt ProjectID: projectID, IsClosed: isShowClosed, IsPull: isPullOption, - LabelIDs: labelIDs, + LabelIDs: preparedLabelFilter.SelectedLabelIDs, SortType: sortType, + IssueIDs: keywordMatchedIssueIDs, }) if err != nil { - if issue_indexer.IsAvailable(ctx) { - ctx.ServerError("issueIDsFromSearch", err) - return - } - ctx.Data["IssueIndexerUnavailable"] = true + ctx.ServerError("DBIndexer.Search", err) return } - issues, err = issues_model.GetIssuesByIDs(ctx, ids, true) + issueIDs := issue_indexer.SearchResultToIDSlice(searchResult) + issues, err = issues_model.GetIssuesByIDs(ctx, issueIDs, true) if err != nil { ctx.ServerError("GetIssuesByIDs", err) return @@ -696,9 +704,10 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt return 0 } reviewTyp := issues_model.ReviewTypeApprove - if typ == "reject" { + switch typ { + case "reject": reviewTyp = issues_model.ReviewTypeReject - } else if typ == "waiting" { + case "waiting": reviewTyp = issues_model.ReviewTypeRequest } for _, count := range counts { @@ -727,7 +736,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt ctx.Data["IssueStats"] = issueStats ctx.Data["OpenCount"] = issueStats.OpenCount ctx.Data["ClosedCount"] = issueStats.ClosedCount - ctx.Data["SelLabelIDs"] = labelIDs + ctx.Data["SelLabelIDs"] = preparedLabelFilter.SelectedLabelIDs ctx.Data["ViewType"] = viewType ctx.Data["SortType"] = sortType ctx.Data["MilestoneID"] = milestoneID @@ -768,7 +777,7 @@ func Issues(ctx *context.Context) { ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) } - issues(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList)) + prepareIssueFilterAndList(ctx, ctx.FormInt64("milestone"), ctx.FormInt64("project"), optional.Some(isPullList)) if ctx.Written() { return } diff --git a/routers/web/repo/issue_lock.go b/routers/web/repo/issue_lock.go index 1d5fc8a5f3..bc8aabd90b 100644 --- a/routers/web/repo/issue_lock.go +++ b/routers/web/repo/issue_lock.go @@ -24,11 +24,6 @@ func LockIssue(ctx *context.Context) { return } - if !form.HasValidReason() { - ctx.JSONError(ctx.Tr("repo.issues.lock.unknown_reason")) - return - } - if err := issues_model.LockIssue(ctx, &issues_model.IssueLockOptions{ Doer: ctx.Doer, Issue: issue, diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index 32daa3e48f..887019b146 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "html/template" + "maps" "net/http" "slices" "sort" @@ -136,9 +137,7 @@ func NewIssue(ctx *context.Context) { ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData) - for k, v := range errs { - ret.TemplateErrors[k] = v - } + maps.Copy(ret.TemplateErrors, errs) if ctx.Written() { return } @@ -223,11 +222,11 @@ func DeleteIssue(ctx *context.Context) { } if issue.IsPull { - ctx.Redirect(fmt.Sprintf("%s/pulls", ctx.Repo.Repository.Link()), http.StatusSeeOther) + ctx.Redirect(ctx.Repo.Repository.Link()+"/pulls", http.StatusSeeOther) return } - ctx.Redirect(fmt.Sprintf("%s/issues", ctx.Repo.Repository.Link()), http.StatusSeeOther) + ctx.Redirect(ctx.Repo.Repository.Link()+"/issues", http.StatusSeeOther) } func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(ItemType) KeyType) container.Set[KeyType] { @@ -255,7 +254,7 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo inputLabelIDs, _ := base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) candidateLabels := toSet(pageMetaData.LabelsData.AllLabels, func(label *issues_model.Label) int64 { return label.ID }) if len(inputLabelIDs) > 0 && !candidateLabels.Contains(inputLabelIDs...) { - ctx.NotFound("", nil) + ctx.NotFound(nil) return ret } pageMetaData.LabelsData.SetSelectedLabelIDs(inputLabelIDs) @@ -263,7 +262,7 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo allMilestones := append(slices.Clone(pageMetaData.MilestonesData.OpenMilestones), pageMetaData.MilestonesData.ClosedMilestones...) candidateMilestones := toSet(allMilestones, func(milestone *issues_model.Milestone) int64 { return milestone.ID }) if form.MilestoneID > 0 && !candidateMilestones.Contains(form.MilestoneID) { - ctx.NotFound("", nil) + ctx.NotFound(nil) return ret } pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID @@ -271,18 +270,21 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...) candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID }) if form.ProjectID > 0 && !candidateProjects.Contains(form.ProjectID) { - ctx.NotFound("", nil) + ctx.NotFound(nil) return ret } pageMetaData.ProjectsData.SelectedProjectID = form.ProjectID + // prepare assignees candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID }) inputAssigneeIDs, _ := base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) - if len(inputAssigneeIDs) > 0 && !candidateAssignees.Contains(inputAssigneeIDs...) { - ctx.NotFound("", nil) - return ret + var assigneeIDStrings []string + for _, inputAssigneeID := range inputAssigneeIDs { + if candidateAssignees.Contains(inputAssigneeID) { + assigneeIDStrings = append(assigneeIDStrings, strconv.FormatInt(inputAssigneeID, 10)) + } } - pageMetaData.AssigneesData.SelectedAssigneeIDs = form.AssigneeIDs + pageMetaData.AssigneesData.SelectedAssigneeIDs = strings.Join(assigneeIDStrings, ",") // Check if the passed reviewers (user/team) actually exist var reviewers []*user_model.User @@ -301,14 +303,14 @@ func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueFo if rID < 0 { // negative reviewIDs represent team requests team, ok := teamReviewersMap[-rID] if !ok { - ctx.NotFound("", nil) + ctx.NotFound(nil) return ret } teamReviewers = append(teamReviewers, team) } else { user, ok := userReviewersMap[rID] if !ok { - ctx.NotFound("", nil) + ctx.NotFound(nil) return ret } reviewers = append(reviewers, user) @@ -346,7 +348,7 @@ func NewIssuePost(ctx *context.Context) { if projectID > 0 { if !ctx.Repo.CanRead(unit.TypeProjects) { // User must also be able to see the project. - ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") + ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects") return } } @@ -385,7 +387,7 @@ func NewIssuePost(ctx *context.Context) { if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectID); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { - ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) } else if errors.Is(err, user_model.ErrBlockedUser) { ctx.JSONError(ctx.Tr("repo.issues.new.blocked_user")) } else { diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 272343f460..93cc38bffa 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -79,27 +79,35 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository return data } - data.CanModifyIssueOrPull = ctx.Repo.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived - if !data.CanModifyIssueOrPull { + // it sets "Branches" template data, + // it is used to render the "edit PR target branches" dropdown, and the "branch selector" in the issue's sidebar. + PrepareBranchList(ctx) + if ctx.Written() { return data } - data.retrieveAssigneesDataForIssueWriter(ctx) + // it sets the "Assignees" template data, and the data is also used to "mention" users. + data.retrieveAssigneesData(ctx) if ctx.Written() { return data } - data.retrieveMilestonesDataForIssueWriter(ctx) - if ctx.Written() { + // TODO: the issue/pull permissions are quite complex and unclear + // A reader could create an issue/PR with setting some meta (eg: assignees from issue template, reviewers, target branch) + // A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore. + // For non-creator users, only writers could update some meta (eg: assignees, milestone, project) + // Need to clarify the logic and add some tests in the future + data.CanModifyIssueOrPull = ctx.Repo.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived + if !data.CanModifyIssueOrPull { return data } - data.retrieveProjectsDataForIssueWriter(ctx) + data.retrieveMilestonesDataForIssueWriter(ctx) if ctx.Written() { return data } - PrepareBranchList(ctx) + data.retrieveProjectsDataForIssueWriter(ctx) if ctx.Written() { return data } @@ -131,7 +139,7 @@ func (d *IssuePageMetaData) retrieveMilestonesDataForIssueWriter(ctx *context.Co } } -func (d *IssuePageMetaData) retrieveAssigneesDataForIssueWriter(ctx *context.Context) { +func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) { var err error d.AssigneesData.CandidateAssignees, err = repo_model.GetRepoAssignees(ctx, d.Repository) if err != nil { diff --git a/routers/web/repo/issue_pin.go b/routers/web/repo/issue_pin.go index d7d3205c37..8d3de90d25 100644 --- a/routers/web/repo/issue_pin.go +++ b/routers/web/repo/issue_pin.go @@ -6,6 +6,7 @@ package repo import ( "net/http" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" @@ -22,15 +23,29 @@ func IssuePinOrUnpin(ctx *context.Context) { // If we don't do this, it will crash when trying to add the pin event to the comment history err := issue.LoadRepo(ctx) if err != nil { - ctx.Status(http.StatusInternalServerError) - log.Error(err.Error()) + ctx.ServerError("LoadRepo", err) return } - err = issue.PinOrUnpin(ctx, ctx.Doer) + // PinOrUnpin pins or unpins a Issue + _, err = issues_model.GetIssuePin(ctx, issue) + if err != nil && !db.IsErrNotExist(err) { + ctx.ServerError("GetIssuePin", err) + return + } + + if db.IsErrNotExist(err) { + err = issues_model.PinIssue(ctx, issue, ctx.Doer) + } else { + err = issues_model.UnpinIssue(ctx, issue, ctx.Doer) + } + if err != nil { - ctx.Status(http.StatusInternalServerError) - log.Error(err.Error()) + if issues_model.IsErrIssueMaxPinReached(err) { + ctx.JSONError(ctx.Tr("repo.issues.max_pinned")) + } else { + ctx.ServerError("Pin/Unpin failed", err) + } return } @@ -41,23 +56,20 @@ func IssuePinOrUnpin(ctx *context.Context) { func IssueUnpin(ctx *context.Context) { issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) if err != nil { - ctx.Status(http.StatusInternalServerError) - log.Error(err.Error()) + ctx.ServerError("GetIssueByIndex", err) return } // If we don't do this, it will crash when trying to add the pin event to the comment history err = issue.LoadRepo(ctx) if err != nil { - ctx.Status(http.StatusInternalServerError) - log.Error(err.Error()) + ctx.ServerError("LoadRepo", err) return } - err = issue.Unpin(ctx, ctx.Doer) + err = issues_model.UnpinIssue(ctx, issue, ctx.Doer) if err != nil { - ctx.Status(http.StatusInternalServerError) - log.Error(err.Error()) + ctx.ServerError("UnpinIssue", err) return } @@ -78,15 +90,13 @@ func IssuePinMove(ctx *context.Context) { form := &movePinIssueForm{} if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { - ctx.Status(http.StatusInternalServerError) - log.Error(err.Error()) + ctx.ServerError("Decode", err) return } issue, err := issues_model.GetIssueByID(ctx, form.ID) if err != nil { - ctx.Status(http.StatusInternalServerError) - log.Error(err.Error()) + ctx.ServerError("GetIssueByID", err) return } @@ -96,10 +106,9 @@ func IssuePinMove(ctx *context.Context) { return } - err = issue.MovePin(ctx, form.Position) + err = issues_model.MovePin(ctx, issue, form.Position) if err != nil { - ctx.Status(http.StatusInternalServerError) - log.Error(err.Error()) + ctx.ServerError("MovePin", err) return } diff --git a/routers/web/repo/issue_stopwatch.go b/routers/web/repo/issue_stopwatch.go index 5d6e723311..2de3a7cfec 100644 --- a/routers/web/repo/issue_stopwatch.go +++ b/routers/web/repo/issue_stopwatch.go @@ -4,41 +4,53 @@ package repo import ( - "strings" - "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/eventsource" "code.gitea.io/gitea/services/context" ) -// IssueStopwatch creates or stops a stopwatch for the given issue. -func IssueStopwatch(c *context.Context) { +// IssueStartStopwatch creates a stopwatch for the given issue. +func IssueStartStopwatch(c *context.Context) { issue := GetActionIssue(c) if c.Written() { return } - var showSuccessMessage bool - - if !issues_model.StopwatchExists(c, c.Doer.ID, issue.ID) { - showSuccessMessage = true + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { + c.NotFound(nil) + return } - if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { - c.NotFound("CanUseTimetracker", nil) + if ok, err := issues_model.CreateIssueStopwatch(c, c.Doer, issue); err != nil { + c.ServerError("CreateIssueStopwatch", err) return + } else if !ok { + c.Flash.Warning(c.Tr("repo.issues.stopwatch_already_created")) + } else { + c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) } + c.JSONRedirect("") +} - if err := issues_model.CreateOrStopIssueStopwatch(c, c.Doer, issue); err != nil { - c.ServerError("CreateOrStopIssueStopwatch", err) +// IssueStopStopwatch stops a stopwatch for the given issue. +func IssueStopStopwatch(c *context.Context) { + issue := GetActionIssue(c) + if c.Written() { return } - if showSuccessMessage { - c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) + if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { + c.NotFound(nil) + return } + if ok, err := issues_model.FinishIssueStopwatch(c, c.Doer, issue); err != nil { + c.ServerError("FinishIssueStopwatch", err) + return + } else if !ok { + c.Flash.Warning(c.Tr("repo.issues.stopwatch_already_stopped")) + } c.JSONRedirect("") } @@ -49,11 +61,11 @@ func CancelStopwatch(c *context.Context) { return } if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { - c.NotFound("CanUseTimetracker", nil) + c.NotFound(nil) return } - if err := issues_model.CancelStopwatch(c, c.Doer, issue); err != nil { + if _, err := issues_model.CancelStopwatch(c, c.Doer, issue); err != nil { c.ServerError("CancelStopwatch", err) return } @@ -72,39 +84,3 @@ func CancelStopwatch(c *context.Context) { c.JSONRedirect("") } - -// GetActiveStopwatch is the middleware that sets .ActiveStopwatch on context -func GetActiveStopwatch(ctx *context.Context) { - if strings.HasPrefix(ctx.Req.URL.Path, "/api") { - return - } - - if !ctx.IsSigned { - return - } - - _, sw, issue, err := issues_model.HasUserStopwatch(ctx, ctx.Doer.ID) - if err != nil { - ctx.ServerError("HasUserStopwatch", err) - return - } - - if sw == nil || sw.ID == 0 { - return - } - - ctx.Data["ActiveStopwatch"] = StopwatchTmplInfo{ - issue.Link(), - issue.Repo.FullName(), - issue.Index, - sw.Seconds() + 1, // ensure time is never zero in ui - } -} - -// StopwatchTmplInfo is a view on a stopwatch specifically for template rendering -type StopwatchTmplInfo struct { - IssueLink string - RepoSlug string - IssueIndex int64 - Seconds int64 -} diff --git a/routers/web/repo/issue_suggestions.go b/routers/web/repo/issue_suggestions.go index 46e9f339a5..9ef3942504 100644 --- a/routers/web/repo/issue_suggestions.go +++ b/routers/web/repo/issue_suggestions.go @@ -6,13 +6,10 @@ package repo import ( "net/http" - "code.gitea.io/gitea/models/db" - issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/unit" - issue_indexer "code.gitea.io/gitea/modules/indexer/issues" "code.gitea.io/gitea/modules/optional" - "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" ) // IssueSuggestions returns a list of issue suggestions @@ -29,54 +26,11 @@ func IssueSuggestions(ctx *context.Context) { isPull = optional.Some(false) } - searchOpt := &issue_indexer.SearchOptions{ - Paginator: &db.ListOptions{ - Page: 0, - PageSize: 5, - }, - Keyword: keyword, - RepoIDs: []int64{ctx.Repo.Repository.ID}, - IsPull: isPull, - IsClosed: nil, - SortBy: issue_indexer.SortByUpdatedDesc, - } - - ids, _, err := issue_indexer.SearchIssues(ctx, searchOpt) - if err != nil { - ctx.ServerError("SearchIssues", err) - return - } - issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + suggestions, err := issue_service.GetSuggestion(ctx, ctx.Repo.Repository, isPull, keyword) if err != nil { - ctx.ServerError("FindIssuesByIDs", err) + ctx.ServerError("GetSuggestion", err) return } - suggestions := make([]*structs.Issue, 0, len(issues)) - - for _, issue := range issues { - suggestion := &structs.Issue{ - ID: issue.ID, - Index: issue.Index, - Title: issue.Title, - State: issue.State(), - } - - if issue.IsPull { - if err := issue.LoadPullRequest(ctx); err != nil { - ctx.ServerError("LoadPullRequest", err) - return - } - if issue.PullRequest != nil { - suggestion.PullRequest = &structs.PullRequestMeta{ - HasMerged: issue.PullRequest.HasMerged, - IsWorkInProgress: issue.PullRequest.IsWorkInProgress(ctx), - } - } - } - - suggestions = append(suggestions, suggestion) - } - ctx.JSON(http.StatusOK, suggestions) } diff --git a/routers/web/repo/issue_timetrack.go b/routers/web/repo/issue_timetrack.go index 134ded82d1..985bfd6698 100644 --- a/routers/web/repo/issue_timetrack.go +++ b/routers/web/repo/issue_timetrack.go @@ -25,7 +25,7 @@ func AddTimeManually(c *context.Context) { return } if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { - c.NotFound("CanUseTimetracker", nil) + c.NotFound(nil) return } @@ -56,23 +56,23 @@ func DeleteTime(c *context.Context) { return } if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { - c.NotFound("CanUseTimetracker", nil) + c.NotFound(nil) return } t, err := issues_model.GetTrackedTimeByID(c, c.PathParamInt64("timeid")) if err != nil { if db.IsErrNotExist(err) { - c.NotFound("time not found", err) + c.NotFound(err) return } - c.Error(http.StatusInternalServerError, "GetTrackedTimeByID", err.Error()) + c.HTTPError(http.StatusInternalServerError, "GetTrackedTimeByID", err.Error()) return } // only OP or admin may delete if !c.IsSigned || (!c.IsUserSiteAdmin() && c.Doer.ID != t.UserID) { - c.Error(http.StatusForbidden, "not allowed") + c.HTTPError(http.StatusForbidden, "not allowed") return } @@ -92,7 +92,7 @@ func UpdateIssueTimeEstimate(ctx *context.Context) { } if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index aa49d2e1e8..d4458ed19e 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -4,12 +4,12 @@ package repo import ( - stdCtx "context" "fmt" "math/big" "net/http" "net/url" "sort" + "strconv" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" @@ -25,12 +25,14 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates/vars" + "code.gitea.io/gitea/modules/util" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context/upload" @@ -40,86 +42,80 @@ import ( ) // roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue -func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, permsCache map[int64]access_model.Permission, issue *issues_model.Issue, hasOriginalAuthor bool) (issues_model.RoleDescriptor, error) { - roleDescriptor := issues_model.RoleDescriptor{} - +func roleDescriptor(ctx *context.Context, repo *repo_model.Repository, poster *user_model.User, permsCache map[int64]access_model.Permission, issue *issues_model.Issue, hasOriginalAuthor bool) (roleDesc issues_model.RoleDescriptor, err error) { if hasOriginalAuthor { - return roleDescriptor, nil + // the poster is a migrated user, so no need to detect the role + return roleDesc, nil } - var perm access_model.Permission - var err error - if permsCache != nil { - var ok bool - perm, ok = permsCache[poster.ID] - if !ok { - perm, err = access_model.GetUserRepoPermission(ctx, repo, poster) - if err != nil { - return roleDescriptor, err - } - } - permsCache[poster.ID] = perm - } else { + if poster.IsGhost() || !poster.IsIndividual() { + return roleDesc, nil + } + + roleDesc.IsPoster = issue.IsPoster(poster.ID) // check whether the comment's poster is the issue's poster + + // Guess the role of the poster in the repo by permission + perm, hasPermCache := permsCache[poster.ID] + if !hasPermCache { perm, err = access_model.GetUserRepoPermission(ctx, repo, poster) if err != nil { - return roleDescriptor, err + return roleDesc, err } } - - // If the poster is the actual poster of the issue, enable Poster role. - roleDescriptor.IsPoster = issue.IsPoster(poster.ID) + if permsCache != nil { + permsCache[poster.ID] = perm + } // Check if the poster is owner of the repo. if perm.IsOwner() { - // If the poster isn't an admin, enable the owner role. + // If the poster isn't a site admin, then is must be the repo's owner if !poster.IsAdmin { - roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner - return roleDescriptor, nil + roleDesc.RoleInRepo = issues_model.RoleRepoOwner + return roleDesc, nil } - - // Otherwise check if poster is the real repo admin. - ok, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster) + // Otherwise (poster is site admin), check if poster is the real repo admin. + isRealRepoAdmin, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster) if err != nil { - return roleDescriptor, err + return roleDesc, err } - if ok { - roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner - return roleDescriptor, nil + if isRealRepoAdmin { + roleDesc.RoleInRepo = issues_model.RoleRepoOwner + return roleDesc, nil } } // If repo is organization, check Member role - if err := repo.LoadOwner(ctx); err != nil { - return roleDescriptor, err + if err = repo.LoadOwner(ctx); err != nil { + return roleDesc, err } if repo.Owner.IsOrganization() { if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil { - return roleDescriptor, err + return roleDesc, err } else if isMember { - roleDescriptor.RoleInRepo = issues_model.RoleRepoMember - return roleDescriptor, nil + roleDesc.RoleInRepo = issues_model.RoleRepoMember + return roleDesc, nil } } // If the poster is the collaborator of the repo if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil { - return roleDescriptor, err + return roleDesc, err } else if isCollaborator { - roleDescriptor.RoleInRepo = issues_model.RoleRepoCollaborator - return roleDescriptor, nil + roleDesc.RoleInRepo = issues_model.RoleRepoCollaborator + return roleDesc, nil } hasMergedPR, err := issues_model.HasMergedPullRequestInRepo(ctx, repo.ID, poster.ID) if err != nil { - return roleDescriptor, err + return roleDesc, err } else if hasMergedPR { - roleDescriptor.RoleInRepo = issues_model.RoleRepoContributor + roleDesc.RoleInRepo = issues_model.RoleRepoContributor } else if issue.IsPull { // only display first time contributor in the first opening pull request - roleDescriptor.RoleInRepo = issues_model.RoleRepoFirstTimeContributor + roleDesc.RoleInRepo = issues_model.RoleRepoFirstTimeContributor } - return roleDescriptor, nil + return roleDesc, nil } func getBranchData(ctx *context.Context, issue *issues_model.Issue) { @@ -277,14 +273,29 @@ func combineLabelComments(issue *issues_model.Issue) { } } -// ViewIssue render issue view page -func ViewIssue(ctx *context.Context) { +func prepareIssueViewLoad(ctx *context.Context) *issues_model.Issue { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByIndex", issues_model.IsErrIssueNotExist, err) + return nil + } + issue.Repo = ctx.Repo.Repository + ctx.Data["Issue"] = issue + + if err = issue.LoadPullRequest(ctx); err != nil { + ctx.ServerError("LoadPullRequest", err) + return nil + } + return issue +} + +func handleViewIssueRedirectExternal(ctx *context.Context) { if ctx.PathParam("type") == "issues" { // If issue was requested we check if repo has external tracker and redirect extIssueUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) if err == nil && extIssueUnit != nil { if extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extIssueUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" { - metas := ctx.Repo.Repository.ComposeMetas(ctx) + metas := ctx.Repo.Repository.ComposeCommentMetas(ctx) metas["index"] = ctx.PathParam("index") res, err := vars.Expand(extIssueUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas) if err != nil { @@ -300,18 +311,18 @@ func ViewIssue(ctx *context.Context) { return } } +} - issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) - if err != nil { - if issues_model.IsErrIssueNotExist(err) { - ctx.NotFound("GetIssueByIndex", err) - } else { - ctx.ServerError("GetIssueByIndex", err) - } +// ViewIssue render issue view page +func ViewIssue(ctx *context.Context) { + handleViewIssueRedirectExternal(ctx) + if ctx.Written() { return } - if issue.Repo == nil { - issue.Repo = ctx.Repo.Repository + + issue := prepareIssueViewLoad(ctx) + if ctx.Written() { + return } // Make sure type and URL matches. @@ -343,12 +354,12 @@ func ViewIssue(ctx *context.Context) { ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") - if err = issue.LoadAttributes(ctx); err != nil { + if err := issue.LoadAttributes(ctx); err != nil { ctx.ServerError("LoadAttributes", err) return } - if err = filterXRefComments(ctx, issue); err != nil { + if err := filterXRefComments(ctx, issue); err != nil { ctx.ServerError("filterXRefComments", err) return } @@ -357,7 +368,7 @@ func ViewIssue(ctx *context.Context) { if ctx.IsSigned { // Update issue-user. - if err = activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil { + if err := activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil { ctx.ServerError("ReadBy", err) return } @@ -371,15 +382,13 @@ func ViewIssue(ctx *context.Context) { prepareFuncs := []func(*context.Context, *issues_model.Issue){ prepareIssueViewContent, - func(ctx *context.Context, issue *issues_model.Issue) { - preparePullViewPullInfo(ctx, issue) - }, prepareIssueViewCommentsAndSidebarParticipants, - preparePullViewReviewAndMerge, prepareIssueViewSidebarWatch, prepareIssueViewSidebarTimeTracker, prepareIssueViewSidebarDependency, prepareIssueViewSidebarPin, + func(ctx *context.Context, issue *issues_model.Issue) { preparePullViewPullInfo(ctx, issue) }, + preparePullViewReviewAndMerge, } for _, prepareFunc := range prepareFuncs { @@ -418,9 +427,29 @@ func ViewIssue(ctx *context.Context) { return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) } + if issue.PullRequest != nil && !issue.PullRequest.IsChecking() && !setting.IsProd { + ctx.Data["PullMergeBoxReloadingInterval"] = 1 // in dev env, force using the reloading logic to make sure it won't break + } + ctx.HTML(http.StatusOK, tplIssueView) } +func ViewPullMergeBox(ctx *context.Context) { + issue := prepareIssueViewLoad(ctx) + if !issue.IsPull { + ctx.NotFound(nil) + return + } + preparePullViewPullInfo(ctx, issue) + preparePullViewReviewAndMerge(ctx, issue) + ctx.Data["PullMergeBoxReloading"] = issue.PullRequest.IsChecking() + + // TODO: it should use a dedicated struct to render the pull merge box, to make sure all data is prepared correctly + ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.HTML(http.StatusOK, tplPullMergeBox) +} + func prepareIssueViewSidebarDependency(ctx *context.Context, issue *issues_model.Issue) { if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { ctx.Data["IssueDependencySearchType"] = "pulls" @@ -533,7 +562,7 @@ func preparePullViewDeleteBranch(ctx *context.Context, issue *issues_model.Issue pull := issue.PullRequest isPullBranchDeletable := canDelete && pull.HeadRepo != nil && - git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.HeadBranch) && + gitrepo.IsBranchExist(ctx, pull.HeadRepo, pull.HeadBranch) && (!pull.HasMerged || ctx.Data["HeadBranchCommitID"] == ctx.Data["PullHeadCommitID"]) if isPullBranchDeletable && pull.HasMerged { @@ -550,7 +579,11 @@ func preparePullViewDeleteBranch(ctx *context.Context, issue *issues_model.Issue func prepareIssueViewSidebarPin(ctx *context.Context, issue *issues_model.Issue) { var pinAllowed bool - if !issue.IsPinned() { + if err := issue.LoadPinOrder(ctx); err != nil { + ctx.ServerError("LoadPinOrder", err) + return + } + if issue.PinOrder == 0 { var err error pinAllowed, err = issues_model.IsNewPinAllowed(ctx, issue.RepoID, issue.IsPull) if err != nil { @@ -596,7 +629,9 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue comment.Issue = issue if comment.Type == issues_model.CommentTypeComment || comment.Type == issues_model.CommentTypeReview { - rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo) + rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(comment.ID, 10), + }) comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -672,7 +707,9 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue } } } else if comment.Type.HasContentSupport() { - rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo) + rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(comment.ID, 10), + }) comment.RenderedContent, err = markdown.RenderString(rctx, comment.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -723,12 +760,15 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue } } else if comment.Type == issues_model.CommentTypePullRequestPush { participants = addParticipant(comment.Poster, participants) - if err = comment.LoadPushCommits(ctx); err != nil { - ctx.ServerError("LoadPushCommits", err) + if err = issue_service.LoadCommentPushCommits(ctx, comment); err != nil { + ctx.ServerError("LoadCommentPushCommits", err) return } if !ctx.Repo.CanRead(unit.TypeActions) { for _, commit := range comment.Commits { + if commit.Status == nil { + continue + } commit.Status.HideActionsURL(ctx) git_model.CommitStatusesHideActionsURL(ctx, commit.Statuses) } @@ -794,6 +834,8 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss allowMerge := false canWriteToHeadRepo := false + pull_service.StartPullRequestCheckOnView(ctx, pull) + if ctx.IsSigned { if err := pull.LoadHeadRepo(ctx); err != nil { log.Error("LoadHeadRepo: %v", err) @@ -840,6 +882,7 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss } } + ctx.Data["PullMergeBoxReloadingInterval"] = util.Iif(pull != nil && pull.IsChecking(), 2000, 0) ctx.Data["CanWriteToHeadRepo"] = canWriteToHeadRepo ctx.Data["ShowMergeInstructions"] = canWriteToHeadRepo ctx.Data["AllowMerge"] = allowMerge @@ -950,7 +993,9 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) { var err error - rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository) + rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{ + FootnoteContextID: "0", // Set footnote context ID to 0 for the issue content + }) issue.RenderedContent, err = markdown.RenderString(rctx, issue.Content) if err != nil { ctx.ServerError("RenderString", err) @@ -960,5 +1005,4 @@ func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) { ctx.ServerError("roleDescriptor", err) return } - ctx.Data["Issue"] = issue } diff --git a/routers/web/repo/issue_watch.go b/routers/web/repo/issue_watch.go index e7d55e5555..dfa3491786 100644 --- a/routers/web/repo/issue_watch.go +++ b/routers/web/repo/issue_watch.go @@ -42,7 +42,7 @@ func IssueWatch(ctx *context.Context) { log.Trace("Permission Denied: Not logged in") } } - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index 3a7dc29466..ea15e90e5c 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -35,7 +35,7 @@ const ( // Migrate render migration of repository page func Migrate(ctx *context.Context) { if setting.Repository.DisableMigrations { - ctx.Error(http.StatusForbidden, "Migrate: the site administrator has disabled migrations") + ctx.HTTPError(http.StatusForbidden, "Migrate: the site administrator has disabled migrations") return } @@ -72,7 +72,7 @@ func Migrate(ctx *context.Context) { func handleMigrateError(ctx *context.Context, owner *user_model.User, err error, name string, tpl templates.TplName, form *forms.MigrateRepoForm) { if setting.Repository.DisableMigrations { - ctx.Error(http.StatusForbidden, "MigrateError: the site administrator has disabled migrations") + ctx.HTTPError(http.StatusForbidden, "MigrateError: the site administrator has disabled migrations") return } @@ -152,12 +152,12 @@ func handleMigrateRemoteAddrError(ctx *context.Context, err error, tpl templates func MigratePost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.MigrateRepoForm) if setting.Repository.DisableMigrations { - ctx.Error(http.StatusForbidden, "MigratePost: the site administrator has disabled migrations") + ctx.HTTPError(http.StatusForbidden, "MigratePost: the site administrator has disabled migrations") return } if form.Mirror && setting.Mirror.DisableNewPull { - ctx.Error(http.StatusBadRequest, "MigratePost: the site administrator has disabled creation of new mirrors") + ctx.HTTPError(http.StatusBadRequest, "MigratePost: the site administrator has disabled creation of new mirrors") return } diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go index 6a0e6b25a9..dd53b1d3f1 100644 --- a/routers/web/repo/milestone.go +++ b/routers/web/repo/milestone.go @@ -38,10 +38,7 @@ func Milestones(ctx *context.Context) { isShowClosed := ctx.FormString("state") == "closed" sortType := ctx.FormString("sort") keyword := ctx.FormTrim("q") - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) miles, total, err := db.FindAndCount[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ ListOptions: db.ListOptions{ @@ -148,7 +145,7 @@ func EditMilestone(ctx *context.Context) { m, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) if err != nil { if issues_model.IsErrMilestoneNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetMilestoneByRepoID", err) } @@ -184,7 +181,7 @@ func EditMilestonePost(ctx *context.Context) { m, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) if err != nil { if issues_model.IsErrMilestoneNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetMilestoneByRepoID", err) } @@ -218,7 +215,7 @@ func ChangeMilestoneStatus(ctx *context.Context) { if err := issues_model.ChangeMilestoneStatusByRepoIDAndID(ctx, ctx.Repo.Repository.ID, id, toClose); err != nil { if issues_model.IsErrMilestoneNotExist(err) { - ctx.NotFound("", err) + ctx.NotFound(err) } else { ctx.ServerError("ChangeMilestoneStatusByIDAndRepoID", err) } @@ -245,7 +242,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) if err != nil { if issues_model.IsErrMilestoneNotExist(err) { - ctx.NotFound("GetMilestoneByID", err) + ctx.NotFound(err) return } @@ -263,7 +260,7 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { ctx.Data["Title"] = milestone.Name ctx.Data["Milestone"] = milestone - issues(ctx, milestoneID, projectID, optional.None[bool]()) + prepareIssueFilterAndList(ctx, milestoneID, projectID, optional.None[bool]()) ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0 diff --git a/routers/web/repo/packages.go b/routers/web/repo/packages.go index 65a340a799..d09a57c03f 100644 --- a/routers/web/repo/packages.go +++ b/routers/web/repo/packages.go @@ -21,10 +21,7 @@ const ( // Packages displays a list of all packages in the repository func Packages(ctx *context.Context) { - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) query := ctx.FormTrim("q") packageType := ctx.FormTrim("type") diff --git a/routers/web/repo/patch.go b/routers/web/repo/patch.go deleted file mode 100644 index 4d47a705d6..0000000000 --- a/routers/web/repo/patch.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repo - -import ( - "strings" - - git_model "code.gitea.io/gitea/models/git" - "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" - "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/services/context" - "code.gitea.io/gitea/services/forms" - "code.gitea.io/gitea/services/repository/files" -) - -const ( - tplPatchFile templates.TplName = "repo/editor/patch" -) - -// NewDiffPatch render create patch page -func NewDiffPatch(ctx *context.Context) { - canCommit := renderCommitRights(ctx) - - ctx.Data["PageIsPatch"] = true - - ctx.Data["commit_summary"] = "" - ctx.Data["commit_message"] = "" - if canCommit { - ctx.Data["commit_choice"] = frmCommitChoiceDirect - } else { - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - } - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) - ctx.Data["last_commit"] = ctx.Repo.CommitID - ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - - ctx.HTML(200, tplPatchFile) -} - -// NewDiffPatchPost response for sending patch page -func NewDiffPatchPost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.EditRepoFileForm) - - canCommit := renderCommitRights(ctx) - branchName := ctx.Repo.BranchName - if form.CommitChoice == frmCommitChoiceNewBranch { - branchName = form.NewBranchName - } - ctx.Data["PageIsPatch"] = true - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - ctx.Data["FileContent"] = form.Content - ctx.Data["commit_summary"] = form.CommitSummary - ctx.Data["commit_message"] = form.CommitMessage - ctx.Data["commit_choice"] = form.CommitChoice - ctx.Data["new_branch_name"] = form.NewBranchName - ctx.Data["last_commit"] = ctx.Repo.CommitID - ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") - - if ctx.HasError() { - ctx.HTML(200, tplPatchFile) - return - } - - // Cannot commit to a an existing branch if user doesn't have rights - if branchName == ctx.Repo.BranchName && !canCommit { - ctx.Data["Err_NewBranchName"] = true - ctx.Data["commit_choice"] = frmCommitChoiceNewBranch - ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form) - return - } - - // CommitSummary is optional in the web form, if empty, give it a default message based on add or update - // `message` will be both the summary and message combined - message := strings.TrimSpace(form.CommitSummary) - if len(message) == 0 { - message = ctx.Locale.TrString("repo.editor.patch") - } - - form.CommitMessage = strings.TrimSpace(form.CommitMessage) - if len(form.CommitMessage) > 0 { - message += "\n\n" + form.CommitMessage - } - - fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{ - LastCommitID: form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: branchName, - Message: message, - Content: strings.ReplaceAll(form.Content, "\r", ""), - }) - if err != nil { - if git_model.IsErrBranchAlreadyExists(err) { - // User has specified a branch that already exists - branchErr := err.(git_model.ErrBranchAlreadyExists) - ctx.Data["Err_NewBranchName"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form) - return - } else if files.IsErrCommitIDDoesNotMatch(err) { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplPatchFile, &form) - return - } - ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_apply_patch", err), tplPatchFile, &form) - return - } - - if form.CommitChoice == frmCommitChoiceNewBranch && ctx.Repo.Repository.UnitEnabled(ctx, unit.TypePullRequests) { - ctx.Redirect(ctx.Repo.RepoLink + "/compare/" + util.PathEscapeSegments(ctx.Repo.BranchName) + "..." + util.PathEscapeSegments(form.NewBranchName)) - } else { - ctx.Redirect(ctx.Repo.RepoLink + "/commit/" + fileResponse.Commit.SHA) - } -} diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 346132102f..a57976b4ca 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -39,14 +39,14 @@ const ( // MustEnableRepoProjects check if repo projects are enabled in settings func MustEnableRepoProjects(ctx *context.Context) { if unit.TypeProjects.UnitGlobalDisabled() { - ctx.NotFound("EnableRepoProjects", nil) + ctx.NotFound(nil) return } if ctx.Repo.Repository != nil { projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects) if !ctx.Repo.CanRead(unit.TypeProjects) || !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) { - ctx.NotFound("MustEnableRepoProjects", nil) + ctx.NotFound(nil) return } } @@ -61,10 +61,7 @@ func Projects(ctx *context.Context) { isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" keyword := ctx.FormTrim("q") repo := ctx.Repo.Repository - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) ctx.Data["OpenCount"] = repo.NumOpenProjects ctx.Data["ClosedCount"] = repo.NumClosedProjects @@ -92,6 +89,11 @@ func Projects(ctx *context.Context) { return } + if err := project_service.LoadIssueNumbersForProjects(ctx, projects, ctx.Doer); err != nil { + ctx.ServerError("LoadIssueNumbersForProjects", err) + return + } + for i := range projects { rctx := renderhelper.NewRenderContextRepoComment(ctx, repo) projects[i].RenderedContent, err = markdown.RenderString(rctx, projects[i].Description) @@ -189,14 +191,14 @@ func DeleteProject(ctx *context.Context) { p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) if err != nil { if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetProjectByID", err) } return } if p.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("", nil) + ctx.NotFound(nil) return } @@ -219,14 +221,14 @@ func RenderEditProject(ctx *context.Context) { p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) if err != nil { if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetProjectByID", err) } return } if p.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("", nil) + ctx.NotFound(nil) return } @@ -259,14 +261,14 @@ func EditProjectPost(ctx *context.Context) { p, err := project_model.GetProjectByID(ctx, projectID) if err != nil { if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetProjectByID", err) } return } if p.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("", nil) + ctx.NotFound(nil) return } @@ -291,14 +293,14 @@ func ViewProject(ctx *context.Context) { project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) if err != nil { if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetProjectByID", err) } return } if project.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("", nil) + ctx.NotFound(nil) return } @@ -308,18 +310,22 @@ func ViewProject(ctx *context.Context) { return } - labelIDs := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner) + preparedLabelFilter := issue.PrepareFilterIssueLabels(ctx, ctx.Repo.Repository.ID, ctx.Repo.Owner) - assigneeID := ctx.FormInt64("assignee") // TODO: use "optional" but not 0 in the future + assigneeID := ctx.FormString("assignee") - issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns, &issues_model.IssuesOptions{ - LabelIDs: labelIDs, - AssigneeID: optional.Some(assigneeID), + issuesMap, err := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{ + RepoIDs: []int64{ctx.Repo.Repository.ID}, + LabelIDs: preparedLabelFilter.SelectedLabelIDs, + AssigneeID: assigneeID, }) if err != nil { ctx.ServerError("LoadIssuesOfColumns", err) return } + for _, column := range columns { + column.NumIssues = int64(len(issuesMap[column.ID])) + } if project.CardType != project_model.CardTypeTextOnly { issuesAttachmentMap := make(map[int64][]*repo_model.Attachment) @@ -372,8 +378,8 @@ func ViewProject(ctx *context.Context) { } // Get the exclusive scope for every label ID - labelExclusiveScopes := make([]string, 0, len(labelIDs)) - for _, labelID := range labelIDs { + labelExclusiveScopes := make([]string, 0, len(preparedLabelFilter.SelectedLabelIDs)) + for _, labelID := range preparedLabelFilter.SelectedLabelIDs { foundExclusiveScope := false for _, label := range labels { if label.ID == labelID || label.ID == -labelID { @@ -388,7 +394,7 @@ func ViewProject(ctx *context.Context) { } for _, l := range labels { - l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) + l.LoadSelectedLabelsAfterClick(preparedLabelFilter.SelectedLabelIDs, labelExclusiveScopes) } ctx.Data["Labels"] = labels ctx.Data["NumLabels"] = len(labels) @@ -471,7 +477,7 @@ func DeleteProjectColumn(ctx *context.Context) { project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) if err != nil { if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetProjectByID", err) } @@ -518,7 +524,7 @@ func AddColumnToProjectPost(ctx *context.Context) { project, err := project_model.GetProjectForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id")) if err != nil { if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetProjectByID", err) } @@ -556,7 +562,7 @@ func checkProjectColumnChangePermissions(ctx *context.Context) (*project_model.P project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) if err != nil { if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetProjectByID", err) } @@ -642,21 +648,21 @@ func MoveIssues(ctx *context.Context) { project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64("id")) if err != nil { if project_model.IsErrProjectNotExist(err) { - ctx.NotFound("ProjectNotExist", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetProjectByID", err) } return } if project.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("InvalidRepoID", nil) + ctx.NotFound(nil) return } column, err := project_model.GetColumn(ctx, ctx.PathParamInt64("columnID")) if err != nil { if project_model.IsErrProjectColumnNotExist(err) { - ctx.NotFound("ProjectColumnNotExist", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetProjectColumn", err) } @@ -664,7 +670,7 @@ func MoveIssues(ctx *context.Context) { } if column.ProjectID != project.ID { - ctx.NotFound("ColumnNotInProject", nil) + ctx.NotFound(nil) return } @@ -689,7 +695,7 @@ func MoveIssues(ctx *context.Context) { movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) if err != nil { if issues_model.IsErrIssueNotExist(err) { - ctx.NotFound("IssueNotExisting", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetIssueByID", err) } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index e6fb492d6e..f662152e2e 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -24,8 +24,10 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/emoji" + "code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/graceful" issue_template "code.gitea.io/gitea/modules/issue/template" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -81,7 +83,7 @@ func getRepository(ctx *context.Context, repoID int64) *repo_model.Repository { repo, err := repo_model.GetRepositoryByID(ctx, repoID) if err != nil { if repo_model.IsErrRepoNotExist(err) { - ctx.NotFound("GetRepositoryByID", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetRepositoryByID", err) } @@ -101,7 +103,7 @@ func getRepository(ctx *context.Context, repoID int64) *repo_model.Repository { unit.TypeCode, ctx.Repo, perm) - ctx.NotFound("getRepository", nil) + ctx.NotFound(nil) return nil } return repo @@ -111,7 +113,7 @@ func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) if err != nil { if issues_model.IsErrIssueNotExist(err) { - ctx.NotFound("GetIssueByIndex", err) + ctx.NotFound(err) } else { ctx.ServerError("GetIssueByIndex", err) } @@ -129,7 +131,7 @@ func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { ctx.Data["Issue"] = issue if !issue.IsPull { - ctx.NotFound("ViewPullCommits", nil) + ctx.NotFound(nil) return nil, false } @@ -181,6 +183,7 @@ func setMergeTarget(ctx *context.Context, pull *issues_model.PullRequest) { // GetPullDiffStats get Pull Requests diff stats func GetPullDiffStats(ctx *context.Context) { + // FIXME: this getPullInfo seems to be a duplicate call with other route handlers issue, ok := getPullInfo(ctx) if !ok { return @@ -188,34 +191,23 @@ func GetPullDiffStats(ctx *context.Context) { pull := issue.PullRequest mergeBaseCommitID := GetMergedBaseCommitID(ctx, issue) - if mergeBaseCommitID == "" { - ctx.NotFound("PullFiles", nil) - return + return // no merge base, do nothing, do not stop the route handler, see below } + // do not report 500 server error to end users if error occurs, otherwise a PR missing ref won't be able to view. headCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(pull.GetGitRefName()) if err != nil { - ctx.ServerError("GetRefCommitID", err) + log.Error("Failed to GetRefCommitID: %v, repo: %v", err, ctx.Repo.Repository.FullName()) return } - - diffOptions := &gitdiff.DiffOptions{ - BeforeCommitID: mergeBaseCommitID, - AfterCommitID: headCommitID, - MaxLines: setting.Git.MaxGitDiffLines, - MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, - MaxFiles: setting.Git.MaxGitDiffFiles, - WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), - } - - diff, err := gitdiff.GetPullDiffStats(ctx.Repo.GitRepo, diffOptions) + diffShortStat, err := gitdiff.GetDiffShortStat(ctx.Repo.GitRepo, mergeBaseCommitID, headCommitID) if err != nil { - ctx.ServerError("GetPullDiffStats", err) + log.Error("Failed to GetDiffShortStat: %v, repo: %v", err, ctx.Repo.Repository.FullName()) return } - ctx.Data["Diff"] = diff + ctx.Data["DiffShortStat"] = diffShortStat } func GetMergedBaseCommitID(ctx *context.Context, issue *issues_model.Issue) string { @@ -242,7 +234,7 @@ func GetMergedBaseCommitID(ctx *context.Context, issue *issues_model.Issue) stri } if commitSHA != "" { // Get immediate parent of the first commit in the patch, grab history back - parentCommit, _, err = git.NewCommand(ctx, "rev-list", "-1", "--skip=1").AddDynamicArguments(commitSHA).RunStdString(&git.RunOpts{Dir: ctx.Repo.GitRepo.Path}) + parentCommit, _, err = git.NewCommand("rev-list", "-1", "--skip=1").AddDynamicArguments(commitSHA).RunStdString(ctx, &git.RunOpts{Dir: ctx.Repo.GitRepo.Path}) if err == nil { parentCommit = strings.TrimSpace(parentCommit) } @@ -300,7 +292,7 @@ func prepareMergedViewPullInfo(ctx *context.Context, issue *issues_model.Issue) if len(compareInfo.Commits) != 0 { sha := compareInfo.Commits[0].ID.String() - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, sha, db.ListOptionsAll) + commitStatuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, sha, db.ListOptionsAll) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) return nil @@ -356,7 +348,7 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C defer baseGitRepo.Close() } - if !baseGitRepo.IsBranchExist(pull.BaseBranch) { + if !gitrepo.IsBranchExist(ctx, pull.BaseRepo, pull.BaseBranch) { ctx.Data["BaseBranchNotExist"] = true ctx.Data["IsPullRequestBroken"] = true ctx.Data["BaseTarget"] = pull.BaseBranch @@ -367,7 +359,7 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C ctx.ServerError(fmt.Sprintf("GetRefCommitID(%s)", pull.GetGitRefName()), err) return nil } - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) + commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) return nil @@ -413,9 +405,9 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C defer closer.Close() if pull.Flow == issues_model.PullRequestFlowGithub { - headBranchExist = headGitRepo.IsBranchExist(pull.HeadBranch) + headBranchExist = gitrepo.IsBranchExist(ctx, pull.HeadRepo, pull.HeadBranch) } else { - headBranchExist = git.IsReferenceExist(ctx, baseGitRepo.Path, pull.GetGitRefName()) + headBranchExist = gitrepo.IsReferenceExist(ctx, pull.BaseRepo, pull.GetGitRefName()) } if headBranchExist { @@ -463,7 +455,7 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git.C return nil } - commitStatuses, _, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) + commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) if err != nil { ctx.ServerError("GetLatestCommitStatus", err) return nil @@ -624,14 +616,18 @@ func ViewPullCommits(ctx *context.Context) { if ctx.Written() { return } else if prInfo == nil { - ctx.NotFound("ViewPullCommits", nil) + ctx.NotFound(nil) return } ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name - commits := processGitCommits(ctx, prInfo.Commits) + commits, err := processGitCommits(ctx, prInfo.Commits) + if err != nil { + ctx.ServerError("processGitCommits", err) + return + } ctx.Data["Commits"] = commits ctx.Data["CommitCount"] = len(commits) @@ -668,7 +664,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi if ctx.Written() { return } else if prInfo == nil { - ctx.NotFound("ViewPullFiles", nil) + ctx.NotFound(nil) return } @@ -693,7 +689,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi } if !(foundStartCommit && foundEndCommit) { - ctx.NotFound("Given SHA1 not found for this PR", nil) + ctx.NotFound(nil) return } } @@ -748,34 +744,40 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, MaxFiles: maxFiles, WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.Data["WhitespaceBehavior"].(string)), - FileOnly: fileOnly, } if !willShowSpecifiedCommit { diffOptions.BeforeCommitID = startCommitID } - var methodWithError string - var diff *gitdiff.Diff + diff, err := gitdiff.GetDiffForRender(ctx, gitRepo, diffOptions, files...) + if err != nil { + ctx.ServerError("GetDiff", err) + return + } // if we're not logged in or only a single commit (or commit range) is shown we // have to load only the diff and not get the viewed information // as the viewed information is designed to be loaded only on latest PR // diff and if you're signed in. - if !ctx.IsSigned || willShowSpecifiedCommit || willShowSpecifiedCommitRange { - diff, err = gitdiff.GetDiff(ctx, gitRepo, diffOptions, files...) - methodWithError = "GetDiff" - } else { - diff, err = gitdiff.SyncAndGetUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diffOptions, files...) - methodWithError = "SyncAndGetUserSpecificDiff" + var reviewState *pull_model.ReviewState + if ctx.IsSigned && !willShowSpecifiedCommit && !willShowSpecifiedCommitRange { + reviewState, err = gitdiff.SyncUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diff, diffOptions) + if err != nil { + ctx.ServerError("SyncUserSpecificDiff", err) + return + } } + + diffShortStat, err := gitdiff.GetDiffShortStat(ctx.Repo.GitRepo, startCommitID, endCommitID) if err != nil { - ctx.ServerError(methodWithError, err) + ctx.ServerError("GetDiffShortStat", err) return } + ctx.Data["DiffShortStat"] = diffShortStat ctx.PageData["prReview"] = map[string]any{ - "numberOfFiles": diff.NumFiles, + "numberOfFiles": diffShortStat.NumFiles, "numberOfViewedFiles": diff.NumViewedFiles, } @@ -784,18 +786,18 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi return } + allComments := issues_model.CommentList{} for _, file := range diff.Files { for _, section := range file.Sections { for _, line := range section.Lines { - for _, comment := range line.Comments { - if err := comment.LoadAttachments(ctx); err != nil { - ctx.ServerError("LoadAttachments", err) - return - } - } + allComments = append(allComments, line.Comments...) } } } + if err := allComments.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) if err != nil { @@ -812,8 +814,27 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi } } + if !fileOnly { + // note: use mergeBase is set to false because we already have the merge base from the pull request info + diffTree, err := gitdiff.GetDiffTree(ctx, gitRepo, false, startCommitID, endCommitID) + if err != nil { + ctx.ServerError("GetDiffTree", err) + return + } + var filesViewedState map[string]pull_model.ViewedState + if reviewState != nil { + filesViewedState = reviewState.UpdatedFiles + } + + renderedIconPool := fileicon.NewRenderedIconPool() + ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(renderedIconPool, diffTree, filesViewedState) + ctx.PageData["FolderIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder()) + ctx.PageData["FolderOpenIcon"] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolderOpen()) + ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML() + } + ctx.Data["Diff"] = diff - ctx.Data["DiffNotAvailable"] = diff.NumFiles == 0 + ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 baseCommit, err := ctx.Repo.GitRepo.GetCommit(startCommitID) if err != nil { @@ -931,11 +952,11 @@ func UpdatePullRequest(ctx *context.Context) { return } if issue.IsClosed { - ctx.NotFound("MergePullRequest", nil) + ctx.NotFound(nil) return } if issue.PullRequest.HasMerged { - ctx.NotFound("MergePullRequest", nil) + ctx.NotFound(nil) return } @@ -966,7 +987,9 @@ func UpdatePullRequest(ctx *context.Context) { // default merge commit message message := fmt.Sprintf("Merge branch '%s' into %s", issue.PullRequest.BaseBranch, issue.PullRequest.HeadBranch) - if err = pull_service.Update(ctx, issue.PullRequest, ctx.Doer, message, rebase); err != nil { + // The update process should not be cancelled by the user + // so we set the context to be a background context + if err = pull_service.Update(graceful.GetManager().ShutdownContext(), issue.PullRequest, ctx.Doer, message, rebase); err != nil { if pull_service.IsErrMergeConflicts(err) { conflictError := err.(pull_service.ErrMergeConflicts) flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ @@ -1038,7 +1061,7 @@ func MergePullRequest(ctx *context.Context) { } else { ctx.JSONError(ctx.Tr("repo.issues.closed_title")) } - case errors.Is(err, pull_service.ErrUserNotAllowedToMerge): + case errors.Is(err, pull_service.ErrNoPermissionToMerge): ctx.JSONError(ctx.Tr("repo.pulls.update_not_allowed")) case errors.Is(err, pull_service.ErrHasMerged): ctx.JSONError(ctx.Tr("repo.pulls.has_merged")) @@ -1046,7 +1069,7 @@ func MergePullRequest(ctx *context.Context) { ctx.JSONError(ctx.Tr("repo.pulls.no_merge_wip")) case errors.Is(err, pull_service.ErrNotMergeableState): ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) - case pull_service.IsErrDisallowedToMerge(err): + case errors.Is(err, pull_service.ErrNotReadyToMerge): ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) case asymkey_service.IsErrWontSign(err): ctx.JSONError(err.Error()) // has no translation ... @@ -1235,13 +1258,23 @@ func CancelAutoMergePullRequest(ctx *context.Context) { } func stopTimerIfAvailable(ctx *context.Context, user *user_model.User, issue *issues_model.Issue) error { - if issues_model.StopwatchExists(ctx, user.ID, issue.ID) { - if err := issues_model.CreateOrStopIssueStopwatch(ctx, user, issue); err != nil { - return err + _, err := issues_model.FinishIssueStopwatch(ctx, user, issue) + return err +} + +func PullsNewRedirect(ctx *context.Context) { + branch := ctx.PathParam("*") + redirectRepo := ctx.Repo.Repository + repo := ctx.Repo.Repository + if repo.IsFork { + if err := repo.GetBaseRepo(ctx); err != nil { + ctx.ServerError("GetBaseRepo", err) + return } + redirectRepo = repo.BaseRepo + branch = fmt.Sprintf("%s:%s", repo.OwnerName, branch) } - - return nil + ctx.Redirect(fmt.Sprintf("%s/compare/%s...%s?expand=1", redirectRepo.Link(), util.PathEscapeSegments(redirectRepo.DefaultBranch), util.PathEscapeSegments(branch))) } // CompareAndPullRequestPost response for creating pull request @@ -1261,11 +1294,6 @@ func CompareAndPullRequestPost(ctx *context.Context) { ) ci := ParseCompareInfo(ctx) - defer func() { - if ci != nil && ci.HeadGitRepo != nil { - ci.HeadGitRepo.Close() - } - }() if ctx.Written() { return } @@ -1334,7 +1362,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { if err := pull_service.NewPullRequest(ctx, prOpts); err != nil { switch { case repo_model.IsErrUserDoesNotHaveAccessToRepo(err): - ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) + ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) case git.IsErrPushRejected(err): pushrejErr := err.(*git.ErrPushRejected) message := pushrejErr.Message @@ -1405,7 +1433,7 @@ func CleanUpPullRequest(ctx *context.Context) { // Don't cleanup unmerged and unclosed PRs and agit PRs if !pr.HasMerged && !issue.IsClosed && pr.Flow != issues_model.PullRequestFlowGithub { - ctx.NotFound("CleanUpPullRequest", nil) + ctx.NotFound(nil) return } @@ -1416,7 +1444,7 @@ func CleanUpPullRequest(ctx *context.Context) { return } if exist { - ctx.NotFound("CleanUpPullRequest", nil) + ctx.NotFound(nil) return } @@ -1425,7 +1453,7 @@ func CleanUpPullRequest(ctx *context.Context) { return } else if pr.HeadRepo == nil { // Forked repository has already been deleted - ctx.NotFound("CleanUpPullRequest", nil) + ctx.NotFound(nil) return } else if err = pr.LoadBaseRepo(ctx); err != nil { ctx.ServerError("LoadBaseRepo", err) @@ -1437,7 +1465,7 @@ func CleanUpPullRequest(ctx *context.Context) { if err := repo_service.CanDeleteBranch(ctx, pr.HeadRepo, pr.HeadBranch, ctx.Doer); err != nil { if errors.Is(err, util.ErrPermissionDenied) { - ctx.NotFound("CanDeleteBranch", nil) + ctx.NotFound(nil) } else { ctx.ServerError("CanDeleteBranch", err) } @@ -1537,7 +1565,7 @@ func DownloadPullDiffOrPatch(ctx *context.Context, patch bool) { pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { - ctx.NotFound("GetPullRequestByIndex", err) + ctx.NotFound(err) } else { ctx.ServerError("GetPullRequestByIndex", err) } @@ -1560,18 +1588,18 @@ func UpdatePullRequestTarget(ctx *context.Context) { } pr := issue.PullRequest if !issue.IsPull { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } targetBranch := ctx.FormTrim("target_branch") if len(targetBranch) == 0 { - ctx.Error(http.StatusNoContent) + ctx.HTTPError(http.StatusNoContent) return } @@ -1630,7 +1658,7 @@ func SetAllowEdits(ctx *context.Context) { pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { - ctx.NotFound("GetPullRequestByIndex", err) + ctx.NotFound(err) } else { ctx.ServerError("GetPullRequestByIndex", err) } @@ -1639,7 +1667,7 @@ func SetAllowEdits(ctx *context.Context) { if err := pull_service.SetAllowEdits(ctx, ctx.Doer, pr, form.AllowMaintainerEdit); err != nil { if errors.Is(err, pull_service.ErrUserHasNoPermissionForAction) { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } ctx.ServerError("SetAllowEdits", err) diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 3e9e615b15..929e131d61 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -133,7 +133,7 @@ func UpdateResolveConversation(ctx *context.Context) { } if comment.Issue.RepoID != ctx.Repo.Repository.ID { - ctx.NotFound("comment's repoID is incorrect", errors.New("comment's repoID is incorrect")) + ctx.NotFound(errors.New("comment's repoID is incorrect")) return } @@ -143,12 +143,12 @@ func UpdateResolveConversation(ctx *context.Context) { return } if !permResult { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } if !comment.Issue.IsPull { - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } @@ -159,7 +159,7 @@ func UpdateResolveConversation(ctx *context.Context) { return } } else { - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } @@ -209,12 +209,13 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) } - if origin == "diff" { + switch origin { + case "diff": ctx.HTML(http.StatusOK, tplDiffConversation) - } else if origin == "timeline" { + case "timeline": ctx.HTML(http.StatusOK, tplTimelineConversation) - } else { - ctx.Error(http.StatusBadRequest, "Unknown origin: "+origin) + default: + ctx.HTTPError(http.StatusBadRequest, "Unknown origin: "+origin) } } diff --git a/routers/web/repo/recent_commits.go b/routers/web/repo/recent_commits.go index dc72081900..2660116062 100644 --- a/routers/web/repo/recent_commits.go +++ b/routers/web/repo/recent_commits.go @@ -4,12 +4,10 @@ package repo import ( - "errors" "net/http" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/services/context" - contributors_service "code.gitea.io/gitea/services/repository" ) const ( @@ -26,16 +24,3 @@ func RecentCommits(ctx *context.Context) { ctx.HTML(http.StatusOK, tplRecentCommits) } - -// RecentCommitsData returns JSON of recent commits data -func RecentCommitsData(ctx *context.Context) { - if contributorStats, err := contributors_service.GetContributorStats(ctx, ctx.Cache, ctx.Repo.Repository, ctx.Repo.CommitID); err != nil { - if errors.Is(err, contributors_service.ErrAwaitGeneration) { - ctx.Status(http.StatusAccepted) - return - } - ctx.ServerError("RecentCommitsData", err) - } else { - ctx.JSON(http.StatusOK, contributorStats["total"].Weeks) - } -} diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 284fd27abf..36ea20c23e 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "code.gitea.io/gitea/models/db" @@ -17,6 +18,7 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" @@ -101,7 +103,7 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions) releaseInfos := make([]*ReleaseInfo, 0, len(releases)) for _, r := range releases { if r.Publisher, ok = cacheUsers[r.PublisherID]; !ok { - r.Publisher, err = user_model.GetUserByID(ctx, r.PublisherID) + r.Publisher, err = user_model.GetPossibleUserByID(ctx, r.PublisherID) if err != nil { if user_model.IsErrUserNotExist(err) { r.Publisher = user_model.NewGhostUser() @@ -112,7 +114,9 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions) cacheUsers[r.PublisherID] = r.Publisher } - rctx := renderhelper.NewRenderContextRepoComment(ctx, r.Repo) + rctx := renderhelper.NewRenderContextRepoComment(ctx, r.Repo, renderhelper.RepoCommentOptions{ + FootnoteContextID: strconv.FormatInt(r.ID, 10), + }) r.RenderedNote, err = markdown.RenderString(rctx, r.Note) if err != nil { return nil, err @@ -129,7 +133,7 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions) } if canReadActions { - statuses, _, err := git_model.GetLatestCommitStatus(ctx, r.Repo.ID, r.Sha1, db.ListOptionsAll) + statuses, err := git_model.GetLatestCommitStatus(ctx, r.Repo.ID, r.Sha1, db.ListOptionsAll) if err != nil { return nil, err } @@ -285,7 +289,7 @@ func SingleRelease(ctx *context.Context) { return } if len(releases) != 1 { - ctx.NotFound("SingleRelease", err) + ctx.NotFound(err) return } @@ -311,7 +315,7 @@ func LatestRelease(ctx *context.Context) { release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil { if repo_model.IsErrReleaseNotExist(err) { - ctx.NotFound("LatestRelease", err) + ctx.NotFound(err) return } ctx.ServerError("GetLatestReleaseByRepoID", err) @@ -377,7 +381,7 @@ func NewRelease(ctx *context.Context) { ctx.Data["ShowCreateTagOnlyButton"] = false ctx.Data["tag_name"] = rel.TagName - ctx.Data["tag_target"] = rel.Target + ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch) ctx.Data["title"] = rel.Title ctx.Data["content"] = rel.Note ctx.Data["attachments"] = rel.Attachments @@ -420,7 +424,7 @@ func NewReleasePost(ctx *context.Context) { return } - if !ctx.Repo.GitRepo.IsBranchExist(form.Target) { + if !gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, form.Target) { ctx.RenderWithErr(ctx.Tr("form.target_branch_not_exist"), tplReleaseNew, &form) return } @@ -525,7 +529,7 @@ func EditRelease(ctx *context.Context) { rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) if err != nil { if repo_model.IsErrReleaseNotExist(err) { - ctx.NotFound("GetRelease", err) + ctx.NotFound(err) } else { ctx.ServerError("GetRelease", err) } @@ -533,7 +537,7 @@ func EditRelease(ctx *context.Context) { } ctx.Data["ID"] = rel.ID ctx.Data["tag_name"] = rel.TagName - ctx.Data["tag_target"] = rel.Target + ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch) ctx.Data["title"] = rel.Title ctx.Data["content"] = rel.Note ctx.Data["prerelease"] = rel.IsPrerelease @@ -568,18 +572,18 @@ func EditReleasePost(ctx *context.Context) { rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) if err != nil { if repo_model.IsErrReleaseNotExist(err) { - ctx.NotFound("GetRelease", err) + ctx.NotFound(err) } else { ctx.ServerError("GetRelease", err) } return } if rel.IsTag { - ctx.NotFound("GetRelease", err) + ctx.NotFound(err) return } ctx.Data["tag_name"] = rel.TagName - ctx.Data["tag_target"] = rel.Target + ctx.Data["tag_target"] = util.IfZero(rel.Target, ctx.Repo.Repository.DefaultBranch) ctx.Data["title"] = rel.Title ctx.Data["content"] = rel.Note ctx.Data["prerelease"] = rel.IsPrerelease @@ -639,7 +643,7 @@ func deleteReleaseOrTag(ctx *context.Context, isDelTag bool) { rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")) if err != nil { if repo_model.IsErrReleaseNotExist(err) { - ctx.NotFound("GetReleaseForRepoByID", err) + ctx.NotFound(err) } else { ctx.Flash.Error("DeleteReleaseByID: " + err.Error()) redirect() diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index efd271c01d..689174dfa1 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -30,7 +30,7 @@ func RenderFile(ctx *context.Context) { } if err != nil { if git.IsErrNotExist(err) { - ctx.NotFound("GetBlobByPath", err) + ctx.NotFound(err) } else { ctx.ServerError("GetBlobByPath", err) } diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 6c2bc74e69..828ec08a8a 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -45,14 +45,14 @@ const ( // MustBeNotEmpty render when a repo is a empty git dir func MustBeNotEmpty(ctx *context.Context) { if ctx.Repo.Repository.IsEmpty { - ctx.NotFound("MustBeNotEmpty", nil) + ctx.NotFound(nil) } } // MustBeEditable check that repo can be edited func MustBeEditable(ctx *context.Context) { if !ctx.Repo.Repository.CanEnableEditor() { - ctx.NotFound("", nil) + ctx.NotFound(nil) return } } @@ -60,7 +60,7 @@ func MustBeEditable(ctx *context.Context) { // MustBeAbleToUpload check that repo can be uploaded to func MustBeAbleToUpload(ctx *context.Context) { if !setting.Repository.Upload.Enabled { - ctx.NotFound("", nil) + ctx.NotFound(nil) } } @@ -87,17 +87,13 @@ func checkContextUser(ctx *context.Context, uid int64) *user_model.User { return nil } - if !ctx.Doer.IsAdmin { - orgsAvailable := []*organization.Organization{} - for i := 0; i < len(orgs); i++ { - if orgs[i].CanCreateRepo() { - orgsAvailable = append(orgsAvailable, orgs[i]) - } + var orgsAvailable []*organization.Organization + for i := range orgs { + if ctx.Doer.CanCreateRepoIn(orgs[i].AsUser()) { + orgsAvailable = append(orgsAvailable, orgs[i]) } - ctx.Data["Orgs"] = orgsAvailable - } else { - ctx.Data["Orgs"] = orgs } + ctx.Data["Orgs"] = orgsAvailable // Not equal means current user is an organization. if uid == ctx.Doer.ID || uid == 0 { @@ -116,7 +112,7 @@ func checkContextUser(ctx *context.Context, uid int64) *user_model.User { // Check ownership of organization. if !org.IsOrganization() { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return nil } if !ctx.Doer.IsAdmin { @@ -125,7 +121,7 @@ func checkContextUser(ctx *context.Context, uid int64) *user_model.User { ctx.ServerError("CanCreateOrgRepo", err) return nil } else if !canCreate { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return nil } } else { @@ -154,8 +150,8 @@ func createCommon(ctx *context.Context) { ctx.Data["Licenses"] = repo_module.Licenses ctx.Data["Readmes"] = repo_module.Readmes ctx.Data["IsForcedPrivate"] = setting.Repository.ForcePrivate - ctx.Data["CanCreateRepo"] = ctx.Doer.CanCreateRepo() - ctx.Data["MaxCreationLimit"] = ctx.Doer.MaxCreationLimit() + ctx.Data["CanCreateRepoInDoer"] = ctx.Doer.CanCreateRepoIn(ctx.Doer) + ctx.Data["MaxCreationLimitOfDoer"] = ctx.Doer.MaxCreationLimit() ctx.Data["SupportedObjectFormats"] = git.DefaultFeatures().SupportedObjectFormats ctx.Data["DefaultObjectFormat"] = git.Sha1ObjectFormat } @@ -304,112 +300,18 @@ func CreatePost(ctx *context.Context) { handleCreateError(ctx, ctxUser, err, "CreatePost", tplCreate, &form) } -const ( - tplWatchUnwatch templates.TplName = "repo/watch_unwatch" - tplStarUnstar templates.TplName = "repo/star_unstar" -) - -// Action response for actions to a repository -func Action(ctx *context.Context) { - var err error - switch ctx.PathParam("action") { - case "watch": - err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) - case "unwatch": - err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) - case "star": - err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) - case "unstar": - err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) - case "accept_transfer": - err = acceptOrRejectRepoTransfer(ctx, true) - case "reject_transfer": - err = acceptOrRejectRepoTransfer(ctx, false) - case "desc": // FIXME: this is not used - if !ctx.Repo.IsOwner() { - ctx.Error(http.StatusNotFound) - return - } - - ctx.Repo.Repository.Description = ctx.FormString("desc") - ctx.Repo.Repository.Website = ctx.FormString("site") - err = repo_service.UpdateRepository(ctx, ctx.Repo.Repository, false) - } - - if err != nil { - if errors.Is(err, user_model.ErrBlockedUser) { - ctx.Flash.Error(ctx.Tr("repo.action.blocked_user")) - } else { - ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.PathParam("action")), err) - return - } - } - - switch ctx.PathParam("action") { - case "watch", "unwatch": - ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) - case "star", "unstar": - ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) - } - - // see the `hx-trigger="refreshUserCards ..."` comments in tmpl - ctx.RespHeader().Add("hx-trigger", "refreshUserCards") - - switch ctx.PathParam("action") { - case "watch", "unwatch", "star", "unstar": - // we have to reload the repository because NumStars or NumWatching (used in the templates) has just changed - ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) - if err != nil { - ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.PathParam("action")), err) - return - } - } - - switch ctx.PathParam("action") { - case "watch", "unwatch": - ctx.HTML(http.StatusOK, tplWatchUnwatch) - return - case "star", "unstar": - ctx.HTML(http.StatusOK, tplStarUnstar) - return - } - - ctx.RedirectToCurrentSite(ctx.FormString("redirect_to"), ctx.Repo.RepoLink) -} - -func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error { - repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) - if err != nil { - return err - } - - if err := repoTransfer.LoadAttributes(ctx); err != nil { - return err - } - - if !repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer) { - return errors.New("user does not have enough permissions") - } - - if accept { - if ctx.Repo.GitRepo != nil { - ctx.Repo.GitRepo.Close() - ctx.Repo.GitRepo = nil - } - - if err := repo_service.TransferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams); err != nil { - return err - } - ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) - } else { - if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { - return err - } - ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) +func handleActionError(ctx *context.Context, err error) { + switch { + case errors.Is(err, user_model.ErrBlockedUser): + ctx.Flash.Error(ctx.Tr("repo.action.blocked_user")) + case repo_service.IsRepositoryLimitReached(err): + limit := err.(repo_service.LimitReachedError).Limit + ctx.Flash.Error(ctx.TrN(limit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", limit)) + case errors.Is(err, util.ErrPermissionDenied): + ctx.HTTPError(http.StatusNotFound) + default: + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.PathParam("action")), err) } - - ctx.Redirect(ctx.Repo.Repository.Link()) - return nil } // RedirectDownload return a file based on the following infos: @@ -433,7 +335,7 @@ func RedirectDownload(ctx *context.Context) { release := releases[0] att, err := repo_model.GetAttachmentByReleaseIDFileName(ctx, release.ID, fileName) if err != nil { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } if att != nil { @@ -445,12 +347,12 @@ func RedirectDownload(ctx *context.Context) { // We only fetch the latest release if the tag is "latest" and no release with the tag "latest" exists release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } att, err := repo_model.GetAttachmentByReleaseIDFileName(ctx, release.ID, fileName) if err != nil { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } if att != nil { @@ -458,7 +360,7 @@ func RedirectDownload(ctx *context.Context) { return } } - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) } // Download an archive of a repository @@ -466,9 +368,9 @@ func Download(ctx *context.Context) { aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, ctx.PathParam("*")) if err != nil { if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { - ctx.Error(http.StatusBadRequest, err.Error()) + ctx.HTTPError(http.StatusBadRequest, err.Error()) } else if errors.Is(err, archiver_service.RepoRefNotFoundError{}) { - ctx.Error(http.StatusNotFound, err.Error()) + ctx.HTTPError(http.StatusNotFound, err.Error()) } else { ctx.ServerError("archiver_service.NewRequest", err) } @@ -523,11 +425,11 @@ func download(ctx *context.Context, archiveName string, archiver *repo_model.Rep func InitiateDownload(ctx *context.Context) { aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, ctx.PathParam("*")) if err != nil { - ctx.Error(http.StatusBadRequest, "invalid archive request") + ctx.HTTPError(http.StatusBadRequest, "invalid archive request") return } if aReq == nil { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -559,7 +461,7 @@ func SearchRepo(ctx *context.Context) { if page <= 0 { page = 1 } - opts := &repo_model.SearchRepoOptions{ + opts := repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ Page: page, PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), @@ -599,7 +501,7 @@ func SearchRepo(ctx *context.Context) { opts.Collaborate = optional.Some(true) case "": default: - ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid search mode: \"%s\"", mode)) + ctx.HTTPError(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid search mode: \"%s\"", mode)) return } @@ -621,11 +523,11 @@ func SearchRepo(ctx *context.Context) { if orderBy, ok := searchModeMap[sortMode]; ok { opts.OrderBy = orderBy } else { - ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid sort mode: \"%s\"", sortMode)) + ctx.HTTPError(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid sort mode: \"%s\"", sortMode)) return } } else { - ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid sort order: \"%s\"", sortOrder)) + ctx.HTTPError(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid sort order: \"%s\"", sortOrder)) return } } diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go index d589586dad..12216fc620 100644 --- a/routers/web/repo/search.go +++ b/routers/web/repo/search.go @@ -5,11 +5,11 @@ package repo import ( "net/http" - "strings" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/git" code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/indexer/code/gitgrep" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/routers/common" @@ -18,16 +18,6 @@ import ( const tplSearch templates.TplName = "repo/search" -func indexSettingToGitGrepPathspecList() (list []string) { - for _, expr := range setting.Indexer.IncludePatterns { - list = append(list, ":(glob)"+expr.PatternString()) - } - for _, expr := range setting.Indexer.ExcludePatterns { - list = append(list, ":(glob,exclude)"+expr.PatternString()) - } - return list -} - // Search render repository search page func Search(ctx *context.Context) { ctx.Data["PageIsViewCode"] = true @@ -48,10 +38,10 @@ func Search(ctx *context.Context) { if setting.Indexer.RepoIndexerEnabled { var err error total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ - RepoIDs: []int64{ctx.Repo.Repository.ID}, - Keyword: prepareSearch.Keyword, - IsKeywordFuzzy: prepareSearch.IsFuzzy, - Language: prepareSearch.Language, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + Keyword: prepareSearch.Keyword, + SearchMode: prepareSearch.SearchMode, + Language: prepareSearch.Language, Paginator: &db.ListOptions{ Page: page, PageSize: setting.UI.RepoSearchPagingNum, @@ -67,32 +57,14 @@ func Search(ctx *context.Context) { ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) } } else { - res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, prepareSearch.Keyword, git.GrepOptions{ - ContextLineNumber: 1, - IsFuzzy: prepareSearch.IsFuzzy, - RefName: git.RefNameFromBranch(ctx.Repo.Repository.DefaultBranch).String(), // BranchName should be default branch or the first existing branch - PathspecList: indexSettingToGitGrepPathspecList(), - }) + var err error + // ref should be default branch or the first existing branch + searchRef := git.RefNameFromBranch(ctx.Repo.Repository.DefaultBranch) + searchResults, total, err = gitgrep.PerformSearch(ctx, page, ctx.Repo.Repository.ID, ctx.Repo.GitRepo, searchRef, prepareSearch.Keyword, prepareSearch.SearchMode) if err != nil { - // TODO: if no branch exists, it reports: exit status 128, fatal: this operation must be run in a work tree. - ctx.ServerError("GrepSearch", err) + ctx.ServerError("gitgrep.PerformSearch", err) return } - total = len(res) - pageStart := min((page-1)*setting.UI.RepoSearchPagingNum, len(res)) - pageEnd := min(page*setting.UI.RepoSearchPagingNum, len(res)) - res = res[pageStart:pageEnd] - for _, r := range res { - searchResults = append(searchResults, &code_indexer.Result{ - RepoID: ctx.Repo.Repository.ID, - Filename: r.Filename, - CommitID: ctx.Repo.CommitID, - // UpdatedUnix: not supported yet - // Language: not supported yet - // Color: not supported yet - Lines: code_indexer.HighlightSearchResultCode(r.Filename, "", r.LineNumbers, strings.Join(r.LineCodes, "\n")), - }) - } } ctx.Data["Repo"] = ctx.Repo.Repository diff --git a/routers/web/repo/search_test.go b/routers/web/repo/search_test.go deleted file mode 100644 index 33a1610384..0000000000 --- a/routers/web/repo/search_test.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repo - -import ( - "testing" - - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/test" - - "github.com/stretchr/testify/assert" -) - -func TestIndexSettingToGitGrepPathspecList(t *testing.T) { - defer test.MockVariableValue(&setting.Indexer.IncludePatterns, setting.IndexerGlobFromString("a"))() - defer test.MockVariableValue(&setting.Indexer.ExcludePatterns, setting.IndexerGlobFromString("b"))() - assert.Equal(t, []string{":(glob)a", ":(glob,exclude)b"}, indexSettingToGitGrepPathspecList()) -} diff --git a/routers/web/repo/setting/default_branch.go b/routers/web/repo/setting/default_branch.go index 881d148afc..044c9e556a 100644 --- a/routers/web/repo/setting/default_branch.go +++ b/routers/web/repo/setting/default_branch.go @@ -34,7 +34,7 @@ func SetDefaultBranchPost(ctx *context.Context) { } branch := ctx.FormString("branch") - if err := repo_service.SetRepoDefaultBranch(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, branch); err != nil { + if err := repo_service.SetRepoDefaultBranch(ctx, ctx.Repo.Repository, branch); err != nil { switch { case git_model.IsErrBranchNotExist(err): ctx.Status(http.StatusNotFound) @@ -49,6 +49,6 @@ func SetDefaultBranchPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) default: - ctx.NotFound("", nil) + ctx.NotFound(nil) } } diff --git a/routers/web/repo/setting/git_hooks.go b/routers/web/repo/setting/git_hooks.go index 1d92211303..ba4b5e85b6 100644 --- a/routers/web/repo/setting/git_hooks.go +++ b/routers/web/repo/setting/git_hooks.go @@ -34,7 +34,7 @@ func GitHooksEdit(ctx *context.Context) { hook, err := ctx.Repo.GitRepo.GetHook(name) if err != nil { if err == git.ErrNotValidHook { - ctx.NotFound("GetHook", err) + ctx.NotFound(err) } else { ctx.ServerError("GetHook", err) } @@ -50,7 +50,7 @@ func GitHooksEditPost(ctx *context.Context) { hook, err := ctx.Repo.GitRepo.GetHook(name) if err != nil { if err == git.ErrNotValidHook { - ctx.NotFound("GetHook", err) + ctx.NotFound(err) } else { ctx.ServerError("GetHook", err) } diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go index 2df483fa34..af6708e841 100644 --- a/routers/web/repo/setting/lfs.go +++ b/routers/web/repo/setting/lfs.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/attribute" "code.gitea.io/gitea/modules/git/pipeline" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" @@ -41,13 +42,10 @@ const ( // LFSFiles shows a repository's LFS files func LFSFiles(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.NotFound("LFSFiles", nil) + ctx.NotFound(nil) return } - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) total, err := git_model.CountLFSMetaObjects(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("LFSFiles", err) @@ -71,15 +69,12 @@ func LFSFiles(ctx *context.Context) { // LFSLocks shows a repository's LFS locks func LFSLocks(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.NotFound("LFSLocks", nil) + ctx.NotFound(nil) return } ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) total, err := git_model.CountLFSLockByRepoID(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("LFSLocks", err) @@ -109,17 +104,13 @@ func LFSLocks(ctx *context.Context) { } // Clone base repo. - tmpBasePath, err := repo_module.CreateTemporaryPath("locks") + tmpBasePath, cleanup, err := repo_module.CreateTemporaryPath("locks") if err != nil { log.Error("Failed to create temporary path: %v", err) ctx.ServerError("LFSLocks", err) return } - defer func() { - if err := repo_module.RemoveTemporaryPath(tmpBasePath); err != nil { - log.Error("LFSLocks: RemoveTemporaryPath: %v", err) - } - }() + defer cleanup() if err := git.Clone(ctx, ctx.Repo.Repository.RepoPath(), tmpBasePath, git.CloneRepoOptions{ Bare: true, @@ -138,39 +129,24 @@ func LFSLocks(ctx *context.Context) { } defer gitRepo.Close() - filenames := make([]string, len(lfsLocks)) - - for i, lock := range lfsLocks { - filenames[i] = lock.Path - } - - if err := gitRepo.ReadTreeToIndex(ctx.Repo.Repository.DefaultBranch); err != nil { - log.Error("Unable to read the default branch to the index: %s (%v)", ctx.Repo.Repository.DefaultBranch, err) - ctx.ServerError("LFSLocks", fmt.Errorf("unable to read the default branch to the index: %s (%w)", ctx.Repo.Repository.DefaultBranch, err)) - return - } - - name2attribute2info, err := gitRepo.CheckAttribute(git.CheckAttributeOpts{ - Attributes: []string{"lockable"}, - Filenames: filenames, - CachedOnly: true, - }) + checker, err := attribute.NewBatchChecker(gitRepo, ctx.Repo.Repository.DefaultBranch, []string{attribute.Lockable}) if err != nil { log.Error("Unable to check attributes in %s (%v)", tmpBasePath, err) ctx.ServerError("LFSLocks", err) return } + defer checker.Close() lockables := make([]bool, len(lfsLocks)) + filenames := make([]string, len(lfsLocks)) for i, lock := range lfsLocks { - attribute2info, has := name2attribute2info[lock.Path] - if !has { - continue - } - if attribute2info["lockable"] != "set" { + filenames[i] = lock.Path + attrs, err := checker.CheckPath(lock.Path) + if err != nil { + log.Error("Unable to check attributes in %s: %s (%v)", tmpBasePath, lock.Path, err) continue } - lockables[i] = true + lockables[i] = attrs.Get(attribute.Lockable).ToBool().Value() } ctx.Data["Lockables"] = lockables @@ -197,7 +173,7 @@ func LFSLocks(ctx *context.Context) { // LFSLockFile locks a file func LFSLockFile(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.NotFound("LFSLocks", nil) + ctx.NotFound(nil) return } originalPath := ctx.FormString("path") @@ -238,7 +214,7 @@ func LFSLockFile(ctx *context.Context) { // LFSUnlock forcibly unlocks an LFS lock func LFSUnlock(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.NotFound("LFSUnlock", nil) + ctx.NotFound(nil) return } _, err := git_model.DeleteLFSLockByID(ctx, ctx.PathParamInt64("lid"), ctx.Repo.Repository, ctx.Doer, true) @@ -252,7 +228,7 @@ func LFSUnlock(ctx *context.Context) { // LFSFileGet serves a single LFS file func LFSFileGet(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.NotFound("LFSFileGet", nil) + ctx.NotFound(nil) return } ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs" @@ -260,7 +236,7 @@ func LFSFileGet(ctx *context.Context) { p := lfs.Pointer{Oid: oid} if !p.IsValid() { - ctx.NotFound("LFSFileGet", nil) + ctx.NotFound(nil) return } @@ -269,7 +245,7 @@ func LFSFileGet(ctx *context.Context) { meta, err := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, oid) if err != nil { if err == git_model.ErrLFSObjectNotExist { - ctx.NotFound("LFSFileGet", nil) + ctx.NotFound(nil) return } ctx.ServerError("LFSFileGet", err) @@ -291,8 +267,10 @@ func LFSFileGet(ctx *context.Context) { buf = buf[:n] st := typesniffer.DetectContentType(buf) + // FIXME: there is no IsPlainText set, but template uses it ctx.Data["IsTextFile"] = st.IsText() ctx.Data["FileSize"] = meta.Size + // FIXME: the last field is the URL-base64-encoded filename, it should not be "direct" ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct") switch { case st.IsRepresentableAsText(): @@ -333,8 +311,6 @@ func LFSFileGet(ctx *context.Context) { } ctx.Data["LineNums"] = gotemplate.HTML(output.String()) - case st.IsPDF(): - ctx.Data["IsPDFFile"] = true case st.IsVideo(): ctx.Data["IsVideoFile"] = true case st.IsAudio(): @@ -350,13 +326,13 @@ func LFSFileGet(ctx *context.Context) { // LFSDelete disassociates the provided oid from the repository and if the lfs file is no longer associated with any repositories - deletes it func LFSDelete(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.NotFound("LFSDelete", nil) + ctx.NotFound(nil) return } oid := ctx.PathParam("oid") p := lfs.Pointer{Oid: oid} if !p.IsValid() { - ctx.NotFound("LFSDelete", nil) + ctx.NotFound(nil) return } @@ -381,13 +357,13 @@ func LFSDelete(ctx *context.Context) { // LFSFileFind guesses a sha for the provided oid (or uses the provided sha) and then finds the commits that contain this sha func LFSFileFind(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.NotFound("LFSFind", nil) + ctx.NotFound(nil) return } oid := ctx.FormString("oid") size := ctx.FormInt64("size") if len(oid) == 0 || size == 0 { - ctx.NotFound("LFSFind", nil) + ctx.NotFound(nil) return } sha := ctx.FormString("sha") @@ -421,7 +397,7 @@ func LFSFileFind(ctx *context.Context) { // LFSPointerFiles will search the repository for pointer files and report which are missing LFS files in the content store func LFSPointerFiles(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.NotFound("LFSFileGet", nil) + ctx.NotFound(nil) return } ctx.Data["PageIsSettingsLFS"] = true @@ -532,7 +508,7 @@ func LFSPointerFiles(ctx *context.Context) { // LFSAutoAssociate auto associates accessible lfs files func LFSAutoAssociate(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.NotFound("LFSAutoAssociate", nil) + ctx.NotFound(nil) return } oids := ctx.FormStrings("oid") diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go index 06a9e69507..0eea5e3f34 100644 --- a/routers/web/repo/setting/protected_branch.go +++ b/routers/web/repo/setting/protected_branch.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" "time" @@ -16,6 +17,7 @@ import ( "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web" @@ -88,7 +90,7 @@ func SettingsProtectedBranch(c *context.Context) { c.Data["recent_status_checks"] = contexts if c.Repo.Owner.IsOrganization() { - teams, err := organization.OrgFromUser(c.Repo.Owner).TeamsWithAccessToRepo(c, c.Repo.Repository.ID, perm.AccessModeRead) + teams, err := organization.GetTeamsWithAccessToAnyRepoUnit(c, c.Repo.Owner.ID, c.Repo.Repository.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests) if err != nil { c.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) return @@ -110,7 +112,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) { var protectBranch *git_model.ProtectedBranch if f.RuleName == "" { ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_rule_name")) - ctx.Redirect(fmt.Sprintf("%s/settings/branches/edit", ctx.Repo.RepoLink)) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/branches/edit") return } @@ -261,7 +263,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) { protectBranch.BlockOnOutdatedBranch = f.BlockOnOutdatedBranch protectBranch.BlockAdminMergeOverride = f.BlockAdminMergeOverride - err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ + if err = pull_service.CreateOrUpdateProtectedBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ UserIDs: whitelistUsers, TeamIDs: whitelistTeams, ForcePushUserIDs: forcePushAllowlistUsers, @@ -270,24 +272,10 @@ func SettingsProtectedBranchPost(ctx *context.Context) { MergeTeamIDs: mergeWhitelistTeams, ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsTeamIDs: approvalsWhitelistTeams, - }) - if err != nil { - ctx.ServerError("UpdateProtectBranch", err) - return - } - - // FIXME: since we only need to recheck files protected rules, we could improve this - matchedBranches, err := git_model.FindAllMatchedBranches(ctx, ctx.Repo.Repository.ID, protectBranch.RuleName) - if err != nil { - ctx.ServerError("FindAllMatchedBranches", err) + }); err != nil { + ctx.ServerError("CreateOrUpdateProtectedBranch", err) return } - for _, branchName := range matchedBranches { - if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, branchName); err != nil { - ctx.ServerError("CheckPRsForBaseBranch", err) - return - } - } ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", protectBranch.RuleName)) ctx.Redirect(fmt.Sprintf("%s/settings/branches?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName)) @@ -297,32 +285,32 @@ func SettingsProtectedBranchPost(ctx *context.Context) { func DeleteProtectedBranchRulePost(ctx *context.Context) { ruleID := ctx.PathParamInt64("id") if ruleID <= 0 { - ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID))) - ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", strconv.FormatInt(ruleID, 10))) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/branches") return } rule, err := git_model.GetProtectedBranchRuleByID(ctx, ctx.Repo.Repository.ID, ruleID) if err != nil { - ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID))) - ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", strconv.FormatInt(ruleID, 10))) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/branches") return } if rule == nil { - ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", fmt.Sprintf("%d", ruleID))) - ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", strconv.FormatInt(ruleID, 10))) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/branches") return } if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository, ruleID); err != nil { ctx.Flash.Error(ctx.Tr("repo.settings.remove_protected_branch_failed", rule.RuleName)) - ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/branches") return } ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", rule.RuleName)) - ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/branches") } func UpdateBranchProtectionPriories(ctx *context.Context) { @@ -340,13 +328,13 @@ func RenameBranchPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.RenameBranchForm) if !ctx.Repo.CanCreateBranch() { - ctx.NotFound("RenameBranch", nil) + ctx.NotFound(nil) return } if ctx.HasError() { ctx.Flash.Error(ctx.GetErrMsg()) - ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + ctx.Redirect(ctx.Repo.RepoLink + "/branches") return } @@ -355,13 +343,13 @@ func RenameBranchPost(ctx *context.Context) { switch { case repo_model.IsErrUserDoesNotHaveAccessToRepo(err): ctx.Flash.Error(ctx.Tr("repo.branch.rename_default_or_protected_branch_error")) - ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + ctx.Redirect(ctx.Repo.RepoLink + "/branches") case git_model.IsErrBranchAlreadyExists(err): ctx.Flash.Error(ctx.Tr("repo.branch.branch_already_exists", form.To)) - ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + ctx.Redirect(ctx.Repo.RepoLink + "/branches") case errors.Is(err, git_model.ErrBranchIsProtected): ctx.Flash.Error(ctx.Tr("repo.branch.rename_protected_branch_failed")) - ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + ctx.Redirect(ctx.Repo.RepoLink + "/branches") default: ctx.ServerError("RenameBranch", err) } @@ -370,16 +358,16 @@ func RenameBranchPost(ctx *context.Context) { if msg == "target_exist" { ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_exist", form.To)) - ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + ctx.Redirect(ctx.Repo.RepoLink + "/branches") return } if msg == "from_not_exist" { ctx.Flash.Error(ctx.Tr("repo.settings.rename_branch_failed_not_exist", form.From)) - ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + ctx.Redirect(ctx.Repo.RepoLink + "/branches") return } ctx.Flash.Success(ctx.Tr("repo.settings.rename_branch_success", form.From, form.To)) - ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + ctx.Redirect(ctx.Repo.RepoLink + "/branches") } diff --git a/routers/web/repo/setting/protected_tag.go b/routers/web/repo/setting/protected_tag.go index 1730ad4a8b..50f5a28c4c 100644 --- a/routers/web/repo/setting/protected_tag.go +++ b/routers/web/repo/setting/protected_tag.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" @@ -156,7 +157,7 @@ func setTagsContext(ctx *context.Context) error { ctx.Data["Users"] = users if ctx.Repo.Owner.IsOrganization() { - teams, err := organization.OrgFromUser(ctx.Repo.Owner).TeamsWithAccessToRepo(ctx, ctx.Repo.Repository.ID, perm.AccessModeRead) + teams, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, ctx.Repo.Owner.ID, ctx.Repo.Repository.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests) if err != nil { ctx.ServerError("Repo.Owner.TeamsWithAccessToRepo", err) return err @@ -183,7 +184,7 @@ func selectProtectedTagByContext(ctx *context.Context) *git_model.ProtectedTag { return tag } - ctx.NotFound("", fmt.Errorf("ProtectedTag[%v] not associated to repository %v", id, ctx.Repo.Repository)) + ctx.NotFound(fmt.Errorf("ProtectedTag[%v] not associated to repository %v", id, ctx.Repo.Repository)) return nil } diff --git a/routers/web/repo/setting/public_access.go b/routers/web/repo/setting/public_access.go new file mode 100644 index 0000000000..368d34294a --- /dev/null +++ b/routers/web/repo/setting/public_access.go @@ -0,0 +1,155 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "slices" + "strconv" + + "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" +) + +const tplRepoSettingsPublicAccess templates.TplName = "repo/settings/public_access" + +func parsePublicAccessMode(permission string, allowed []string) (ret struct { + AnonymousAccessMode, EveryoneAccessMode perm.AccessMode +}, +) { + ret.AnonymousAccessMode = perm.AccessModeNone + ret.EveryoneAccessMode = perm.AccessModeNone + + // if site admin forces repositories to be private, then do not allow any other access mode, + // otherwise the "force private" setting would be bypassed + if setting.Repository.ForcePrivate { + return ret + } + if !slices.Contains(allowed, permission) { + return ret + } + switch permission { + case paAnonymousRead: + ret.AnonymousAccessMode = perm.AccessModeRead + case paEveryoneRead: + ret.EveryoneAccessMode = perm.AccessModeRead + case paEveryoneWrite: + ret.EveryoneAccessMode = perm.AccessModeWrite + } + return ret +} + +const ( + paNotSet = "not-set" + paAnonymousRead = "anonymous-read" + paEveryoneRead = "everyone-read" + paEveryoneWrite = "everyone-write" +) + +type repoUnitPublicAccess struct { + UnitType unit.Type + FormKey string + DisplayName string + PublicAccessTypes []string + UnitPublicAccess string +} + +func repoUnitPublicAccesses(ctx *context.Context) []*repoUnitPublicAccess { + accesses := []*repoUnitPublicAccess{ + { + UnitType: unit.TypeCode, + DisplayName: ctx.Locale.TrString("repo.code"), + PublicAccessTypes: []string{paAnonymousRead, paEveryoneRead}, + }, + { + UnitType: unit.TypeIssues, + DisplayName: ctx.Locale.TrString("issues"), + PublicAccessTypes: []string{paAnonymousRead, paEveryoneRead}, + }, + { + UnitType: unit.TypePullRequests, + DisplayName: ctx.Locale.TrString("pull_requests"), + PublicAccessTypes: []string{paAnonymousRead, paEveryoneRead}, + }, + { + UnitType: unit.TypeReleases, + DisplayName: ctx.Locale.TrString("repo.releases"), + PublicAccessTypes: []string{paAnonymousRead, paEveryoneRead}, + }, + { + UnitType: unit.TypeWiki, + DisplayName: ctx.Locale.TrString("repo.wiki"), + PublicAccessTypes: []string{paAnonymousRead, paEveryoneRead, paEveryoneWrite}, + }, + { + UnitType: unit.TypeProjects, + DisplayName: ctx.Locale.TrString("repo.projects"), + PublicAccessTypes: []string{paAnonymousRead, paEveryoneRead}, + }, + { + UnitType: unit.TypePackages, + DisplayName: ctx.Locale.TrString("repo.packages"), + PublicAccessTypes: []string{paAnonymousRead, paEveryoneRead}, + }, + { + UnitType: unit.TypeActions, + DisplayName: ctx.Locale.TrString("repo.actions"), + PublicAccessTypes: []string{paAnonymousRead, paEveryoneRead}, + }, + } + for _, ua := range accesses { + ua.FormKey = "repo-unit-access-" + strconv.Itoa(int(ua.UnitType)) + for _, u := range ctx.Repo.Repository.Units { + if u.Type == ua.UnitType { + ua.UnitPublicAccess = paNotSet + switch { + case u.EveryoneAccessMode == perm.AccessModeWrite: + ua.UnitPublicAccess = paEveryoneWrite + case u.EveryoneAccessMode == perm.AccessModeRead: + ua.UnitPublicAccess = paEveryoneRead + case u.AnonymousAccessMode == perm.AccessModeRead: + ua.UnitPublicAccess = paAnonymousRead + } + break + } + } + } + return slices.DeleteFunc(accesses, func(ua *repoUnitPublicAccess) bool { + return ua.UnitPublicAccess == "" + }) +} + +func PublicAccess(ctx *context.Context) { + ctx.Data["PageIsSettingsPublicAccess"] = true + ctx.Data["RepoUnitPublicAccesses"] = repoUnitPublicAccesses(ctx) + ctx.Data["GlobalForcePrivate"] = setting.Repository.ForcePrivate + if setting.Repository.ForcePrivate { + ctx.Flash.Error(ctx.Tr("form.repository_force_private"), true) + } + ctx.HTML(http.StatusOK, tplRepoSettingsPublicAccess) +} + +func PublicAccessPost(ctx *context.Context) { + accesses := repoUnitPublicAccesses(ctx) + for _, ua := range accesses { + formVal := ctx.FormString(ua.FormKey) + parsed := parsePublicAccessMode(formVal, ua.PublicAccessTypes) + err := repo.UpdateRepoUnitPublicAccess(ctx, &repo.RepoUnit{ + RepoID: ctx.Repo.Repository.ID, + Type: ua.UnitType, + AnonymousAccessMode: parsed.AnonymousAccessMode, + EveryoneAccessMode: parsed.EveryoneAccessMode, + }) + if err != nil { + ctx.ServerError("UpdateRepoUnitPublicAccess", err) + return + } + } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/public_access") +} diff --git a/routers/web/repo/setting/runners.go b/routers/web/repo/setting/runners.go deleted file mode 100644 index 94f2ae7a0c..0000000000 --- a/routers/web/repo/setting/runners.go +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package setting - -import ( - "errors" - "net/http" - "net/url" - - actions_model "code.gitea.io/gitea/models/actions" - "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" - actions_shared "code.gitea.io/gitea/routers/web/shared/actions" - shared_user "code.gitea.io/gitea/routers/web/shared/user" - "code.gitea.io/gitea/services/context" -) - -const ( - // TODO: Separate secrets from runners when layout is ready - tplRepoRunners templates.TplName = "repo/settings/actions" - tplOrgRunners templates.TplName = "org/settings/actions" - tplAdminRunners templates.TplName = "admin/actions" - tplUserRunners templates.TplName = "user/settings/actions" - tplRepoRunnerEdit templates.TplName = "repo/settings/runner_edit" - tplOrgRunnerEdit templates.TplName = "org/settings/runners_edit" - tplAdminRunnerEdit templates.TplName = "admin/runners/edit" - tplUserRunnerEdit templates.TplName = "user/settings/runner_edit" -) - -type runnersCtx struct { - OwnerID int64 - RepoID int64 - IsRepo bool - IsOrg bool - IsAdmin bool - IsUser bool - RunnersTemplate templates.TplName - RunnerEditTemplate templates.TplName - RedirectLink string -} - -func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) { - if ctx.Data["PageIsRepoSettings"] == true { - return &runnersCtx{ - RepoID: ctx.Repo.Repository.ID, - OwnerID: 0, - IsRepo: true, - RunnersTemplate: tplRepoRunners, - RunnerEditTemplate: tplRepoRunnerEdit, - RedirectLink: ctx.Repo.RepoLink + "/settings/actions/runners/", - }, nil - } - - if ctx.Data["PageIsOrgSettings"] == true { - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) - return nil, nil - } - return &runnersCtx{ - RepoID: 0, - OwnerID: ctx.Org.Organization.ID, - IsOrg: true, - RunnersTemplate: tplOrgRunners, - RunnerEditTemplate: tplOrgRunnerEdit, - RedirectLink: ctx.Org.OrgLink + "/settings/actions/runners/", - }, nil - } - - if ctx.Data["PageIsAdmin"] == true { - return &runnersCtx{ - RepoID: 0, - OwnerID: 0, - IsAdmin: true, - RunnersTemplate: tplAdminRunners, - RunnerEditTemplate: tplAdminRunnerEdit, - RedirectLink: setting.AppSubURL + "/-/admin/actions/runners/", - }, nil - } - - if ctx.Data["PageIsUserSettings"] == true { - return &runnersCtx{ - OwnerID: ctx.Doer.ID, - RepoID: 0, - IsUser: true, - RunnersTemplate: tplUserRunners, - RunnerEditTemplate: tplUserRunnerEdit, - RedirectLink: setting.AppSubURL + "/user/settings/actions/runners/", - }, nil - } - - return nil, errors.New("unable to set Runners context") -} - -// Runners render settings/actions/runners page for repo level -func Runners(ctx *context.Context) { - ctx.Data["PageIsSharedSettingsRunners"] = true - ctx.Data["Title"] = ctx.Tr("actions.actions") - ctx.Data["PageType"] = "runners" - - rCtx, err := getRunnersCtx(ctx) - if err != nil { - ctx.ServerError("getRunnersCtx", err) - return - } - - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } - - opts := actions_model.FindRunnerOptions{ - ListOptions: db.ListOptions{ - Page: page, - PageSize: 100, - }, - Sort: ctx.Req.URL.Query().Get("sort"), - Filter: ctx.Req.URL.Query().Get("q"), - } - if rCtx.IsRepo { - opts.RepoID = rCtx.RepoID - opts.WithAvailable = true - } else if rCtx.IsOrg || rCtx.IsUser { - opts.OwnerID = rCtx.OwnerID - opts.WithAvailable = true - } - actions_shared.RunnersList(ctx, opts) - - ctx.HTML(http.StatusOK, rCtx.RunnersTemplate) -} - -// RunnersEdit renders runner edit page for repository level -func RunnersEdit(ctx *context.Context) { - ctx.Data["PageIsSharedSettingsRunners"] = true - ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner") - rCtx, err := getRunnersCtx(ctx) - if err != nil { - ctx.ServerError("getRunnersCtx", err) - return - } - - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } - - actions_shared.RunnerDetails(ctx, page, - ctx.PathParamInt64("runnerid"), rCtx.OwnerID, rCtx.RepoID, - ) - ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate) -} - -func RunnersEditPost(ctx *context.Context) { - rCtx, err := getRunnersCtx(ctx) - if err != nil { - ctx.ServerError("getRunnersCtx", err) - return - } - actions_shared.RunnerDetailsEditPost(ctx, ctx.PathParamInt64("runnerid"), - rCtx.OwnerID, rCtx.RepoID, - rCtx.RedirectLink+url.PathEscape(ctx.PathParam("runnerid"))) -} - -func ResetRunnerRegistrationToken(ctx *context.Context) { - rCtx, err := getRunnersCtx(ctx) - if err != nil { - ctx.ServerError("getRunnersCtx", err) - return - } - actions_shared.RunnerResetRegistrationToken(ctx, rCtx.OwnerID, rCtx.RepoID, rCtx.RedirectLink) -} - -// RunnerDeletePost response for deleting runner -func RunnerDeletePost(ctx *context.Context) { - rCtx, err := getRunnersCtx(ctx) - if err != nil { - ctx.ServerError("getRunnersCtx", err) - return - } - actions_shared.RunnerDeletePost(ctx, ctx.PathParamInt64("runnerid"), rCtx.RedirectLink, rCtx.RedirectLink+url.PathEscape(ctx.PathParam("runnerid"))) -} - -func RedirectToDefaultSetting(ctx *context.Context) { - ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners") -} diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go index 46cb875f9b..c6e2d18249 100644 --- a/routers/web/repo/setting/secrets.go +++ b/routers/web/repo/setting/secrets.go @@ -44,9 +44,8 @@ func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { } if ctx.Data["PageIsOrgSettings"] == true { - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return nil, nil } return &secretsCtx{ diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 5b34fc60da..6e16ead183 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -6,15 +6,12 @@ package setting import ( "errors" - "fmt" "net/http" "strings" "time" - actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" - "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -38,6 +35,8 @@ import ( mirror_service "code.gitea.io/gitea/services/mirror" repo_service "code.gitea.io/gitea/services/repository" wiki_service "code.gitea.io/gitea/services/wiki" + + "xorm.io/xorm/convert" ) const ( @@ -49,15 +48,6 @@ const ( tplDeployKeys templates.TplName = "repo/settings/deploy_keys" ) -func parseEveryoneAccessMode(permission string, allowed ...perm.AccessMode) perm.AccessMode { - // if site admin forces repositories to be private, then do not allow any other access mode, - // otherwise the "force private" setting would be bypassed - if setting.Repository.ForcePrivate { - return perm.AccessModeNone - } - return perm.ParseAccessMode(permission, allowed...) -} - // SettingsCtxData is a middleware that sets all the general context data for the // settings template. func SettingsCtxData(ctx *context.Context) { @@ -69,9 +59,10 @@ func SettingsCtxData(ctx *context.Context) { ctx.Data["DisableNewPushMirrors"] = setting.Mirror.DisableNewPush ctx.Data["DefaultMirrorInterval"] = setting.Mirror.DefaultInterval ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval + ctx.Data["CanConvertFork"] = ctx.Repo.Repository.IsFork && ctx.Doer.CanCreateRepoIn(ctx.Repo.Repository.Owner) signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath()) - ctx.Data["SigningKeyAvailable"] = len(signing) > 0 + ctx.Data["SigningKeyAvailable"] = signing != nil ctx.Data["SigningSettings"] = setting.Repository.Signing ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled @@ -106,8 +97,6 @@ func Settings(ctx *context.Context) { // SettingsPost response for changes of a repository func SettingsPost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.RepoSettingForm) - ctx.Data["ForcePrivate"] = setting.Repository.ForcePrivate ctx.Data["MirrorsEnabled"] = setting.Mirror.Enabled ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull @@ -116,877 +105,937 @@ func SettingsPost(ctx *context.Context) { ctx.Data["MinimumMirrorInterval"] = setting.Mirror.MinInterval signing, _ := asymkey_service.SigningKey(ctx, ctx.Repo.Repository.RepoPath()) - ctx.Data["SigningKeyAvailable"] = len(signing) > 0 + ctx.Data["SigningKeyAvailable"] = signing != nil ctx.Data["SigningSettings"] = setting.Repository.Signing ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - repo := ctx.Repo.Repository - switch ctx.FormString("action") { case "update": - if ctx.HasError() { - ctx.HTML(http.StatusOK, tplSettingsOptions) - return - } + handleSettingsPostUpdate(ctx) + case "mirror": + handleSettingsPostMirror(ctx) + case "mirror-sync": + handleSettingsPostMirrorSync(ctx) + case "push-mirror-sync": + handleSettingsPostPushMirrorSync(ctx) + case "push-mirror-update": + handleSettingsPostPushMirrorUpdate(ctx) + case "push-mirror-remove": + handleSettingsPostPushMirrorRemove(ctx) + case "push-mirror-add": + handleSettingsPostPushMirrorAdd(ctx) + case "advanced": + handleSettingsPostAdvanced(ctx) + case "signing": + handleSettingsPostSigning(ctx) + case "admin": + handleSettingsPostAdmin(ctx) + case "admin_index": + handleSettingsPostAdminIndex(ctx) + case "convert": + handleSettingsPostConvert(ctx) + case "convert_fork": + handleSettingsPostConvertFork(ctx) + case "transfer": + handleSettingsPostTransfer(ctx) + case "cancel_transfer": + handleSettingsPostCancelTransfer(ctx) + case "delete": + handleSettingsPostDelete(ctx) + case "delete-wiki": + handleSettingsPostDeleteWiki(ctx) + case "archive": + handleSettingsPostArchive(ctx) + case "unarchive": + handleSettingsPostUnarchive(ctx) + case "visibility": + handleSettingsPostVisibility(ctx) + default: + ctx.NotFound(nil) + } +} - newRepoName := form.RepoName - // Check if repository name has been changed. - if repo.LowerName != strings.ToLower(newRepoName) { - // Close the GitRepo if open - if ctx.Repo.GitRepo != nil { - ctx.Repo.GitRepo.Close() - ctx.Repo.GitRepo = nil - } - if err := repo_service.ChangeRepositoryName(ctx, ctx.Doer, repo, newRepoName); err != nil { +func handleSettingsPostUpdate(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplSettingsOptions) + return + } + + newRepoName := form.RepoName + // Check if repository name has been changed. + if repo.LowerName != strings.ToLower(newRepoName) { + // Close the GitRepo if open + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + if err := repo_service.ChangeRepositoryName(ctx, ctx.Doer, repo, newRepoName); err != nil { + ctx.Data["Err_RepoName"] = true + switch { + case repo_model.IsErrRepoAlreadyExist(err): + ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tplSettingsOptions, &form) + case db.IsErrNameReserved(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) + case repo_model.IsErrRepoFilesAlreadyExist(err): ctx.Data["Err_RepoName"] = true switch { - case repo_model.IsErrRepoAlreadyExist(err): - ctx.RenderWithErr(ctx.Tr("form.repo_name_been_taken"), tplSettingsOptions, &form) - case db.IsErrNameReserved(err): - ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplSettingsOptions, &form) - case repo_model.IsErrRepoFilesAlreadyExist(err): - ctx.Data["Err_RepoName"] = true - switch { - case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplSettingsOptions, form) - case setting.Repository.AllowAdoptionOfUnadoptedRepositories: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplSettingsOptions, form) - case setting.Repository.AllowDeleteOfUnadoptedRepositories: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplSettingsOptions, form) - default: - ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplSettingsOptions, form) - } - case db.IsErrNamePatternNotAllowed(err): - ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) + case ctx.IsUserSiteAdmin() || (setting.Repository.AllowAdoptionOfUnadoptedRepositories && setting.Repository.AllowDeleteOfUnadoptedRepositories): + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt_or_delete"), tplSettingsOptions, form) + case setting.Repository.AllowAdoptionOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.adopt"), tplSettingsOptions, form) + case setting.Repository.AllowDeleteOfUnadoptedRepositories: + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist.delete"), tplSettingsOptions, form) default: - ctx.ServerError("ChangeRepositoryName", err) + ctx.RenderWithErr(ctx.Tr("form.repository_files_already_exist"), tplSettingsOptions, form) } - return + case db.IsErrNamePatternNotAllowed(err): + ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplSettingsOptions, &form) + default: + ctx.ServerError("ChangeRepositoryName", err) } - - log.Trace("Repository name changed: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newRepoName) - } - // In case it's just a case change. - repo.Name = newRepoName - repo.LowerName = strings.ToLower(newRepoName) - repo.Description = form.Description - repo.Website = form.Website - repo.IsTemplate = form.Template - - // Visibility of forked repository is forced sync with base repository. - if repo.IsFork { - form.Private = repo.BaseRepo.IsPrivate || repo.BaseRepo.Owner.Visibility == structs.VisibleTypePrivate - } - - if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { - ctx.ServerError("UpdateRepository", err) return } - log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(repo.Link() + "/settings") + log.Trace("Repository name changed: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newRepoName) + } + // In case it's just a case change. + repo.Name = newRepoName + repo.LowerName = strings.ToLower(newRepoName) + repo.Description = form.Description + repo.Website = form.Website + repo.IsTemplate = form.Template + + // Visibility of forked repository is forced sync with base repository. + if repo.IsFork { + form.Private = repo.BaseRepo.IsPrivate || repo.BaseRepo.Owner.Visibility == structs.VisibleTypePrivate + } - case "mirror": - if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { - ctx.NotFound("", nil) - return - } + if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) - pullMirror, err := repo_model.GetMirrorByRepoID(ctx, ctx.Repo.Repository.ID) - if err == repo_model.ErrMirrorNotExist { - ctx.NotFound("", nil) - return - } - if err != nil { - ctx.ServerError("GetMirrorByRepoID", err) - return - } - // This section doesn't require repo_name/RepoName to be set in the form, don't show it - // as an error on the UI for this action - ctx.Data["Err_RepoName"] = nil - - interval, err := time.ParseDuration(form.Interval) - if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { - ctx.Data["Err_Interval"] = true - ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form) - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") +} - pullMirror.EnablePrune = form.EnablePrune - pullMirror.Interval = interval - pullMirror.ScheduleNextUpdate() - if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil { - ctx.ServerError("UpdateMirror", err) - return - } +func handleSettingsPostMirror(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { + ctx.NotFound(nil) + return + } - u, err := git.GetRemoteURL(ctx, ctx.Repo.Repository.RepoPath(), pullMirror.GetRemoteName()) - if err != nil { - ctx.Data["Err_MirrorAddress"] = true - handleSettingRemoteAddrError(ctx, err, form) - return - } - if u.User != nil && form.MirrorPassword == "" && form.MirrorUsername == u.User.Username() { - form.MirrorPassword, _ = u.User.Password() - } + pullMirror, err := repo_model.GetMirrorByRepoID(ctx, ctx.Repo.Repository.ID) + if err == repo_model.ErrMirrorNotExist { + ctx.NotFound(nil) + return + } + if err != nil { + ctx.ServerError("GetMirrorByRepoID", err) + return + } + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil + + interval, err := time.ParseDuration(form.Interval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.Data["Err_Interval"] = true + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form) + return + } - address, err := git.ParseRemoteAddr(form.MirrorAddress, form.MirrorUsername, form.MirrorPassword) - if err == nil { - err = migrations.IsMigrateURLAllowed(address, ctx.Doer) - } - if err != nil { - ctx.Data["Err_MirrorAddress"] = true - handleSettingRemoteAddrError(ctx, err, form) - return - } + pullMirror.EnablePrune = form.EnablePrune + pullMirror.Interval = interval + pullMirror.ScheduleNextUpdate() + if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil { + ctx.ServerError("UpdateMirror", err) + return + } - if err := mirror_service.UpdateAddress(ctx, pullMirror, address); err != nil { - ctx.ServerError("UpdateAddress", err) - return - } + u, err := git.GetRemoteURL(ctx, ctx.Repo.Repository.RepoPath(), pullMirror.GetRemoteName()) + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + if u.User != nil && form.MirrorPassword == "" && form.MirrorUsername == u.User.Username() { + form.MirrorPassword, _ = u.User.Password() + } - remoteAddress, err := util.SanitizeURL(form.MirrorAddress) - if err != nil { - ctx.Data["Err_MirrorAddress"] = true - handleSettingRemoteAddrError(ctx, err, form) - return - } - pullMirror.RemoteAddress = remoteAddress + address, err := git.ParseRemoteAddr(form.MirrorAddress, form.MirrorUsername, form.MirrorPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(address, ctx.Doer) + } + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } - form.LFS = form.LFS && setting.LFS.StartServer + if err := mirror_service.UpdateAddress(ctx, pullMirror, address); err != nil { + ctx.ServerError("UpdateAddress", err) + return + } - if len(form.LFSEndpoint) > 0 { - ep := lfs.DetermineEndpoint("", form.LFSEndpoint) - if ep == nil { - ctx.Data["Err_LFSEndpoint"] = true - ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_lfs_endpoint"), tplSettingsOptions, &form) - return - } - err = migrations.IsMigrateURLAllowed(ep.String(), ctx.Doer) - if err != nil { - ctx.Data["Err_LFSEndpoint"] = true - handleSettingRemoteAddrError(ctx, err, form) - return - } - } + remoteAddress, err := util.SanitizeURL(form.MirrorAddress) + if err != nil { + ctx.Data["Err_MirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } + pullMirror.RemoteAddress = remoteAddress + + form.LFS = form.LFS && setting.LFS.StartServer - pullMirror.LFS = form.LFS - pullMirror.LFSEndpoint = form.LFSEndpoint - if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil { - ctx.ServerError("UpdateMirror", err) + if len(form.LFSEndpoint) > 0 { + ep := lfs.DetermineEndpoint("", form.LFSEndpoint) + if ep == nil { + ctx.Data["Err_LFSEndpoint"] = true + ctx.RenderWithErr(ctx.Tr("repo.migrate.invalid_lfs_endpoint"), tplSettingsOptions, &form) return } - - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(repo.Link() + "/settings") - - case "mirror-sync": - if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { - ctx.NotFound("", nil) + err = migrations.IsMigrateURLAllowed(ep.String(), ctx.Doer) + if err != nil { + ctx.Data["Err_LFSEndpoint"] = true + handleSettingRemoteAddrError(ctx, err, form) return } + } - mirror_service.AddPullMirrorToQueue(repo.ID) - - ctx.Flash.Info(ctx.Tr("repo.settings.pull_mirror_sync_in_progress", repo.OriginalURL)) - ctx.Redirect(repo.Link() + "/settings") + pullMirror.LFS = form.LFS + pullMirror.LFSEndpoint = form.LFSEndpoint + if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil { + ctx.ServerError("UpdateMirror", err) + return + } - case "push-mirror-sync": - if !setting.Mirror.Enabled { - ctx.NotFound("", nil) - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") +} - m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) - if m == nil { - ctx.NotFound("", nil) - return - } +func handleSettingsPostMirrorSync(ctx *context.Context) { + repo := ctx.Repo.Repository + if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { + ctx.NotFound(nil) + return + } - mirror_service.AddPushMirrorToQueue(m.ID) + mirror_service.AddPullMirrorToQueue(repo.ID) - ctx.Flash.Info(ctx.Tr("repo.settings.push_mirror_sync_in_progress", m.RemoteAddress)) - ctx.Redirect(repo.Link() + "/settings") + ctx.Flash.Info(ctx.Tr("repo.settings.pull_mirror_sync_in_progress", repo.OriginalURL)) + ctx.Redirect(repo.Link() + "/settings") +} - case "push-mirror-update": - if !setting.Mirror.Enabled || repo.IsArchived { - ctx.NotFound("", nil) - return - } +func handleSettingsPostPushMirrorSync(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository - // This section doesn't require repo_name/RepoName to be set in the form, don't show it - // as an error on the UI for this action - ctx.Data["Err_RepoName"] = nil + if !setting.Mirror.Enabled { + ctx.NotFound(nil) + return + } - interval, err := time.ParseDuration(form.PushMirrorInterval) - if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { - ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &forms.RepoSettingForm{}) - return - } + m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) + if m == nil { + ctx.NotFound(nil) + return + } - m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) - if m == nil { - ctx.NotFound("", nil) - return - } + mirror_service.AddPushMirrorToQueue(m.ID) - m.Interval = interval - if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil { - ctx.ServerError("UpdatePushMirrorInterval", err) - return - } - // Background why we are adding it to Queue - // If we observed its implementation in the context of `push-mirror-sync` where it - // is evident that pushing to the queue is necessary for updates. - // So, there are updates within the given interval, it is necessary to update the queue accordingly. - if !ctx.FormBool("push_mirror_defer_sync") { - // push_mirror_defer_sync is mainly for testing purpose, we do not really want to sync the push mirror immediately - mirror_service.AddPushMirrorToQueue(m.ID) - } - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(repo.Link() + "/settings") + ctx.Flash.Info(ctx.Tr("repo.settings.push_mirror_sync_in_progress", m.RemoteAddress)) + ctx.Redirect(repo.Link() + "/settings") +} - case "push-mirror-remove": - if !setting.Mirror.Enabled || repo.IsArchived { - ctx.NotFound("", nil) - return - } +func handleSettingsPostPushMirrorUpdate(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository - // This section doesn't require repo_name/RepoName to be set in the form, don't show it - // as an error on the UI for this action - ctx.Data["Err_RepoName"] = nil + if !setting.Mirror.Enabled || repo.IsArchived { + ctx.NotFound(nil) + return + } - m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) - if m == nil { - ctx.NotFound("", nil) - return - } + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil - if err := mirror_service.RemovePushMirrorRemote(ctx, m); err != nil { - ctx.ServerError("RemovePushMirrorRemote", err) - return - } + interval, err := time.ParseDuration(form.PushMirrorInterval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &forms.RepoSettingForm{}) + return + } - if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { - ctx.ServerError("DeletePushMirrorByID", err) - return - } + m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) + if m == nil { + ctx.NotFound(nil) + return + } - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(repo.Link() + "/settings") + m.Interval = interval + if err := repo_model.UpdatePushMirrorInterval(ctx, m); err != nil { + ctx.ServerError("UpdatePushMirrorInterval", err) + return + } + // Background why we are adding it to Queue + // If we observed its implementation in the context of `push-mirror-sync` where it + // is evident that pushing to the queue is necessary for updates. + // So, there are updates within the given interval, it is necessary to update the queue accordingly. + if !ctx.FormBool("push_mirror_defer_sync") { + // push_mirror_defer_sync is mainly for testing purpose, we do not really want to sync the push mirror immediately + mirror_service.AddPushMirrorToQueue(m.ID) + } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") +} - case "push-mirror-add": - if setting.Mirror.DisableNewPush || repo.IsArchived { - ctx.NotFound("", nil) - return - } +func handleSettingsPostPushMirrorRemove(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository - // This section doesn't require repo_name/RepoName to be set in the form, don't show it - // as an error on the UI for this action - ctx.Data["Err_RepoName"] = nil + if !setting.Mirror.Enabled || repo.IsArchived { + ctx.NotFound(nil) + return + } - interval, err := time.ParseDuration(form.PushMirrorInterval) - if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { - ctx.Data["Err_PushMirrorInterval"] = true - ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form) - return - } + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil - address, err := git.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword) - if err == nil { - err = migrations.IsMigrateURLAllowed(address, ctx.Doer) - } - if err != nil { - ctx.Data["Err_PushMirrorAddress"] = true - handleSettingRemoteAddrError(ctx, err, form) - return - } + m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) + if m == nil { + ctx.NotFound(nil) + return + } - remoteSuffix, err := util.CryptoRandomString(10) - if err != nil { - ctx.ServerError("RandomString", err) - return - } + if err := mirror_service.RemovePushMirrorRemote(ctx, m); err != nil { + ctx.ServerError("RemovePushMirrorRemote", err) + return + } - remoteAddress, err := util.SanitizeURL(form.PushMirrorAddress) - if err != nil { - ctx.Data["Err_PushMirrorAddress"] = true - handleSettingRemoteAddrError(ctx, err, form) - return - } + if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { + ctx.ServerError("DeletePushMirrorByID", err) + return + } - m := &repo_model.PushMirror{ - RepoID: repo.ID, - Repo: repo, - RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), - SyncOnCommit: form.PushMirrorSyncOnCommit, - Interval: interval, - RemoteAddress: remoteAddress, - } - if err := db.Insert(ctx, m); err != nil { - ctx.ServerError("InsertPushMirror", err) - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") +} - if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil { - if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { - log.Error("DeletePushMirrors %v", err) - } - ctx.ServerError("AddPushMirrorRemote", err) - return - } +func handleSettingsPostPushMirrorAdd(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(repo.Link() + "/settings") + if setting.Mirror.DisableNewPush || repo.IsArchived { + ctx.NotFound(nil) + return + } - case "advanced": - var repoChanged bool - var units []repo_model.RepoUnit - var deleteUnitTypes []unit_model.Type + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil - // This section doesn't require repo_name/RepoName to be set in the form, don't show it - // as an error on the UI for this action - ctx.Data["Err_RepoName"] = nil + interval, err := time.ParseDuration(form.PushMirrorInterval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.Data["Err_PushMirrorInterval"] = true + ctx.RenderWithErr(ctx.Tr("repo.mirror_interval_invalid"), tplSettingsOptions, &form) + return + } - if repo.CloseIssuesViaCommitInAnyBranch != form.EnableCloseIssuesViaCommitInAnyBranch { - repo.CloseIssuesViaCommitInAnyBranch = form.EnableCloseIssuesViaCommitInAnyBranch - repoChanged = true - } + address, err := git.ParseRemoteAddr(form.PushMirrorAddress, form.PushMirrorUsername, form.PushMirrorPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(address, ctx.Doer) + } + if err != nil { + ctx.Data["Err_PushMirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } - if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() { - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypeCode, - EveryoneAccessMode: parseEveryoneAccessMode(form.DefaultCodeEveryoneAccess, perm.AccessModeNone, perm.AccessModeRead), - }) - } else if !unit_model.TypeCode.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeCode) - } + remoteSuffix, err := util.CryptoRandomString(10) + if err != nil { + ctx.ServerError("RandomString", err) + return + } - if form.EnableWiki && form.EnableExternalWiki && !unit_model.TypeExternalWiki.UnitGlobalDisabled() { - if !validation.IsValidExternalURL(form.ExternalWikiURL) { - ctx.Flash.Error(ctx.Tr("repo.settings.external_wiki_url_error")) - ctx.Redirect(repo.Link() + "/settings") - return - } + remoteAddress, err := util.SanitizeURL(form.PushMirrorAddress) + if err != nil { + ctx.Data["Err_PushMirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + return + } - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypeExternalWiki, - Config: &repo_model.ExternalWikiConfig{ - ExternalWikiURL: form.ExternalWikiURL, - }, - }) - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) - } else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() { - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypeWiki, - Config: new(repo_model.UnitConfig), - EveryoneAccessMode: parseEveryoneAccessMode(form.DefaultWikiEveryoneAccess, perm.AccessModeNone, perm.AccessModeRead, perm.AccessModeWrite), - }) - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) - } else { - if !unit_model.TypeExternalWiki.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) - } - if !unit_model.TypeWiki.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) - } - } + m := &repo_model.PushMirror{ + RepoID: repo.ID, + Repo: repo, + RemoteName: "remote_mirror_" + remoteSuffix, + SyncOnCommit: form.PushMirrorSyncOnCommit, + Interval: interval, + RemoteAddress: remoteAddress, + } + if err := db.Insert(ctx, m); err != nil { + ctx.ServerError("InsertPushMirror", err) + return + } - if form.DefaultWikiBranch != "" { - if err := wiki_service.ChangeDefaultWikiBranch(ctx, repo, form.DefaultWikiBranch); err != nil { - log.Error("ChangeDefaultWikiBranch failed, err: %v", err) - ctx.Flash.Warning(ctx.Tr("repo.settings.failed_to_change_default_wiki_branch")) - } + if err := mirror_service.AddPushMirrorRemote(ctx, m, address); err != nil { + if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { + log.Error("DeletePushMirrors %v", err) } + ctx.ServerError("AddPushMirrorRemote", err) + return + } - if form.EnableIssues && form.EnableExternalTracker && !unit_model.TypeExternalTracker.UnitGlobalDisabled() { - if !validation.IsValidExternalURL(form.ExternalTrackerURL) { - ctx.Flash.Error(ctx.Tr("repo.settings.external_tracker_url_error")) - ctx.Redirect(repo.Link() + "/settings") - return - } - if len(form.TrackerURLFormat) != 0 && !validation.IsValidExternalTrackerURLFormat(form.TrackerURLFormat) { - ctx.Flash.Error(ctx.Tr("repo.settings.tracker_url_format_error")) - ctx.Redirect(repo.Link() + "/settings") - return - } - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypeExternalTracker, - Config: &repo_model.ExternalTrackerConfig{ - ExternalTrackerURL: form.ExternalTrackerURL, - ExternalTrackerFormat: form.TrackerURLFormat, - ExternalTrackerStyle: form.TrackerIssueStyle, - ExternalTrackerRegexpPattern: form.ExternalTrackerRegexpPattern, - }, - }) - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) - } else if form.EnableIssues && !form.EnableExternalTracker && !unit_model.TypeIssues.UnitGlobalDisabled() { - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypeIssues, - Config: &repo_model.IssuesConfig{ - EnableTimetracker: form.EnableTimetracker, - AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, - EnableDependencies: form.EnableIssueDependencies, - }, - EveryoneAccessMode: parseEveryoneAccessMode(form.DefaultIssuesEveryoneAccess, perm.AccessModeNone, perm.AccessModeRead), - }) - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) - } else { - if !unit_model.TypeExternalTracker.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) - } - if !unit_model.TypeIssues.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) - } - } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") +} - if form.EnableProjects && !unit_model.TypeProjects.UnitGlobalDisabled() { - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypeProjects, - Config: &repo_model.ProjectsConfig{ - ProjectsMode: repo_model.ProjectsMode(form.ProjectsMode), - }, - }) - } else if !unit_model.TypeProjects.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects) +func newRepoUnit(repo *repo_model.Repository, unitType unit_model.Type, config convert.Conversion) repo_model.RepoUnit { + repoUnit := repo_model.RepoUnit{RepoID: repo.ID, Type: unitType, Config: config} + for _, u := range repo.Units { + if u.Type == unitType { + repoUnit.EveryoneAccessMode = u.EveryoneAccessMode + repoUnit.AnonymousAccessMode = u.AnonymousAccessMode } + } + return repoUnit +} - if form.EnableReleases && !unit_model.TypeReleases.UnitGlobalDisabled() { - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypeReleases, - }) - } else if !unit_model.TypeReleases.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases) - } +func handleSettingsPostAdvanced(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + var repoChanged bool + var units []repo_model.RepoUnit + var deleteUnitTypes []unit_model.Type - if form.EnablePackages && !unit_model.TypePackages.UnitGlobalDisabled() { - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypePackages, - }) - } else if !unit_model.TypePackages.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages) - } + // This section doesn't require repo_name/RepoName to be set in the form, don't show it + // as an error on the UI for this action + ctx.Data["Err_RepoName"] = nil - if form.EnableActions && !unit_model.TypeActions.UnitGlobalDisabled() { - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypeActions, - }) - } else if !unit_model.TypeActions.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions) - } + if repo.CloseIssuesViaCommitInAnyBranch != form.EnableCloseIssuesViaCommitInAnyBranch { + repo.CloseIssuesViaCommitInAnyBranch = form.EnableCloseIssuesViaCommitInAnyBranch + repoChanged = true + } - if form.EnablePulls && !unit_model.TypePullRequests.UnitGlobalDisabled() { - units = append(units, repo_model.RepoUnit{ - RepoID: repo.ID, - Type: unit_model.TypePullRequests, - Config: &repo_model.PullRequestsConfig{ - IgnoreWhitespaceConflicts: form.PullsIgnoreWhitespace, - AllowMerge: form.PullsAllowMerge, - AllowRebase: form.PullsAllowRebase, - AllowRebaseMerge: form.PullsAllowRebaseMerge, - AllowSquash: form.PullsAllowSquash, - AllowFastForwardOnly: form.PullsAllowFastForwardOnly, - AllowManualMerge: form.PullsAllowManualMerge, - AutodetectManualMerge: form.EnableAutodetectManualMerge, - AllowRebaseUpdate: form.PullsAllowRebaseUpdate, - DefaultDeleteBranchAfterMerge: form.DefaultDeleteBranchAfterMerge, - DefaultMergeStyle: repo_model.MergeStyle(form.PullsDefaultMergeStyle), - DefaultAllowMaintainerEdit: form.DefaultAllowMaintainerEdit, - }, - }) - } else if !unit_model.TypePullRequests.UnitGlobalDisabled() { - deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests) - } + if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() { + units = append(units, newRepoUnit(repo, unit_model.TypeCode, nil)) + } else if !unit_model.TypeCode.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeCode) + } - if len(units) == 0 { - ctx.Flash.Error(ctx.Tr("repo.settings.update_settings_no_unit")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + if form.EnableWiki && form.EnableExternalWiki && !unit_model.TypeExternalWiki.UnitGlobalDisabled() { + if !validation.IsValidExternalURL(form.ExternalWikiURL) { + ctx.Flash.Error(ctx.Tr("repo.settings.external_wiki_url_error")) + ctx.Redirect(repo.Link() + "/settings") return } - if err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil { - ctx.ServerError("UpdateRepositoryUnits", err) - return + units = append(units, newRepoUnit(repo, unit_model.TypeExternalWiki, &repo_model.ExternalWikiConfig{ + ExternalWikiURL: form.ExternalWikiURL, + })) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) + } else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() { + units = append(units, newRepoUnit(repo, unit_model.TypeWiki, new(repo_model.UnitConfig))) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) + } else { + if !unit_model.TypeExternalWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) } - if repoChanged { - if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { - ctx.ServerError("UpdateRepository", err) - return - } + if !unit_model.TypeWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) } - log.Trace("Repository advanced settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) - - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + } - case "signing": - changed := false - trustModel := repo_model.ToTrustModel(form.TrustModel) - if trustModel != repo.TrustModel { - repo.TrustModel = trustModel - changed = true + if form.DefaultWikiBranch != "" { + if err := wiki_service.ChangeDefaultWikiBranch(ctx, repo, form.DefaultWikiBranch); err != nil { + log.Error("ChangeDefaultWikiBranch failed, err: %v", err) + ctx.Flash.Warning(ctx.Tr("repo.settings.failed_to_change_default_wiki_branch")) } + } - if changed { - if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { - ctx.ServerError("UpdateRepository", err) - return - } + if form.EnableIssues && form.EnableExternalTracker && !unit_model.TypeExternalTracker.UnitGlobalDisabled() { + if !validation.IsValidExternalURL(form.ExternalTrackerURL) { + ctx.Flash.Error(ctx.Tr("repo.settings.external_tracker_url_error")) + ctx.Redirect(repo.Link() + "/settings") + return } - log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) - - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") - - case "admin": - if !ctx.Doer.IsAdmin { - ctx.Error(http.StatusForbidden) + if len(form.TrackerURLFormat) != 0 && !validation.IsValidExternalTrackerURLFormat(form.TrackerURLFormat) { + ctx.Flash.Error(ctx.Tr("repo.settings.tracker_url_format_error")) + ctx.Redirect(repo.Link() + "/settings") return } - - if repo.IsFsckEnabled != form.EnableHealthCheck { - repo.IsFsckEnabled = form.EnableHealthCheck + units = append(units, newRepoUnit(repo, unit_model.TypeExternalTracker, &repo_model.ExternalTrackerConfig{ + ExternalTrackerURL: form.ExternalTrackerURL, + ExternalTrackerFormat: form.TrackerURLFormat, + ExternalTrackerStyle: form.TrackerIssueStyle, + ExternalTrackerRegexpPattern: form.ExternalTrackerRegexpPattern, + })) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) + } else if form.EnableIssues && !form.EnableExternalTracker && !unit_model.TypeIssues.UnitGlobalDisabled() { + units = append(units, newRepoUnit(repo, unit_model.TypeIssues, &repo_model.IssuesConfig{ + EnableTimetracker: form.EnableTimetracker, + AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, + EnableDependencies: form.EnableIssueDependencies, + })) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) + } else { + if !unit_model.TypeExternalTracker.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) } - - if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { - ctx.ServerError("UpdateRepository", err) - return + if !unit_model.TypeIssues.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) } + } - log.Trace("Repository admin settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + if form.EnableProjects && !unit_model.TypeProjects.UnitGlobalDisabled() { + units = append(units, newRepoUnit(repo, unit_model.TypeProjects, &repo_model.ProjectsConfig{ + ProjectsMode: repo_model.ProjectsMode(form.ProjectsMode), + })) + } else if !unit_model.TypeProjects.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects) + } - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + if form.EnableReleases && !unit_model.TypeReleases.UnitGlobalDisabled() { + units = append(units, newRepoUnit(repo, unit_model.TypeReleases, nil)) + } else if !unit_model.TypeReleases.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases) + } - case "admin_index": - if !ctx.Doer.IsAdmin { - ctx.Error(http.StatusForbidden) - return - } + if form.EnablePackages && !unit_model.TypePackages.UnitGlobalDisabled() { + units = append(units, newRepoUnit(repo, unit_model.TypePackages, nil)) + } else if !unit_model.TypePackages.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages) + } - switch form.RequestReindexType { - case "stats": - if err := stats.UpdateRepoIndexer(ctx.Repo.Repository); err != nil { - ctx.ServerError("UpdateStatsRepondexer", err) - return - } - case "code": - if !setting.Indexer.RepoIndexerEnabled { - ctx.Error(http.StatusForbidden) - return - } - code.UpdateRepoIndexer(ctx.Repo.Repository) - default: - ctx.NotFound("", nil) - return - } + if form.EnableActions && !unit_model.TypeActions.UnitGlobalDisabled() { + units = append(units, newRepoUnit(repo, unit_model.TypeActions, nil)) + } else if !unit_model.TypeActions.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions) + } - log.Trace("Repository reindex for %s requested: %s/%s", form.RequestReindexType, ctx.Repo.Owner.Name, repo.Name) + if form.EnablePulls && !unit_model.TypePullRequests.UnitGlobalDisabled() { + units = append(units, newRepoUnit(repo, unit_model.TypePullRequests, &repo_model.PullRequestsConfig{ + IgnoreWhitespaceConflicts: form.PullsIgnoreWhitespace, + AllowMerge: form.PullsAllowMerge, + AllowRebase: form.PullsAllowRebase, + AllowRebaseMerge: form.PullsAllowRebaseMerge, + AllowSquash: form.PullsAllowSquash, + AllowFastForwardOnly: form.PullsAllowFastForwardOnly, + AllowManualMerge: form.PullsAllowManualMerge, + AutodetectManualMerge: form.EnableAutodetectManualMerge, + AllowRebaseUpdate: form.PullsAllowRebaseUpdate, + DefaultDeleteBranchAfterMerge: form.DefaultDeleteBranchAfterMerge, + DefaultMergeStyle: repo_model.MergeStyle(form.PullsDefaultMergeStyle), + DefaultAllowMaintainerEdit: form.DefaultAllowMaintainerEdit, + })) + } else if !unit_model.TypePullRequests.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests) + } - ctx.Flash.Success(ctx.Tr("repo.settings.reindex_requested")) + if len(units) == 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.update_settings_no_unit")) ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } - case "convert": - if !ctx.Repo.IsOwner() { - ctx.Error(http.StatusNotFound) - return - } - if repo.Name != form.RepoName { - ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + if err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil { + ctx.ServerError("UpdateRepositoryUnits", err) + return + } + if repoChanged { + if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { + ctx.ServerError("UpdateRepository", err) return } + } + log.Trace("Repository advanced settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) - if !repo.IsMirror { - ctx.Error(http.StatusNotFound) - return - } - repo.IsMirror = false + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} - if _, err := repo_service.CleanUpMigrateInfo(ctx, repo); err != nil { - ctx.ServerError("CleanUpMigrateInfo", err) - return - } else if err = repo_model.DeleteMirrorByRepoID(ctx, ctx.Repo.Repository.ID); err != nil { - ctx.ServerError("DeleteMirrorByRepoID", err) +func handleSettingsPostSigning(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + trustModel := repo_model.ToTrustModel(form.TrustModel) + if trustModel != repo.TrustModel { + repo.TrustModel = trustModel + if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "trust_model"); err != nil { + ctx.ServerError("UpdateRepositoryColsNoAutoTime", err) return } - log.Trace("Repository converted from mirror to regular: %s", repo.FullName()) - ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed")) - ctx.Redirect(repo.Link()) + log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + } - case "convert_fork": - if !ctx.Repo.IsOwner() { - ctx.Error(http.StatusNotFound) - return - } - if err := repo.LoadOwner(ctx); err != nil { - ctx.ServerError("Convert Fork", err) - return - } - if repo.Name != form.RepoName { - ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} - if !repo.IsFork { - ctx.Error(http.StatusNotFound) - return - } +func handleSettingsPostAdmin(ctx *context.Context) { + if !ctx.Doer.IsAdmin { + ctx.HTTPError(http.StatusForbidden) + return + } - if !ctx.Repo.Owner.CanCreateRepo() { - maxCreationLimit := ctx.Repo.Owner.MaxCreationLimit() - msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) - ctx.Flash.Error(msg) - ctx.Redirect(repo.Link() + "/settings") + repo := ctx.Repo.Repository + form := web.GetForm(ctx).(*forms.RepoSettingForm) + if repo.IsFsckEnabled != form.EnableHealthCheck { + repo.IsFsckEnabled = form.EnableHealthCheck + if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_fsck_enabled"); err != nil { + ctx.ServerError("UpdateRepositoryColsNoAutoTime", err) return } + log.Trace("Repository admin settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + } - if err := repo_service.ConvertForkToNormalRepository(ctx, repo); err != nil { - log.Error("Unable to convert repository %-v from fork. Error: %v", repo, err) - ctx.ServerError("Convert Fork", err) - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} - log.Trace("Repository converted from fork to regular: %s", repo.FullName()) - ctx.Flash.Success(ctx.Tr("repo.settings.convert_fork_succeed")) - ctx.Redirect(repo.Link()) +func handleSettingsPostAdminIndex(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + if !ctx.Doer.IsAdmin { + ctx.HTTPError(http.StatusForbidden) + return + } - case "transfer": - if !ctx.Repo.IsOwner() { - ctx.Error(http.StatusNotFound) + switch form.RequestReindexType { + case "stats": + if err := stats.UpdateRepoIndexer(ctx.Repo.Repository); err != nil { + ctx.ServerError("UpdateStatsRepondexer", err) return } - if repo.Name != form.RepoName { - ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + case "code": + if !setting.Indexer.RepoIndexerEnabled { + ctx.HTTPError(http.StatusForbidden) return } + code.UpdateRepoIndexer(ctx.Repo.Repository) + default: + ctx.NotFound(nil) + return + } - newOwner, err := user_model.GetUserByName(ctx, ctx.FormString("new_owner_name")) - if err != nil { - if user_model.IsErrUserNotExist(err) { - ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) - return - } - ctx.ServerError("IsUserExist", err) - return - } + log.Trace("Repository reindex for %s requested: %s/%s", form.RequestReindexType, ctx.Repo.Owner.Name, repo.Name) - if newOwner.Type == user_model.UserTypeOrganization { - if !ctx.Doer.IsAdmin && newOwner.Visibility == structs.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) { - // The user shouldn't know about this organization - ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) - return - } - } + ctx.Flash.Success(ctx.Tr("repo.settings.reindex_requested")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} - // Close the GitRepo if open - if ctx.Repo.GitRepo != nil { - ctx.Repo.GitRepo.Close() - ctx.Repo.GitRepo = nil - } +func handleSettingsPostConvert(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + if !ctx.Repo.IsOwner() { + ctx.HTTPError(http.StatusNotFound) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } - oldFullname := repo.FullName() - if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil { - if repo_model.IsErrRepoAlreadyExist(err) { - ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) - } else if repo_model.IsErrRepoTransferInProgress(err) { - ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil) - } else if errors.Is(err, user_model.ErrBlockedUser) { - ctx.RenderWithErr(ctx.Tr("repo.settings.transfer.blocked_user"), tplSettingsOptions, nil) - } else { - ctx.ServerError("TransferOwnership", err) - } + if !repo.IsMirror { + ctx.HTTPError(http.StatusNotFound) + return + } + repo.IsMirror = false - return - } + if _, err := repo_service.CleanUpMigrateInfo(ctx, repo); err != nil { + ctx.ServerError("CleanUpMigrateInfo", err) + return + } else if err = repo_model.DeleteMirrorByRepoID(ctx, ctx.Repo.Repository.ID); err != nil { + ctx.ServerError("DeleteMirrorByRepoID", err) + return + } + log.Trace("Repository converted from mirror to regular: %s", repo.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed")) + ctx.Redirect(repo.Link()) +} - if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer { - log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) - ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName())) - } else { - log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName()) - ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed")) - } +func handleSettingsPostConvertFork(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + if !ctx.Repo.IsOwner() { + ctx.HTTPError(http.StatusNotFound) + return + } + if err := repo.LoadOwner(ctx); err != nil { + ctx.ServerError("Convert Fork", err) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + if !repo.IsFork { + ctx.HTTPError(http.StatusNotFound) + return + } + + if !ctx.Doer.CanForkRepoIn(ctx.Repo.Owner) { + maxCreationLimit := ctx.Repo.Owner.MaxCreationLimit() + msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) + ctx.Flash.Error(msg) ctx.Redirect(repo.Link() + "/settings") + return + } - case "cancel_transfer": - if !ctx.Repo.IsOwner() { - ctx.Error(http.StatusNotFound) - return - } + if err := repo_service.ConvertForkToNormalRepository(ctx, repo); err != nil { + log.Error("Unable to convert repository %-v from fork. Error: %v", repo, err) + ctx.ServerError("Convert Fork", err) + return + } - repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) - if err != nil { - if repo_model.IsErrNoPendingTransfer(err) { - ctx.Flash.Error("repo.settings.transfer_abort_invalid") - ctx.Redirect(repo.Link() + "/settings") - } else { - ctx.ServerError("GetPendingRepositoryTransfer", err) - } + log.Trace("Repository converted from fork to regular: %s", repo.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.convert_fork_succeed")) + ctx.Redirect(repo.Link()) +} - return - } +func handleSettingsPostTransfer(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + if !ctx.Repo.IsOwner() { + ctx.HTTPError(http.StatusNotFound) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } - if err := repoTransfer.LoadAttributes(ctx); err != nil { - ctx.ServerError("LoadRecipient", err) + newOwner, err := user_model.GetUserByName(ctx, ctx.FormString("new_owner_name")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) return } + ctx.ServerError("IsUserExist", err) + return + } - if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { - ctx.ServerError("CancelRepositoryTransfer", err) + if newOwner.Type == user_model.UserTypeOrganization { + if !ctx.Doer.IsAdmin && newOwner.Visibility == structs.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) { + // The user shouldn't know about this organization + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) return } + } - log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name) - ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name)) - ctx.Redirect(repo.Link() + "/settings") + // Close the GitRepo if open + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } - case "delete": - if !ctx.Repo.IsOwner() { - ctx.Error(http.StatusNotFound) - return - } - if repo.Name != form.RepoName { - ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) - return + oldFullname := repo.FullName() + if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil { + if repo_model.IsErrRepoAlreadyExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) + } else if repo_model.IsErrRepoTransferInProgress(err) { + ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil) + } else if repo_service.IsRepositoryLimitReached(err) { + limit := err.(repo_service.LimitReachedError).Limit + ctx.RenderWithErr(ctx.TrN(limit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", limit), tplSettingsOptions, nil) + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.RenderWithErr(ctx.Tr("repo.settings.transfer.blocked_user"), tplSettingsOptions, nil) + } else { + ctx.ServerError("TransferOwnership", err) } - // Close the gitrepository before doing this. - if ctx.Repo.GitRepo != nil { - ctx.Repo.GitRepo.Close() - } + return + } - if err := repo_service.DeleteRepository(ctx, ctx.Doer, ctx.Repo.Repository, true); err != nil { - ctx.ServerError("DeleteRepository", err) - return - } - log.Trace("Repository deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) + if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer { + log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName())) + } else { + log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName()) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed")) + } + ctx.Redirect(repo.Link() + "/settings") +} - ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) - ctx.Redirect(ctx.Repo.Owner.DashboardLink()) +func handleSettingsPostCancelTransfer(ctx *context.Context) { + repo := ctx.Repo.Repository + if !ctx.Repo.IsOwner() { + ctx.HTTPError(http.StatusNotFound) + return + } - case "delete-wiki": - if !ctx.Repo.IsOwner() { - ctx.Error(http.StatusNotFound) - return - } - if repo.Name != form.RepoName { - ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) - return + repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) + if err != nil { + if repo_model.IsErrNoPendingTransfer(err) { + ctx.Flash.Error("repo.settings.transfer_abort_invalid") + ctx.Redirect(repo.Link() + "/settings") + } else { + ctx.ServerError("GetPendingRepositoryTransfer", err) } + return + } - err := wiki_service.DeleteWiki(ctx, repo) - if err != nil { - log.Error("Delete Wiki: %v", err.Error()) - } - log.Trace("Repository wiki deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) + if err := repo_service.CancelRepositoryTransfer(ctx, repoTransfer, ctx.Doer); err != nil { + ctx.ServerError("CancelRepositoryTransfer", err) + return + } - ctx.Flash.Success(ctx.Tr("repo.settings.wiki_deletion_success")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name)) + ctx.Redirect(repo.Link() + "/settings") +} - case "archive": - if !ctx.Repo.IsOwner() { - ctx.Error(http.StatusForbidden) - return - } +func handleSettingsPostDelete(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + if !ctx.Repo.IsOwner() { + ctx.HTTPError(http.StatusNotFound) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } - if repo.IsMirror { - ctx.Flash.Error(ctx.Tr("repo.settings.archive.error_ismirror")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") - return - } + // Close the gitrepository before doing this. + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + } - if err := repo_model.SetArchiveRepoState(ctx, repo, true); err != nil { - log.Error("Tried to archive a repo: %s", err) - ctx.Flash.Error(ctx.Tr("repo.settings.archive.error")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") - return - } + if err := repo_service.DeleteRepository(ctx, ctx.Doer, ctx.Repo.Repository, true); err != nil { + ctx.ServerError("DeleteRepository", err) + return + } + log.Trace("Repository deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) - if err := actions_model.CleanRepoScheduleTasks(ctx, repo); err != nil { - log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) - } + ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) + ctx.Redirect(ctx.Repo.Owner.DashboardLink()) +} + +func handleSettingsPostDeleteWiki(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + if !ctx.Repo.IsOwner() { + ctx.HTTPError(http.StatusNotFound) + return + } + if repo.Name != form.RepoName { + ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + return + } + + err := wiki_service.DeleteWiki(ctx, repo) + if err != nil { + log.Error("Delete Wiki: %v", err.Error()) + } + log.Trace("Repository wiki deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) - // update issue indexer - issue_indexer.UpdateRepoIndexer(ctx, repo.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.wiki_deletion_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} - ctx.Flash.Success(ctx.Tr("repo.settings.archive.success")) +func handleSettingsPostArchive(ctx *context.Context) { + repo := ctx.Repo.Repository + if !ctx.Repo.IsOwner() { + ctx.HTTPError(http.StatusForbidden) + return + } - log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + if repo.IsMirror { + ctx.Flash.Error(ctx.Tr("repo.settings.archive.error_ismirror")) ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } - case "unarchive": - if !ctx.Repo.IsOwner() { - ctx.Error(http.StatusForbidden) - return - } + if err := repo_model.SetArchiveRepoState(ctx, repo, true); err != nil { + log.Error("Tried to archive a repo: %s", err) + ctx.Flash.Error(ctx.Tr("repo.settings.archive.error")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } - if err := repo_model.SetArchiveRepoState(ctx, repo, false); err != nil { - log.Error("Tried to unarchive a repo: %s", err) - ctx.Flash.Error(ctx.Tr("repo.settings.unarchive.error")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") - return - } + if err := actions_service.CleanRepoScheduleTasks(ctx, repo); err != nil { + log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } - if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) { - if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil { - log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) - } - } + // update issue indexer + issue_indexer.UpdateRepoIndexer(ctx, repo.ID) - // update issue indexer - issue_indexer.UpdateRepoIndexer(ctx, repo.ID) + ctx.Flash.Success(ctx.Tr("repo.settings.archive.success")) - ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success")) + log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} + +func handleSettingsPostUnarchive(ctx *context.Context) { + repo := ctx.Repo.Repository + if !ctx.Repo.IsOwner() { + ctx.HTTPError(http.StatusForbidden) + return + } - log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + if err := repo_model.SetArchiveRepoState(ctx, repo, false); err != nil { + log.Error("Tried to unarchive a repo: %s", err) + ctx.Flash.Error(ctx.Tr("repo.settings.unarchive.error")) ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } - case "visibility": - if repo.IsFork { - ctx.Flash.Error(ctx.Tr("repo.settings.visibility.fork_error")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") - return + if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) { + if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil { + log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) } + } - var err error + // update issue indexer + issue_indexer.UpdateRepoIndexer(ctx, repo.ID) - // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public - if setting.Repository.ForcePrivate && repo.IsPrivate && !ctx.Doer.IsAdmin { - ctx.RenderWithErr(ctx.Tr("form.repository_force_private"), tplSettingsOptions, form) - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success")) - if repo.IsPrivate { - err = repo_service.MakeRepoPublic(ctx, repo) - } else { - err = repo_service.MakeRepoPrivate(ctx, repo) - } + log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} - if err != nil { - log.Error("Tried to change the visibility of the repo: %s", err) - ctx.Flash.Error(ctx.Tr("repo.settings.visibility.error")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") - return - } +func handleSettingsPostVisibility(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository + if repo.IsFork { + ctx.Flash.Error(ctx.Tr("repo.settings.visibility.fork_error")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } - ctx.Flash.Success(ctx.Tr("repo.settings.visibility.success")) + var err error - log.Trace("Repository visibility changed: %s/%s", ctx.Repo.Owner.Name, repo.Name) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public + if setting.Repository.ForcePrivate && repo.IsPrivate && !ctx.Doer.IsAdmin { + ctx.RenderWithErr(ctx.Tr("form.repository_force_private"), tplSettingsOptions, form) + return + } - default: - ctx.NotFound("", nil) + if repo.IsPrivate { + err = repo_service.MakeRepoPublic(ctx, repo) + } else { + err = repo_service.MakeRepoPrivate(ctx, repo) + } + + if err != nil { + log.Error("Tried to change the visibility of the repo: %s", err) + ctx.Flash.Error(ctx.Tr("repo.settings.visibility.error")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return } + + ctx.Flash.Success(ctx.Tr("repo.settings.visibility.success")) + + log.Trace("Repository visibility changed: %s/%s", ctx.Repo.Owner.Name, repo.Name) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") } func handleSettingRemoteAddrError(ctx *context.Context, err error, form *forms.RepoSettingForm) { diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index 09586cc68d..15ebea888c 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/contexttest" @@ -24,23 +25,8 @@ import ( "github.com/stretchr/testify/assert" ) -func createSSHAuthorizedKeysTmpPath(t *testing.T) func() { - tmpDir := t.TempDir() - - oldPath := setting.SSH.RootPath - setting.SSH.RootPath = tmpDir - - return func() { - setting.SSH.RootPath = oldPath - } -} - func TestAddReadOnlyDeployKey(t *testing.T) { - if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil { - defer deferable() - } else { - return - } + defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())() unittest.PrepareTestEnv(t) ctx, _ := contexttest.MockContext(t, "user2/repo1/settings/keys") @@ -54,7 +40,7 @@ func TestAddReadOnlyDeployKey(t *testing.T) { } web.SetForm(ctx, &addKeyForm) DeployKeysPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ Name: addKeyForm.Title, @@ -64,11 +50,7 @@ func TestAddReadOnlyDeployKey(t *testing.T) { } func TestAddReadWriteOnlyDeployKey(t *testing.T) { - if deferable := createSSHAuthorizedKeysTmpPath(t); deferable != nil { - defer deferable() - } else { - return - } + defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())() unittest.PrepareTestEnv(t) @@ -84,7 +66,7 @@ func TestAddReadWriteOnlyDeployKey(t *testing.T) { } web.SetForm(ctx, &addKeyForm) DeployKeysPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{ Name: addKeyForm.Title, @@ -121,7 +103,7 @@ func TestCollaborationPost(t *testing.T) { CollaborationPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) exists, err := repo_model.IsCollaborator(ctx, re.ID, 4) assert.NoError(t, err) @@ -147,7 +129,7 @@ func TestCollaborationPost_InactiveUser(t *testing.T) { CollaborationPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assert.NotEmpty(t, ctx.Flash.ErrorMsg) } @@ -179,7 +161,7 @@ func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) { CollaborationPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) exists, err := repo_model.IsCollaborator(ctx, re.ID, 4) assert.NoError(t, err) @@ -188,7 +170,7 @@ func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) { // Try adding the same collaborator again CollaborationPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assert.NotEmpty(t, ctx.Flash.ErrorMsg) } @@ -210,7 +192,7 @@ func TestCollaborationPost_NonExistentUser(t *testing.T) { CollaborationPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assert.NotEmpty(t, ctx.Flash.ErrorMsg) } @@ -250,7 +232,7 @@ func TestAddTeamPost(t *testing.T) { AddTeamPost(ctx) assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assert.Empty(t, ctx.Flash.ErrorMsg) } @@ -290,7 +272,7 @@ func TestAddTeamPost_NotAllowed(t *testing.T) { AddTeamPost(ctx) assert.False(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assert.NotEmpty(t, ctx.Flash.ErrorMsg) } @@ -331,7 +313,7 @@ func TestAddTeamPost_AddTeamTwice(t *testing.T) { AddTeamPost(ctx) assert.True(t, repo_service.HasRepository(db.DefaultContext, team, re.ID)) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assert.NotEmpty(t, ctx.Flash.ErrorMsg) } @@ -364,7 +346,7 @@ func TestAddTeamPost_NonExistentTeam(t *testing.T) { ctx.Repo = repo AddTeamPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assert.NotEmpty(t, ctx.Flash.ErrorMsg) } diff --git a/routers/web/repo/setting/variables.go b/routers/web/repo/setting/variables.go deleted file mode 100644 index 9b5453f043..0000000000 --- a/routers/web/repo/setting/variables.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package setting - -import ( - "errors" - "net/http" - - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" - shared "code.gitea.io/gitea/routers/web/shared/actions" - shared_user "code.gitea.io/gitea/routers/web/shared/user" - "code.gitea.io/gitea/services/context" -) - -const ( - tplRepoVariables templates.TplName = "repo/settings/actions" - tplOrgVariables templates.TplName = "org/settings/actions" - tplUserVariables templates.TplName = "user/settings/actions" - tplAdminVariables templates.TplName = "admin/actions" -) - -type variablesCtx struct { - OwnerID int64 - RepoID int64 - IsRepo bool - IsOrg bool - IsUser bool - IsGlobal bool - VariablesTemplate templates.TplName - RedirectLink string -} - -func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) { - if ctx.Data["PageIsRepoSettings"] == true { - return &variablesCtx{ - OwnerID: 0, - RepoID: ctx.Repo.Repository.ID, - IsRepo: true, - VariablesTemplate: tplRepoVariables, - RedirectLink: ctx.Repo.RepoLink + "/settings/actions/variables", - }, nil - } - - if ctx.Data["PageIsOrgSettings"] == true { - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) - return nil, nil - } - return &variablesCtx{ - OwnerID: ctx.ContextUser.ID, - RepoID: 0, - IsOrg: true, - VariablesTemplate: tplOrgVariables, - RedirectLink: ctx.Org.OrgLink + "/settings/actions/variables", - }, nil - } - - if ctx.Data["PageIsUserSettings"] == true { - return &variablesCtx{ - OwnerID: ctx.Doer.ID, - RepoID: 0, - IsUser: true, - VariablesTemplate: tplUserVariables, - RedirectLink: setting.AppSubURL + "/user/settings/actions/variables", - }, nil - } - - if ctx.Data["PageIsAdmin"] == true { - return &variablesCtx{ - OwnerID: 0, - RepoID: 0, - IsGlobal: true, - VariablesTemplate: tplAdminVariables, - RedirectLink: setting.AppSubURL + "/-/admin/actions/variables", - }, nil - } - - return nil, errors.New("unable to set Variables context") -} - -func Variables(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("actions.variables") - ctx.Data["PageType"] = "variables" - ctx.Data["PageIsSharedSettingsVariables"] = true - - vCtx, err := getVariablesCtx(ctx) - if err != nil { - ctx.ServerError("getVariablesCtx", err) - return - } - - shared.SetVariablesContext(ctx, vCtx.OwnerID, vCtx.RepoID) - if ctx.Written() { - return - } - - ctx.HTML(http.StatusOK, vCtx.VariablesTemplate) -} - -func VariableCreate(ctx *context.Context) { - vCtx, err := getVariablesCtx(ctx) - if err != nil { - ctx.ServerError("getVariablesCtx", err) - return - } - - if ctx.HasError() { // form binding validation error - ctx.JSONError(ctx.GetErrMsg()) - return - } - - shared.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, vCtx.RedirectLink) -} - -func VariableUpdate(ctx *context.Context) { - vCtx, err := getVariablesCtx(ctx) - if err != nil { - ctx.ServerError("getVariablesCtx", err) - return - } - - if ctx.HasError() { // form binding validation error - ctx.JSONError(ctx.GetErrMsg()) - return - } - - shared.UpdateVariable(ctx, vCtx.RedirectLink) -} - -func VariableDelete(ctx *context.Context) { - vCtx, err := getVariablesCtx(ctx) - if err != nil { - ctx.ServerError("getVariablesCtx", err) - return - } - shared.DeleteVariable(ctx, vCtx.RedirectLink) -} diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index 1b0ba83af4..006abafe57 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -112,7 +112,7 @@ func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { func checkHookType(ctx *context.Context) string { hookType := strings.ToLower(ctx.PathParam("type")) if !util.SliceContainsString(setting.Webhook.Types, hookType, true) { - ctx.NotFound("checkHookType", nil) + ctx.NotFound(nil) return "" } return hookType @@ -163,27 +163,30 @@ func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent { SendEverything: form.SendEverything(), ChooseEvents: form.ChooseEvents(), HookEvents: webhook_module.HookEvents{ - Create: form.Create, - Delete: form.Delete, - Fork: form.Fork, - Issues: form.Issues, - IssueAssign: form.IssueAssign, - IssueLabel: form.IssueLabel, - IssueMilestone: form.IssueMilestone, - IssueComment: form.IssueComment, - Release: form.Release, - Push: form.Push, - PullRequest: form.PullRequest, - PullRequestAssign: form.PullRequestAssign, - PullRequestLabel: form.PullRequestLabel, - PullRequestMilestone: form.PullRequestMilestone, - PullRequestComment: form.PullRequestComment, - PullRequestReview: form.PullRequestReview, - PullRequestSync: form.PullRequestSync, - PullRequestReviewRequest: form.PullRequestReviewRequest, - Wiki: form.Wiki, - Repository: form.Repository, - Package: form.Package, + webhook_module.HookEventCreate: form.Create, + webhook_module.HookEventDelete: form.Delete, + webhook_module.HookEventFork: form.Fork, + webhook_module.HookEventIssues: form.Issues, + webhook_module.HookEventIssueAssign: form.IssueAssign, + webhook_module.HookEventIssueLabel: form.IssueLabel, + webhook_module.HookEventIssueMilestone: form.IssueMilestone, + webhook_module.HookEventIssueComment: form.IssueComment, + webhook_module.HookEventRelease: form.Release, + webhook_module.HookEventPush: form.Push, + webhook_module.HookEventPullRequest: form.PullRequest, + webhook_module.HookEventPullRequestAssign: form.PullRequestAssign, + webhook_module.HookEventPullRequestLabel: form.PullRequestLabel, + webhook_module.HookEventPullRequestMilestone: form.PullRequestMilestone, + webhook_module.HookEventPullRequestComment: form.PullRequestComment, + webhook_module.HookEventPullRequestReview: form.PullRequestReview, + webhook_module.HookEventPullRequestSync: form.PullRequestSync, + webhook_module.HookEventPullRequestReviewRequest: form.PullRequestReviewRequest, + webhook_module.HookEventWiki: form.Wiki, + webhook_module.HookEventRepository: form.Repository, + webhook_module.HookEventPackage: form.Package, + webhook_module.HookEventStatus: form.Status, + webhook_module.HookEventWorkflowRun: form.WorkflowRun, + webhook_module.HookEventWorkflowJob: form.WorkflowJob, }, BranchFilter: form.BranchFilter, } @@ -600,7 +603,7 @@ func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) { } if err != nil || w == nil { if webhook.IsErrWebhookNotExist(err) { - ctx.NotFound("GetWebhookByID", nil) + ctx.NotFound(nil) } else { ctx.ServerError("GetWebhookByID", err) } @@ -654,6 +657,8 @@ func TestWebhook(ctx *context.Context) { } // Grab latest commit or fake one if it's empty repository. + // Note: in old code, the "ctx.Repo.Commit" is the last commit of the default branch. + // New code doesn't set that commit, so it always uses the fake commit to test webhook. commit := ctx.Repo.Commit if commit == nil { ghost := user_model.NewGhostUser() @@ -715,7 +720,7 @@ func ReplayWebhook(ctx *context.Context) { if err := webhook_service.ReplayHookTask(ctx, w, hookTaskUUID); err != nil { if webhook.IsErrHookTaskNotExist(err) { - ctx.NotFound("ReplayHookTask", nil) + ctx.NotFound(nil) } else { ctx.ServerError("ReplayHookTask", err) } diff --git a/routers/web/repo/star.go b/routers/web/repo/star.go new file mode 100644 index 0000000000..00c06b7d02 --- /dev/null +++ b/routers/web/repo/star.go @@ -0,0 +1,31 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" +) + +const tplStarUnstar templates.TplName = "repo/star_unstar" + +func ActionStar(ctx *context.Context) { + err := repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, ctx.PathParam("action") == "star") + if err != nil { + handleActionError(ctx, err) + return + } + + ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) + if err != nil { + ctx.ServerError("GetRepositoryByName", err) + return + } + ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl + ctx.HTML(http.StatusOK, tplStarUnstar) +} diff --git a/routers/web/repo/transfer.go b/routers/web/repo/transfer.go new file mode 100644 index 0000000000..5553eee674 --- /dev/null +++ b/routers/web/repo/transfer.go @@ -0,0 +1,38 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +func acceptTransfer(ctx *context.Context) { + err := repo_service.AcceptTransferOwnership(ctx, ctx.Repo.Repository, ctx.Doer) + if err == nil { + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) + ctx.Redirect(ctx.Repo.Repository.Link()) + return + } + handleActionError(ctx, err) +} + +func rejectTransfer(ctx *context.Context) { + err := repo_service.RejectRepositoryTransfer(ctx, ctx.Repo.Repository, ctx.Doer) + if err == nil { + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) + ctx.Redirect(ctx.Repo.Repository.Link()) + return + } + handleActionError(ctx, err) +} + +func ActionTransfer(ctx *context.Context) { + switch ctx.PathParam("action") { + case "accept_transfer": + acceptTransfer(ctx) + case "reject_transfer": + rejectTransfer(ctx) + } +} diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go index d11af4669f..7d7f5a1473 100644 --- a/routers/web/repo/treelist.go +++ b/routers/web/repo/treelist.go @@ -4,11 +4,18 @@ package repo import ( + "html/template" "net/http" + "path" + "strings" + pull_model "code.gitea.io/gitea/models/pull" "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/gitdiff" + files_service "code.gitea.io/gitea/services/repository/files" "github.com/go-enry/go-enry/v2" ) @@ -52,3 +59,95 @@ func isExcludedEntry(entry *git.TreeEntry) bool { return false } + +// WebDiffFileItem is used by frontend, check the field names in frontend before changing +type WebDiffFileItem struct { + FullName string + DisplayName string + NameHash string + DiffStatus string + EntryMode string + IsViewed bool + Children []*WebDiffFileItem + FileIcon template.HTML +} + +// WebDiffFileTree is used by frontend, check the field names in frontend before changing +type WebDiffFileTree struct { + TreeRoot WebDiffFileItem +} + +// transformDiffTreeForWeb transforms a gitdiff.DiffTree into a WebDiffFileTree for Web UI rendering +// it also takes a map of file names to their viewed state, which is used to mark files as viewed +func transformDiffTreeForWeb(renderedIconPool *fileicon.RenderedIconPool, diffTree *gitdiff.DiffTree, filesViewedState map[string]pull_model.ViewedState) (dft WebDiffFileTree) { + dirNodes := map[string]*WebDiffFileItem{"": &dft.TreeRoot} + addItem := func(item *WebDiffFileItem) { + var parentPath string + pos := strings.LastIndexByte(item.FullName, '/') + if pos == -1 { + item.DisplayName = item.FullName + } else { + parentPath = item.FullName[:pos] + item.DisplayName = item.FullName[pos+1:] + } + parentNode, parentExists := dirNodes[parentPath] + if !parentExists { + parentNode = &dft.TreeRoot + fields := strings.Split(parentPath, "/") + for idx, field := range fields { + nodePath := strings.Join(fields[:idx+1], "/") + node, ok := dirNodes[nodePath] + if !ok { + node = &WebDiffFileItem{EntryMode: "tree", DisplayName: field, FullName: nodePath} + dirNodes[nodePath] = node + parentNode.Children = append(parentNode.Children, node) + } + parentNode = node + } + } + parentNode.Children = append(parentNode.Children, item) + } + + for _, file := range diffTree.Files { + item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status} + item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed + item.NameHash = git.HashFilePathForWebUI(item.FullName) + item.FileIcon = fileicon.RenderEntryIconHTML(renderedIconPool, &fileicon.EntryInfo{BaseName: path.Base(file.HeadPath), EntryMode: file.HeadMode}) + + switch file.HeadMode { + case git.EntryModeTree: + item.EntryMode = "tree" + case git.EntryModeCommit: + item.EntryMode = "commit" // submodule + default: + // default to empty, and will be treated as "blob" file because there is no "symlink" support yet + } + addItem(item) + } + + var mergeSingleDir func(node *WebDiffFileItem) + mergeSingleDir = func(node *WebDiffFileItem) { + if len(node.Children) == 1 { + if child := node.Children[0]; child.EntryMode == "tree" { + node.FullName = child.FullName + node.DisplayName = node.DisplayName + "/" + child.DisplayName + node.Children = child.Children + mergeSingleDir(node) + } + } + } + for _, node := range dft.TreeRoot.Children { + mergeSingleDir(node) + } + return dft +} + +func TreeViewNodes(ctx *context.Context) { + renderedIconPool := fileicon.NewRenderedIconPool() + results, err := files_service.GetTreeViewNodes(ctx, renderedIconPool, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormString("sub_path")) + if err != nil { + ctx.ServerError("GetTreeViewNodes", err) + return + } + ctx.JSON(http.StatusOK, map[string]any{"fileTreeNodes": results, "renderedIconPool": renderedIconPool.IconSVGs}) +} diff --git a/routers/web/repo/treelist_test.go b/routers/web/repo/treelist_test.go new file mode 100644 index 0000000000..94ba60661b --- /dev/null +++ b/routers/web/repo/treelist_test.go @@ -0,0 +1,68 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "html/template" + "testing" + + pull_model "code.gitea.io/gitea/models/pull" + "code.gitea.io/gitea/modules/fileicon" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/gitdiff" + + "github.com/stretchr/testify/assert" +) + +func TestTransformDiffTreeForWeb(t *testing.T) { + renderedIconPool := fileicon.NewRenderedIconPool() + ret := transformDiffTreeForWeb(renderedIconPool, &gitdiff.DiffTree{Files: []*gitdiff.DiffTreeRecord{ + { + Status: "changed", + HeadPath: "dir-a/dir-a-x/file-deep", + HeadMode: git.EntryModeBlob, + }, + { + Status: "added", + HeadPath: "file1", + HeadMode: git.EntryModeBlob, + }, + }}, map[string]pull_model.ViewedState{ + "dir-a/dir-a-x/file-deep": pull_model.Viewed, + }) + + mockIconForFile := func(id string) template.HTML { + return template.HTML(`<svg class="svg git-entry-icon octicon-file" width="16" height="16" aria-hidden="true"><use xlink:href="#` + id + `"></use></svg>`) + } + assert.Equal(t, WebDiffFileTree{ + TreeRoot: WebDiffFileItem{ + Children: []*WebDiffFileItem{ + { + EntryMode: "tree", + DisplayName: "dir-a/dir-a-x", + FullName: "dir-a/dir-a-x", + Children: []*WebDiffFileItem{ + { + EntryMode: "", + DisplayName: "file-deep", + FullName: "dir-a/dir-a-x/file-deep", + NameHash: "4acf7eef1c943a09e9f754e93ff190db8583236b", + DiffStatus: "changed", + IsViewed: true, + FileIcon: mockIconForFile(`svg-mfi-file`), + }, + }, + }, + { + EntryMode: "", + DisplayName: "file1", + FullName: "file1", + NameHash: "60b27f004e454aca81b0480209cce5081ec52390", + DiffStatus: "added", + FileIcon: mockIconForFile(`svg-mfi-file`), + }, + }, + }, + }, ret) +} diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 05ecf2ab79..773919c054 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "net/url" + "path" "strings" "time" @@ -29,6 +30,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/charset" + "code.gitea.io/gitea/modules/fileicon" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" @@ -38,6 +40,7 @@ import ( "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" + asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" @@ -46,69 +49,74 @@ import ( ) const ( - tplRepoEMPTY templates.TplName = "repo/empty" - tplRepoHome templates.TplName = "repo/home" - tplRepoViewList templates.TplName = "repo/view_list" - tplWatchers templates.TplName = "repo/watchers" - tplForks templates.TplName = "repo/forks" - tplMigrating templates.TplName = "repo/migrate/migrating" + tplRepoEMPTY templates.TplName = "repo/empty" + tplRepoHome templates.TplName = "repo/home" + tplRepoView templates.TplName = "repo/view" + tplRepoViewContent templates.TplName = "repo/view_content" + tplRepoViewList templates.TplName = "repo/view_list" + tplWatchers templates.TplName = "repo/watchers" + tplForks templates.TplName = "repo/forks" + tplMigrating templates.TplName = "repo/migrate/migrating" ) type fileInfo struct { - isTextFile bool - isLFSFile bool - fileSize int64 - lfsMeta *lfs.Pointer - st typesniffer.SniffedType + fileSize int64 + lfsMeta *lfs.Pointer + st typesniffer.SniffedType } -func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) ([]byte, io.ReadCloser, *fileInfo, error) { - dataRc, err := blob.DataAsync() +func (fi *fileInfo) isLFSFile() bool { + return fi.lfsMeta != nil && fi.lfsMeta.Oid != "" +} + +func getFileReader(ctx gocontext.Context, repoID int64, blob *git.Blob) (buf []byte, dataRc io.ReadCloser, fi *fileInfo, err error) { + dataRc, err = blob.DataAsync() if err != nil { return nil, nil, nil, err } - buf := make([]byte, 1024) + const prefetchSize = lfs.MetaFileMaxSize + + buf = make([]byte, prefetchSize) n, _ := util.ReadAtMost(dataRc, buf) buf = buf[:n] - st := typesniffer.DetectContentType(buf) - isTextFile := st.IsText() + fi = &fileInfo{fileSize: blob.Size(), st: typesniffer.DetectContentType(buf)} // FIXME: what happens when README file is an image? - if !isTextFile || !setting.LFS.StartServer { - return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + if !fi.st.IsText() || !setting.LFS.StartServer { + return buf, dataRc, fi, nil } pointer, _ := lfs.ReadPointerFromBuffer(buf) - if !pointer.IsValid() { // fallback to plain file - return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + if !pointer.IsValid() { // fallback to a plain file + return buf, dataRc, fi, nil } meta, err := git_model.GetLFSMetaObjectByOid(ctx, repoID, pointer.Oid) - if err != nil { // fallback to plain file + if err != nil { // fallback to a plain file log.Warn("Unable to access LFS pointer %s in repo %d: %v", pointer.Oid, repoID, err) - return buf, dataRc, &fileInfo{isTextFile, false, blob.Size(), nil, st}, nil + return buf, dataRc, fi, nil } - dataRc.Close() - + // close the old dataRc and open the real LFS target + _ = dataRc.Close() dataRc, err = lfs.ReadMetaObject(pointer) if err != nil { return nil, nil, nil, err } - buf = make([]byte, 1024) + buf = make([]byte, prefetchSize) n, err = util.ReadAtMost(dataRc, buf) if err != nil { - dataRc.Close() - return nil, nil, nil, err + _ = dataRc.Close() + return nil, nil, fi, err } buf = buf[:n] - - st = typesniffer.DetectContentType(buf) - - return buf, dataRc, &fileInfo{st.IsText(), true, meta.Size, &meta.Pointer, st}, nil + fi.st = typesniffer.DetectContentType(buf) + fi.fileSize = blob.Size() + fi.lfsMeta = &meta.Pointer + return buf, dataRc, fi, nil } func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { @@ -116,7 +124,7 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { // or of directory if not in root directory. ctx.Data["LatestCommit"] = latestCommit if latestCommit != nil { - verification := asymkey_model.ParseCommitWithSignature(ctx, latestCommit) + verification := asymkey_service.ParseCommitWithSignature(ctx, latestCommit) if err := asymkey_model.CalculateTrustStatus(verification, ctx.Repo.Repository.GetTrustModel(), func(user *user_model.User) (bool, error) { return repo_model.IsOwnerMemberCollaborator(ctx, ctx.Repo.Repository, user.ID) @@ -127,7 +135,7 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { ctx.Data["LatestCommitVerification"] = verification ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) - statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll) + statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll) if err != nil { log.Error("GetLatestCommitStatus: %v", err) } @@ -215,7 +223,7 @@ func checkHomeCodeViewable(ctx *context.Context) { } } - ctx.NotFound("Home", errors.New(ctx.Locale.TrString("units.error.no_unit_allowed_repo"))) + ctx.NotFound(errors.New(ctx.Locale.TrString("units.error.no_unit_allowed_repo"))) } // LastCommit returns lastCommit data for the provided branch/tag/commit and directory (in url) and filenames in body @@ -249,6 +257,19 @@ func LastCommit(ctx *context.Context) { ctx.HTML(http.StatusOK, tplRepoViewList) } +func prepareDirectoryFileIcons(ctx *context.Context, files []git.CommitInfo) { + renderedIconPool := fileicon.NewRenderedIconPool() + fileIcons := map[string]template.HTML{} + for _, f := range files { + fullPath := path.Join(ctx.Repo.TreePath, f.Entry.Name()) + entryInfo := fileicon.EntryInfoFromGitTreeEntry(ctx.Repo.Commit, fullPath, f.Entry) + fileIcons[f.Entry.Name()] = fileicon.RenderEntryIconHTML(renderedIconPool, entryInfo) + } + fileIcons[".."] = fileicon.RenderEntryIconHTML(renderedIconPool, fileicon.EntryInfoFolder()) + ctx.Data["FileIcons"] = fileIcons + ctx.Data["FileIconPoolHTML"] = renderedIconPool.RenderToHTML() +} + func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entries { tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath) if err != nil { @@ -290,6 +311,7 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri return nil } ctx.Data["Files"] = files + prepareDirectoryFileIcons(ctx, files) for _, f := range files { if f.Commit == nil { ctx.Data["HasFilesWithoutLatestCommit"] = true @@ -378,9 +400,10 @@ func Forks(ctx *context.Context) { } pager := context.NewPagination(int(total), pageSize, page, 5) + ctx.Data["ShowRepoOwnerAvatar"] = true + ctx.Data["ShowRepoOwnerOnList"] = true ctx.Data["Page"] = pager - - ctx.Data["Forks"] = forks + ctx.Data["Repos"] = forks ctx.HTML(http.StatusOK, tplForks) } diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 5784297918..2d5bddd939 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -9,7 +9,6 @@ import ( "image" "io" "path" - "slices" "strings" git_model "code.gitea.io/gitea/models/git" @@ -19,44 +18,165 @@ import ( "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/git/attribute" "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" - files_service "code.gitea.io/gitea/services/repository/files" "github.com/nektos/act/pkg/model" ) -func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { - ctx.Data["IsViewFile"] = true - ctx.Data["HideRepoInfo"] = true - blob := entry.Blob() - buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob) +func prepareLatestCommitInfo(ctx *context.Context) bool { + commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath) if err != nil { - ctx.ServerError("getFileReader", err) - return + ctx.ServerError("GetCommitByPath", err) + return false } - defer dataRc.Close() - ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefFullName.ShortName()) - ctx.Data["FileIsSymlink"] = entry.IsLink() - ctx.Data["FileName"] = blob.Name() - ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + return loadLatestCommitData(ctx, commit) +} - commit, err := ctx.Repo.Commit.GetCommitByPath(ctx.Repo.TreePath) +func prepareFileViewLfsAttrs(ctx *context.Context) (*attribute.Attributes, bool) { + attrsMap, err := attribute.CheckAttributes(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, attribute.CheckAttributeOpts{ + Filenames: []string{ctx.Repo.TreePath}, + Attributes: []string{attribute.LinguistGenerated, attribute.LinguistVendored, attribute.LinguistLanguage, attribute.GitlabLanguage}, + }) if err != nil { - ctx.ServerError("GetCommitByPath", err) - return + ctx.ServerError("attribute.CheckAttributes", err) + return nil, false + } + attrs := attrsMap[ctx.Repo.TreePath] + if attrs == nil { + // this case shouldn't happen, just in case. + setting.PanicInDevOrTesting("no attributes found for %s", ctx.Repo.TreePath) + attrs = attribute.NewAttributes() + } + ctx.Data["IsVendored"], ctx.Data["IsGenerated"] = attrs.GetVendored().Value(), attrs.GetGenerated().Value() + return attrs, true +} + +func handleFileViewRenderMarkup(ctx *context.Context, filename string, sniffedType typesniffer.SniffedType, prefetchBuf []byte, utf8Reader io.Reader) bool { + markupType := markup.DetectMarkupTypeByFileName(filename) + if markupType == "" { + markupType = markup.DetectRendererType(filename, sniffedType, prefetchBuf) + } + if markupType == "" { + return false + } + + ctx.Data["HasSourceRenderedToggle"] = true + + if ctx.FormString("display") == "source" { + return false + } + + ctx.Data["MarkupType"] = markupType + metas := ctx.Repo.Repository.ComposeRepoFileMetas(ctx) + metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL() + rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ + CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), + CurrentTreePath: path.Dir(ctx.Repo.TreePath), + }). + WithMarkupType(markupType). + WithRelativePath(ctx.Repo.TreePath). + WithMetas(metas) + + var err error + ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, utf8Reader) + if err != nil { + ctx.ServerError("Render", err) + return true + } + // to prevent iframe from loading third-party url + ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") + return true +} + +func handleFileViewRenderSource(ctx *context.Context, filename string, attrs *attribute.Attributes, fInfo *fileInfo, utf8Reader io.Reader) bool { + if ctx.FormString("display") == "rendered" || !fInfo.st.IsRepresentableAsText() { + return false + } + + if !fInfo.st.IsText() { + if ctx.FormString("display") == "" { + // not text but representable as text, e.g. SVG + // since there is no "display" is specified, let other renders to handle + return false + } + ctx.Data["HasSourceRenderedToggle"] = true + } + + buf, _ := io.ReadAll(utf8Reader) + // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html + // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; + // Gitea uses the definition (like most modern editors): + // empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines; + // When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL. + // To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines. + // This NumLines is only used for the display on the UI: "xxx lines" + if len(buf) == 0 { + ctx.Data["NumLines"] = 0 + } else { + ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1 + } + + language := attrs.GetLanguage().Value() + fileContent, lexerName, err := highlight.File(filename, language, buf) + ctx.Data["LexerName"] = lexerName + if err != nil { + log.Error("highlight.File failed, fallback to plain text: %v", err) + fileContent = highlight.PlainText(buf) + } + status := &charset.EscapeStatus{} + statuses := make([]*charset.EscapeStatus, len(fileContent)) + for i, line := range fileContent { + statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale) + status = status.Or(statuses[i]) } + ctx.Data["EscapeStatus"] = status + ctx.Data["FileContent"] = fileContent + ctx.Data["LineEscapeStatus"] = statuses + return true +} + +func handleFileViewRenderImage(ctx *context.Context, fInfo *fileInfo, prefetchBuf []byte) bool { + if !fInfo.st.IsImage() { + return false + } + if fInfo.st.IsSvgImage() && !setting.UI.SVG.Enabled { + return false + } + if fInfo.st.IsSvgImage() { + ctx.Data["HasSourceRenderedToggle"] = true + } else { + img, _, err := image.DecodeConfig(bytes.NewReader(prefetchBuf)) + if err == nil { // ignore the error for the formats that are not supported by image.DecodeConfig + ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) + } + } + return true +} - if !loadLatestCommitData(ctx, commit) { +func prepareFileView(ctx *context.Context, entry *git.TreeEntry) { + ctx.Data["IsViewFile"] = true + ctx.Data["HideRepoInfo"] = true + + if !prepareLatestCommitInfo(ctx) { return } + blob := entry.Blob() + + ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefFullName.ShortName()) + ctx.Data["FileIsSymlink"] = entry.IsLink() + ctx.Data["FileTreePath"] = ctx.Repo.TreePath + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + if ctx.Repo.TreePath == ".editorconfig" { _, editorconfigWarning, editorconfigErr := ctx.Repo.GetEditorconfig(ctx.Repo.Commit) if editorconfigWarning != nil { @@ -79,7 +199,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { if workFlowErr != nil { ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error()) } - } else if slices.Contains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) { + } else if issue_service.IsCodeOwnerFile(ctx.Repo.TreePath) { if data, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize); err == nil { _, warnings := issue_model.GetCodeOwnersFromContent(ctx, data) if len(warnings) > 0 { @@ -88,226 +208,103 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) { } } - isDisplayingSource := ctx.FormString("display") == "source" - isDisplayingRendered := !isDisplayingSource + // Don't call any other repository functions depends on git.Repository until the dataRc closed to + // avoid creating an unnecessary temporary cat file. + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, blob) + if err != nil { + ctx.ServerError("getFileReader", err) + return + } + defer dataRc.Close() - if fInfo.isLFSFile { + if fInfo.isLFSFile() { ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/media/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) } - isRepresentableAsText := fInfo.st.IsRepresentableAsText() - if !isRepresentableAsText { - // If we can't show plain text, always try to render. - isDisplayingSource = false - isDisplayingRendered = true + if !prepareFileViewEditorButtons(ctx) { + return } - ctx.Data["IsLFSFile"] = fInfo.isLFSFile + + ctx.Data["IsLFSFile"] = fInfo.isLFSFile() ctx.Data["FileSize"] = fInfo.fileSize - ctx.Data["IsTextFile"] = fInfo.isTextFile - ctx.Data["IsRepresentableAsText"] = isRepresentableAsText - ctx.Data["IsDisplayingSource"] = isDisplayingSource - ctx.Data["IsDisplayingRendered"] = isDisplayingRendered + ctx.Data["IsRepresentableAsText"] = fInfo.st.IsRepresentableAsText() ctx.Data["IsExecutable"] = entry.IsExecutable() + ctx.Data["CanCopyContent"] = fInfo.st.IsRepresentableAsText() || fInfo.st.IsImage() - isTextSource := fInfo.isTextFile || isDisplayingSource - ctx.Data["IsTextSource"] = isTextSource - if isTextSource { - ctx.Data["CanCopyContent"] = true - } - - // Check LFS Lock - lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) - ctx.Data["LFSLock"] = lfsLock - if err != nil { - ctx.ServerError("GetTreePathLock", err) + attrs, ok := prepareFileViewLfsAttrs(ctx) + if !ok { return } - if lfsLock != nil { - u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID) - if err != nil { - ctx.ServerError("GetTreePathLock", err) - return - } - ctx.Data["LFSLockOwner"] = u.Name - ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink() - ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") - } - // Assume file is not editable first. - if fInfo.isLFSFile { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_lfs_files") - } else if !isRepresentableAsText { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.cannot_edit_non_text_files") - } + // TODO: in the future maybe we need more accurate flags, for example: + // * IsRepresentableAsText: some files are text, some are not + // * IsRenderableXxx: some files are rendered by backend "markup" engine, some are rendered by frontend (pdf, 3d) + // * DefaultViewMode: when there is no "display" query parameter, which view mode should be used by default, source or rendered + utf8Reader := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) switch { - case isRepresentableAsText: - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } - - if fInfo.st.IsSvgImage() { - ctx.Data["IsImageFile"] = true - ctx.Data["CanCopyContent"] = true - ctx.Data["HasSourceRenderedToggle"] = true - } - - rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{}) - - shouldRenderSource := ctx.FormString("display") == "source" - readmeExist := util.IsReadmeFileName(blob.Name()) - ctx.Data["ReadmeExist"] = readmeExist - - markupType := markup.DetectMarkupTypeByFileName(blob.Name()) - if markupType == "" { - markupType = markup.DetectRendererType(blob.Name(), bytes.NewReader(buf)) - } - if markupType != "" { - ctx.Data["HasSourceRenderedToggle"] = true - } - if markupType != "" && !shouldRenderSource { - ctx.Data["IsMarkup"] = true - ctx.Data["MarkupType"] = markupType - metas := ctx.Repo.Repository.ComposeDocumentMetas(ctx) - metas["RefTypeNameSubURL"] = ctx.Repo.RefTypeNameSubURL() - rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), - }). - WithMarkupType(markupType). - WithRelativePath(ctx.Repo.TreePath). - WithMetas(metas) - - ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) - if err != nil { - ctx.ServerError("Render", err) - return - } - // to prevent iframe load third-party url - ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'") - } else { - buf, _ := io.ReadAll(rd) - - // The Open Group Base Specification: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html - // empty: 0 lines; "a": 1 incomplete-line; "a\n": 1 line; "a\nb": 1 line, 1 incomplete-line; - // Gitea uses the definition (like most modern editors): - // empty: 0 lines; "a": 1 line; "a\n": 2 lines; "a\nb": 2 lines; - // When rendering, the last empty line is not rendered in UI, while the line-number is still counted, to tell users that the file contains a trailing EOL. - // To make the UI more consistent, it could use an icon mark to indicate that there is no trailing EOL, and show line-number as the rendered lines. - // This NumLines is only used for the display on the UI: "xxx lines" - if len(buf) == 0 { - ctx.Data["NumLines"] = 0 - } else { - ctx.Data["NumLines"] = bytes.Count(buf, []byte{'\n'}) + 1 - } - - language, err := files_service.TryGetContentLanguage(ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath) - if err != nil { - log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err) - } - - fileContent, lexerName, err := highlight.File(blob.Name(), language, buf) - ctx.Data["LexerName"] = lexerName - if err != nil { - log.Error("highlight.File failed, fallback to plain text: %v", err) - fileContent = highlight.PlainText(buf) - } - status := &charset.EscapeStatus{} - statuses := make([]*charset.EscapeStatus, len(fileContent)) - for i, line := range fileContent { - statuses[i], fileContent[i] = charset.EscapeControlHTML(line, ctx.Locale) - status = status.Or(statuses[i]) - } - ctx.Data["EscapeStatus"] = status - ctx.Data["FileContent"] = fileContent - ctx.Data["LineEscapeStatus"] = statuses - } - if !fInfo.isLFSFile { - if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { - if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { - ctx.Data["CanEditFile"] = false - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") - } else { - ctx.Data["CanEditFile"] = true - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.edit_this_file") - } - } else if !ctx.Repo.RefFullName.IsBranch() { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") - } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { - ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") - } - } - - case fInfo.st.IsPDF(): - ctx.Data["IsPDFFile"] = true + case fInfo.fileSize >= setting.UI.MaxDisplayFileSize: + ctx.Data["IsFileTooLarge"] = true + case handleFileViewRenderMarkup(ctx, entry.Name(), fInfo.st, buf, utf8Reader): + // it also sets ctx.Data["FileContent"] and more + ctx.Data["IsMarkup"] = true + case handleFileViewRenderSource(ctx, entry.Name(), attrs, fInfo, utf8Reader): + // it also sets ctx.Data["FileContent"] and more + ctx.Data["IsDisplayingSource"] = true + case handleFileViewRenderImage(ctx, fInfo, buf): + ctx.Data["IsImageFile"] = true case fInfo.st.IsVideo(): ctx.Data["IsVideoFile"] = true case fInfo.st.IsAudio(): ctx.Data["IsAudioFile"] = true - case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()): - ctx.Data["IsImageFile"] = true - ctx.Data["CanCopyContent"] = true default: - if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { - ctx.Data["IsFileTooLarge"] = true - break - } + // unable to render anything, show the "view raw" or let frontend handle it + } +} - // TODO: this logic duplicates with "isRepresentableAsText=true", it is not the same as "LFSFileGet" in "lfs.go" - // It is used by "external renders", markupRender will execute external programs to get rendered content. - if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType != "" { - rd := io.MultiReader(bytes.NewReader(buf), dataRc) - ctx.Data["IsMarkup"] = true - ctx.Data["MarkupType"] = markupType - - rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ - CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Dir(ctx.Repo.TreePath), - }). - WithMarkupType(markupType). - WithRelativePath(ctx.Repo.TreePath) - - ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) - if err != nil { - ctx.ServerError("Render", err) - return - } - } +func prepareFileViewEditorButtons(ctx *context.Context) bool { + // archived or mirror repository, the buttons should not be shown + if !ctx.Repo.Repository.CanEnableEditor() { + return true } - if ctx.Repo.GitRepo != nil { - checker, deferable := ctx.Repo.GitRepo.CheckAttributeReader(ctx.Repo.CommitID) - if checker != nil { - defer deferable() - attrs, err := checker.CheckPath(ctx.Repo.TreePath) - if err == nil { - ctx.Data["IsVendored"] = git.AttributeToBool(attrs, git.AttributeLinguistVendored).Value() - ctx.Data["IsGenerated"] = git.AttributeToBool(attrs, git.AttributeLinguistGenerated).Value() - } - } + // The buttons should not be shown if it's not a branch + if !ctx.Repo.RefFullName.IsBranch() { + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") + return true } - if fInfo.st.IsImage() && !fInfo.st.IsSvgImage() { - img, _, err := image.DecodeConfig(bytes.NewReader(buf)) - if err == nil { - // There are Image formats go can't decode - // Instead of throwing an error in that case, we show the size only when we can decode - ctx.Data["ImageSize"] = fmt.Sprintf("%dx%dpx", img.Width, img.Height) - } + if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + ctx.Data["CanEditFile"] = true + ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") + ctx.Data["CanDeleteFile"] = true + ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") + return true } - if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { - if lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID { - ctx.Data["CanDeleteFile"] = false - ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.this_file_locked") - } else { - ctx.Data["CanDeleteFile"] = true - ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.delete_this_file") + lfsLock, err := git_model.GetTreePathLock(ctx, ctx.Repo.Repository.ID, ctx.Repo.TreePath) + ctx.Data["LFSLock"] = lfsLock + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return false + } + if lfsLock != nil { + u, err := user_model.GetUserByID(ctx, lfsLock.OwnerID) + if err != nil { + ctx.ServerError("GetTreePathLock", err) + return false } - } else if !ctx.Repo.RefFullName.IsBranch() { - ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_be_on_a_branch") - } else if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { - ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") + ctx.Data["LFSLockOwner"] = u.Name + ctx.Data["LFSLockOwnerHomeLink"] = u.HomeLink() + ctx.Data["LFSLockHint"] = ctx.Tr("repo.editor.this_file_locked") } + + // it's a lfs file and the user is not the owner of the lock + isLFSLocked := lfsLock != nil && lfsLock.OwnerID != ctx.Doer.ID + ctx.Data["CanEditFile"] = !isLFSLocked + ctx.Data["EditFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.edit_this_file")) + ctx.Data["CanDeleteFile"] = !isLFSLocked + ctx.Data["DeleteFileTooltip"] = util.Iif(isLFSLocked, ctx.Tr("repo.editor.this_file_locked"), ctx.Tr("repo.editor.delete_this_file")) + return true } diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index d141df181c..c7396d44e3 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -9,6 +9,7 @@ import ( "html/template" "net/http" "path" + "strconv" "strings" "time" @@ -17,7 +18,11 @@ import ( 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/git" + giturl "code.gitea.io/gitea/modules/git/url" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" @@ -74,7 +79,7 @@ func prepareOpenWithEditorApps(ctx *context.Context) { schema, _, _ := strings.Cut(app.OpenURL, ":") var iconHTML template.HTML if schema == "vscode" || schema == "vscodium" || schema == "jetbrains" { - iconHTML = svg.RenderHTML(fmt.Sprintf("gitea-%s", schema), 16) + iconHTML = svg.RenderHTML("gitea-"+schema, 16) } else { iconHTML = svg.RenderHTML("gitea-git", 16) // TODO: it could support user's customized icon in the future } @@ -138,7 +143,7 @@ func prepareToRenderDirectory(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+path.Base(ctx.Repo.TreePath), ctx.Repo.RefFullName.ShortName()) } - subfolder, readmeFile, err := findReadmeFileInEntries(ctx, entries, true) + subfolder, readmeFile, err := findReadmeFileInEntries(ctx, ctx.Repo.TreePath, entries, true) if err != nil { ctx.ServerError("findReadmeFileInEntries", err) return @@ -257,6 +262,10 @@ func updateContextRepoEmptyAndStatus(ctx *context.Context, empty bool, status re func handleRepoEmptyOrBroken(ctx *context.Context) { showEmpty := true + if ctx.Repo.GitRepo == nil { + // in case the repo really exists and works, but the status was incorrectly marked as "broken", we need to open and check it again + ctx.Repo.GitRepo, _ = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository) + } if ctx.Repo.GitRepo != nil { reallyEmpty, err := ctx.Repo.GitRepo.IsEmpty() if err != nil { @@ -267,7 +276,7 @@ func handleRepoEmptyOrBroken(ctx *context.Context) { } else if reallyEmpty { showEmpty = true // the repo is really empty updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryReady) - } else if branches, _, _ := ctx.Repo.GitRepo.GetBranches(0, 1); len(branches) == 0 { + } else if branches, _, _ := ctx.Repo.GitRepo.GetBranchNames(0, 1); len(branches) == 0 { showEmpty = true // it is not really empty, but there is no branch // at the moment, other repo units like "actions" are not able to handle such case, // so we just mark the repo as empty to prevent from displaying these units. @@ -300,31 +309,94 @@ func handleRepoEmptyOrBroken(ctx *context.Context) { ctx.Redirect(link) } +func handleRepoViewSubmodule(ctx *context.Context, submodule *git.SubModule) { + submoduleRepoURL, err := giturl.ParseRepositoryURL(ctx, submodule.URL) + if err != nil { + HandleGitError(ctx, "prepareToRenderDirOrFile: ParseRepositoryURL", err) + return + } + submoduleURL := giturl.MakeRepositoryWebLink(submoduleRepoURL) + if httplib.IsCurrentGiteaSiteURL(ctx, submoduleURL) { + ctx.RedirectToCurrentSite(submoduleURL) + } else { + // don't auto-redirect to external URL, to avoid open redirect or phishing + ctx.Data["NotFoundPrompt"] = submoduleURL + ctx.NotFound(nil) + } +} + func prepareToRenderDirOrFile(entry *git.TreeEntry) func(ctx *context.Context) { return func(ctx *context.Context) { + if entry.IsSubModule() { + submodule, err := ctx.Repo.Commit.GetSubModule(entry.Name()) + if err != nil { + HandleGitError(ctx, "prepareToRenderDirOrFile: GetSubModule", err) + return + } + handleRepoViewSubmodule(ctx, submodule) + return + } if entry.IsDir() { prepareToRenderDirectory(ctx) } else { - prepareToRenderFile(ctx, entry) + prepareFileView(ctx, entry) } } } func handleRepoHomeFeed(ctx *context.Context) bool { - if setting.Other.EnableFeed { - isFeed, _, showFeedType := feed.GetFeedType(ctx.PathParam("reponame"), ctx.Req) - if isFeed { - switch { - case ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType): - feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType) - case ctx.Repo.TreePath == "": - feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType) - case ctx.Repo.TreePath != "": - feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType) - } - return true + if !setting.Other.EnableFeed { + return false + } + isFeed, showFeedType := feed.GetFeedType(ctx.PathParam("reponame"), ctx.Req) + if !isFeed { + return false + } + if ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType) { + feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType) + } else if ctx.Repo.TreePath == "" { + feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType) + } else { + feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType) + } + return true +} + +func prepareHomeTreeSideBarSwitch(ctx *context.Context) { + showFileTree := true + if ctx.Doer != nil { + v, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyCodeViewShowFileTree, "true") + if err != nil { + log.Error("GetUserSetting: %v", err) + } else { + showFileTree, _ = strconv.ParseBool(v) } } + ctx.Data["UserSettingCodeViewShowFileTree"] = showFileTree +} + +func redirectSrcToRaw(ctx *context.Context) bool { + // GitHub redirects a tree path with "?raw=1" to the raw path + // It is useful to embed some raw contents into Markdown files, + // then viewing the Markdown in "src" path could embed the raw content correctly. + if ctx.Repo.TreePath != "" && ctx.FormBool("raw") { + ctx.Redirect(ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)) + return true + } + return false +} + +func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) bool { + if ctx.Repo.TreePath == "" || !ctx.FormBool("follow_symlink") { + return false + } + if treePathEntry.IsLink() { + if res, err := git.EntryFollowLinks(ctx.Repo.Commit, ctx.Repo.TreePath, treePathEntry); err == nil { + redirect := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(res.TargetFullPath) + "?" + ctx.Req.URL.RawQuery + ctx.Redirect(redirect) + return true + } // else: don't handle the links we cannot resolve, so ignore the error + } return false } @@ -333,6 +405,9 @@ func Home(ctx *context.Context) { if handleRepoHomeFeed(ctx) { return } + if redirectSrcToRaw(ctx) { + return + } // Check whether the repo is viewable: not in migration, and the code unit should be enabled // Ideally the "feed" logic should be after this, but old code did so, so keep it as-is. @@ -342,7 +417,7 @@ func Home(ctx *context.Context) { } title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name - if len(ctx.Repo.Repository.Description) > 0 { + if ctx.Repo.Repository.Description != "" { title += ": " + ctx.Repo.Repository.Description } ctx.Data["Title"] = title @@ -355,6 +430,8 @@ func Home(ctx *context.Context) { return } + prepareHomeTreeSideBarSwitch(ctx) + // get the current git entry which doer user is currently looking at. entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) if err != nil { @@ -362,6 +439,10 @@ func Home(ctx *context.Context) { return } + if redirectFollowSymlink(ctx, entry) { + return + } + // prepare the tree path var treeNames, paths []string branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() @@ -410,5 +491,39 @@ func Home(ctx *context.Context) { } } - ctx.HTML(http.StatusOK, tplRepoHome) + if ctx.FormBool("only_content") { + ctx.HTML(http.StatusOK, tplRepoViewContent) + } else if len(treeNames) != 0 { + ctx.HTML(http.StatusOK, tplRepoView) + } else { + ctx.HTML(http.StatusOK, tplRepoHome) + } +} + +func RedirectRepoTreeToSrc(ctx *context.Context) { + // Redirect "/owner/repo/tree/*" requests to "/owner/repo/src/*", + // then use the deprecated "/src/*" handler to guess the ref type and render a file list page. + // This is done intentionally so that Gitea's repo URL structure matches other forges (GitHub/GitLab) provide, + // allowing us to construct submodule URLs across forges easily. + // For example, when viewing a submodule, we can simply construct the link as: + // * "https://gitea/owner/repo/tree/{CommitID}" + // * "https://github/owner/repo/tree/{CommitID}" + // * "https://gitlab/owner/repo/tree/{CommitID}" + // Then no matter which forge the submodule is using, the link works. + redirect := ctx.Repo.RepoLink + "/src/" + ctx.PathParamRaw("*") + if ctx.Req.URL.RawQuery != "" { + redirect += "?" + ctx.Req.URL.RawQuery + } + ctx.Redirect(redirect) +} + +func RedirectRepoBlobToCommit(ctx *context.Context) { + // redirect "/owner/repo/blob/*" requests to "/owner/repo/src/commit/*" + // just like GitHub: browse files of a commit by "https://github/owner/repo/blob/{CommitID}" + // TODO: maybe we could guess more types to redirect to the related pages in the future + redirect := ctx.Repo.RepoLink + "/src/commit/" + ctx.PathParamRaw("*") + if ctx.Req.URL.RawQuery != "" { + redirect += "?" + ctx.Req.URL.RawQuery + } + ctx.Redirect(redirect) } diff --git a/routers/web/repo/view_home_test.go b/routers/web/repo/view_home_test.go new file mode 100644 index 0000000000..6264dba71c --- /dev/null +++ b/routers/web/repo/view_home_test.go @@ -0,0 +1,32 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/unittest" + git_module "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestViewHomeSubmoduleRedirect(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockContext(t, "/user2/repo1/src/branch/master/test-submodule") + submodule := &git_module.SubModule{Path: "test-submodule", URL: setting.AppURL + "user2/repo-other.git"} + handleRepoViewSubmodule(ctx, submodule) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) + assert.Equal(t, "/user2/repo-other", ctx.Resp.Header().Get("Location")) + + ctx, _ = contexttest.MockContext(t, "/user2/repo1/src/branch/master/test-submodule") + submodule = &git_module.SubModule{Path: "test-submodule", URL: "https://other/user2/repo-other.git"} + handleRepoViewSubmodule(ctx, submodule) + // do not auto-redirect for external URLs, to avoid open redirect or phishing + assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus()) +} diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index 48befe47f8..ba03febff3 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -32,15 +32,7 @@ import ( // entries == ctx.Repo.Commit.SubTree(ctx.Repo.TreePath).ListEntries() // // FIXME: There has to be a more efficient way of doing this -func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) { - // Create a list of extensions in priority order - // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md - // 2. Txt files - e.g. README.txt - // 3. No extension - e.g. README - exts := append(localizedExtensions(".md", ctx.Locale.Language()), ".txt", "") // sorted by priority - extCount := len(exts) - readmeFiles := make([]*git.TreeEntry, extCount+1) - +func findReadmeFileInEntries(ctx *context.Context, parentDir string, entries []*git.TreeEntry, tryWellKnownDirs bool) (string, *git.TreeEntry, error) { docsEntries := make([]*git.TreeEntry, 3) // (one of docs/, .gitea/ or .github/) for _, entry := range entries { if tryWellKnownDirs && entry.IsDir() { @@ -62,16 +54,23 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try docsEntries[2] = entry } } - continue } + } + + // Create a list of extensions in priority order + // 1. Markdown files - with and without localisation - e.g. README.en-us.md or README.md + // 2. Txt files - e.g. README.txt + // 3. No extension - e.g. README + exts := append(localizedExtensions(".md", ctx.Locale.Language()), ".txt", "") // sorted by priority + extCount := len(exts) + readmeFiles := make([]*git.TreeEntry, extCount+1) + for _, entry := range entries { if i, ok := util.IsReadmeFileExtension(entry.Name(), exts...); ok { - log.Debug("Potential readme file: %s", entry.Name()) + fullPath := path.Join(parentDir, entry.Name()) if readmeFiles[i] == nil || base.NaturalSortLess(readmeFiles[i].Name(), entry.Blob().Name()) { if entry.IsLink() { - target, err := entry.FollowLinks() - if err != nil && !git.IsErrBadLink(err) { - return "", nil, err - } else if target != nil && (target.IsExecutable() || target.IsRegular()) { + res, err := git.EntryFollowLinks(ctx.Repo.Commit, fullPath, entry) + if err == nil && (res.TargetEntry.IsExecutable() || res.TargetEntry.IsRegular()) { readmeFiles[i] = entry } } else { @@ -80,6 +79,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try } } } + var readmeFile *git.TreeEntry for _, f := range readmeFiles { if f != nil { @@ -103,7 +103,7 @@ func findReadmeFileInEntries(ctx *context.Context, entries []*git.TreeEntry, try return "", nil, err } - subfolder, readmeFile, err := findReadmeFileInEntries(ctx, childEntries, false) + subfolder, readmeFile, err := findReadmeFileInEntries(ctx, parentDir, childEntries, false) if err != nil && !git.IsErrNotExist(err) { return "", nil, err } @@ -139,46 +139,52 @@ func localizedExtensions(ext, languageCode string) (localizedExts []string) { } func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFile *git.TreeEntry) { - target := readmeFile - if readmeFile != nil && readmeFile.IsLink() { - target, _ = readmeFile.FollowLinks() - } - if target == nil { - // if findReadmeFile() failed and/or gave us a broken symlink (which it shouldn't) - // simply skip rendering the README + if readmeFile == nil { return } + readmeFullPath := path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name()) + readmeTargetEntry := readmeFile + if readmeFile.IsLink() { + if res, err := git.EntryFollowLinks(ctx.Repo.Commit, readmeFullPath, readmeFile); err == nil { + readmeTargetEntry = res.TargetEntry + } else { + readmeTargetEntry = nil // if we cannot resolve the symlink, we cannot render the readme, ignore the error + } + } + if readmeTargetEntry == nil { + return // if no valid README entry found, skip rendering the README + } + ctx.Data["RawFileLink"] = "" - ctx.Data["ReadmeInList"] = true + ctx.Data["ReadmeInList"] = path.Join(subfolder, readmeFile.Name()) // the relative path to the readme file to the current tree path ctx.Data["ReadmeExist"] = true ctx.Data["FileIsSymlink"] = readmeFile.IsLink() - buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, target.Blob()) + buf, dataRc, fInfo, err := getFileReader(ctx, ctx.Repo.Repository.ID, readmeTargetEntry.Blob()) if err != nil { ctx.ServerError("getFileReader", err) return } defer dataRc.Close() - ctx.Data["FileIsText"] = fInfo.isTextFile - ctx.Data["FileName"] = path.Join(subfolder, readmeFile.Name()) + ctx.Data["FileIsText"] = fInfo.st.IsText() + ctx.Data["FileTreePath"] = readmeFullPath ctx.Data["FileSize"] = fInfo.fileSize - ctx.Data["IsLFSFile"] = fInfo.isLFSFile + ctx.Data["IsLFSFile"] = fInfo.isLFSFile() - if fInfo.isLFSFile { + if fInfo.isLFSFile() { filenameBase64 := base64.RawURLEncoding.EncodeToString([]byte(readmeFile.Name())) ctx.Data["RawFileLink"] = fmt.Sprintf("%s.git/info/lfs/objects/%s/%s", ctx.Repo.Repository.Link(), url.PathEscape(fInfo.lfsMeta.Oid), url.PathEscape(filenameBase64)) } - if !fInfo.isTextFile { + if !fInfo.st.IsText() { return } if fInfo.fileSize >= setting.UI.MaxDisplayFileSize { // Pretend that this is a normal text file to display 'This file is too large to be shown' ctx.Data["IsFileTooLarge"] = true - ctx.Data["IsTextFile"] = true return } @@ -190,10 +196,10 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil rctx := renderhelper.NewRenderContextRepoFile(ctx, ctx.Repo.Repository, renderhelper.RepoFileOptions{ CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), - CurrentTreePath: path.Join(ctx.Repo.TreePath, subfolder), + CurrentTreePath: path.Dir(readmeFullPath), }). WithMarkupType(markupType). - WithRelativePath(path.Join(ctx.Repo.TreePath, subfolder, readmeFile.Name())) // ctx.Repo.TreePath is the directory not the Readme so we must append the Readme filename (and path). + WithRelativePath(readmeFullPath) ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRender(ctx, rctx, rd) if err != nil { @@ -212,7 +218,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale) } - if !fInfo.isLFSFile && ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { + if !fInfo.isLFSFile() && ctx.Repo.Repository.CanEnableEditor() { ctx.Data["CanEditReadmeFile"] = true } } diff --git a/routers/web/repo/watch.go b/routers/web/repo/watch.go new file mode 100644 index 0000000000..70c548b8ce --- /dev/null +++ b/routers/web/repo/watch.go @@ -0,0 +1,31 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" +) + +const tplWatchUnwatch templates.TplName = "repo/watch_unwatch" + +func ActionWatch(ctx *context.Context) { + err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, ctx.PathParam("action") == "watch") + if err != nil { + handleActionError(ctx, err) + return + } + + ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name) + if err != nil { + ctx.ServerError("GetRepositoryByName", err) + return + } + ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl + ctx.HTML(http.StatusOK, tplWatchUnwatch) +} diff --git a/routers/web/repo/webgit.go b/routers/web/repo/webgit.go new file mode 100644 index 0000000000..5f390197e7 --- /dev/null +++ b/routers/web/repo/webgit.go @@ -0,0 +1,40 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + 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/util" + "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" +) + +func WebGitOperationCommonData(ctx *context.Context) { + // TODO: more places like "wiki page" and "merging a pull request or creating an auto merge merging task" + emails, err := user_model.GetActivatedEmailAddresses(ctx, ctx.Doer.ID) + if err != nil { + log.Error("WebGitOperationCommonData: GetActivatedEmailAddresses: %v", err) + } + if ctx.Doer.KeepEmailPrivate { + emails = append([]string{ctx.Doer.GetPlaceholderEmail()}, emails...) + } + ctx.Data["CommitCandidateEmails"] = emails + ctx.Data["CommitDefaultEmail"] = ctx.Doer.GetEmail() +} + +func WebGitOperationGetCommitChosenEmailIdentity(ctx *context.Context, email string) (_ *files_service.IdentityOptions, valid bool) { + if ctx.Data["CommitCandidateEmails"] == nil { + setting.PanicInDevOrTesting("no CommitCandidateEmails in context data") + } + emails, _ := ctx.Data["CommitCandidateEmails"].([]string) + if email == "" { + return nil, true + } + if util.SliceContainsString(emails, email, true) { + return &files_service.IdentityOptions{GitUserEmail: email}, true + } + return nil, false +} diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index 19366c0104..69858c9692 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -7,14 +7,13 @@ package repo import ( "bytes" gocontext "context" - "fmt" + "html/template" "io" "net/http" "net/url" "path/filepath" "strings" - git_model "code.gitea.io/gitea/models/git" "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" @@ -33,6 +32,7 @@ import ( "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" + git_service "code.gitea.io/gitea/services/git" notify_service "code.gitea.io/gitea/services/notify" wiki_service "code.gitea.io/gitea/services/wiki" ) @@ -58,13 +58,13 @@ func MustEnableWiki(ctx *context.Context) { ctx.Repo.Repository, ctx.Repo.Permission) } - ctx.NotFound("MustEnableWiki", nil) + ctx.NotFound(nil) return } - unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalWiki) + repoUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalWiki) if err == nil { - ctx.Redirect(unit.ExternalWikiConfig().ExternalWikiURL) + ctx.Redirect(repoUnit.ExternalWikiConfig().ExternalWikiURL) return } } @@ -96,7 +96,7 @@ func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) } func findWikiRepoCommit(ctx *context.Context) (*git.Repository, *git.Commit, error) { - wikiGitRepo, errGitRepo := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + wikiGitRepo, errGitRepo := gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository.WikiStorageRepo()) if errGitRepo != nil { ctx.ServerError("OpenRepository", errGitRepo) return nil, nil, errGitRepo @@ -105,12 +105,12 @@ func findWikiRepoCommit(ctx *context.Context) (*git.Repository, *git.Commit, err commit, errCommit := wikiGitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultWikiBranch) if git.IsErrNotExist(errCommit) { // if the default branch recorded in database is out of sync, then re-sync it - gitRepoDefaultBranch, errBranch := gitrepo.GetWikiDefaultBranch(ctx, ctx.Repo.Repository) + gitRepoDefaultBranch, errBranch := gitrepo.GetDefaultBranch(ctx, ctx.Repo.Repository.WikiStorageRepo()) if errBranch != nil { return wikiGitRepo, nil, errBranch } // update the default branch in the database - errDb := repo_model.UpdateRepositoryCols(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, DefaultWikiBranch: gitRepoDefaultBranch}, "default_wiki_branch") + errDb := repo_model.UpdateRepositoryColsNoAutoTime(ctx, &repo_model.Repository{ID: ctx.Repo.Repository.ID, DefaultWikiBranch: gitRepoDefaultBranch}, "default_wiki_branch") if errDb != nil { return wikiGitRepo, nil, errDb } @@ -179,23 +179,17 @@ func wikiContentsByName(ctx *context.Context, commit *git.Commit, wikiName wiki_ } func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { - wikiRepo, commit, err := findWikiRepoCommit(ctx) + wikiGitRepo, commit, err := findWikiRepoCommit(ctx) if err != nil { - if wikiRepo != nil { - wikiRepo.Close() - } if !git.IsErrNotExist(err) { ctx.ServerError("GetBranchCommit", err) } return nil, nil } - // Get page list. + // get the wiki pages list. entries, err := commit.ListEntries() if err != nil { - if wikiRepo != nil { - wikiRepo.Close() - } ctx.ServerError("ListEntries", err) return nil, nil } @@ -209,9 +203,6 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { if repo_model.IsErrWikiInvalidFileName(err) { continue } - if wikiRepo != nil { - wikiRepo.Close() - } ctx.ServerError("WikiFilenameToName", err) return nil, nil } else if wikiName == "_Sidebar" || wikiName == "_Footer" { @@ -250,58 +241,26 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { ctx.Redirect(util.URLJoin(ctx.Repo.RepoLink, "wiki/raw", string(pageName))) } if entry == nil || ctx.Written() { - if wikiRepo != nil { - wikiRepo.Close() - } return nil, nil } - // get filecontent + // get page content data := wikiContentsByEntry(ctx, entry) if ctx.Written() { - if wikiRepo != nil { - wikiRepo.Close() - } return nil, nil } - var sidebarContent []byte - if !isSideBar { - sidebarContent, _, _, _ = wikiContentsByName(ctx, commit, "_Sidebar") - if ctx.Written() { - if wikiRepo != nil { - wikiRepo.Close() - } - return nil, nil - } - } else { - sidebarContent = data - } - - var footerContent []byte - if !isFooter { - footerContent, _, _, _ = wikiContentsByName(ctx, commit, "_Footer") - if ctx.Written() { - if wikiRepo != nil { - wikiRepo.Close() - } - return nil, nil - } - } else { - footerContent = data - } - rctx := renderhelper.NewRenderContextRepoWiki(ctx, ctx.Repo.Repository) - buf := &strings.Builder{} - renderFn := func(data []byte) (escaped *charset.EscapeStatus, output string, err error) { + renderFn := func(data []byte) (escaped *charset.EscapeStatus, output template.HTML, err error) { + buf := &strings.Builder{} markupRd, markupWr := io.Pipe() defer markupWr.Close() done := make(chan struct{}) go func() { // We allow NBSP here this is rendered escaped, _ = charset.EscapeControlReader(markupRd, buf, ctx.Locale, charset.RuneNBSP) - output = buf.String() + output = template.HTML(buf.String()) buf.Reset() close(done) }() @@ -312,75 +271,61 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { return escaped, output, err } - ctx.Data["EscapeStatus"], ctx.Data["content"], err = renderFn(data) + ctx.Data["EscapeStatus"], ctx.Data["WikiContentHTML"], err = renderFn(data) if err != nil { - if wikiRepo != nil { - wikiRepo.Close() - } ctx.ServerError("Render", err) return nil, nil } if rctx.SidebarTocNode != nil { - sb := &strings.Builder{} - err = markdown.SpecializedMarkdown(rctx).Renderer().Render(sb, nil, rctx.SidebarTocNode) - if err != nil { + sb := strings.Builder{} + if err = markdown.SpecializedMarkdown(rctx).Renderer().Render(&sb, nil, rctx.SidebarTocNode); err != nil { log.Error("Failed to render wiki sidebar TOC: %v", err) - } else { - ctx.Data["sidebarTocContent"] = sb.String() } + ctx.Data["WikiSidebarTocHTML"] = templates.SanitizeHTML(sb.String()) } if !isSideBar { - buf.Reset() - ctx.Data["sidebarEscapeStatus"], ctx.Data["sidebarContent"], err = renderFn(sidebarContent) + sidebarContent, _, _, _ := wikiContentsByName(ctx, commit, "_Sidebar") + if ctx.Written() { + return nil, nil + } + ctx.Data["WikiSidebarEscapeStatus"], ctx.Data["WikiSidebarHTML"], err = renderFn(sidebarContent) if err != nil { - if wikiRepo != nil { - wikiRepo.Close() - } ctx.ServerError("Render", err) return nil, nil } - ctx.Data["sidebarPresent"] = sidebarContent != nil - } else { - ctx.Data["sidebarPresent"] = false } if !isFooter { - buf.Reset() - ctx.Data["footerEscapeStatus"], ctx.Data["footerContent"], err = renderFn(footerContent) + footerContent, _, _, _ := wikiContentsByName(ctx, commit, "_Footer") + if ctx.Written() { + return nil, nil + } + ctx.Data["WikiFooterEscapeStatus"], ctx.Data["WikiFooterHTML"], err = renderFn(footerContent) if err != nil { - if wikiRepo != nil { - wikiRepo.Close() - } ctx.ServerError("Render", err) return nil, nil } - ctx.Data["footerPresent"] = footerContent != nil - } else { - ctx.Data["footerPresent"] = false } // get commit count - wiki revisions - commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename) + commitsCount, _ := wikiGitRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename) ctx.Data["CommitCount"] = commitsCount - return wikiRepo, entry + return wikiGitRepo, entry } func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { - wikiRepo, commit, err := findWikiRepoCommit(ctx) + wikiGitRepo, commit, err := findWikiRepoCommit(ctx) if err != nil { - if wikiRepo != nil { - wikiRepo.Close() - } if !git.IsErrNotExist(err) { ctx.ServerError("GetBranchCommit", err) } return nil, nil } - // get requested pagename + // get requested page name pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) if len(pageName) == 0 { pageName = "Home" @@ -395,64 +340,48 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name - // lookup filename in wiki - get filecontent, gitTree entry , real filename - data, entry, pageFilename, noEntry := wikiContentsByName(ctx, commit, pageName) + // lookup filename in wiki - get page content, gitTree entry , real filename + _, entry, pageFilename, noEntry := wikiContentsByName(ctx, commit, pageName) if noEntry { ctx.Redirect(ctx.Repo.RepoLink + "/wiki/?action=_pages") } if entry == nil || ctx.Written() { - if wikiRepo != nil { - wikiRepo.Close() - } return nil, nil } - ctx.Data["content"] = string(data) - ctx.Data["sidebarPresent"] = false - ctx.Data["sidebarContent"] = "" - ctx.Data["footerPresent"] = false - ctx.Data["footerContent"] = "" - // get commit count - wiki revisions - commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename) + commitsCount, _ := wikiGitRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename) ctx.Data["CommitCount"] = commitsCount // get page - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) // get Commit Count - commitsHistory, err := wikiRepo.CommitsByFileAndRange( + commitsHistory, err := wikiGitRepo.CommitsByFileAndRange( git.CommitsByFileAndRangeOptions{ Revision: ctx.Repo.Repository.DefaultWikiBranch, File: pageFilename, Page: page, }) if err != nil { - if wikiRepo != nil { - wikiRepo.Close() - } ctx.ServerError("CommitsByFileAndRange", err) return nil, nil } - ctx.Data["Commits"] = git_model.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository) + ctx.Data["Commits"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository) + if err != nil { + ctx.ServerError("ConvertFromGitCommit", err) + return nil, nil + } pager := context.NewPagination(int(commitsCount), setting.Git.CommitsRangeSize, page, 5) pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager - return wikiRepo, entry + return wikiGitRepo, entry } func renderEditPage(ctx *context.Context) { - wikiRepo, commit, err := findWikiRepoCommit(ctx) - defer func() { - if wikiRepo != nil { - _ = wikiRepo.Close() - } - }() + _, commit, err := findWikiRepoCommit(ctx) if err != nil { if !git.IsErrNotExist(err) { ctx.ServerError("GetBranchCommit", err) @@ -460,7 +389,7 @@ func renderEditPage(ctx *context.Context) { return } - // get requested pagename + // get requested page name pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) if len(pageName) == 0 { pageName = "Home" @@ -478,23 +407,19 @@ func renderEditPage(ctx *context.Context) { ctx.Redirect(ctx.Repo.RepoLink + "/wiki/?action=_pages") } if isRaw { - ctx.Error(http.StatusForbidden, "Editing of raw wiki files is not allowed") + ctx.HTTPError(http.StatusForbidden, "Editing of raw wiki files is not allowed") } if entry == nil || ctx.Written() { return } - // get filecontent + // get wiki page content data := wikiContentsByEntry(ctx, entry) if ctx.Written() { return } - ctx.Data["content"] = string(data) - ctx.Data["sidebarPresent"] = false - ctx.Data["sidebarContent"] = "" - ctx.Data["footerPresent"] = false - ctx.Data["footerContent"] = "" + ctx.Data["WikiEditContent"] = string(data) } // WikiPost renders post of wiki page @@ -502,14 +427,14 @@ func WikiPost(ctx *context.Context) { switch ctx.FormString("action") { case "_new": if !ctx.Repo.CanWrite(unit.TypeWiki) { - ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + ctx.NotFound(nil) return } NewWikiPost(ctx) return case "_delete": if !ctx.Repo.CanWrite(unit.TypeWiki) { - ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + ctx.NotFound(nil) return } DeleteWikiPagePost(ctx) @@ -517,7 +442,7 @@ func WikiPost(ctx *context.Context) { } if !ctx.Repo.CanWrite(unit.TypeWiki) { - ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + ctx.NotFound(nil) return } EditWikiPost(ctx) @@ -536,14 +461,14 @@ func Wiki(ctx *context.Context) { return case "_edit": if !ctx.Repo.CanWrite(unit.TypeWiki) { - ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + ctx.NotFound(nil) return } EditWiki(ctx) return case "_new": if !ctx.Repo.CanWrite(unit.TypeWiki) { - ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + ctx.NotFound(nil) return } NewWiki(ctx) @@ -556,12 +481,7 @@ func Wiki(ctx *context.Context) { return } - wikiRepo, entry := renderViewPage(ctx) - defer func() { - if wikiRepo != nil { - wikiRepo.Close() - } - }() + wikiGitRepo, entry := renderViewPage(ctx) if ctx.Written() { return } @@ -574,10 +494,10 @@ func Wiki(ctx *context.Context) { wikiPath := entry.Name() if markup.DetectMarkupTypeByFileName(wikiPath) != markdown.MarkupName { ext := strings.ToUpper(filepath.Ext(wikiPath)) - ctx.Data["FormatWarning"] = fmt.Sprintf("%s rendering is not supported at the moment. Rendered as Markdown.", ext) + ctx.Data["FormatWarning"] = ext + " rendering is not supported at the moment. Rendered as Markdown." } // Get last change information. - lastCommit, err := wikiRepo.GetCommitByPath(wikiPath) + lastCommit, err := wikiGitRepo.GetCommitByPath(wikiPath) if err != nil { ctx.ServerError("GetCommitByPath", err) return @@ -597,13 +517,7 @@ func WikiRevision(ctx *context.Context) { return } - wikiRepo, entry := renderRevisionPage(ctx) - defer func() { - if wikiRepo != nil { - wikiRepo.Close() - } - }() - + wikiGitRepo, entry := renderRevisionPage(ctx) if ctx.Written() { return } @@ -615,7 +529,7 @@ func WikiRevision(ctx *context.Context) { // Get last change information. wikiPath := entry.Name() - lastCommit, err := wikiRepo.GetCommitByPath(wikiPath) + lastCommit, err := wikiGitRepo.GetCommitByPath(wikiPath) if err != nil { ctx.ServerError("GetCommitByPath", err) return @@ -635,12 +549,7 @@ func WikiPages(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.wiki.pages") ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived - wikiRepo, commit, err := findWikiRepoCommit(ctx) - defer func() { - if wikiRepo != nil { - _ = wikiRepo.Close() - } - }() + _, commit, err := findWikiRepoCommit(ctx) if err != nil { ctx.Redirect(ctx.Repo.RepoLink + "/wiki") return @@ -694,16 +603,10 @@ func WikiPages(ctx *context.Context) { // WikiRaw outputs raw blob requested by user (image for example) func WikiRaw(ctx *context.Context) { - wikiRepo, commit, err := findWikiRepoCommit(ctx) - defer func() { - if wikiRepo != nil { - wikiRepo.Close() - } - }() - + _, commit, err := findWikiRepoCommit(ctx) if err != nil { if git.IsErrNotExist(err) { - ctx.NotFound("findEntryForFile", nil) + ctx.NotFound(nil) return } ctx.ServerError("findEntryForfile", err) @@ -733,13 +636,13 @@ func WikiRaw(ctx *context.Context) { } if entry != nil { - if err = common.ServeBlob(ctx.Base, ctx.Repo.TreePath, entry.Blob(), nil); err != nil { + if err = common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, entry.Blob(), nil); err != nil { ctx.ServerError("ServeBlob", err) } return } - ctx.NotFound("findEntryForFile", nil) + ctx.NotFound(nil) } // NewWiki render wiki create page diff --git a/routers/web/repo/wiki_test.go b/routers/web/repo/wiki_test.go index c31f29164b..59bf6ed79b 100644 --- a/routers/web/repo/wiki_test.go +++ b/routers/web/repo/wiki_test.go @@ -29,7 +29,7 @@ const ( ) func wikiEntry(t *testing.T, repo *repo_model.Repository, wikiName wiki_service.WebPath) *git.TreeEntry { - wikiRepo, err := gitrepo.OpenWikiRepository(git.DefaultContext, repo) + wikiRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo.WikiStorageRepo()) assert.NoError(t, err) defer wikiRepo.Close() commit, err := wikiRepo.GetBranchCommit("master") @@ -71,7 +71,7 @@ func assertPagesMetas(t *testing.T, expectedNames []string, metas any) { require.Len(t, pageMetas, len(expectedNames)) for i, pageMeta := range pageMetas { - assert.EqualValues(t, expectedNames[i], pageMeta.Name) + assert.Equal(t, expectedNames[i], pageMeta.Name) } } @@ -82,7 +82,7 @@ func TestWiki(t *testing.T) { ctx.SetPathParam("*", "Home") contexttest.LoadRepo(t, ctx, 1) Wiki(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.EqualValues(t, "Home", ctx.Data["Title"]) assertPagesMetas(t, []string{"Home", "Page With Image", "Page With Spaced Name", "Unescaped File"}, ctx.Data["Pages"]) @@ -90,7 +90,7 @@ func TestWiki(t *testing.T) { ctx.SetPathParam("*", "jpeg.jpg") contexttest.LoadRepo(t, ctx, 1) Wiki(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assert.Equal(t, "/user2/repo1/wiki/raw/jpeg.jpg", ctx.Resp.Header().Get("Location")) } @@ -100,7 +100,7 @@ func TestWikiPages(t *testing.T) { ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki/?action=_pages") contexttest.LoadRepo(t, ctx, 1) WikiPages(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assertPagesMetas(t, []string{"Home", "Page With Image", "Page With Spaced Name", "Unescaped File"}, ctx.Data["Pages"]) } @@ -111,7 +111,7 @@ func TestNewWiki(t *testing.T) { contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 1) NewWiki(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.EqualValues(t, ctx.Tr("repo.wiki.new_page"), ctx.Data["Title"]) } @@ -131,7 +131,7 @@ func TestNewWikiPost(t *testing.T) { Message: message, }) NewWikiPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assertWikiExists(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title)) assert.Equal(t, content, wikiContent(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title))) } @@ -149,7 +149,7 @@ func TestNewWikiPost_ReservedName(t *testing.T) { Message: message, }) NewWikiPost(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.EqualValues(t, ctx.Tr("repo.wiki.reserved_page", "_edit"), ctx.Flash.ErrorMsg) assertWikiNotExists(t, ctx.Repo.Repository, "_edit") } @@ -162,16 +162,16 @@ func TestEditWiki(t *testing.T) { contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 1) EditWiki(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.EqualValues(t, "Home", ctx.Data["Title"]) - assert.Equal(t, wikiContent(t, ctx.Repo.Repository, "Home"), ctx.Data["content"]) + assert.Equal(t, wikiContent(t, ctx.Repo.Repository, "Home"), ctx.Data["WikiEditContent"]) ctx, _ = contexttest.MockContext(t, "user2/repo1/wiki/jpeg.jpg?action=_edit") ctx.SetPathParam("*", "jpeg.jpg") contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 1) EditWiki(ctx) - assert.EqualValues(t, http.StatusForbidden, ctx.Resp.Status()) + assert.Equal(t, http.StatusForbidden, ctx.Resp.WrittenStatus()) } func TestEditWikiPost(t *testing.T) { @@ -190,7 +190,7 @@ func TestEditWikiPost(t *testing.T) { Message: message, }) EditWikiPost(ctx) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assertWikiExists(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title)) assert.Equal(t, content, wikiContent(t, ctx.Repo.Repository, wiki_service.UserTitleToWebPath("", title))) if title != "Home" { @@ -206,7 +206,7 @@ func TestDeleteWikiPagePost(t *testing.T) { contexttest.LoadUser(t, ctx, 2) contexttest.LoadRepo(t, ctx, 1) DeleteWikiPagePost(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assertWikiNotExists(t, ctx.Repo.Repository, "Home") } @@ -228,10 +228,10 @@ func TestWikiRaw(t *testing.T) { contexttest.LoadRepo(t, ctx, 1) WikiRaw(ctx) if filetype == "" { - assert.EqualValues(t, http.StatusNotFound, ctx.Resp.Status(), "filepath: %s", filepath) + assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus(), "filepath: %s", filepath) } else { - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status(), "filepath: %s", filepath) - assert.EqualValues(t, filetype, ctx.Resp.Header().Get("Content-Type"), "filepath: %s", filepath) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus(), "filepath: %s", filepath) + assert.Equal(t, filetype, ctx.Resp.Header().Get("Content-Type"), "filepath: %s", filepath) } } } @@ -245,7 +245,12 @@ func TestDefaultWikiBranch(t *testing.T) { assert.NoError(t, wiki_service.ChangeDefaultWikiBranch(db.DefaultContext, repoWithNoWiki, "main")) // repo with wiki - assert.NoError(t, repo_model.UpdateRepositoryCols(db.DefaultContext, &repo_model.Repository{ID: 1, DefaultWikiBranch: "wrong-branch"})) + assert.NoError(t, repo_model.UpdateRepositoryColsNoAutoTime( + db.DefaultContext, + &repo_model.Repository{ID: 1, DefaultWikiBranch: "wrong-branch"}, + "default_wiki_branch", + ), + ) ctx, _ := contexttest.MockContext(t, "user2/repo1/wiki") ctx.SetPathParam("*", "Home") diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go index 6d77bdd2fa..648f8046a4 100644 --- a/routers/web/shared/actions/runners.go +++ b/routers/web/shared/actions/runners.go @@ -5,18 +5,127 @@ package actions import ( "errors" + "net/http" + "net/url" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) -// RunnersList prepares data for runners list -func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) { +const ( + // TODO: Separate secrets from runners when layout is ready + tplRepoRunners templates.TplName = "repo/settings/actions" + tplOrgRunners templates.TplName = "org/settings/actions" + tplAdminRunners templates.TplName = "admin/actions" + tplUserRunners templates.TplName = "user/settings/actions" + tplRepoRunnerEdit templates.TplName = "repo/settings/runner_edit" + tplOrgRunnerEdit templates.TplName = "org/settings/runners_edit" + tplAdminRunnerEdit templates.TplName = "admin/runners/edit" + tplUserRunnerEdit templates.TplName = "user/settings/runner_edit" +) + +type runnersCtx struct { + OwnerID int64 + RepoID int64 + IsRepo bool + IsOrg bool + IsAdmin bool + IsUser bool + RunnersTemplate templates.TplName + RunnerEditTemplate templates.TplName + RedirectLink string +} + +func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &runnersCtx{ + RepoID: ctx.Repo.Repository.ID, + OwnerID: 0, + IsRepo: true, + RunnersTemplate: tplRepoRunners, + RunnerEditTemplate: tplRepoRunnerEdit, + RedirectLink: ctx.Repo.RepoLink + "/settings/actions/runners/", + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return nil, nil + } + return &runnersCtx{ + RepoID: 0, + OwnerID: ctx.Org.Organization.ID, + IsOrg: true, + RunnersTemplate: tplOrgRunners, + RunnerEditTemplate: tplOrgRunnerEdit, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/runners/", + }, nil + } + + if ctx.Data["PageIsAdmin"] == true { + return &runnersCtx{ + RepoID: 0, + OwnerID: 0, + IsAdmin: true, + RunnersTemplate: tplAdminRunners, + RunnerEditTemplate: tplAdminRunnerEdit, + RedirectLink: setting.AppSubURL + "/-/admin/actions/runners/", + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &runnersCtx{ + OwnerID: ctx.Doer.ID, + RepoID: 0, + IsUser: true, + RunnersTemplate: tplUserRunners, + RunnerEditTemplate: tplUserRunnerEdit, + RedirectLink: setting.AppSubURL + "/user/settings/actions/runners/", + }, nil + } + + return nil, errors.New("unable to set Runners context") +} + +// Runners render settings/actions/runners page for repo level +func Runners(ctx *context.Context) { + ctx.Data["PageIsSharedSettingsRunners"] = true + ctx.Data["Title"] = ctx.Tr("actions.actions") + ctx.Data["PageType"] = "runners" + + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + page := max(ctx.FormInt("page"), 1) + + opts := actions_model.FindRunnerOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: 100, + }, + Sort: ctx.Req.URL.Query().Get("sort"), + Filter: ctx.Req.URL.Query().Get("q"), + } + if rCtx.IsRepo { + opts.RepoID = rCtx.RepoID + opts.WithAvailable = true + } else if rCtx.IsOrg || rCtx.IsUser { + opts.OwnerID = rCtx.OwnerID + opts.WithAvailable = true + } + runners, count, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts) if err != nil { ctx.ServerError("CountRunners", err) @@ -53,10 +162,26 @@ func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) { pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, rCtx.RunnersTemplate) } -// RunnerDetails prepares data for runners edit page -func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int64) { +// RunnersEdit renders runner edit page for repository level +func RunnersEdit(ctx *context.Context) { + ctx.Data["PageIsSharedSettingsRunners"] = true + ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner") + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + page := max(ctx.FormInt("page"), 1) + + runnerID := ctx.PathParamInt64("runnerid") + ownerID := rCtx.OwnerID + repoID := rCtx.RepoID + runner, err := actions_model.GetRunnerByID(ctx, runnerID) if err != nil { ctx.ServerError("GetRunnerByID", err) @@ -66,9 +191,9 @@ func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int ctx.ServerError("LoadAttributes", err) return } - if !runner.Editable(ownerID, repoID) { + if !runner.EditableInContext(ownerID, repoID) { err = errors.New("no permission to edit this runner") - ctx.NotFound("RunnerDetails", err) + ctx.NotFound(err) return } @@ -97,18 +222,30 @@ func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int ctx.Data["Tasks"] = tasks pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate) } -// RunnerDetailsEditPost response for edit runner details -func RunnerDetailsEditPost(ctx *context.Context, runnerID, ownerID, repoID int64, redirectTo string) { +func RunnersEditPost(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + runnerID := ctx.PathParamInt64("runnerid") + ownerID := rCtx.OwnerID + repoID := rCtx.RepoID + redirectTo := rCtx.RedirectLink + runner, err := actions_model.GetRunnerByID(ctx, runnerID) if err != nil { log.Warn("RunnerDetailsEditPost.GetRunnerByID failed: %v, url: %s", err, ctx.Req.URL) ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err) return } - if !runner.Editable(ownerID, repoID) { - ctx.NotFound("RunnerDetailsEditPost.Editable", util.NewPermissionDeniedErrorf("no permission to edit this runner")) + if !runner.EditableInContext(ownerID, repoID) { + ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner")) return } @@ -129,10 +266,18 @@ func RunnerDetailsEditPost(ctx *context.Context, runnerID, ownerID, repoID int64 ctx.Redirect(redirectTo) } -// RunnerResetRegistrationToken reset registration token -func RunnerResetRegistrationToken(ctx *context.Context, ownerID, repoID int64, redirectTo string) { - _, err := actions_model.NewRunnerToken(ctx, ownerID, repoID) +func ResetRunnerRegistrationToken(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + ownerID := rCtx.OwnerID + repoID := rCtx.RepoID + redirectTo := rCtx.RedirectLink + + if _, err := actions_model.NewRunnerToken(ctx, ownerID, repoID); err != nil { ctx.ServerError("ResetRunnerRegistrationToken", err) return } @@ -140,11 +285,28 @@ func RunnerResetRegistrationToken(ctx *context.Context, ownerID, repoID int64, r ctx.JSONRedirect(redirectTo) } -// RunnerDeletePost response for deleting a runner -func RunnerDeletePost(ctx *context.Context, runnerID int64, - successRedirectTo, failedRedirectTo string, -) { - if err := actions_model.DeleteRunner(ctx, runnerID); err != nil { +// RunnerDeletePost response for deleting runner +func RunnerDeletePost(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + runner := findActionsRunner(ctx, rCtx) + if ctx.Written() { + return + } + + if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) { + ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to delete this runner")) + return + } + + successRedirectTo := rCtx.RedirectLink + failedRedirectTo := rCtx.RedirectLink + url.PathEscape(ctx.PathParam("runnerid")) + + if err := actions_model.DeleteRunner(ctx, runner.ID); err != nil { log.Warn("DeleteRunnerPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL) ctx.Flash.Warning(ctx.Tr("actions.runners.delete_runner_failed")) @@ -158,3 +320,41 @@ func RunnerDeletePost(ctx *context.Context, runnerID int64, ctx.JSONRedirect(successRedirectTo) } + +func RedirectToDefaultSetting(ctx *context.Context) { + ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners") +} + +func findActionsRunner(ctx *context.Context, rCtx *runnersCtx) *actions_model.ActionRunner { + runnerID := ctx.PathParamInt64("runnerid") + opts := &actions_model.FindRunnerOptions{ + IDs: []int64{runnerID}, + } + switch { + case rCtx.IsRepo: + opts.RepoID = rCtx.RepoID + if opts.RepoID == 0 { + panic("repoID is 0") + } + case rCtx.IsOrg, rCtx.IsUser: + opts.OwnerID = rCtx.OwnerID + if opts.OwnerID == 0 { + panic("ownerID is 0") + } + case rCtx.IsAdmin: + // do nothing + default: + panic("invalid actions runner context") + } + + got, err := db.Find[actions_model.ActionRunner](ctx, opts) + if err != nil { + ctx.ServerError("FindRunner", err) + return nil + } else if len(got) == 0 { + ctx.NotFound(errors.New("runner not found")) + return nil + } + + return got[0] +} diff --git a/routers/web/shared/actions/variables.go b/routers/web/shared/actions/variables.go index f895475748..a43c2c2690 100644 --- a/routers/web/shared/actions/variables.go +++ b/routers/web/shared/actions/variables.go @@ -4,31 +4,127 @@ package actions import ( + "errors" + "net/http" + actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) -func SetVariablesContext(ctx *context.Context, ownerID, repoID int64) { +const ( + tplRepoVariables templates.TplName = "repo/settings/actions" + tplOrgVariables templates.TplName = "org/settings/actions" + tplUserVariables templates.TplName = "user/settings/actions" + tplAdminVariables templates.TplName = "admin/actions" +) + +type variablesCtx struct { + OwnerID int64 + RepoID int64 + IsRepo bool + IsOrg bool + IsUser bool + IsGlobal bool + VariablesTemplate templates.TplName + RedirectLink string +} + +func getVariablesCtx(ctx *context.Context) (*variablesCtx, error) { + if ctx.Data["PageIsRepoSettings"] == true { + return &variablesCtx{ + OwnerID: 0, + RepoID: ctx.Repo.Repository.ID, + IsRepo: true, + VariablesTemplate: tplRepoVariables, + RedirectLink: ctx.Repo.RepoLink + "/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsOrgSettings"] == true { + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return nil, nil + } + return &variablesCtx{ + OwnerID: ctx.ContextUser.ID, + RepoID: 0, + IsOrg: true, + VariablesTemplate: tplOrgVariables, + RedirectLink: ctx.Org.OrgLink + "/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsUserSettings"] == true { + return &variablesCtx{ + OwnerID: ctx.Doer.ID, + RepoID: 0, + IsUser: true, + VariablesTemplate: tplUserVariables, + RedirectLink: setting.AppSubURL + "/user/settings/actions/variables", + }, nil + } + + if ctx.Data["PageIsAdmin"] == true { + return &variablesCtx{ + OwnerID: 0, + RepoID: 0, + IsGlobal: true, + VariablesTemplate: tplAdminVariables, + RedirectLink: setting.AppSubURL + "/-/admin/actions/variables", + }, nil + } + + return nil, errors.New("unable to set Variables context") +} + +func Variables(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("actions.variables") + ctx.Data["PageType"] = "variables" + ctx.Data["PageIsSharedSettingsVariables"] = true + + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + variables, err := db.Find[actions_model.ActionVariable](ctx, actions_model.FindVariablesOpts{ - OwnerID: ownerID, - RepoID: repoID, + OwnerID: vCtx.OwnerID, + RepoID: vCtx.RepoID, }) if err != nil { ctx.ServerError("FindVariables", err) return } ctx.Data["Variables"] = variables + ctx.Data["DataMaxLength"] = actions_model.VariableDataMaxLength + ctx.Data["DescriptionMaxLength"] = actions_model.VariableDescriptionMaxLength + ctx.HTML(http.StatusOK, vCtx.VariablesTemplate) } -func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL string) { +func VariableCreate(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + if ctx.HasError() { // form binding validation error + ctx.JSONError(ctx.GetErrMsg()) + return + } + form := web.GetForm(ctx).(*forms.EditVariableForm) - v, err := actions_service.CreateVariable(ctx, ownerID, repoID, form.Name, form.Data) + v, err := actions_service.CreateVariable(ctx, vCtx.OwnerID, vCtx.RepoID, form.Name, form.Data, form.Description) if err != nil { log.Error("CreateVariable: %v", err) ctx.JSONError(ctx.Tr("actions.variables.creation.failed")) @@ -36,30 +132,93 @@ func CreateVariable(ctx *context.Context, ownerID, repoID int64, redirectURL str } ctx.Flash.Success(ctx.Tr("actions.variables.creation.success", v.Name)) - ctx.JSONRedirect(redirectURL) + ctx.JSONRedirect(vCtx.RedirectLink) } -func UpdateVariable(ctx *context.Context, redirectURL string) { +func VariableUpdate(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + + if ctx.HasError() { // form binding validation error + ctx.JSONError(ctx.GetErrMsg()) + return + } + id := ctx.PathParamInt64("variable_id") + + variable := findActionsVariable(ctx, id, vCtx) + if ctx.Written() { + return + } + form := web.GetForm(ctx).(*forms.EditVariableForm) + variable.Name = form.Name + variable.Data = form.Data + variable.Description = form.Description - if ok, err := actions_service.UpdateVariable(ctx, id, form.Name, form.Data); err != nil || !ok { + if ok, err := actions_service.UpdateVariableNameData(ctx, variable); err != nil || !ok { log.Error("UpdateVariable: %v", err) ctx.JSONError(ctx.Tr("actions.variables.update.failed")) return } ctx.Flash.Success(ctx.Tr("actions.variables.update.success")) - ctx.JSONRedirect(redirectURL) + ctx.JSONRedirect(vCtx.RedirectLink) +} + +func findActionsVariable(ctx *context.Context, id int64, vCtx *variablesCtx) *actions_model.ActionVariable { + opts := actions_model.FindVariablesOpts{ + IDs: []int64{id}, + } + switch { + case vCtx.IsRepo: + opts.RepoID = vCtx.RepoID + if opts.RepoID == 0 { + panic("RepoID is 0") + } + case vCtx.IsOrg, vCtx.IsUser: + opts.OwnerID = vCtx.OwnerID + if opts.OwnerID == 0 { + panic("OwnerID is 0") + } + case vCtx.IsGlobal: + // do nothing + default: + panic("invalid actions variable") + } + + got, err := actions_model.FindVariables(ctx, opts) + if err != nil { + ctx.ServerError("FindVariables", err) + return nil + } else if len(got) == 0 { + ctx.NotFound(nil) + return nil + } + return got[0] } -func DeleteVariable(ctx *context.Context, redirectURL string) { +func VariableDelete(ctx *context.Context) { + vCtx, err := getVariablesCtx(ctx) + if err != nil { + ctx.ServerError("getVariablesCtx", err) + return + } + id := ctx.PathParamInt64("variable_id") - if err := actions_service.DeleteVariableByID(ctx, id); err != nil { + variable := findActionsVariable(ctx, id, vCtx) + if ctx.Written() { + return + } + + if err := actions_service.DeleteVariableByID(ctx, variable.ID); err != nil { log.Error("Delete variable [%d] failed: %v", id, err) ctx.JSONError(ctx.Tr("actions.variables.deletion.failed")) return } ctx.Flash.Success(ctx.Tr("actions.variables.deletion.success")) - ctx.JSONRedirect(redirectURL) + ctx.JSONRedirect(vCtx.RedirectLink) } diff --git a/routers/web/shared/issue/issue_label.go b/routers/web/shared/issue/issue_label.go index eacea36b02..e2eeaaf0af 100644 --- a/routers/web/shared/issue/issue_label.go +++ b/routers/web/shared/issue/issue_label.go @@ -14,14 +14,18 @@ import ( ) // PrepareFilterIssueLabels reads the "labels" query parameter, sets `ctx.Data["Labels"]` and `ctx.Data["SelectLabels"]` -func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (labelIDs []int64) { +func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_model.User) (ret struct { + AllLabels []*issues_model.Label + SelectedLabelIDs []int64 +}, +) { // 1,-2 means including label 1 and excluding label 2 // 0 means issues with no label // blank means labels will not be filtered for issues selectLabels := ctx.FormString("labels") if selectLabels != "" { var err error - labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) + ret.SelectedLabelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) if err != nil { ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) } @@ -32,7 +36,7 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo repoLabels, err := issues_model.GetLabelsByRepoID(ctx, repoID, "", db.ListOptions{}) if err != nil { ctx.ServerError("GetLabelsByRepoID", err) - return nil + return ret } allLabels = append(allLabels, repoLabels...) } @@ -41,14 +45,14 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo orgLabels, err := issues_model.GetLabelsByOrgID(ctx, owner.ID, "", db.ListOptions{}) if err != nil { ctx.ServerError("GetLabelsByOrgID", err) - return nil + return ret } allLabels = append(allLabels, orgLabels...) } // Get the exclusive scope for every label ID - labelExclusiveScopes := make([]string, 0, len(labelIDs)) - for _, labelID := range labelIDs { + labelExclusiveScopes := make([]string, 0, len(ret.SelectedLabelIDs)) + for _, labelID := range ret.SelectedLabelIDs { foundExclusiveScope := false for _, label := range allLabels { if label.ID == labelID || label.ID == -labelID { @@ -63,9 +67,10 @@ func PrepareFilterIssueLabels(ctx *context.Context, repoID int64, owner *user_mo } for _, l := range allLabels { - l.LoadSelectedLabelsAfterClick(labelIDs, labelExclusiveScopes) + l.LoadSelectedLabelsAfterClick(ret.SelectedLabelIDs, labelExclusiveScopes) } ctx.Data["Labels"] = allLabels ctx.Data["SelectLabels"] = selectLabels - return labelIDs + ret.AllLabels = allLabels + return ret } diff --git a/routers/web/shared/label/label.go b/routers/web/shared/label/label.go new file mode 100644 index 0000000000..6968a318c4 --- /dev/null +++ b/routers/web/shared/label/label.go @@ -0,0 +1,26 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package label + +import ( + "code.gitea.io/gitea/modules/label" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +func GetLabelEditForm(ctx *context.Context) *forms.CreateLabelForm { + form := web.GetForm(ctx).(*forms.CreateLabelForm) + if ctx.HasError() { + ctx.JSONError(ctx.Data["ErrorMsg"].(string)) + return nil + } + var err error + form.Color, err = label.NormalizeColor(form.Color) + if err != nil { + ctx.JSONError(ctx.Tr("repo.issues.label_color_invalid")) + return nil + } + return form +} diff --git a/routers/web/shared/packages/packages.go b/routers/web/shared/packages/packages.go index a1bcb09850..a18dedf89c 100644 --- a/routers/web/shared/packages/packages.go +++ b/routers/web/shared/packages/packages.go @@ -8,7 +8,6 @@ import ( "net/http" "time" - "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" @@ -159,12 +158,18 @@ func SetRulePreviewContext(ctx *context.Context, owner *user_model.User) { PackageID: p.ID, IsInternal: optional.Some(false), Sort: packages_model.SortCreatedDesc, - Paginator: db.NewAbsoluteListOptions(pcr.KeepCount, 200), }) if err != nil { ctx.ServerError("SearchVersions", err) return } + if pcr.KeepCount > 0 { + if pcr.KeepCount < len(pvs) { + pvs = pvs[pcr.KeepCount:] + } else { + pvs = nil + } + } for _, pv := range pvs { if skip, err := container_service.ShouldBeSkipped(ctx, pcr, p, pv); err != nil { ctx.ServerError("ShouldBeSkipped", err) @@ -177,7 +182,6 @@ func SetRulePreviewContext(ctx *context.Context, owner *user_model.User) { if pcr.MatchFullName { toMatch = p.LowerName + "/" + pv.LowerVersion } - if pcr.KeepPatternMatcher != nil && pcr.KeepPatternMatcher.MatchString(toMatch) { continue } @@ -210,7 +214,7 @@ func getCleanupRuleByContext(ctx *context.Context, owner *user_model.User) *pack pcr, err := packages_model.GetCleanupRuleByID(ctx, id) if err != nil { if err == packages_model.ErrPackageCleanupRuleNotExist { - ctx.NotFound("", err) + ctx.NotFound(err) } else { ctx.ServerError("GetCleanupRuleByID", err) } @@ -221,7 +225,7 @@ func getCleanupRuleByContext(ctx *context.Context, owner *user_model.User) *pack return pcr } - ctx.NotFound("", fmt.Errorf("PackageCleanupRule[%v] not associated to owner %v", id, owner)) + ctx.NotFound(fmt.Errorf("PackageCleanupRule[%v] not associated to owner %v", id, owner)) return nil } diff --git a/routers/web/shared/project/column.go b/routers/web/shared/project/column.go index 141c80716f..b6ffaea7f8 100644 --- a/routers/web/shared/project/column.go +++ b/routers/web/shared/project/column.go @@ -17,7 +17,7 @@ func MoveColumns(ctx *context.Context) { return } if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { - ctx.NotFound("CanBeAccessedByOwnerRepo", nil) + ctx.NotFound(nil) return } diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go index 3bd421f86a..29f4e9520d 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -22,19 +22,21 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { } ctx.Data["Secrets"] = secrets + ctx.Data["DataMaxLength"] = secret_model.SecretDataMaxLength + ctx.Data["DescriptionMaxLength"] = secret_model.SecretDescriptionMaxLength } func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { form := web.GetForm(ctx).(*forms.AddSecretForm) - s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.ReserveLineBreakForTextarea(form.Data)) + s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.ReserveLineBreakForTextarea(form.Data), form.Description) if err != nil { log.Error("CreateOrUpdateSecret failed: %v", err) - ctx.JSONError(ctx.Tr("secrets.creation.failed")) + ctx.JSONError(ctx.Tr("secrets.save_failed")) return } - ctx.Flash.Success(ctx.Tr("secrets.creation.success", s.Name)) + ctx.Flash.Success(ctx.Tr("secrets.save_success", s.Name)) ctx.JSONRedirect(redirectURL) } diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 62b146c7f3..2bd0abc4c0 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -24,19 +24,8 @@ import ( "code.gitea.io/gitea/services/context" ) -// prepareContextForCommonProfile store some common data into context data for user's profile related pages (including the nav menu) -// It is designed to be fast and safe to be called multiple times in one request -func prepareContextForCommonProfile(ctx *context.Context) { - ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled - ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["EnableFeed"] = setting.Other.EnableFeed - ctx.Data["FeedURL"] = ctx.ContextUser.HomeLink() -} - -// PrepareContextForProfileBigAvatar set the context for big avatar view on the profile page -func PrepareContextForProfileBigAvatar(ctx *context.Context) { - prepareContextForCommonProfile(ctx) - +// prepareContextForProfileBigAvatar set the context for big avatar view on the profile page +func prepareContextForProfileBigAvatar(ctx *context.Context) { ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate if setting.Service.UserLocationMapURL != "" { @@ -58,13 +47,12 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { ctx.Data["RenderedDescription"] = content } - showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) orgs, err := db.Find[organization.Organization](ctx, organization.FindOrgOptions{ - UserID: ctx.ContextUser.ID, - IncludePrivate: showPrivate, + UserID: ctx.ContextUser.ID, + IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, ctx.ContextUser), ListOptions: db.ListOptions{ Page: 1, - // query one more results (without a separate counting) to see whether we need to add the "show more orgs" link + // query one more result (without a separate counting) to see whether we need to add the "show more orgs" link PageSize: setting.UI.User.OrgPagingNum + 1, }, }) @@ -138,17 +126,45 @@ func FindOwnerProfileReadme(ctx *context.Context, doer *user_model.User, optProf return profileDbRepo, profileReadmeBlob } -func RenderUserHeader(ctx *context.Context) { - prepareContextForCommonProfile(ctx) - - _, profileReadmeBlob := FindOwnerProfileReadme(ctx, ctx.Doer) - ctx.Data["HasUserProfileReadme"] = profileReadmeBlob != nil +type PrepareOwnerHeaderResult struct { + ProfilePublicRepo *repo_model.Repository + ProfilePublicReadmeBlob *git.Blob + ProfilePrivateRepo *repo_model.Repository + ProfilePrivateReadmeBlob *git.Blob + HasOrgProfileReadme bool } -func LoadHeaderCount(ctx *context.Context) error { - prepareContextForCommonProfile(ctx) +const ( + RepoNameProfilePrivate = ".profile-private" + RepoNameProfile = ".profile" +) + +func RenderUserOrgHeader(ctx *context.Context) (result *PrepareOwnerHeaderResult, err error) { + ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["EnableFeed"] = setting.Other.EnableFeed + ctx.Data["FeedURL"] = ctx.ContextUser.HomeLink() + + if err := loadHeaderCount(ctx); err != nil { + return nil, err + } - repoCount, err := repo_model.CountRepository(ctx, &repo_model.SearchRepoOptions{ + result = &PrepareOwnerHeaderResult{} + if ctx.ContextUser.IsOrganization() { + result.ProfilePublicRepo, result.ProfilePublicReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer) + result.ProfilePrivateRepo, result.ProfilePrivateReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer, RepoNameProfilePrivate) + result.HasOrgProfileReadme = result.ProfilePublicReadmeBlob != nil || result.ProfilePrivateReadmeBlob != nil + ctx.Data["HasOrgProfileReadme"] = result.HasOrgProfileReadme // many pages need it to show the "overview" tab + } else { + _, profileReadmeBlob := FindOwnerProfileReadme(ctx, ctx.Doer) + ctx.Data["HasUserProfileReadme"] = profileReadmeBlob != nil + prepareContextForProfileBigAvatar(ctx) + } + return result, nil +} + +func loadHeaderCount(ctx *context.Context) error { + repoCount, err := repo_model.CountRepository(ctx, repo_model.SearchRepoOptions{ Actor: ctx.Doer, OwnerID: ctx.ContextUser.ID, Private: ctx.IsSigned, @@ -178,29 +194,3 @@ func LoadHeaderCount(ctx *context.Context) error { return nil } - -const ( - RepoNameProfilePrivate = ".profile-private" - RepoNameProfile = ".profile" -) - -type PrepareOrgHeaderResult struct { - ProfilePublicRepo *repo_model.Repository - ProfilePublicReadmeBlob *git.Blob - ProfilePrivateRepo *repo_model.Repository - ProfilePrivateReadmeBlob *git.Blob - HasOrgProfileReadme bool -} - -func PrepareOrgHeader(ctx *context.Context) (result *PrepareOrgHeaderResult, err error) { - if err = LoadHeaderCount(ctx); err != nil { - return nil, err - } - - result = &PrepareOrgHeaderResult{} - result.ProfilePublicRepo, result.ProfilePublicReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer) - result.ProfilePrivateRepo, result.ProfilePrivateReadmeBlob = FindOwnerProfileReadme(ctx, ctx.Doer, RepoNameProfilePrivate) - result.HasOrgProfileReadme = result.ProfilePublicReadmeBlob != nil || result.ProfilePrivateReadmeBlob != nil - ctx.Data["HasOrgProfileReadme"] = result.HasOrgProfileReadme // many pages need it to show the "overview" tab - return result, nil -} diff --git a/routers/web/shared/user/helper.go b/routers/web/shared/user/helper.go index b82181a1df..3fc39fd3ab 100644 --- a/routers/web/shared/user/helper.go +++ b/routers/web/shared/user/helper.go @@ -8,9 +8,7 @@ import ( "slices" "strconv" - "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/optional" ) func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { @@ -34,19 +32,20 @@ func MakeSelfOnTop(doer *user.User, users []*user.User) []*user.User { // So it's better to make it work like GitHub: users could input username directly. // Since it only converts the username to ID directly and is only used internally (to search issues), so no permission check is needed. // Return values: -// * nil: no filter -// * some(id): match the id, the id could be -1 to match the issues without assignee -// * some(NonExistingID): match no issue (due to the user doesn't exist) -func GetFilterUserIDByName(ctx context.Context, name string) optional.Option[int64] { +// * "": no filter +// * "{the-id}": match the id +// * "(none)": match no issue (due to the user doesn't exist) +func GetFilterUserIDByName(ctx context.Context, name string) string { if name == "" { - return optional.None[int64]() + return "" } u, err := user.GetUserByName(ctx, name) if err != nil { if id, err := strconv.ParseInt(name, 10, 64); err == nil { - return optional.Some(id) + return strconv.FormatInt(id, 10) } - return optional.Some(db.NonExistingID) + // The "(none)" is for internal usage only: when doer tries to search non-existing user, use "(none)" to return empty result. + return "(none)" } - return optional.Some(u.ID) + return strconv.FormatInt(u.ID, 10) } diff --git a/routers/web/user/avatar.go b/routers/web/user/avatar.go index f77bd602b3..6d3179bc48 100644 --- a/routers/web/user/avatar.go +++ b/routers/web/user/avatar.go @@ -4,7 +4,6 @@ package user import ( - "strings" "time" "code.gitea.io/gitea/models/avatars" @@ -17,31 +16,22 @@ func cacheableRedirect(ctx *context.Context, location string) { // here we should not use `setting.StaticCacheTime`, it is pretty long (default: 6 hours) // we must make sure the redirection cache time is short enough, otherwise a user won't see the updated avatar in 6 hours // it's OK to make the cache time short, it is only a redirection, and doesn't cost much to make a new request - httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 5*time.Minute) + httpcache.SetCacheControlInHeader(ctx.Resp.Header(), &httpcache.CacheControlOptions{MaxAge: 5 * time.Minute}) ctx.Redirect(location) } -// AvatarByUserName redirect browser to user avatar of requested size -func AvatarByUserName(ctx *context.Context) { - userName := ctx.PathParam("username") - size := int(ctx.PathParamInt64("size")) - - var user *user_model.User - if strings.ToLower(userName) != user_model.GhostUserLowerName { +// AvatarByUsernameSize redirect browser to user avatar of requested size +func AvatarByUsernameSize(ctx *context.Context) { + username := ctx.PathParam("username") + user := user_model.GetSystemUserByName(username) + if user == nil { var err error - if user, err = user_model.GetUserByName(ctx, userName); err != nil { - if user_model.IsErrUserNotExist(err) { - ctx.NotFound("GetUserByName", err) - return - } - ctx.ServerError("Invalid user: "+userName, err) + if user, err = user_model.GetUserByName(ctx, username); err != nil { + ctx.NotFoundOrServerError("GetUserByName", user_model.IsErrUserNotExist, err) return } - } else { - user = user_model.NewGhostUser() } - - cacheableRedirect(ctx, user.AvatarLinkWithSize(ctx, size)) + cacheableRedirect(ctx, user.AvatarLinkWithSize(ctx, int(ctx.PathParamInt64("size")))) } // AvatarByEmailHash redirects the browser to the email avatar link diff --git a/routers/web/user/code.go b/routers/web/user/code.go index 665ce1a6a6..11579c40a6 100644 --- a/routers/web/user/code.go +++ b/routers/web/user/code.go @@ -5,6 +5,7 @@ package user import ( "net/http" + "slices" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" @@ -26,11 +27,8 @@ func CodeSearch(ctx *context.Context) { ctx.Redirect(ctx.ContextUser.HomeLink()) return } - shared_user.PrepareContextForProfileBigAvatar(ctx) - shared_user.RenderUserHeader(ctx) - - if err := shared_user.LoadHeaderCount(ctx); err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -68,10 +66,10 @@ func CodeSearch(ctx *context.Context) { if len(repoIDs) > 0 { total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ - RepoIDs: repoIDs, - Keyword: prepareSearch.Keyword, - IsKeywordFuzzy: prepareSearch.IsFuzzy, - Language: prepareSearch.Language, + RepoIDs: repoIDs, + Keyword: prepareSearch.Keyword, + SearchMode: prepareSearch.SearchMode, + Language: prepareSearch.Language, Paginator: &db.ListOptions{ Page: page, PageSize: setting.UI.RepoSearchPagingNum, @@ -89,14 +87,7 @@ func CodeSearch(ctx *context.Context) { loadRepoIDs := make([]int64, 0, len(searchResults)) for _, result := range searchResults { - var find bool - for _, id := range loadRepoIDs { - if id == result.RepoID { - find = true - break - } - } - if !find { + if !slices.Contains(loadRepoIDs, result.RepoID) { loadRepoIDs = append(loadRepoIDs, result.RepoID) } } diff --git a/routers/web/user/home.go b/routers/web/user/home.go index c79648a455..b53a3daedb 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -26,6 +26,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/indexer" issue_indexer "code.gitea.io/gitea/modules/indexer/issues" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" @@ -41,8 +42,8 @@ import ( issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" - "github.com/keybase/go-crypto/openpgp" - "github.com/keybase/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" "xorm.io/builder" ) @@ -118,7 +119,7 @@ func Dashboard(ctx *context.Context) { ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) } - feeds, count, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{ + feeds, count, err := feed_service.GetFeedsForDashboard(ctx, activities_model.GetFeedsOptions{ RequestedUser: ctxUser, RequestedTeam: ctx.Org.Team, Actor: ctx.Doer, @@ -136,11 +137,10 @@ func Dashboard(ctx *context.Context) { return } - ctx.Data["Feeds"] = feeds - - pager := context.NewPagination(int(count), setting.UI.FeedPagingNum, page, 5) + pager := context.NewPagination(count, setting.UI.FeedPagingNum, page, 5).WithCurRows(len(feeds)) pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager + ctx.Data["Feeds"] = feeds ctx.HTML(http.StatusOK, tplDashboard) } @@ -176,7 +176,7 @@ func Milestones(ctx *context.Context) { } var ( - userRepoCond = repo_model.SearchRepositoryCondition(&repoOpts) // all repo condition user could visit + userRepoCond = repo_model.SearchRepositoryCondition(repoOpts) // all repo condition user could visit repoCond = userRepoCond repoIDs []int64 @@ -197,7 +197,7 @@ func Milestones(ctx *context.Context) { reposQuery = reposQuery[1 : len(reposQuery)-1] // for each ID (delimiter ",") add to int to repoIDs - for _, rID := range strings.Split(reposQuery, ",") { + for rID := range strings.SplitSeq(reposQuery, ",") { // Ensure nonempty string entries if rID != "" && rID != "0" { rIDint64, err := strconv.ParseInt(rID, 10, 64) @@ -242,7 +242,7 @@ func Milestones(ctx *context.Context) { return } - showRepos, _, err := repo_model.SearchRepositoryByCondition(ctx, &repoOpts, userRepoCond, false) + showRepos, _, err := repo_model.SearchRepositoryByCondition(ctx, repoOpts, userRepoCond, false) if err != nil { ctx.ServerError("SearchRepositoryByCondition", err) return @@ -417,7 +417,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { IsPull: optional.Some(isPullList), SortType: sortType, IsArchived: optional.Some(false), - User: ctx.Doer, + Doer: ctx.Doer, } // -------------------------------------------------------------------------- // Build opts (IssuesOptions), which contains filter information. @@ -429,7 +429,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // Get repository IDs where User/Org/Team has access. if ctx.Org != nil && ctx.Org.Organization != nil { - opts.Org = ctx.Org.Organization + opts.Owner = ctx.Org.Organization.AsUser() opts.Team = ctx.Org.Team issue.PrepareFilterIssueLabels(ctx, 0, ctx.Org.Organization.AsUser()) @@ -447,7 +447,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { ctx.Data["FilterAssigneeUsername"] = assigneeUsername opts.AssigneeID = user.GetFilterUserIDByName(ctx, assigneeUsername) - isFuzzy := ctx.FormBool("fuzzy") + searchMode := ctx.FormString("search_mode") // Search all repositories which // @@ -461,7 +461,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // As team: // - Team org's owns the repository. // - Team has read permission to repository. - repoOpts := &repo_model.SearchRepoOptions{ + repoOpts := repo_model.SearchRepoOptions{ Actor: ctx.Doer, OwnerID: ctxUser.ID, Private: true, @@ -500,9 +500,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { case issues_model.FilterModeAll: case issues_model.FilterModeYourRepositories: case issues_model.FilterModeAssign: - opts.AssigneeID = optional.Some(ctx.Doer.ID) + opts.AssigneeID = strconv.FormatInt(ctx.Doer.ID, 10) case issues_model.FilterModeCreate: - opts.PosterID = optional.Some(ctx.Doer.ID) + opts.PosterID = strconv.FormatInt(ctx.Doer.ID, 10) case issues_model.FilterModeMention: opts.MentionedID = ctx.Doer.ID case issues_model.FilterModeReviewRequested: @@ -520,10 +520,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { opts.IsClosed = optional.Some(isShowClosed) // Make sure page number is at least 1. Will be posted to ctx.Data. - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) opts.Paginator = &db.ListOptions{ Page: page, PageSize: setting.UI.IssuePagingNum, @@ -549,7 +546,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { var issues issues_model.IssueList { issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts).Copy( - func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy }, + func(o *issue_indexer.SearchOptions) { + o.SearchMode = indexer.SearchModeType(searchMode) + }, )) if err != nil { ctx.ServerError("issueIDsFromSearch", err) @@ -576,17 +575,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { // ------------------------------- // Fill stats to post to ctx.Data. // ------------------------------- - issueStats, err := getUserIssueStats(ctx, filterMode, issue_indexer.ToSearchOptions(keyword, opts).Copy( + issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, issue_indexer.ToSearchOptions(keyword, opts).Copy( func(o *issue_indexer.SearchOptions) { - o.IsFuzzyKeyword = isFuzzy - // If the doer is the same as the context user, which means the doer is viewing his own dashboard, - // it's not enough to show the repos that the doer owns or has been explicitly granted access to, - // because the doer may create issues or be mentioned in any public repo. - // So we need search issues in all public repos. - o.AllPublic = ctx.Doer.ID == ctxUser.ID - o.MentionID = nil - o.ReviewRequestedID = nil - o.ReviewedID = nil + o.SearchMode = indexer.SearchModeType(searchMode) }, )) if err != nil { @@ -623,9 +614,10 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { return 0 } reviewTyp := issues_model.ReviewTypeApprove - if typ == "reject" { + switch typ { + case "reject": reviewTyp = issues_model.ReviewTypeReject - } else if typ == "waiting" { + case "waiting": reviewTyp = issues_model.ReviewTypeRequest } for _, count := range counts { @@ -641,7 +633,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { ctx.Data["ViewType"] = viewType ctx.Data["SortType"] = sortType ctx.Data["IsShowClosed"] = isShowClosed - ctx.Data["IsFuzzy"] = isFuzzy + ctx.Data["SearchModes"] = issue_indexer.SupportedSearchModes() + ctx.Data["SelectedSearchMode"] = ctx.FormTrim("search_mode") if isShowClosed { ctx.Data["State"] = "closed" @@ -703,7 +696,7 @@ func ShowGPGKeys(ctx *context.Context) { headers := make(map[string]string) if len(failedEntitiesID) > 0 { // If some key need re-import to be exported - headers["Note"] = fmt.Sprintf("The keys with the following IDs couldn't be exported and need to be reuploaded %s", strings.Join(failedEntitiesID, ", ")) + headers["Note"] = "The keys with the following IDs couldn't be exported and need to be reuploaded " + strings.Join(failedEntitiesID, ", ") } else if len(entities) == 0 { headers["Note"] = "This user hasn't uploaded any GPG keys." } @@ -732,7 +725,7 @@ func UsernameSubRoute(ctx *context.Context) { // check view permissions if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { - ctx.NotFound("user", fmt.Errorf("%s", ctx.ContextUser.Name)) + ctx.NotFound(fmt.Errorf("%s", ctx.ContextUser.Name)) return false } return true @@ -740,7 +733,7 @@ func UsernameSubRoute(ctx *context.Context) { switch { case strings.HasSuffix(username, ".png"): if reloadParam(".png") { - AvatarByUserName(ctx) + AvatarByUsernameSize(ctx) } case strings.HasSuffix(username, ".keys"): if reloadParam(".keys") { @@ -752,7 +745,7 @@ func UsernameSubRoute(ctx *context.Context) { } case strings.HasSuffix(username, ".rss"): if !setting.Other.EnableFeed { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } if reloadParam(".rss") { @@ -760,7 +753,7 @@ func UsernameSubRoute(ctx *context.Context) { } case strings.HasSuffix(username, ".atom"): if !setting.Other.EnableFeed { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } if reloadParam(".atom") { @@ -775,10 +768,19 @@ func UsernameSubRoute(ctx *context.Context) { } } -func getUserIssueStats(ctx *context.Context, filterMode int, opts *issue_indexer.SearchOptions) (ret *issues_model.IssueStats, err error) { +func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMode int, opts *issue_indexer.SearchOptions) (ret *issues_model.IssueStats, err error) { ret = &issues_model.IssueStats{} doerID := ctx.Doer.ID + opts = opts.Copy(func(o *issue_indexer.SearchOptions) { + // If the doer is the same as the context user, which means the doer is viewing his own dashboard, + // it's not enough to show the repos that the doer owns or has been explicitly granted access to, + // because the doer may create issues or be mentioned in any public repo. + // So we need search issues in all public repos. + o.AllPublic = doerID == ctxUser.ID + }) + + // Open/Closed are for the tabs of the issue list { openClosedOpts := opts.Copy() switch filterMode { @@ -787,9 +789,9 @@ func getUserIssueStats(ctx *context.Context, filterMode int, opts *issue_indexer case issues_model.FilterModeYourRepositories: openClosedOpts.AllPublic = false case issues_model.FilterModeAssign: - openClosedOpts.AssigneeID = optional.Some(doerID) + openClosedOpts.AssigneeID = strconv.FormatInt(doerID, 10) case issues_model.FilterModeCreate: - openClosedOpts.PosterID = optional.Some(doerID) + openClosedOpts.PosterID = strconv.FormatInt(doerID, 10) case issues_model.FilterModeMention: openClosedOpts.MentionID = optional.Some(doerID) case issues_model.FilterModeReviewRequested: @@ -809,15 +811,24 @@ func getUserIssueStats(ctx *context.Context, filterMode int, opts *issue_indexer } } + // Below stats are for the left sidebar + opts = opts.Copy(func(o *issue_indexer.SearchOptions) { + o.AssigneeID = "" + o.PosterID = "" + o.MentionID = nil + o.ReviewRequestedID = nil + o.ReviewedID = nil + }) + ret.YourRepositoriesCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AllPublic = false })) if err != nil { return nil, err } - ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = optional.Some(doerID) })) + ret.AssignCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.AssigneeID = strconv.FormatInt(doerID, 10) })) if err != nil { return nil, err } - ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = optional.Some(doerID) })) + ret.CreateCount, err = issue_indexer.CountIssues(ctx, opts.Copy(func(o *issue_indexer.SearchOptions) { o.PosterID = strconv.FormatInt(doerID, 10) })) if err != nil { return nil, err } diff --git a/routers/web/user/home_test.go b/routers/web/user/home_test.go index 51246551ea..68ad79b11e 100644 --- a/routers/web/user/home_test.go +++ b/routers/web/user/home_test.go @@ -28,7 +28,7 @@ func TestArchivedIssues(t *testing.T) { ctx.Req.Form.Set("state", "open") // Assume: User 30 has access to two Repos with Issues, one of the Repos being archived. - repos, _, _ := repo_model.GetUserRepositories(db.DefaultContext, &repo_model.SearchRepoOptions{Actor: ctx.Doer}) + repos, _, _ := repo_model.GetUserRepositories(db.DefaultContext, repo_model.SearchRepoOptions{Actor: ctx.Doer}) assert.Len(t, repos, 3) IsArchived := make(map[int64]bool) NumIssues := make(map[int64]int) @@ -37,15 +37,15 @@ func TestArchivedIssues(t *testing.T) { NumIssues[repo.ID] = repo.NumIssues } assert.False(t, IsArchived[50]) - assert.EqualValues(t, 1, NumIssues[50]) + assert.Equal(t, 1, NumIssues[50]) assert.True(t, IsArchived[51]) - assert.EqualValues(t, 1, NumIssues[51]) + assert.Equal(t, 1, NumIssues[51]) // Act Issues(ctx) // Assert: One Issue (ID 30) from one Repo (ID 50) is retrieved, while nothing from archived Repo 51 is retrieved - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.Len(t, ctx.Data["Issues"], 1) } @@ -58,7 +58,7 @@ func TestIssues(t *testing.T) { contexttest.LoadUser(t, ctx, 2) ctx.Req.Form.Set("state", "closed") Issues(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) assert.Len(t, ctx.Data["Issues"], 1) @@ -72,7 +72,7 @@ func TestPulls(t *testing.T) { contexttest.LoadUser(t, ctx, 2) ctx.Req.Form.Set("state", "open") Pulls(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.Len(t, ctx.Data["Issues"], 5) } @@ -87,7 +87,7 @@ func TestMilestones(t *testing.T) { ctx.Req.Form.Set("state", "closed") ctx.Req.Form.Set("sort", "furthestduedate") Milestones(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) @@ -107,7 +107,7 @@ func TestMilestonesForSpecificRepo(t *testing.T) { ctx.Req.Form.Set("state", "closed") ctx.Req.Form.Set("sort", "furthestduedate") Milestones(ctx) - assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.EqualValues(t, map[int64]int64{1: 1}, ctx.Data["Counts"]) assert.EqualValues(t, true, ctx.Data["IsShowClosed"]) assert.EqualValues(t, "furthestduedate", ctx.Data["SortType"]) diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index 1c91ff6364..610a9b8076 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -4,7 +4,6 @@ package user import ( - goctx "context" "errors" "fmt" "net/http" @@ -35,32 +34,6 @@ const ( tplNotificationSubscriptions templates.TplName = "user/notification/notification_subscriptions" ) -// GetNotificationCount is the middleware that sets the notification count in the context -func GetNotificationCount(ctx *context.Context) { - if strings.HasPrefix(ctx.Req.URL.Path, "/api") { - return - } - - if !ctx.IsSigned { - return - } - - ctx.Data["NotificationUnreadCount"] = func() int64 { - count, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ - UserID: ctx.Doer.ID, - Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread}, - }) - if err != nil { - if err != goctx.Canceled { - log.Error("Unable to GetNotificationCount for user:%-v: %v", ctx.Doer, err) - } - return -1 - } - - return count - } -} - // Notifications is the notifications page func Notifications(ctx *context.Context) { getNotifications(ctx) @@ -230,10 +203,7 @@ func NotificationPurgePost(ctx *context.Context) { // NotificationSubscriptions returns the list of subscribed issues func NotificationSubscriptions(ctx *context.Context) { - page := ctx.FormInt("page") - if page < 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) sortType := ctx.FormString("sort") ctx.Data["SortType"] = sortType @@ -314,16 +284,8 @@ func NotificationSubscriptions(ctx *context.Context) { ctx.Data["CommitLastStatus"] = lastStatus ctx.Data["CommitStatuses"] = commitStatuses ctx.Data["Issues"] = issues - ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, "") - commitStatus, err := pull_service.GetIssuesLastCommitStatus(ctx, issues) - if err != nil { - ctx.ServerError("GetIssuesLastCommitStatus", err) - return - } - ctx.Data["CommitStatus"] = commitStatus - approvalCounts, err := issues.GetApprovalCounts(ctx) if err != nil { ctx.ServerError("ApprovalCounts", err) @@ -335,9 +297,10 @@ func NotificationSubscriptions(ctx *context.Context) { return 0 } reviewTyp := issues_model.ReviewTypeApprove - if typ == "reject" { + switch typ { + case "reject": reviewTyp = issues_model.ReviewTypeReject - } else if typ == "waiting" { + case "waiting": reviewTyp = issues_model.ReviewTypeRequest } for _, count := range counts { @@ -365,10 +328,7 @@ func NotificationSubscriptions(ctx *context.Context) { // NotificationWatching returns the list of watching repos func NotificationWatching(ctx *context.Context) { - page := ctx.FormInt("page") - if page < 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) keyword := ctx.FormTrim("q") ctx.Data["Keyword"] = keyword @@ -416,7 +376,7 @@ func NotificationWatching(ctx *context.Context) { private := ctx.FormOptionalBool("private") ctx.Data["IsPrivate"] = private - repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ PageSize: setting.UI.User.RepoPagingNum, Page: page, diff --git a/routers/web/user/package.go b/routers/web/user/package.go index 1f75faf1c6..216acdf927 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -4,6 +4,8 @@ package user import ( + gocontext "context" + "errors" "net/http" "net/url" @@ -20,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/optional" alpine_module "code.gitea.io/gitea/modules/packages/alpine" arch_module "code.gitea.io/gitea/modules/packages/arch" + container_module "code.gitea.io/gitea/modules/packages/container" debian_module "code.gitea.io/gitea/modules/packages/debian" rpm_module "code.gitea.io/gitea/modules/packages/rpm" "code.gitea.io/gitea/modules/setting" @@ -31,6 +34,7 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" packages_service "code.gitea.io/gitea/services/packages" + container_service "code.gitea.io/gitea/services/packages/container" ) const ( @@ -42,11 +46,11 @@ const ( // ListPackages displays a list of all packages of the context user func ListPackages(ctx *context.Context) { - shared_user.PrepareContextForProfileBigAvatar(ctx) - page := ctx.FormInt("page") - if page <= 1 { - page = 1 + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return } + page := max(ctx.FormInt("page"), 1) query := ctx.FormTrim("q") packageType := ctx.FormTrim("type") @@ -94,8 +98,6 @@ func ListPackages(ctx *context.Context) { return } - shared_user.RenderUserHeader(ctx) - ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["IsPackagesPage"] = true ctx.Data["Query"] = query @@ -106,9 +108,8 @@ func ListPackages(ctx *context.Context) { ctx.Data["Total"] = total ctx.Data["RepositoryAccessMap"] = repositoryAccessMap - err = shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -126,11 +127,9 @@ func ListPackages(ctx *context.Context) { ctx.Data["IsOrganizationOwner"] = false } } - pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager - ctx.HTML(http.StatusOK, tplPackagesList) } @@ -139,7 +138,7 @@ func RedirectToLastVersion(ctx *context.Context) { p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.PathParam("type")), ctx.PathParam("name")) if err != nil { if err == packages_model.ErrPackageNotExist { - ctx.NotFound("GetPackageByName", err) + ctx.NotFound(err) } else { ctx.ServerError("GetPackageByName", err) } @@ -155,7 +154,7 @@ func RedirectToLastVersion(ctx *context.Context) { return } if len(pvs) == 0 { - ctx.NotFound("", err) + ctx.NotFound(err) return } @@ -164,16 +163,36 @@ func RedirectToLastVersion(ctx *context.Context) { ctx.ServerError("GetPackageDescriptor", err) return } - ctx.Redirect(pd.VersionWebLink()) } +func viewPackageContainerImage(ctx gocontext.Context, pd *packages_model.PackageDescriptor, digest string) (*container_module.Metadata, error) { + manifestBlob, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{ + OwnerID: pd.Owner.ID, + Image: pd.Package.LowerName, + Digest: digest, + }) + if err != nil { + return nil, err + } + manifestReader, err := packages_service.OpenBlobStream(manifestBlob.Blob) + if err != nil { + return nil, err + } + defer manifestReader.Close() + _, _, metadata, err := container_service.ParseManifestMetadata(ctx, manifestReader, pd.Owner.ID, pd.Package.LowerName) + return metadata, err +} + // ViewPackageVersion displays a single package version func ViewPackageVersion(ctx *context.Context) { - pd := ctx.Package.Descriptor - - shared_user.RenderUserHeader(ctx) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + versionSub := ctx.PathParam("version_sub") + pd := ctx.Package.Descriptor ctx.Data["Title"] = pd.Package.Name ctx.Data["IsPackagesPage"] = true ctx.Data["PackageDescriptor"] = pd @@ -261,21 +280,30 @@ func ViewPackageVersion(ctx *context.Context) { ctx.Data["Groups"] = util.Sorted(groups.Values()) ctx.Data["Architectures"] = util.Sorted(architectures.Values()) - } - - var ( - total int64 - pvs []*packages_model.PackageVersion - ) - switch pd.Package.Type { case packages_model.TypeContainer: - pvs, total, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{ + imageMetadata := pd.Metadata + if versionSub != "" { + imageMetadata, err = viewPackageContainerImage(ctx, pd, versionSub) + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound(nil) + return + } else if err != nil { + ctx.ServerError("viewPackageContainerImage", err) + return + } + } + ctx.Data["ContainerImageMetadata"] = imageMetadata + } + var pvs []*packages_model.PackageVersion + var pvsTotal int64 + if pd.Package.Type == packages_model.TypeContainer { + pvs, pvsTotal, err = container_model.SearchImageTags(ctx, &container_model.ImageTagsSearchOptions{ Paginator: db.NewAbsoluteListOptions(0, 5), PackageID: pd.Package.ID, IsTagged: true, }) - default: - pvs, total, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ + } else { + pvs, pvsTotal, err = packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ Paginator: db.NewAbsoluteListOptions(0, 5), PackageID: pd.Package.ID, IsInternal: optional.Some(false), @@ -285,9 +313,8 @@ func ViewPackageVersion(ctx *context.Context) { ctx.ServerError("", err) return } - ctx.Data["LatestVersions"] = pvs - ctx.Data["TotalVersionCount"] = total + ctx.Data["TotalVersionCount"] = pvsTotal ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin() @@ -301,33 +328,27 @@ func ViewPackageVersion(ctx *context.Context) { hasRepositoryAccess = permission.HasAnyUnitAccess() } ctx.Data["HasRepositoryAccess"] = hasRepositoryAccess - - err = shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) - return - } - ctx.HTML(http.StatusOK, tplPackagesView) } // ListPackageVersions lists all versions of a package func ListPackageVersions(ctx *context.Context) { - shared_user.PrepareContextForProfileBigAvatar(ctx) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } + p, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.Type(ctx.PathParam("type")), ctx.PathParam("name")) if err != nil { if err == packages_model.ErrPackageNotExist { - ctx.NotFound("GetPackageByName", err) + ctx.NotFound(err) } else { ctx.ServerError("GetPackageByName", err) } return } - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) pagination := &db.ListOptions{ PageSize: setting.UI.PackagesPagingNum, Page: page, @@ -336,8 +357,6 @@ func ListPackageVersions(ctx *context.Context) { query := ctx.FormTrim("q") sort := ctx.FormTrim("sort") - shared_user.RenderUserHeader(ctx) - ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["IsPackagesPage"] = true ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{ @@ -393,12 +412,6 @@ func ListPackageVersions(ctx *context.Context) { ctx.Data["Total"] = total - err = shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) - return - } - pager := context.NewPagination(int(total), setting.UI.PackagesPagingNum, page, 5) pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager @@ -410,25 +423,22 @@ func ListPackageVersions(ctx *context.Context) { func PackageSettings(ctx *context.Context) { pd := ctx.Package.Descriptor - shared_user.RenderUserHeader(ctx) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } ctx.Data["Title"] = pd.Package.Name ctx.Data["IsPackagesPage"] = true ctx.Data["PackageDescriptor"] = pd - repos, _, _ := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + repos, _, _ := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{ Actor: pd.Owner, Private: true, }) ctx.Data["Repos"] = repos ctx.Data["CanWritePackages"] = ctx.Package.AccessMode >= perm.AccessModeWrite || ctx.IsUserSiteAdmin() - err := shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) - return - } - ctx.HTML(http.StatusOK, tplPackagesSettings) } @@ -496,16 +506,16 @@ func DownloadPackageFile(ctx *context.Context) { pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.PathParamInt64("fileid")) if err != nil { if err == packages_model.ErrPackageFileNotExist { - ctx.NotFound("", err) + ctx.NotFound(err) } else { ctx.ServerError("GetFileForVersionByID", err) } return } - s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + s, u, _, err := packages_service.OpenFileForDownload(ctx, pf) if err != nil { - ctx.ServerError("GetPackageFileStream", err) + ctx.ServerError("OpenFileForDownload", err) return } diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 006ffdcf7e..d7052914b6 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -56,34 +56,29 @@ func OwnerProfile(ctx *context.Context) { func userProfile(ctx *context.Context) { // check view permissions if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { - ctx.NotFound("user", fmt.Errorf("%s", ctx.ContextUser.Name)) + ctx.NotFound(fmt.Errorf("%s", ctx.ContextUser.Name)) return } ctx.Data["Title"] = ctx.ContextUser.DisplayName() ctx.Data["PageIsUserProfile"] = true - // prepare heatmap data - if setting.Service.EnableUserHeatmap { - data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer) - if err != nil { - ctx.ServerError("GetUserHeatmapDataByUser", err) - return - } - ctx.Data["HeatmapData"] = data - ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) - } - profileDbRepo, profileReadmeBlob := shared_user.FindOwnerProfileReadme(ctx, ctx.Doer) - showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) - prepareUserProfileTabData(ctx, showPrivate, profileDbRepo, profileReadmeBlob) - // call PrepareContextForProfileBigAvatar later to avoid re-querying the NumFollowers & NumFollowing - shared_user.PrepareContextForProfileBigAvatar(ctx) + prepareUserProfileTabData(ctx, profileDbRepo, profileReadmeBlob) + + // prepare the user nav header data after "prepareUserProfileTabData" to avoid re-querying the NumFollowers & NumFollowing + // because ctx.Data["NumFollowers"] and "NumFollowing" logic duplicates in both of them + // and the "profile readme" related logic also duplicates in both of FindOwnerProfileReadme and RenderUserOrgHeader + // TODO: it is a bad design and should be refactored later, + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) + return + } ctx.HTML(http.StatusOK, tplProfile) } -func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDbRepo *repo_model.Repository, profileReadme *git.Blob) { +func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.Repository, profileReadme *git.Blob) { // if there is a profile readme, default to "overview" page, otherwise, default to "repositories" page // if there is not a profile readme, the overview tab should be treated as the repositories tab tab := ctx.FormString("tab") @@ -166,8 +161,20 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb ctx.Data["Cards"] = following total = int(numFollowing) case "activity": + // prepare heatmap data + if setting.Service.EnableUserHeatmap { + data, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserHeatmapDataByUser", err) + return + } + ctx.Data["HeatmapData"] = data + ctx.Data["HeatmapTotalContributions"] = activities_model.GetTotalContributionsInHeatmap(data) + } + date := ctx.FormString("date") pagingNum = setting.UI.FeedPagingNum + showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) items, count, err := feed_service.GetFeeds(ctx, activities_model.GetFeedsOptions{ RequestedUser: ctx.ContextUser, Actor: ctx.Doer, @@ -190,7 +197,8 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb total = int(count) case "stars": ctx.Data["PageIsProfileStarList"] = true - repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ctx.Data["ShowRepoOwnerOnList"] = true + repos, count, err = repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ PageSize: pagingNum, Page: page, @@ -217,7 +225,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb total = int(count) case "watching": - repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + repos, count, err = repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ PageSize: pagingNum, Page: page, @@ -258,8 +266,8 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb } case "organizations": orgs, count, err := db.FindAndCount[organization.Organization](ctx, organization.FindOrgOptions{ - UserID: ctx.ContextUser.ID, - IncludePrivate: showPrivate, + UserID: ctx.ContextUser.ID, + IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, ctx.ContextUser), ListOptions: db.ListOptions{ Page: page, PageSize: pagingNum, @@ -272,7 +280,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb ctx.Data["Cards"] = orgs total = int(count) default: // default to "repositories" - repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + repos, count, err = repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ PageSize: pagingNum, Page: page, @@ -302,9 +310,8 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb ctx.Data["Repos"] = repos ctx.Data["Total"] = total - err = shared_user.LoadHeaderCount(ctx) - if err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } @@ -313,8 +320,8 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb ctx.Data["Page"] = pager } -// Action response for follow/unfollow user request -func Action(ctx *context.Context) { +// ActionUserFollow is for follow/unfollow user request +func ActionUserFollow(ctx *context.Context) { var err error switch ctx.FormString("action") { case "follow": @@ -325,12 +332,14 @@ func Action(ctx *context.Context) { if err != nil { log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err) - ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + ctx.HTTPError(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + return + } + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } - if ctx.ContextUser.IsIndividual() { - shared_user.PrepareContextForProfileBigAvatar(ctx) ctx.HTML(http.StatusOK, tplProfileBigAvatar) return } else if ctx.ContextUser.IsOrganization() { @@ -339,6 +348,6 @@ func Action(ctx *context.Context) { ctx.HTML(http.StatusOK, tplFollowUnfollow) return } - log.Error("Failed to apply action %q: unsupport context user type: %s", ctx.FormString("action"), ctx.ContextUser.Type) - ctx.Error(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + log.Error("Failed to apply action %q: unsupported context user type: %s", ctx.FormString("action"), ctx.ContextUser.Type) + ctx.HTTPError(http.StatusBadRequest, fmt.Sprintf("Action %q failed", ctx.FormString("action"))) } diff --git a/routers/web/user/search.go b/routers/web/user/search.go index be5eee90a9..9acb9694d7 100644 --- a/routers/web/user/search.go +++ b/routers/web/user/search.go @@ -16,7 +16,7 @@ import ( // SearchCandidates searches candidate users for dropdown list func SearchCandidates(ctx *context.Context) { - users, _, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + users, _, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Keyword: ctx.FormTrim("q"), Type: user_model.UserTypeIndividual, diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go index 3acc3c7a54..b124d5e1de 100644 --- a/routers/web/user/setting/account.go +++ b/routers/web/user/setting/account.go @@ -6,7 +6,6 @@ package setting import ( "errors" - "fmt" "net/http" "time" @@ -37,7 +36,7 @@ const ( // Account renders change user's password, user's email and user suicide page func Account(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials, setting.UserFeatureDeletion) && !setting.Service.EnableNotifyMail { - ctx.NotFound("Not Found", fmt.Errorf("account setting are not allowed to be changed")) + ctx.NotFound(errors.New("account setting are not allowed to be changed")) return } @@ -54,7 +53,7 @@ func Account(ctx *context.Context) { // AccountPost response for change user's password func AccountPost(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { - ctx.NotFound("Not Found", fmt.Errorf("password setting is not allowed to be changed")) + ctx.NotFound(errors.New("password setting is not allowed to be changed")) return } @@ -105,7 +104,7 @@ func AccountPost(ctx *context.Context) { // EmailPost response for change user's email func EmailPost(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { - ctx.NotFound("Not Found", fmt.Errorf("emails are not allowed to be changed")) + ctx.NotFound(errors.New("emails are not allowed to be changed")) return } @@ -239,7 +238,7 @@ func EmailPost(ctx *context.Context) { // DeleteEmail response for delete user's email func DeleteEmail(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { - ctx.NotFound("Not Found", fmt.Errorf("emails are not allowed to be changed")) + ctx.NotFound(errors.New("emails are not allowed to be changed")) return } email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, ctx.FormInt64("id")) @@ -261,7 +260,7 @@ func DeleteEmail(ctx *context.Context) { // DeleteAccount render user suicide page and response for delete user himself func DeleteAccount(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureDeletion) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } diff --git a/routers/web/user/setting/account_test.go b/routers/web/user/setting/account_test.go index 9fdc5e4d53..9b8cffc868 100644 --- a/routers/web/user/setting/account_test.go +++ b/routers/web/user/setting/account_test.go @@ -95,7 +95,7 @@ func TestChangePassword(t *testing.T) { AccountPost(ctx) assert.Contains(t, ctx.Flash.ErrorMsg, req.Message) - assert.EqualValues(t, http.StatusSeeOther, ctx.Resp.Status()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) }) } } diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index cf71d01dc1..9c43ddd3ea 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -6,12 +6,14 @@ package setting import ( "net/http" + "strings" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -39,18 +41,30 @@ func ApplicationsPost(ctx *context.Context) { ctx.Data["PageIsSettingsApplications"] = true ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) - if ctx.HasError() { - loadApplicationsData(ctx) - - ctx.HTML(http.StatusOK, tplSettingsApplications) - return + _ = ctx.Req.ParseForm() + var scopeNames []string + const accessTokenScopePrefix = "scope-" + for k, v := range ctx.Req.Form { + if strings.HasPrefix(k, accessTokenScopePrefix) { + scopeNames = append(scopeNames, v...) + } } - scope, err := form.GetScope() + scope, err := auth_model.AccessTokenScope(strings.Join(scopeNames, ",")).Normalize() if err != nil { ctx.ServerError("GetScope", err) return } + if !scope.HasPermissionScope() { + ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true) + } + + if ctx.HasError() { + loadApplicationsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + t := &auth_model.AccessToken{ UID: ctx.Doer.ID, Name: form.Name, @@ -99,7 +113,14 @@ func loadApplicationsData(ctx *context.Context) { } ctx.Data["Tokens"] = tokens ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled - ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin + + // Handle specific ordered token categories for admin or non-admin users + tokenCategoryNames := auth_model.GetAccessTokenCategories() + if !ctx.Doer.IsAdmin { + tokenCategoryNames = util.SliceRemoveAll(tokenCategoryNames, "admin") + } + ctx.Data["TokenCategories"] = tokenCategoryNames + if setting.OAuth2.Enabled { ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{ OwnerID: ctx.Doer.ID, diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go index 127aed9845..6b5a7a2e2a 100644 --- a/routers/web/user/setting/keys.go +++ b/routers/web/user/setting/keys.go @@ -5,7 +5,7 @@ package setting import ( - "fmt" + "errors" "net/http" asymkey_model "code.gitea.io/gitea/models/asymkey" @@ -26,7 +26,7 @@ const ( // Keys render user's SSH/GPG public keys page func Keys(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys, setting.UserFeatureManageGPGKeys) { - ctx.NotFound("Not Found", fmt.Errorf("keys setting is not allowed to be changed")) + ctx.NotFound(errors.New("keys setting is not allowed to be changed")) return } @@ -87,7 +87,7 @@ func KeysPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "gpg": if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { - ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + ctx.NotFound(errors.New("gpg keys setting is not allowed to be visited")) return } @@ -168,7 +168,7 @@ func KeysPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "ssh": if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { - ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + ctx.NotFound(errors.New("ssh keys setting is not allowed to be visited")) return } @@ -212,7 +212,7 @@ func KeysPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "verify_ssh": if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { - ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + ctx.NotFound(errors.New("ssh keys setting is not allowed to be visited")) return } @@ -249,7 +249,7 @@ func DeleteKey(ctx *context.Context) { switch ctx.FormString("type") { case "gpg": if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { - ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + ctx.NotFound(errors.New("gpg keys setting is not allowed to be visited")) return } if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { @@ -259,7 +259,7 @@ func DeleteKey(ctx *context.Context) { } case "ssh": if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { - ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + ctx.NotFound(errors.New("ssh keys setting is not allowed to be visited")) return } diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go index 783deca710..f460acce10 100644 --- a/routers/web/user/setting/oauth2_common.go +++ b/routers/web/user/setting/oauth2_common.go @@ -28,8 +28,8 @@ func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context) { ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID) if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() { - if err := shared_user.LoadHeaderCount(ctx); err != nil { - ctx.ServerError("LoadHeaderCount", err) + if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { + ctx.ServerError("RenderUserOrgHeader", err) return } } @@ -76,14 +76,14 @@ func (oa *OAuth2CommonHandlers) EditShow(ctx *context.Context) { app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.PathParamInt64("id")) if err != nil { if auth.IsErrOAuthApplicationNotFound(err) { - ctx.NotFound("Application not found", err) + ctx.NotFound(err) return } ctx.ServerError("GetOAuth2ApplicationByID", err) return } if app.UID != oa.OwnerID { - ctx.NotFound("Application not found", nil) + ctx.NotFound(nil) return } ctx.Data["App"] = app @@ -98,14 +98,14 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.PathParamInt64("id")) if err != nil { if auth.IsErrOAuthApplicationNotFound(err) { - ctx.NotFound("Application not found", err) + ctx.NotFound(err) return } ctx.ServerError("GetOAuth2ApplicationByID", err) return } if app.UID != oa.OwnerID { - ctx.NotFound("Application not found", nil) + ctx.NotFound(nil) return } ctx.Data["App"] = app @@ -135,14 +135,14 @@ func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) { app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.PathParamInt64("id")) if err != nil { if auth.IsErrOAuthApplicationNotFound(err) { - ctx.NotFound("Application not found", err) + ctx.NotFound(err) return } ctx.ServerError("GetOAuth2ApplicationByID", err) return } if app.UID != oa.OwnerID { - ctx.NotFound("Application not found", nil) + ctx.NotFound(nil) return } ctx.Data["App"] = app diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index ebf682bf58..98995cd69c 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/typesniffer" @@ -206,8 +207,8 @@ func Organization(ctx *context.Context) { PageSize: setting.UI.Admin.UserPagingNum, Page: ctx.FormInt("page"), }, - UserID: ctx.Doer.ID, - IncludePrivate: ctx.IsSigned, + UserID: ctx.Doer.ID, + IncludeVisibility: structs.VisibleTypePrivate, } if opts.Page <= 0 { @@ -284,7 +285,7 @@ func Repos(ctx *context.Context) { return } - userRepos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + userRepos, _, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{ Actor: ctxUser, Private: true, ListOptions: db.ListOptions{ @@ -309,7 +310,7 @@ func Repos(ctx *context.Context) { ctx.Data["Dirs"] = repoNames ctx.Data["ReposMap"] = repos } else { - repos, count64, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts}) + repos, count64, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{Actor: ctxUser, Private: true, ListOptions: opts}) if err != nil { ctx.ServerError("GetUserRepositories", err) return @@ -338,13 +339,7 @@ func Repos(ctx *context.Context) { func Appearance(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings.appearance") ctx.Data["PageIsSettingsAppearance"] = true - - allThemes := webtheme.GetAvailableThemes() - if webtheme.IsThemeAvailable(setting.UI.DefaultTheme) { - allThemes = util.SliceRemoveAll(allThemes, setting.UI.DefaultTheme) - allThemes = append([]string{setting.UI.DefaultTheme}, allThemes...) // move the default theme to the top - } - ctx.Data["AllThemes"] = allThemes + ctx.Data["AllThemes"] = webtheme.GetAvailableThemes() ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) var hiddenCommentTypes *big.Int diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go index 7bb10248e8..e5e23c820c 100644 --- a/routers/web/user/setting/security/2fa.go +++ b/routers/web/user/setting/security/2fa.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" @@ -27,7 +28,7 @@ import ( // RegenerateScratchTwoFactor regenerates the user's 2FA scratch code. func RegenerateScratchTwoFactor(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -63,7 +64,7 @@ func RegenerateScratchTwoFactor(ctx *context.Context) { // DisableTwoFactor deletes the user's 2FA settings. func DisableTwoFactor(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -157,12 +158,13 @@ func twofaGenerateSecretAndQr(ctx *context.Context) bool { // EnrollTwoFactor shows the page where the user can enroll into 2FA. func EnrollTwoFactor(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true + ctx.Data["ShowTwoFactorRequiredMessage"] = false t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) if t != nil { @@ -187,13 +189,14 @@ func EnrollTwoFactor(ctx *context.Context) { // EnrollTwoFactorPost handles enrolling the user into 2FA. func EnrollTwoFactorPost(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true + ctx.Data["ShowTwoFactorRequiredMessage"] = false t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) if t != nil { @@ -246,6 +249,10 @@ func EnrollTwoFactorPost(ctx *context.Context) { return } + newTwoFactorErr := auth.NewTwoFactor(ctx, t) + if newTwoFactorErr == nil { + _ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true) + } // Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used // If we can detect the unique constraint failure below we can move this to after the NewTwoFactor if err := ctx.Session.Delete("twofaSecret"); err != nil { @@ -261,10 +268,10 @@ func EnrollTwoFactorPost(ctx *context.Context) { log.Error("Unable to save changes to the session: %v", err) } - if err = auth.NewTwoFactor(ctx, t); err != nil { + if newTwoFactorErr != nil { // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us. // If there is a unique constraint fail we should just tolerate the error - ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err) + ctx.ServerError("SettingsTwoFactor: Failed to save two factor", newTwoFactorErr) return } diff --git a/routers/web/user/setting/security/openid.go b/routers/web/user/setting/security/openid.go index 30eb6f63f8..9a57657912 100644 --- a/routers/web/user/setting/security/openid.go +++ b/routers/web/user/setting/security/openid.go @@ -18,7 +18,7 @@ import ( // OpenIDPost response for change user's openid func OpenIDPost(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -111,7 +111,7 @@ func settingsOpenIDVerify(ctx *context.Context) { // DeleteOpenID response for delete user's openid func DeleteOpenID(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -128,7 +128,7 @@ func DeleteOpenID(ctx *context.Context) { // ToggleOpenIDVisibility response for toggle visibility of user's openid func ToggleOpenIDVisibility(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go index 38d1910e2d..cc4c44993a 100644 --- a/routers/web/user/setting/security/security.go +++ b/routers/web/user/setting/security/security.go @@ -27,7 +27,7 @@ const ( func Security(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA, setting.UserFeatureManageCredentials) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -47,7 +47,7 @@ func Security(ctx *context.Context) { // DeleteAccountLink delete a single account link func DeleteAccountLink(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index 70bfaac6e0..eb9f46af52 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -13,6 +13,7 @@ import ( user_model "code.gitea.io/gitea/models/user" wa "code.gitea.io/gitea/modules/auth/webauthn" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/session" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" @@ -25,7 +26,7 @@ import ( // WebAuthnRegister initializes the webauthn registration procedure func WebAuthnRegister(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -41,7 +42,7 @@ func WebAuthnRegister(ctx *context.Context) { return } if cred != nil { - ctx.Error(http.StatusConflict, "Name already taken") + ctx.HTTPError(http.StatusConflict, "Name already taken") return } @@ -72,7 +73,7 @@ func WebAuthnRegister(ctx *context.Context) { // WebauthnRegisterPost receives the response of the security key func WebauthnRegisterPost(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } @@ -109,7 +110,7 @@ func WebauthnRegisterPost(ctx *context.Context) { return } if dbCred != nil { - ctx.Error(http.StatusConflict, "Name already taken") + ctx.HTTPError(http.StatusConflict, "Name already taken") return } @@ -120,14 +121,14 @@ func WebauthnRegisterPost(ctx *context.Context) { return } _ = ctx.Session.Delete("webauthnName") - + _ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true) ctx.JSON(http.StatusCreated, cred) } // WebauthnDelete deletes an security key by id func WebauthnDelete(ctx *context.Context) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } diff --git a/routers/web/user/setting/settings.go b/routers/web/user/setting/settings.go new file mode 100644 index 0000000000..111931633d --- /dev/null +++ b/routers/web/user/setting/settings.go @@ -0,0 +1,26 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strconv" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/services/context" +) + +func UpdatePreferences(ctx *context.Context) { + type preferencesForm struct { + CodeViewShowFileTree bool `json:"codeViewShowFileTree"` + } + form := &preferencesForm{} + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.HTTPError(http.StatusBadRequest, "json decode failed") + return + } + _ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyCodeViewShowFileTree, strconv.FormatBool(form.CodeViewShowFileTree)) + ctx.JSONOK() +} diff --git a/routers/web/user/stop_watch.go b/routers/web/user/stop_watch.go index 38f74ea455..1d1cc61cc9 100644 --- a/routers/web/user/stop_watch.go +++ b/routers/web/user/stop_watch.go @@ -19,19 +19,19 @@ func GetStopwatches(ctx *context.Context) { PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), }) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.HTTPError(http.StatusInternalServerError, err.Error()) return } count, err := issues_model.CountUserStopwatches(ctx, ctx.Doer.ID) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.HTTPError(http.StatusInternalServerError, err.Error()) return } apiSWs, err := convert.ToStopWatches(ctx, sws) if err != nil { - ctx.Error(http.StatusInternalServerError, err.Error()) + ctx.HTTPError(http.StatusInternalServerError, err.Error()) return } diff --git a/routers/web/web.go b/routers/web/web.go index 609a7d77ac..1039f9e739 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -37,6 +37,7 @@ import ( "code.gitea.io/gitea/routers/web/repo" "code.gitea.io/gitea/routers/web/repo/actions" repo_setting "code.gitea.io/gitea/routers/web/repo/setting" + shared_actions "code.gitea.io/gitea/routers/web/shared/actions" "code.gitea.io/gitea/routers/web/shared/project" "code.gitea.io/gitea/routers/web/user" user_setting "code.gitea.io/gitea/routers/web/user/setting" @@ -118,7 +119,7 @@ func webAuth(authMethod auth_service.Method) func(*context.Context) { ar, err := common.AuthShared(ctx.Base, ctx.Session, authMethod) if err != nil { log.Error("Failed to verify user: %v", err) - ctx.Error(http.StatusUnauthorized, "Verify") + ctx.HTTPError(http.StatusUnauthorized, "Failed to authenticate user") return } ctx.Doer = ar.Doer @@ -153,7 +154,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont if ctx.Doer.MustChangePassword { if ctx.Req.URL.Path != "/user/settings/change_password" { if strings.HasPrefix(ctx.Req.UserAgent(), "git") { - ctx.Error(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password")) + ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password")) return } ctx.Data["Title"] = ctx.Tr("auth.must_change_password") @@ -177,7 +178,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont return } - if !options.SignOutRequired && !options.DisableCSRF && ctx.Req.Method == "POST" { + if !options.SignOutRequired && !options.DisableCSRF && ctx.Req.Method == http.MethodPost { ctx.Csrf.Validate(ctx) if ctx.Written() { return @@ -210,7 +211,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont if options.AdminRequired { if !ctx.Doer.IsAdmin { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } ctx.Data["PageIsAdmin"] = true @@ -232,8 +233,8 @@ func Routes() *web.Router { routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler routes.Methods("GET, HEAD, OPTIONS", "/assets/*", optionsCorsHandler(), public.FileHandlerFunc()) - routes.Methods("GET, HEAD", "/avatars/*", storageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) - routes.Methods("GET, HEAD", "/repo-avatars/*", storageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) + routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) + routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) routes.Methods("GET, HEAD", "/apple-touch-icon.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) routes.Methods("GET, HEAD", "/apple-touch-icon-precomposed.png", misc.StaticRedirect("/assets/img/apple-touch-icon.png")) routes.Methods("GET, HEAD", "/favicon.ico", misc.StaticRedirect("/assets/img/favicon.png")) @@ -279,62 +280,60 @@ func Routes() *web.Router { routes.Get("/api/swagger", append(mid, misc.Swagger)...) // Render V1 by default } - // TODO: These really seem like things that could be folded into Contexter or as helper functions - mid = append(mid, user.GetNotificationCount) - mid = append(mid, repo.GetActiveStopwatch) mid = append(mid, goGet) + mid = append(mid, common.PageTmplFunctions) - others := web.NewRouter() - others.Use(mid...) - registerRoutes(others) - routes.Mount("", others) + webRoutes := web.NewRouter() + webRoutes.Use(mid...) + webRoutes.Group("", func() { registerWebRoutes(webRoutes) }, common.BlockExpensive(), common.QoS()) + routes.Mount("", webRoutes) return routes } var optSignInIgnoreCsrf = verifyAuthWithOptions(&common.VerifyOptions{DisableCSRF: true}) -// registerRoutes register routes -func registerRoutes(m *web.Router) { +// registerWebRoutes register routes +func registerWebRoutes(m *web.Router) { // required to be signed in or signed out reqSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: true}) reqSignOut := verifyAuthWithOptions(&common.VerifyOptions{SignOutRequired: true}) // optional sign in (if signed in, use the user as doer, if not, no doer) - optSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView}) - optExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView}) + optSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict}) + optExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView}) validation.AddBindingRules() linkAccountEnabled := func(ctx *context.Context) { if !setting.Service.EnableOpenIDSignIn && !setting.Service.EnableOpenIDSignUp && !setting.OAuth2.Enabled { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } } openIDSignInEnabled := func(ctx *context.Context) { if !setting.Service.EnableOpenIDSignIn { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } } openIDSignUpEnabled := func(ctx *context.Context) { if !setting.Service.EnableOpenIDSignUp { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } } oauth2Enabled := func(ctx *context.Context) { if !setting.OAuth2.Enabled { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } } reqMilestonesDashboardPageEnabled := func(ctx *context.Context) { if !setting.Service.ShowMilestonesDashboardPage { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } } @@ -342,49 +341,56 @@ func registerRoutes(m *web.Router) { // webhooksEnabled requires webhooks to be enabled by admin. webhooksEnabled := func(ctx *context.Context) { if setting.DisableWebhooks { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) + return + } + } + + starsEnabled := func(ctx *context.Context) { + if setting.Repository.DisableStars { + ctx.HTTPError(http.StatusForbidden) return } } lfsServerEnabled := func(ctx *context.Context) { if !setting.LFS.StartServer { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } } federationEnabled := func(ctx *context.Context) { if !setting.Federation.Enabled { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } } dlSourceEnabled := func(ctx *context.Context) { if setting.Repository.DisableDownloadSourceArchives { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } } sitemapEnabled := func(ctx *context.Context) { if !setting.Other.EnableSitemap { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } } packagesEnabled := func(ctx *context.Context) { if !setting.Packages.Enabled { - ctx.Error(http.StatusForbidden) + ctx.HTTPError(http.StatusForbidden) return } } feedEnabled := func(ctx *context.Context) { if !setting.Other.EnableFeed { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } } @@ -393,18 +399,18 @@ func registerRoutes(m *web.Router) { return func(ctx *context.Context) { // only check global disabled units when ignoreGlobal is false if !ignoreGlobal && unitType.UnitGlobalDisabled() { - ctx.NotFound("Repo unit is is disabled: "+unitType.LogString(), nil) + ctx.NotFound(nil) return } if ctx.ContextUser == nil { - ctx.NotFound("ContextUser is nil", nil) + ctx.NotFound(nil) return } if ctx.ContextUser.IsOrganization() { if ctx.Org.Organization.UnitPermission(ctx, ctx.Doer, unitType) < accessMode { - ctx.NotFound("ContextUser is org but doer has no access to unit", nil) + ctx.NotFound(nil) return } } @@ -442,10 +448,10 @@ func registerRoutes(m *web.Router) { addSettingsVariablesRoutes := func() { m.Group("/variables", func() { - m.Get("", repo_setting.Variables) - m.Post("/new", web.Bind(forms.EditVariableForm{}), repo_setting.VariableCreate) - m.Post("/{variable_id}/edit", web.Bind(forms.EditVariableForm{}), repo_setting.VariableUpdate) - m.Post("/{variable_id}/delete", repo_setting.VariableDelete) + m.Get("", shared_actions.Variables) + m.Post("/new", web.Bind(forms.EditVariableForm{}), shared_actions.VariableCreate) + m.Post("/{variable_id}/edit", web.Bind(forms.EditVariableForm{}), shared_actions.VariableUpdate) + m.Post("/{variable_id}/delete", shared_actions.VariableDelete) }) } @@ -459,11 +465,11 @@ func registerRoutes(m *web.Router) { addSettingsRunnersRoutes := func() { m.Group("/runners", func() { - m.Get("", repo_setting.Runners) - m.Combo("/{runnerid}").Get(repo_setting.RunnersEdit). - Post(web.Bind(forms.EditRunnerForm{}), repo_setting.RunnersEditPost) - m.Post("/{runnerid}/delete", repo_setting.RunnerDeletePost) - m.Post("/reset_registration_token", repo_setting.ResetRunnerRegistrationToken) + m.Get("", shared_actions.Runners) + m.Combo("/{runnerid}").Get(shared_actions.RunnersEdit). + Post(web.Bind(forms.EditRunnerForm{}), shared_actions.RunnersEditPost) + m.Post("/{runnerid}/delete", shared_actions.RunnerDeletePost) + m.Post("/reset_registration_token", shared_actions.ResetRunnerRegistrationToken) }) } @@ -498,7 +504,7 @@ func registerRoutes(m *web.Router) { m.Get("/organizations", explore.Organizations) m.Get("/code", func(ctx *context.Context) { if unit.TypeCode.UnitGlobalDisabled() { - ctx.NotFound("Repo unit code is disabled", nil) + ctx.NotFound(nil) return } }, explore.Code) @@ -572,6 +578,7 @@ func registerRoutes(m *web.Router) { m.Group("/user/settings", func() { m.Get("", user_setting.Profile) m.Post("", web.Bind(forms.UpdateProfileForm{}), user_setting.ProfilePost) + m.Post("/update_preferences", user_setting.UpdatePreferences) m.Get("/change_password", auth.MustChangePassword) m.Post("/change_password", web.Bind(forms.MustChangePasswordForm{}), auth.MustChangePasswordPost) m.Post("/avatar", web.Bind(forms.AvatarForm{}), user_setting.AvatarPost) @@ -681,7 +688,7 @@ func registerRoutes(m *web.Router) { m.Get("/activate", auth.Activate) m.Post("/activate", auth.ActivatePost) m.Any("/activate_email", auth.ActivateEmail) - m.Get("/avatar/{username}/{size}", user.AvatarByUserName) + m.Get("/avatar/{username}/{size}", user.AvatarByUsernameSize) m.Get("/recover_account", auth.ResetPasswd) m.Post("/recover_account", auth.ResetPasswdPost) m.Get("/forgot_password", auth.ForgotPasswd) @@ -720,6 +727,7 @@ func registerRoutes(m *web.Router) { m.Group("/monitor", func() { m.Get("/stats", admin.MonitorStats) m.Get("/cron", admin.CronTasks) + m.Get("/perftrace", admin.PerfTrace) m.Get("/stacktrace", admin.Stacktrace) m.Post("/stacktrace/cancel/{pid}", admin.StacktraceCancel) m.Get("/queue", admin.Queues) @@ -814,7 +822,7 @@ func registerRoutes(m *web.Router) { m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment) }, optSignIn) - m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), user.Action) + m.Post("/{username}", reqSignIn, context.UserAssignmentWeb(), user.ActionUserFollow) reqRepoAdmin := context.RequireRepoAdmin() reqRepoCodeWriter := context.RequireUnitWriter(unit.TypeCode) @@ -838,7 +846,7 @@ func registerRoutes(m *web.Router) { reqPackageAccess := func(accessMode perm.AccessMode) func(ctx *context.Context) { return func(ctx *context.Context) { if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() { - ctx.NotFound("", nil) + ctx.NotFound(nil) } } } @@ -846,15 +854,15 @@ func registerRoutes(m *web.Router) { individualPermsChecker := func(ctx *context.Context) { // org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked. if ctx.ContextUser.IsIndividual() { - switch { - case ctx.ContextUser.Visibility == structs.VisibleTypePrivate: + switch ctx.ContextUser.Visibility { + case structs.VisibleTypePrivate: if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) { - ctx.NotFound("Visit Project", nil) + ctx.NotFound(nil) return } - case ctx.ContextUser.Visibility == structs.VisibleTypeLimited: + case structs.VisibleTypeLimited: if ctx.Doer == nil { - ctx.NotFound("Visit Project", nil) + ctx.NotFound(nil) return } } @@ -864,7 +872,7 @@ func registerRoutes(m *web.Router) { m.Group("/org", func() { m.Group("/{org}", func() { m.Get("/members", org.Members) - }, context.OrgAssignment()) + }, context.OrgAssignment(context.OrgAssignmentOptions{})) }, optSignIn) // end "/org": members @@ -890,19 +898,20 @@ func registerRoutes(m *web.Router) { m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones) m.Post("/members/action/{action}", org.MembersAction) m.Get("/teams", org.Teams) - }, context.OrgAssignment(true, false, true)) + }, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true})) m.Group("/{org}", func() { m.Get("/teams/{team}", org.TeamMembers) m.Get("/teams/{team}/repositories", org.TeamRepositories) m.Post("/teams/{team}/action/{action}", org.TeamsAction) m.Post("/teams/{team}/action/repo/{action}", org.TeamsRepoAction) - }, context.OrgAssignment(true, false, true)) + }, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true})) - // require admin permission + // require member/team-admin permission (old logic is: requireMember=true, requireTeamAdmin=true) + // but it doesn't seem right: requireTeamAdmin does nothing m.Group("/{org}", func() { m.Get("/teams/-/search", org.SearchTeam) - }, context.OrgAssignment(true, false, false, true)) + }, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamAdmin: true})) // require owner permission m.Group("/{org}", func() { @@ -912,6 +921,8 @@ func registerRoutes(m *web.Router) { m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost) m.Post("/teams/{team}/delete", org.DeleteTeam) + m.Get("/worktime", context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true}), org.Worktime) + m.Group("/settings", func() { m.Combo("").Get(org.Settings). Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost) @@ -953,7 +964,8 @@ func registerRoutes(m *web.Router) { addSettingsVariablesRoutes() }, actions.MustEnableActions) - m.Methods("GET,POST", "/delete", org.SettingsDelete) + m.Post("/rename", web.Bind(forms.RenameOrgForm{}), org.SettingsRenamePost) + m.Post("/delete", org.SettingsDeleteOrgPost) m.Group("/packages", func() { m.Get("", org.Packages) @@ -979,7 +991,7 @@ func registerRoutes(m *web.Router) { m.Post("", web.Bind(forms.BlockUserForm{}), org.BlockedUsersPost) }) }, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true)) - }, context.OrgAssignment(true, true)) + }, context.OrgAssignment(context.OrgAssignmentOptions{RequireOwner: true})) }, reqSignIn) // end "/org": most org routes @@ -1001,6 +1013,7 @@ func registerRoutes(m *web.Router) { m.Get("/versions", user.ListPackageVersions) m.Group("/{version}", func() { m.Get("", user.ViewPackageVersion) + m.Get("/{version_sub}", user.ViewPackageVersion) m.Get("/files/{fileid}", user.DownloadPackageFile) m.Group("/settings", func() { m.Get("", user.PackageSettings) @@ -1018,7 +1031,7 @@ func registerRoutes(m *web.Router) { m.Get("", org.Projects) m.Get("/{id}", org.ViewProject) }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) - m.Group("", func() { //nolint:dupl + m.Group("", func() { //nolint:dupl // duplicates lines 1421-1441 m.Get("/new", org.RenderNewProject) m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) m.Group("/{id}", func() { @@ -1040,7 +1053,7 @@ func registerRoutes(m *web.Router) { }) }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) { if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { - ctx.NotFound("NewProject", nil) + ctx.NotFound(nil) return } }) @@ -1049,7 +1062,7 @@ func registerRoutes(m *web.Router) { m.Group("", func() { m.Get("/code", user.CodeSearch) }, reqUnitAccess(unit.TypeCode, perm.AccessModeRead, false), individualPermsChecker) - }, optSignIn, context.UserAssignmentWeb(), context.OrgAssignment()) + }, optSignIn, context.UserAssignmentWeb(), context.OrgAssignment(context.OrgAssignmentOptions{})) // end "/{username}/-": packages, projects, code m.Group("/{username}/{reponame}/-", func() { @@ -1067,6 +1080,8 @@ func registerRoutes(m *web.Router) { m.Post("/avatar", web.Bind(forms.AvatarForm{}), repo_setting.SettingsAvatar) m.Post("/avatar/delete", repo_setting.SettingsDeleteAvatar) + m.Combo("/public_access").Get(repo_setting.PublicAccess).Post(repo_setting.PublicAccessPost) + m.Group("/collaboration", func() { m.Combo("").Get(repo_setting.Collaboration).Post(repo_setting.CollaborationPost) m.Post("/access_mode", repo_setting.ChangeCollaborationAccessMode) @@ -1135,7 +1150,7 @@ func registerRoutes(m *web.Router) { }) }) m.Group("/actions", func() { - m.Get("", repo_setting.RedirectToDefaultSetting) + m.Get("", shared_actions.RedirectToDefaultSetting) addSettingsRunnersRoutes() addSettingsSecretsRoutes() addSettingsVariablesRoutes() @@ -1146,12 +1161,12 @@ func registerRoutes(m *web.Router) { m.Post("/cancel", repo.MigrateCancelPost) }) }, - reqSignIn, context.RepoAssignment, reqRepoAdmin, context.RepoRef(), + reqSignIn, context.RepoAssignment, reqRepoAdmin, ctxDataSet("PageIsRepoSettings", true, "LFSStartServer", setting.LFS.StartServer), ) // end "/{username}/{reponame}/settings" - // user/org home, including rss feeds + // user/org home, including rss feeds like "/{username}/{reponame}.rss" m.Get("/{username}/{reponame}", optSignIn, context.RepoAssignment, context.RepoRefByType(git.RefTypeBranch), repo.SetEditorconfigIfExists, repo.Home) m.Post("/{username}/{reponame}/markup", optSignIn, context.RepoAssignment, reqUnitsWithMarkdown, web.Bind(structs.MarkupOption{}), misc.Markup) @@ -1163,10 +1178,16 @@ func registerRoutes(m *web.Router) { m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList) m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.TreeList) }) + m.Group("/tree-view", func() { + m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeViewNodes) + m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeViewNodes) + m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.TreeViewNodes) + }) m.Get("/compare", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff) m.Combo("/compare/*", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists). Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff). Post(reqSignIn, context.RepoMustNotBeArchived(), reqUnitPullsReader, repo.MustAllowPulls, web.Bind(forms.CreateIssueForm{}), repo.SetWhitespaceBehavior, repo.CompareAndPullRequestPost) + m.Get("/pulls/new/*", repo.PullsNewRedirect) }, optSignIn, context.RepoAssignment, reqUnitCodeReader) // end "/{username}/{reponame}": repo code: find, compare, list @@ -1184,16 +1205,17 @@ func registerRoutes(m *web.Router) { }) }) } + // FIXME: many "pulls" requests are sent to "issues" endpoints correctly, so the issue endpoints have to tolerate pull request permissions at the moment + m.Group("/{username}/{reponame}/{type:issues}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests)) + m.Group("/{username}/{reponame}/{type:pulls}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, reqUnitPullsReader) + m.Group("/{username}/{reponame}", func() { m.Get("/comments/{id}/attachments", repo.GetCommentAttachments) m.Get("/labels", repo.RetrieveLabelsForList, repo.Labels) m.Get("/milestones", repo.Milestones) - m.Get("/milestone/{id}", context.RepoRef(), repo.MilestoneIssuesAndPulls) + m.Get("/milestone/{id}", repo.MilestoneIssuesAndPulls) m.Get("/issues/suggestions", repo.IssueSuggestions) }, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) // issue/pull attachments, labels, milestones - - m.Group("/{username}/{reponame}/{type:issues}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, reqUnitIssuesReader) - m.Group("/{username}/{reponame}/{type:pulls}", addIssuesPullsViewRoutes, optSignIn, context.RepoAssignment, reqUnitPullsReader) // end "/{username}/{reponame}": view milestone, label, issue, pull, etc m.Group("/{username}/{reponame}/{type:issues}", func() { @@ -1205,14 +1227,14 @@ func registerRoutes(m *web.Router) { m.Group("/{username}/{reponame}", func() { // edit issues, pulls, labels, milestones, etc m.Group("/issues", func() { m.Group("/new", func() { - m.Combo("").Get(context.RepoRef(), repo.NewIssue). + m.Combo("").Get(repo.NewIssue). Post(web.Bind(forms.CreateIssueForm{}), repo.NewIssuePost) - m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate) + m.Get("/choose", repo.NewIssueChooseTemplate) }) m.Get("/search", repo.SearchRepoIssuesJSON) }, reqUnitIssuesReader) - addIssuesPullsRoutes := func() { + addIssuesPullsUpdateRoutes := func() { // for "/{username}/{reponame}/issues" or "/{username}/{reponame}/pulls" m.Group("/{index}", func() { m.Post("/title", repo.UpdateIssueTitle) @@ -1231,7 +1253,8 @@ func registerRoutes(m *web.Router) { m.Post("/add", web.Bind(forms.AddTimeManuallyForm{}), repo.AddTimeManually) m.Post("/{timeid}/delete", repo.DeleteTime) m.Group("/stopwatch", func() { - m.Post("/toggle", repo.IssueStopwatch) + m.Post("/start", repo.IssueStartStopwatch) + m.Post("/stop", repo.IssueStopStopwatch) m.Post("/cancel", repo.CancelStopwatch) }) }) @@ -1255,8 +1278,9 @@ func registerRoutes(m *web.Router) { m.Delete("/unpin/{index}", reqRepoAdmin, repo.IssueUnpin) m.Post("/move_pin", reqRepoAdmin, repo.IssuePinMove) } - m.Group("/{type:issues}", addIssuesPullsRoutes, reqUnitIssuesReader, context.RepoMustNotBeArchived()) - m.Group("/{type:pulls}", addIssuesPullsRoutes, reqUnitPullsReader, context.RepoMustNotBeArchived()) + // FIXME: many "pulls" requests are sent to "issues" endpoints incorrectly, so the issue endpoints have to tolerate pull request permissions at the moment + m.Group("/{type:issues}", addIssuesPullsUpdateRoutes, context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests), context.RepoMustNotBeArchived()) + m.Group("/{type:pulls}", addIssuesPullsUpdateRoutes, reqUnitPullsReader, context.RepoMustNotBeArchived()) m.Group("/comments/{id}", func() { m.Post("", repo.UpdateCommentContent) @@ -1269,7 +1293,7 @@ func registerRoutes(m *web.Router) { m.Post("/edit", web.Bind(forms.CreateLabelForm{}), repo.UpdateLabel) m.Post("/delete", repo.DeleteLabel) m.Post("/initialize", web.Bind(forms.InitializeLabelsForm{}), repo.InitializeLabels) - }, reqRepoIssuesOrPullsWriter, context.RepoRef()) + }, reqRepoIssuesOrPullsWriter) m.Group("/milestones", func() { m.Combo("/new").Get(repo.NewMilestone). @@ -1278,9 +1302,9 @@ func registerRoutes(m *web.Router) { m.Post("/{id}/edit", web.Bind(forms.CreateMilestoneForm{}), repo.EditMilestonePost) m.Post("/{id}/{action}", repo.ChangeMilestoneStatus) m.Post("/delete", repo.DeleteMilestone) - }, reqRepoIssuesOrPullsWriter, context.RepoRef()) + }, reqRepoIssuesOrPullsWriter) - // FIXME: need to move these routes to the proper place + // FIXME: many "pulls" requests are sent to "issues" endpoints incorrectly, need to move these routes to the proper place m.Group("/issues", func() { m.Post("/request_review", repo.UpdatePullReviewRequest) m.Post("/dismiss_review", reqRepoAdmin, web.Bind(forms.DismissReviewForm{}), repo.DismissReview) @@ -1290,27 +1314,38 @@ func registerRoutes(m *web.Router) { }, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived()) // end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones - m.Group("/{username}/{reponame}", func() { // repo code + m.Group("/{username}/{reponame}", func() { // repo code (at least "code reader") m.Group("", func() { m.Group("", func() { - m.Combo("/_edit/*").Get(repo.EditFile). - Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) - m.Combo("/_new/*").Get(repo.NewFile). - Post(web.Bind(forms.EditRepoFileForm{}), repo.NewFilePost) - m.Post("/_preview/*", web.Bind(forms.EditPreviewDiffForm{}), repo.DiffPreviewPost) - m.Combo("/_delete/*").Get(repo.DeleteFile). - Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) - m.Combo("/_upload/*", repo.MustBeAbleToUpload). + // "GET" requests only need "code reader" permission, "POST" requests need "code writer" permission. + // Because reader can "fork and edit" + canWriteToBranch := context.CanWriteToBranch() + m.Post("/_preview/*", repo.DiffPreviewPost) // read-only, fine with "code reader" + m.Post("/_fork/*", repo.ForkToEditPost) // read-only, fork to own repo, fine with "code reader" + + // the path params are used in PrepareCommitFormOptions to construct the correct form action URL + m.Combo("/{editor_action:_edit}/*"). + Get(repo.EditFile). + Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost) + m.Combo("/{editor_action:_new}/*"). + Get(repo.EditFile). + Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost) + m.Combo("/{editor_action:_delete}/*"). + Get(repo.DeleteFile). + Post(web.Bind(forms.DeleteRepoFileForm{}), canWriteToBranch, repo.DeleteFilePost) + m.Combo("/{editor_action:_upload}/*", repo.MustBeAbleToUpload). Get(repo.UploadFile). - Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost) - m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch). - Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) - m.Combo("/_cherrypick/{sha:([a-f0-9]{7,64})}/*").Get(repo.CherryPick). - Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost) - }, context.RepoRefByType(git.RefTypeBranch), context.CanWriteToBranch()) + Post(web.Bind(forms.UploadRepoFileForm{}), canWriteToBranch, repo.UploadFilePost) + m.Combo("/{editor_action:_diffpatch}/*"). + Get(repo.NewDiffPatch). + Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.NewDiffPatchPost) + m.Combo("/{editor_action:_cherrypick}/{sha:([a-f0-9]{7,64})}/*"). + Get(repo.CherryPick). + Post(web.Bind(forms.CherryPickForm{}), canWriteToBranch, repo.CherryPickPost) + }, context.RepoRefByType(git.RefTypeBranch), repo.WebGitOperationCommonData) m.Group("", func() { m.Post("/upload-file", repo.UploadFileToServer) - m.Post("/upload-remove", web.Bind(forms.RemoveUploadFileForm{}), repo.RemoveUploadFileFromServer) + m.Post("/upload-remove", repo.RemoveUploadFileFromServer) }, repo.MustBeAbleToUpload, reqRepoCodeWriter) }, repo.MustBeEditable, context.RepoMustNotBeArchived()) @@ -1357,7 +1392,7 @@ func registerRoutes(m *web.Router) { m.Post("/delete", repo.DeleteRelease) m.Post("/attachments", repo.UploadReleaseAttachment) m.Post("/attachments/remove", repo.DeleteAttachment) - }, reqSignIn, context.RepoMustNotBeArchived(), reqRepoReleaseWriter, context.RepoRef()) + }, reqSignIn, context.RepoMustNotBeArchived(), reqRepoReleaseWriter) m.Group("/releases", func() { m.Get("/edit/*", repo.EditRelease) m.Post("/edit/*", web.Bind(forms.EditReleaseForm{}), repo.EditReleasePost) @@ -1383,7 +1418,7 @@ func registerRoutes(m *web.Router) { m.Group("/{username}/{reponame}/projects", func() { m.Get("", repo.Projects) m.Get("/{id}", repo.ViewProject) - m.Group("", func() { //nolint:dupl + m.Group("", func() { //nolint:dupl // duplicates lines 1034-1054 m.Get("/new", repo.RenderNewProject) m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) m.Group("/{id}", func() { @@ -1425,10 +1460,12 @@ func registerRoutes(m *web.Router) { m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) m.Get("/logs", actions.Logs) }) + m.Get("/workflow", actions.ViewWorkflowFile) m.Post("/cancel", reqRepoActionsWriter, actions.Cancel) m.Post("/approve", reqRepoActionsWriter, actions.Approve) + m.Post("/delete", reqRepoActionsWriter, actions.Delete) m.Get("/artifacts/{artifact_name}", actions.ArtifactsDownloadView) - m.Delete("/artifacts/{artifact_name}", actions.ArtifactsDeleteView) + m.Delete("/artifacts/{artifact_name}", reqRepoActionsWriter, actions.ArtifactsDeleteView) m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) }) m.Group("/workflows/{workflow_name}", func() { @@ -1470,7 +1507,7 @@ func registerRoutes(m *web.Router) { }) m.Group("/recent-commits", func() { m.Get("", repo.RecentCommits) - m.Get("/data", repo.RecentCommitsData) + m.Get("/data", repo.CodeFrequencyData) // "recent-commits" also uses the same data as "code-frequency" }) }, reqUnitCodeReader) }, @@ -1485,20 +1522,21 @@ func registerRoutes(m *web.Router) { m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue) m.Get(".diff", repo.DownloadPullDiff) m.Get(".patch", repo.DownloadPullPatch) + m.Get("/merge_box", repo.ViewPullMergeBox) m.Group("/commits", func() { - m.Get("", context.RepoRef(), repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits) - m.Get("/list", context.RepoRef(), repo.GetPullCommits) - m.Get("/{sha:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForSingleCommit) + m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits) + m.Get("/list", repo.GetPullCommits) + m.Get("/{sha:[a-f0-9]{7,40}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForSingleCommit) }) m.Post("/merge", context.RepoMustNotBeArchived(), web.Bind(forms.MergePullRequestForm{}), repo.MergePullRequest) m.Post("/cancel_auto_merge", context.RepoMustNotBeArchived(), repo.CancelAutoMergePullRequest) m.Post("/update", repo.UpdatePullRequest) m.Post("/set_allow_maintainer_edit", web.Bind(forms.UpdateAllowEditsForm{}), repo.SetAllowEdits) - m.Post("/cleanup", context.RepoMustNotBeArchived(), context.RepoRef(), repo.CleanUpPullRequest) + m.Post("/cleanup", context.RepoMustNotBeArchived(), repo.CleanUpPullRequest) m.Group("/files", func() { - m.Get("", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForAllCommitsOfPr) - m.Get("/{sha:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesStartingFromCommit) - m.Get("/{shaFrom:[a-f0-9]{7,40}}..{shaTo:[a-f0-9]{7,40}}", context.RepoRef(), repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForRange) + m.Get("", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForAllCommitsOfPr) + m.Get("/{sha:[a-f0-9]{7,40}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesStartingFromCommit) + m.Get("/{shaFrom:[a-f0-9]{7,40}}..{shaTo:[a-f0-9]{7,40}}", repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.SetShowOutdatedComments, repo.ViewPullFilesForRange) m.Group("/reviews", func() { m.Get("/new_comment", repo.RenderNewCodeCommentForm) m.Post("/comments", web.Bind(forms.CodeCommentForm{}), repo.SetShowOutdatedComments, repo.CreateCodeComment) @@ -1513,7 +1551,7 @@ func registerRoutes(m *web.Router) { m.Group("/activity_author_data", func() { m.Get("", repo.ActivityAuthors) m.Get("/{period}", repo.ActivityAuthors) - }, context.RepoRef(), repo.MustBeNotEmpty) + }, repo.MustBeNotEmpty) m.Group("/archive", func() { m.Get("/*", repo.Download) @@ -1582,18 +1620,22 @@ func registerRoutes(m *web.Router) { m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.Home) m.Get("/*", context.RepoRefByType(""), repo.Home) // "/*" route is deprecated, and kept for backward compatibility }, repo.SetEditorconfigIfExists) + m.Get("/tree/*", repo.RedirectRepoTreeToSrc) // redirect "/owner/repo/tree/*" requests to "/owner/repo/src/*" + m.Get("/blob/*", repo.RedirectRepoBlobToCommit) // redirect "/owner/repo/blob/*" requests to "/owner/repo/src/commit/*" - m.Get("/forks", context.RepoRef(), repo.Forks) + m.Get("/forks", repo.Forks) m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff) m.Post("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit) }, optSignIn, context.RepoAssignment, reqUnitCodeReader) // end "/{username}/{reponame}": repo code m.Group("/{username}/{reponame}", func() { - m.Get("/stars", repo.Stars) + m.Get("/stars", starsEnabled, repo.Stars) m.Get("/watchers", repo.Watchers) m.Get("/search", reqUnitCodeReader, repo.Search) - m.Post("/action/{action}", reqSignIn, repo.Action) + m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar) + m.Post("/action/{action:watch|unwatch}", reqSignIn, repo.ActionWatch) + m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer) }, optSignIn, context.RepoAssignment) common.AddOwnerRepoGitLFSRoutes(m, optSignInIgnoreCsrf, lfsServerEnabled) // "/{username}/{reponame}/{lfs-paths}": git-lfs support @@ -1617,14 +1659,15 @@ func registerRoutes(m *web.Router) { m.Group("/devtest", func() { m.Any("", devtest.List) m.Any("/fetch-action-test", devtest.FetchActionTest) - m.Any("/{sub}", devtest.Tmpl) + m.Any("/{sub}", devtest.TmplCommon) + m.Get("/repo-action-view/{run}/{job}", devtest.MockActionsView) m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) }) } m.NotFound(func(w http.ResponseWriter, req *http.Request) { - ctx := context.GetWebContext(req) + ctx := context.GetWebContext(req.Context()) defer routing.RecordFuncInfo(ctx, routing.GetFuncInfo(ctx.NotFound, "WebNotFound"))() - ctx.NotFound("", nil) + ctx.NotFound(nil) }) } diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go index a87c426b3b..a4c9bf902b 100644 --- a/routers/web/webfinger.go +++ b/routers/web/webfinger.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" user_model "code.gitea.io/gitea/models/user" @@ -39,7 +40,7 @@ func WebfingerQuery(ctx *context.Context) { resource, err := url.Parse(ctx.FormTrim("resource")) if err != nil { - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } @@ -50,11 +51,11 @@ func WebfingerQuery(ctx *context.Context) { // allow only the current host parts := strings.SplitN(resource.Opaque, "@", 2) if len(parts) != 2 { - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } if parts[1] != appURL.Host { - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } @@ -65,30 +66,30 @@ func WebfingerQuery(ctx *context.Context) { err = user_model.ErrUserNotExist{} } default: - ctx.Error(http.StatusBadRequest) + ctx.HTTPError(http.StatusBadRequest) return } if err != nil { if user_model.IsErrUserNotExist(err) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) } else { log.Error("Error getting user: %s Error: %v", resource.Opaque, err) - ctx.Error(http.StatusInternalServerError) + ctx.HTTPError(http.StatusInternalServerError) } return } if !user_model.IsUserVisibleToViewer(ctx, u, ctx.Doer) { - ctx.Error(http.StatusNotFound) + ctx.HTTPError(http.StatusNotFound) return } aliases := []string{ u.HTMLURL(), - appURL.String() + "api/v1/activitypub/user-id/" + fmt.Sprint(u.ID), + appURL.String() + "api/v1/activitypub/user-id/" + strconv.FormatInt(u.ID, 10), } if !u.KeepEmailPrivate { - aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email)) + aliases = append(aliases, "mailto:"+u.Email) } links := []*webfingerLink{ @@ -104,7 +105,7 @@ func WebfingerQuery(ctx *context.Context) { { Rel: "self", Type: "application/activity+json", - Href: appURL.String() + "api/v1/activitypub/user-id/" + fmt.Sprint(u.ID), + Href: appURL.String() + "api/v1/activitypub/user-id/" + strconv.FormatInt(u.ID, 10), }, { Rel: "http://openid.net/specs/connect/1.0/issuer", |