diff options
Diffstat (limited to 'routers/web')
133 files changed, 4282 insertions, 3937 deletions
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 2b3bf1f77d..56c384b970 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,12 +194,14 @@ 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, GroupTeamMap: form.Oauth2GroupTeamMap, GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval, + + SSHPublicKeyClaimName: form.Oauth2SSHPublicKeyClaimName, + FullNameClaimName: form.Oauth2FullNameClaimName, } } @@ -252,7 +250,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 +260,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) @@ -302,11 +299,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 +382,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) @@ -421,6 +419,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 d040dbe0ba..5395529d66 100644 --- a/routers/web/admin/diagnosis.go +++ b/routers/web/admin/diagnosis.go @@ -16,13 +16,7 @@ import ( ) func MonitorDiagnosis(ctx *context.Context) { - seconds := ctx.FormInt64("seconds") - if seconds <= 1 { - seconds = 1 - } - if seconds > 300 { - seconds = 300 - } + seconds := min(max(ctx.FormInt64("seconds"), 1), 300) httplib.ServeSetHeaders(ctx.Resp, &httplib.ServeHeaderOptions{ ContentType: "application/zip", 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/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..1f087a7897 100644 --- a/routers/web/auth/2fa.go +++ b/routers/web/auth/2fa.go @@ -9,11 +9,11 @@ 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" "code.gitea.io/gitea/services/context" - "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" ) @@ -74,7 +74,7 @@ func TwoFactorPost(ctx *context.Context) { } if ctx.Session.Get("linkAccount") != nil { - err = externalaccount.LinkAccountFromStore(ctx, ctx.Session, u) + err = linkAccountFromContext(ctx, u) if err != nil { ctx.ServerError("UserSignIn", err) return @@ -87,6 +87,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 f07ef98931..13cd083771 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) } @@ -239,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 } @@ -262,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 } @@ -311,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,9 +329,11 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe "twofaUid", "twofaRemember", "linkAccount", + "linkAccountData", }, 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 + "/" @@ -411,9 +422,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) @@ -507,7 +520,7 @@ func SignUpPost(ctx *context.Context) { Passwd: form.Password, } - if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil, false) { + if !createAndHandleCreatedUser(ctx, tplSignUp, form, u, nil, nil) { // error already handled return } @@ -518,23 +531,24 @@ func SignUpPost(ctx *context.Context) { // createAndHandleCreatedUser calls createUserInContext and // then handleUserCreated. -func createAndHandleCreatedUser(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) bool { - if !createUserInContext(ctx, tpl, form, u, overwrites, gothUser, allowLink) { +func createAndHandleCreatedUser(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) bool { + if !createUserInContext(ctx, tpl, form, u, overwrites, possibleLinkAccountData) { return false } - return handleUserCreated(ctx, u, gothUser) + return handleUserCreated(ctx, u, possibleLinkAccountData) } // createUserInContext creates a user and handles errors within a given context. -// Optionally a template can be specified. -func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, gothUser *goth.User, allowLink bool) (ok bool) { +// Optionally, a template can be specified. +func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, u *user_model.User, overwrites *user_model.CreateUserOverwriteOptions, possibleLinkAccountData *LinkAccountData) (ok bool) { meta := &user_model.Meta{ InitialIP: ctx.RemoteAddr(), InitialUserAgent: ctx.Req.UserAgent(), } 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 { + if possibleLinkAccountData != nil && (user_model.IsErrUserAlreadyExist(err) || user_model.IsErrEmailAlreadyUsed(err)) { + 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) @@ -548,15 +562,15 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, } // TODO: probably we should respect 'remember' user's choice... - linkAccount(ctx, user, *gothUser, true) + oauth2LinkAccount(ctx, user, possibleLinkAccountData, true) return false // user is already created here, all redirects are handled - } else if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingLogin { - showLinkingLogin(ctx, *gothUser) + case setting.OAuth2AccountLinkingLogin: + showLinkingLogin(ctx, &possibleLinkAccountData.AuthSource, possibleLinkAccountData.GothUser) return false // user will be created only after linking login } } - // handle error without template + // handle error without a template if len(tpl) == 0 { ctx.ServerError("CreateUser", err) return false @@ -597,12 +611,18 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, // handleUserCreated does additional steps after a new user is created. // It auto-sets admin for the only user, updates the optional external user and // sends a confirmation email if required. -func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth.User) (ok bool) { +func handleUserCreated(ctx *context.Context, u *user_model.User, possibleLinkAccountData *LinkAccountData) (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 { @@ -612,8 +632,8 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth. } // update external user information - if gothUser != nil { - if err := externalaccount.EnsureLinkExternalToUser(ctx, u, *gothUser); err != nil { + if possibleLinkAccountData != nil { + if err := externalaccount.EnsureLinkExternalToUser(ctx, possibleLinkAccountData.AuthSource.ID, u, possibleLinkAccountData.GothUser); err != nil { log.Error("EnsureLinkExternalToUser failed: %v", 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 b3c61946b9..cf1aa302c4 100644 --- a/routers/web/auth/linkaccount.go +++ b/routers/web/auth/linkaccount.go @@ -5,7 +5,6 @@ package auth import ( "errors" - "fmt" "net/http" "strings" @@ -21,8 +20,6 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" - - "github.com/markbates/goth" ) var tplLinkAccount templates.TplName = "user/auth/link_account" @@ -52,28 +49,28 @@ func LinkAccount(ctx *context.Context) { 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) + linkAccountData := oauth2GetLinkAccountData(ctx) // 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 + // linkAccountData = &LinkAccountData{authSource, gothUser} // intentionally use invalid data to avoid pass the registration check - if !ok { + if linkAccountData == nil { // no account in session, so just redirect to the login page, then the user could restart the process ctx.Redirect(setting.AppSubURL + "/user/login") return } - if missingFields, ok := gothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok { - ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", gothUser.Provider, strings.Join(missingFields, ",")) + if missingFields, ok := linkAccountData.GothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok { + ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", linkAccountData.GothUser.Provider, strings.Join(missingFields, ",")) } - uname, err := extractUserNameFromOAuth2(&gothUser) + uname, err := extractUserNameFromOAuth2(&linkAccountData.GothUser) if err != nil { ctx.ServerError("UserSignIn", err) return } - email := gothUser.Email + email := linkAccountData.GothUser.Email ctx.Data["user_name"] = uname ctx.Data["email"] = email @@ -152,8 +149,8 @@ func LinkAccountPostSignIn(ctx *context.Context) { ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" - gothUser := ctx.Session.Get("linkAccountGothUser") - if gothUser == nil { + linkAccountData := oauth2GetLinkAccountData(ctx) + if linkAccountData == nil { ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session")) return } @@ -169,11 +166,14 @@ func LinkAccountPostSignIn(ctx *context.Context) { return } - linkAccount(ctx, u, gothUser.(goth.User), signInForm.Remember) + oauth2LinkAccount(ctx, u, linkAccountData, signInForm.Remember) } -func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool) { - updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) +func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData *LinkAccountData, remember bool) { + oauth2SignInSync(ctx, &linkAccountData.AuthSource, u, linkAccountData.GothUser) + if ctx.Written() { + return + } // If this user is enrolled in 2FA, we can't sign the user in just yet. // Instead, redirect them to the 2FA authentication page. @@ -185,7 +185,7 @@ func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, r return } - err = externalaccount.LinkAccountToUser(ctx, u, gothUser) + err = externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSource.ID, u, linkAccountData.GothUser) if err != nil { ctx.ServerError("UserLinkAccount", err) return @@ -243,17 +243,11 @@ func LinkAccountPostRegister(ctx *context.Context) { ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin" ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup" - gothUserInterface := ctx.Session.Get("linkAccountGothUser") - if gothUserInterface == nil { + linkAccountData := oauth2GetLinkAccountData(ctx) + if linkAccountData == nil { ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session")) return } - gothUser, ok := gothUserInterface.(goth.User) - if !ok { - ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountGothUser type is %t but not goth.User", gothUserInterface)) - return - } - if ctx.HasError() { ctx.HTML(http.StatusOK, tplLinkAccount) return @@ -296,31 +290,33 @@ func LinkAccountPostRegister(ctx *context.Context) { } } - authSource, err := auth.GetActiveOAuth2SourceByName(ctx, gothUser.Provider) - if err != nil { - ctx.ServerError("CreateUser", err) - return - } - u := &user_model.User{ Name: form.UserName, Email: form.Email, Passwd: form.Password, LoginType: auth.OAuth2, - LoginSource: authSource.ID, - LoginName: gothUser.UserID, + LoginSource: linkAccountData.AuthSource.ID, + LoginName: linkAccountData.GothUser.UserID, } - if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &gothUser, false) { + if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, linkAccountData) { // error already handled return } - source := authSource.Cfg.(*oauth2.Source) - if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + source := linkAccountData.AuthSource.Cfg.(*oauth2.Source) + if err := syncGroupsToTeams(ctx, source, &linkAccountData.GothUser, u); err != nil { ctx.ServerError("SyncGroupsToTeams", err) return } handleSignIn(ctx, u, false) } + +func linkAccountFromContext(ctx *context.Context, user *user_model.User) error { + linkAccountData := oauth2GetLinkAccountData(ctx) + if linkAccountData == nil { + return errors.New("not in LinkAccount session") + } + return externalaccount.LinkAccountToUser(ctx, linkAccountData.AuthSource.ID, user, linkAccountData.GothUser) +} diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 7a9721cf56..3df2734bb6 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -18,8 +18,8 @@ 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" source_service "code.gitea.io/gitea/services/auth/source" "code.gitea.io/gitea/services/auth/source/oauth2" @@ -34,9 +34,8 @@ import ( // SignInOAuth handles the OAuth2 login buttons func SignInOAuth(ctx *context.Context) { - provider := ctx.PathParam("provider") - - authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + authName := ctx.PathParam("provider") + authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName) if err != nil { ctx.ServerError("SignIn", err) return @@ -73,8 +72,6 @@ func SignInOAuth(ctx *context.Context) { // SignInOAuthCallback handles the callback from the given provider func SignInOAuthCallback(ctx *context.Context) { - provider := ctx.PathParam("provider") - if ctx.Req.FormValue("error") != "" { var errorKeyValues []string for k, vv := range ctx.Req.Form { @@ -87,7 +84,8 @@ func SignInOAuthCallback(ctx *context.Context) { } // first look if the provider is still active - authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + authName := ctx.PathParam("provider") + authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName) if err != nil { ctx.ServerError("SignIn", err) return @@ -115,7 +113,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 @@ -132,7 +130,7 @@ func SignInOAuthCallback(ctx *context.Context) { if u == nil { if ctx.Doer != nil { // attach user to the current signed-in user - err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser) + err = externalaccount.LinkAccountToUser(ctx, authSource.ID, ctx.Doer, gothUser) if err != nil { ctx.ServerError("UserLinkAccount", err) return @@ -155,9 +153,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 } @@ -172,12 +171,11 @@ func SignInOAuthCallback(ctx *context.Context) { gothUser.RawData = make(map[string]any) } gothUser.RawData["__giteaAutoRegMissingFields"] = missingFields - showLinkingLogin(ctx, gothUser) + showLinkingLogin(ctx, authSource, gothUser) return } u = &user_model.User{ Name: uname, - FullName: gothUser.Name, Email: gothUser.Email, LoginType: auth.OAuth2, LoginSource: authSource.ID, @@ -191,10 +189,14 @@ 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) { + linkAccountData := &LinkAccountData{*authSource, gothUser} + if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingDisabled { + linkAccountData = nil + } + if !createAndHandleCreatedUser(ctx, "", nil, u, overwriteDefault, linkAccountData) { // error already handled return } @@ -205,7 +207,7 @@ func SignInOAuthCallback(ctx *context.Context) { } } else { // no existing user is found, request attach or new account - showLinkingLogin(ctx, gothUser) + showLinkingLogin(ctx, authSource, gothUser) return } } @@ -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)) @@ -269,9 +271,22 @@ func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *g return isAdmin, isRestricted } -func showLinkingLogin(ctx *context.Context, gothUser goth.User) { +type LinkAccountData struct { + AuthSource auth.Source + GothUser goth.User +} + +func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData { + v, ok := ctx.Session.Get("linkAccountData").(LinkAccountData) + if !ok { + return nil + } + return &v +} + +func showLinkingLogin(ctx *context.Context, authSource *auth.Source, gothUser goth.User) { if err := updateSession(ctx, nil, map[string]any{ - "linkAccountGothUser": gothUser, + "linkAccountData": LinkAccountData{*authSource, gothUser}, }); err != nil { ctx.ServerError("updateSession", err) return @@ -279,7 +294,7 @@ func showLinkingLogin(ctx *context.Context, gothUser goth.User) { ctx.Redirect(setting.AppSubURL + "/user/link_account") } -func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) { +func oauth2UpdateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) { if setting.OAuth2Client.UpdateAvatar && len(url) > 0 { resp, err := http.Get(url) if err == nil { @@ -297,11 +312,14 @@ func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) { } } -func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { - updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) +func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) { + oauth2SignInSync(ctx, authSource, u, gothUser) + if ctx.Written() { + return + } needs2FA := false - if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA { + if !authSource.TwoFactorShouldSkip() { _, err := auth.GetTwoFactorByUID(ctx, u.ID) if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { ctx.ServerError("UserSignIn", err) @@ -310,7 +328,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model needs2FA = err == nil } - oauth2Source := source.Cfg.(*oauth2.Source) + oauth2Source := authSource.Cfg.(*oauth2.Source) groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap) if err != nil { ctx.ServerError("UnmarshalGroupTeamMapping", err) @@ -336,7 +354,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } } - if err := externalaccount.EnsureLinkExternalToUser(ctx, u, gothUser); err != nil { + if err := externalaccount.EnsureLinkExternalToUser(ctx, authSource.ID, u, gothUser); err != nil { ctx.ServerError("EnsureLinkExternalToUser", err) return } @@ -351,10 +369,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 +455,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 00b5b2db52..79989d8fbe 100644 --- a/routers/web/auth/oauth2_provider.go +++ b/routers/web/auth/oauth2_provider.go @@ -4,17 +4,17 @@ package auth import ( - "errors" "fmt" "html" "html/template" "net/http" "net/url" + "strconv" "strings" "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 +98,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 +107,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,18 +125,12 @@ 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 @@ -169,9 +162,7 @@ func IntrospectOAuth(ctx *context.Context) { if err == nil && app != nil { response.Active = true response.Scope = grant.Scope - response.Issuer = setting.AppURL - response.Audience = []string{app.ClientID} - response.Subject = fmt.Sprint(grant.UserID) + response.RegisteredClaims = oauth2_provider.NewJwtRegisteredClaimsFromUser(app.ClientID, grant.UserID, nil /*exp*/) } if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil { response.Username = user.Name @@ -249,7 +240,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", @@ -431,7 +422,14 @@ func GrantApplicationOAuth(ctx *context.Context) { // OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities func OIDCWellKnown(ctx *context.Context) { - ctx.Data["SigningKey"] = oauth2_provider.DefaultSigningKey + if !setting.OAuth2.Enabled { + http.NotFound(ctx.Resp, ctx.Req) + return + } + jwtRegisteredClaims := oauth2_provider.NewJwtRegisteredClaimsFromUser("well-known", 0, nil) + ctx.Data["OidcIssuer"] = jwtRegisteredClaims.Issuer // use the consistent issuer from the JWT registered claims + ctx.Data["OidcBaseUrl"] = strings.TrimSuffix(setting.AppURL, "/") + ctx.Data["SigningKeyMethodAlg"] = oauth2_provider.DefaultSigningKey.SigningMethod().Alg() ctx.JSONTemplate("user/auth/oidc_wellknown") } @@ -464,16 +462,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/oauth_signin_sync.go b/routers/web/auth/oauth_signin_sync.go new file mode 100644 index 0000000000..787ea9223c --- /dev/null +++ b/routers/web/auth/oauth_signin_sync.go @@ -0,0 +1,88 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "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/util" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" + + "github.com/markbates/goth" +) + +func oauth2SignInSync(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) { + oauth2UpdateAvatarIfNeed(ctx, gothUser.AvatarURL, u) + + oauth2Source, _ := authSource.Cfg.(*oauth2.Source) + if !authSource.IsOAuth2() || oauth2Source == nil { + ctx.ServerError("oauth2SignInSync", fmt.Errorf("source %s is not an OAuth2 source", gothUser.Provider)) + return + } + + // sync full name + fullNameKey := util.IfZero(oauth2Source.FullNameClaimName, "name") + fullName, _ := gothUser.RawData[fullNameKey].(string) + fullName = util.IfZero(fullName, gothUser.Name) + + // need to update if the user has no full name set + shouldUpdateFullName := u.FullName == "" + // force to update if the attribute is set + shouldUpdateFullName = shouldUpdateFullName || oauth2Source.FullNameClaimName != "" + // only update if the full name is different + shouldUpdateFullName = shouldUpdateFullName && u.FullName != fullName + if shouldUpdateFullName { + u.FullName = fullName + if err := user_model.UpdateUserCols(ctx, u, "full_name"); err != nil { + log.Error("Unable to sync OAuth2 user full name %s: %v", gothUser.Provider, err) + } + } + + err := oauth2UpdateSSHPubIfNeed(ctx, authSource, &gothUser, u) + if err != nil { + log.Error("Unable to sync OAuth2 SSH public key %s: %v", gothUser.Provider, err) + } +} + +func oauth2SyncGetSSHKeys(source *oauth2.Source, gothUser *goth.User) ([]string, error) { + value, exists := gothUser.RawData[source.SSHPublicKeyClaimName] + if !exists { + return []string{}, nil + } + rawSlice, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("invalid SSH public key value type: %T", value) + } + + sshKeys := make([]string, 0, len(rawSlice)) + for _, v := range rawSlice { + str, ok := v.(string) + if !ok { + return nil, fmt.Errorf("invalid SSH public key value item type: %T", v) + } + sshKeys = append(sshKeys, str) + } + return sshKeys, nil +} + +func oauth2UpdateSSHPubIfNeed(ctx *context.Context, authSource *auth.Source, gothUser *goth.User, user *user_model.User) error { + oauth2Source, _ := authSource.Cfg.(*oauth2.Source) + if oauth2Source == nil || oauth2Source.SSHPublicKeyClaimName == "" { + return nil + } + sshKeys, err := oauth2SyncGetSSHKeys(oauth2Source, gothUser) + if err != nil { + return err + } + if !asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys) { + return nil + } + return asymkey_service.RewriteAllPublicKeys(ctx) +} diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go index c3415cccac..4ef4c96ccc 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 } @@ -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) @@ -364,7 +361,7 @@ func RegisterOpenIDPost(ctx *context.Context) { Email: form.Email, Passwd: password, } - if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil, false) { + if !createUserInContext(ctx, tplSignUpOID, form, u, nil, nil) { // error already handled return } diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index 8dbde85fe6..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" @@ -108,14 +107,14 @@ 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 } diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go index 78f6c3b58e..dacb6be225 100644 --- a/routers/web/auth/webauthn.go +++ b/routers/web/auth/webauthn.go @@ -15,7 +15,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/services/context" - "code.gitea.io/gitea/services/externalaccount" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" @@ -150,7 +149,7 @@ func WebAuthnPasskeyLogin(ctx *context.Context) { // Now handle account linking if that's requested if ctx.Session.Get("linkAccount") != nil { - if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil { + if err := linkAccountFromContext(ctx, user); err != nil { ctx.ServerError("LinkAccountFromStore", err) return } @@ -268,7 +267,7 @@ func WebAuthnLoginAssertionPost(ctx *context.Context) { // Now handle account linking if that's requested if ctx.Session.Get("linkAccount") != nil { - if err := externalaccount.LinkAccountFromStore(ctx, ctx.Session, user); err != nil { + if err := linkAccountFromContext(ctx, user); err != nil { ctx.ServerError("LinkAccountFromStore", err) return } diff --git a/routers/web/base.go b/routers/web/base.go index a284dd0288..e43f36a97b 100644 --- a/routers/web/base.go +++ b/routers/web/base.go @@ -25,7 +25,7 @@ func avatarStorageHandler(storageSetting *setting.Storage, prefix string, objSto if storageSetting.ServeDirect() { return func(w http.ResponseWriter, req *http.Request) { - if req.Method != "GET" && req.Method != "HEAD" { + if req.Method != http.MethodGet && req.Method != http.MethodHead { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } @@ -56,7 +56,7 @@ func avatarStorageHandler(storageSetting *setting.Storage, prefix string, objSto } return func(w http.ResponseWriter, req *http.Request) { - if req.Method != "GET" && req.Method != "HEAD" { + if req.Method != http.MethodGet && req.Method != http.MethodHead { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } 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/mail_preview.go b/routers/web/devtest/mail_preview.go new file mode 100644 index 0000000000..79dd441eab --- /dev/null +++ b/routers/web/devtest/mail_preview.go @@ -0,0 +1,58 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package devtest + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/mailer" + + "gopkg.in/yaml.v3" +) + +func MailPreviewRender(ctx *context.Context) { + tmplName := ctx.PathParam("*") + mockDataContent, err := templates.AssetFS().ReadFile("mail/" + tmplName + ".mock.yml") + mockData := map[string]any{} + if err == nil { + err = yaml.Unmarshal(mockDataContent, &mockData) + if err != nil { + http.Error(ctx.Resp, "Failed to parse mock data: "+err.Error(), http.StatusInternalServerError) + return + } + } + mockData["locale"] = ctx.Locale + err = mailer.LoadedTemplates().BodyTemplates.ExecuteTemplate(ctx.Resp, tmplName, mockData) + if err != nil { + _, _ = ctx.Resp.Write([]byte(err.Error())) + } +} + +func prepareMailPreviewRender(ctx *context.Context, tmplName string) { + tmplSubject := mailer.LoadedTemplates().SubjectTemplates.Lookup(tmplName) + if tmplSubject == nil { + ctx.Data["RenderMailSubject"] = "default subject" + } else { + var buf strings.Builder + err := tmplSubject.Execute(&buf, nil) + if err != nil { + ctx.Data["RenderMailSubject"] = err.Error() + } else { + ctx.Data["RenderMailSubject"] = buf.String() + } + } + ctx.Data["RenderMailTemplateName"] = tmplName +} + +func MailPreview(ctx *context.Context) { + ctx.Data["MailTemplateNames"] = mailer.LoadedTemplates().TemplateNames + tmplName := ctx.FormString("tmpl") + if tmplName != "" { + prepareMailPreviewRender(ctx, tmplName) + } + ctx.HTML(http.StatusOK, "devtest/mail-preview") +} diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 3ce75dfad2..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, @@ -94,6 +94,16 @@ 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, diff --git a/routers/web/explore/code.go b/routers/web/explore/code.go index 8f6518a4fc..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" @@ -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/user.go b/routers/web/explore/user.go index e1e1ec1cfd..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") != "" @@ -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 d3dae9503e..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{ diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go index b04855fa6a..7c59132841 100644 --- a/routers/web/feed/convert.go +++ b/routers/web/feed/convert.go @@ -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), ) } diff --git a/routers/web/feed/file.go b/routers/web/feed/file.go index 407e4fa2d5..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)} diff --git a/routers/web/githttp.go b/routers/web/githttp.go index 8597ffe795..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.HTTPError(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 208cc36dfb..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, }, 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 d42afafe9e..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) 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 e3c2dcf0bd..63ae6c683b 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -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 7d88d6b1ad..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, @@ -54,9 +51,8 @@ func Members(ctx *context.Context) { 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 } 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 ccab2131db..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.HTTPError(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 985fd2ca45..059cce8281 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -43,17 +43,17 @@ func MustEnableProjects(ctx *context.Context) { // 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() { @@ -101,7 +101,6 @@ func Projects(ctx *context.Context) { } ctx.Data["Projects"] = projects - shared_user.RenderUserHeader(ctx) if isShowClosed { ctx.Data["State"] = "closed" @@ -113,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) @@ -152,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 } @@ -167,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) @@ -248,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 { @@ -282,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 } @@ -343,15 +336,15 @@ 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") opts := issues_model.IssuesOptions{ - LabelIDs: labelIDs, - AssigneeID: optional.Some(assigneeID), + LabelIDs: preparedLabelFilter.SelectedLabelIDs, + AssigneeID: assigneeID, Owner: project.Owner, Doer: ctx.Doer, } @@ -406,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 { @@ -422,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) @@ -443,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 } 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 aeea3708b2..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) } @@ -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 { diff --git a/routers/web/org/worktime.go b/routers/web/org/worktime.go index 2336984825..c7b44baf7b 100644 --- a/routers/web/org/worktime.go +++ b/routers/web/org/worktime.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -55,13 +56,14 @@ func Worktime(ctx *context.Context) { var worktimeSumResult any var err error - if worktimeBy == "milestones" { + switch worktimeBy { + case "milestones": worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx.Org.Organization, unixFrom, unixTo) ctx.Data["WorktimeByMilestones"] = true - } else if worktimeBy == "members" { + case "members": worktimeSumResult, err = organization.GetWorktimeByMembers(ctx.Org.Organization, unixFrom, unixTo) ctx.Data["WorktimeByMembers"] = true - } else /* by repos */ { + default: /* by repos */ worktimeSumResult, err = organization.GetWorktimeByRepos(ctx.Org.Organization, unixFrom, unixTo) ctx.Data["WorktimeByRepos"] = true } @@ -69,6 +71,12 @@ func Worktime(ctx *context.Context) { 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 d07d195713..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" @@ -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 } @@ -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 @@ -312,6 +317,8 @@ func prepareWorkflowList(ctx *context.Context, workflows []Workflow) { 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 41f0d2d0ec..52b2e9995e 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -14,7 +14,6 @@ import ( "net/http" "net/url" "strconv" - "strings" "time" actions_model "code.gitea.io/gitea/models/actions" @@ -31,6 +30,7 @@ import ( "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" notify_service "code.gitea.io/gitea/services/notify" @@ -64,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"` @@ -200,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) @@ -223,7 +249,7 @@ func ViewPost(ctx *context_module.Context) { ID: v.ID, Name: v.Name, Status: v.Status.String(), - CanRerun: v.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions), + CanRerun: resp.State.Run.CanRerun, Duration: v.Duration().String(), }) } @@ -278,7 +304,7 @@ func ViewPost(ctx *context_module.Context) { if task != nil { steps, logs, err := convertToViewModel(ctx, req.LogCursors, task) if err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("convertToViewModel", err) return } resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, steps...) @@ -382,7 +408,7 @@ func Rerun(ctx *context_module.Context) { run, err := actions_model.GetRunByIndex(ctx, ctx.Repo.Repository.ID, runIndex) if err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetRunByIndex", err) return } @@ -400,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.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("UpdateRun", err) return } } @@ -415,11 +441,11 @@ 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.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("RerunJob", err) return } } - ctx.JSON(http.StatusOK, struct{}{}) + ctx.JSONOK() return } @@ -429,17 +455,17 @@ 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.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("RerunJob", err) return } } - ctx.JSON(http.StatusOK, struct{}{}) + ctx.JSONOK() } func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { status := job.Status - if !status.IsDone() { + if !status.IsDone() || !job.Run.Status.IsDone() { return nil } @@ -459,7 +485,7 @@ func rerunJob(ctx *context_module.Context, job *actions_model.ActionRunJob, shou } actions_service.CreateCommitStatus(ctx, job) - _ = job.LoadAttributes(ctx) + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) return nil @@ -469,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.HTTPError(http.StatusNotFound, "job is not started") - return - } - - err := job.LoadRun(ctx) - if err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) - return - } - - task, err := actions_model.GetTaskByID(ctx, job.TaskID) - if err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) - return - } - if task.LogExpired { - ctx.HTTPError(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.HTTPError(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) { @@ -538,7 +534,7 @@ 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) @@ -551,7 +547,7 @@ func Cancel(ctx *context_module.Context) { } return nil }); err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("StopTask", err) return } @@ -561,7 +557,11 @@ func Cancel(ctx *context_module.Context) { _ = 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{}{}) } @@ -597,12 +597,18 @@ func Approve(ctx *context_module.Context) { } return nil }); err != nil { - ctx.HTTPError(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) @@ -611,6 +617,33 @@ func Approve(ctx *context_module.Context) { 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. @@ -618,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.HTTPError(http.StatusNotFound, err.Error()) + ctx.NotFound(nil) return nil, nil } - ctx.HTTPError(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.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetRunJobsByRunID", err) return nil, nil } if len(jobs) == 0 { - ctx.HTTPError(http.StatusNotFound) + ctx.NotFound(nil) return nil, nil } @@ -657,7 +690,7 @@ func ArtifactsDeleteView(ctx *context_module.Context) { return } if err = actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("SetArtifactNeedDelete", err) return } ctx.JSON(http.StatusOK, struct{}{}) @@ -673,7 +706,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { ctx.HTTPError(http.StatusNotFound, err.Error()) return } - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("GetRunByIndex", err) return } @@ -682,7 +715,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { ArtifactName: artifactName, }) if err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("FindArtifacts", err) return } if len(artifacts) == 0 { @@ -703,7 +736,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("DownloadArtifactV4", err) return } return @@ -716,7 +749,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { for _, art := range artifacts { f, err := storage.ActionsArtifacts.Open(art.StoragePath) if err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("ActionsArtifacts.Open", err) return } @@ -724,7 +757,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) { if art.ContentEncoding == "gzip" { r, err = gzip.NewReader(f) if err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("gzip.NewReader", err) return } } else { @@ -734,11 +767,11 @@ func ArtifactsDownloadView(ctx *context_module.Context) { w, err := writer.Create(art.ArtifactPath) if err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("writer.Create", err) return } if _, err := io.Copy(w, r); err != nil { - ctx.HTTPError(http.StatusInternalServerError, err.Error()) + ctx.ServerError("io.Copy", err) return } } 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/blame.go b/routers/web/repo/blame.go index efd85b9452..e304633f95 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -8,6 +8,7 @@ import ( gotemplate "html/template" "net/http" "net/url" + "path" "strconv" "strings" @@ -15,13 +16,13 @@ import ( 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 { @@ -69,7 +70,7 @@ func RefBlame(ctx *context.Context) { 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") { @@ -234,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) } @@ -285,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 5d963eff66..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") @@ -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 ec50e1435e..0000000000 --- a/routers/web/repo/cherry_pick.go +++ /dev/null @@ -1,192 +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(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 - } - - gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) - if !valid { - ctx.Data["Err_CommitEmail"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplCherryPick, &form) - return - } - opts := &files.ApplyDiffPatchOptions{ - LastCommitID: form.LastCommit, - OldBranch: ctx.Repo.BranchName, - NewBranch: branchName, - Message: message, - Author: gitCommitter, - Committer: gitCommitter, - } - - // 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(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(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 e212d3b60c..2b2dd5744a 100644 --- a/routers/web/repo/code_frequency.go +++ b/routers/web/repo/code_frequency.go @@ -34,7 +34,7 @@ func CodeFrequencyData(ctx *context.Context) { 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 bbdcf9875e..9a06c9359b 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -21,6 +21,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/gitrepo" "code.gitea.io/gitea/modules/log" @@ -66,10 +67,7 @@ func Commits(ctx *context.Context) { 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 { @@ -77,7 +75,7 @@ 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 @@ -168,10 +166,13 @@ func Graph(ctx *context.Context) { ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name + divOnly := ctx.FormBool("div-only") + queryParams := ctx.Req.URL.Query() + queryParams.Del("div-only") paginator := context.NewPagination(int(graphCommitsCount), setting.UI.GraphMaxCommitNum, page, 5) - paginator.AddParamFromRequest(ctx.Req) + paginator.AddParamFromQuery(queryParams) ctx.Data["Page"] = paginator - if ctx.FormBool("div-only") { + if divOnly { ctx.HTML(http.StatusOK, tplGraphDiv) return } @@ -215,13 +216,12 @@ 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 @@ -230,15 +230,12 @@ func FileHistory(ctx *context.Context) { 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 { @@ -253,7 +250,7 @@ func FileHistory(ctx *context.Context) { 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) @@ -284,7 +281,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 @@ -314,7 +311,7 @@ func Diff(ctx *context.Context) { maxLines, maxFiles = -1, -1 } - diff, err := gitdiff.GetDiffForRender(ctx, gitRepo, &gitdiff.DiffOptions{ + diff, err := gitdiff.GetDiffForRender(ctx, ctx.Repo.RepoLink, gitRepo, &gitdiff.DiffOptions{ AfterCommitID: commitID, SkipTo: ctx.FormString("skip-to"), MaxLines: maxLines, @@ -370,10 +367,14 @@ func Diff(ctx *context.Context) { return } - ctx.PageData["DiffFiles"] = transformDiffTreeForUI(diffTree, nil) + 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) + statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll) if err != nil { log.Error("GetLatestCommitStatus: %v", err) } @@ -417,7 +418,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 @@ -453,6 +454,9 @@ func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_m } 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) } diff --git a/routers/web/repo/common_recentbranches.go b/routers/web/repo/common_recentbranches.go new file mode 100644 index 0000000000..c2083dec73 --- /dev/null +++ b/routers/web/repo/common_recentbranches.go @@ -0,0 +1,73 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + git_model "code.gitea.io/gitea/models/git" + access_model "code.gitea.io/gitea/models/perm/access" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +type RecentBranchesPromptDataStruct struct { + RecentlyPushedNewBranches []*git_model.RecentlyPushedNewBranch +} + +func prepareRecentlyPushedNewBranches(ctx *context.Context) { + if ctx.Doer == nil { + return + } + if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil { + log.Error("GetBaseRepo: %v", err) + return + } + + opts := git_model.FindRecentlyPushedNewBranchesOptions{ + Repo: ctx.Repo.Repository, + BaseRepo: ctx.Repo.Repository, + } + if ctx.Repo.Repository.IsFork { + opts.BaseRepo = ctx.Repo.Repository.BaseRepo + } + + baseRepoPerm, err := access_model.GetUserRepoPermission(ctx, opts.BaseRepo, ctx.Doer) + if err != nil { + log.Error("GetUserRepoPermission: %v", err) + return + } + if !opts.Repo.CanContentChange() || !opts.BaseRepo.CanContentChange() { + return + } + if !opts.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) || !baseRepoPerm.CanRead(unit_model.TypePullRequests) { + return + } + + var finalBranches []*git_model.RecentlyPushedNewBranch + branches, err := git_model.FindRecentlyPushedNewBranches(ctx, ctx.Doer, opts) + if err != nil { + log.Error("FindRecentlyPushedNewBranches failed: %v", err) + return + } + + for _, branch := range branches { + divergingInfo, err := repo_service.GetBranchDivergingInfo(ctx, + branch.BranchRepo, branch.BranchName, // "base" repo for diverging info + opts.BaseRepo, opts.BaseRepo.DefaultBranch, // "head" repo for diverging info + ) + if err != nil { + log.Error("GetBranchDivergingInfo failed: %v", err) + continue + } + branchRepoHasNewCommits := divergingInfo.BaseHasNewCommits + baseRepoCommitsBehind := divergingInfo.HeadCommitsBehind + if branchRepoHasNewCommits || baseRepoCommitsBehind > 0 { + finalBranches = append(finalBranches, branch) + } + } + if len(finalBranches) > 0 { + ctx.Data["RecentBranchesPromptData"] = RecentBranchesPromptDataStruct{finalBranches} + } +} diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 6cea95e387..c771b30e5f 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" @@ -401,12 +402,11 @@ 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(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.GetDiffForRender(ctx, ci.HeadGitRepo, + diff, err := gitdiff.GetDiffForRender(ctx, ci.HeadRepo.Link(), ci.HeadGitRepo, &gitdiff.DiffOptions{ BeforeCommitID: beforeCommitID, AfterCommitID: headCommitID, @@ -645,7 +645,11 @@ func PrepareCompareDiff( return false } - ctx.PageData["DiffFiles"] = transformDiffTreeForUI(diffTree, nil) + 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) @@ -670,6 +674,7 @@ func PrepareCompareDiff( 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()) @@ -678,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) @@ -727,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 } @@ -745,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 } @@ -885,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 @@ -945,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/editor.go b/routers/web/repo/editor.go index 113622f872..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,871 +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), - ) -} + 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 + } -// 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 + if commitFormOptions.NeedFork { + ForkToEdit(ctx) + return nil } - treeNames = strings.Split(treePath, "/") - treePaths = make([]string, len(treeNames)) - for i := range treeNames { - treePaths[i] = strings.Join(treeNames[:i+1], "/") + if commitFormOptions.WillSubmitToFork && !commitFormOptions.TargetRepo.CanEnableEditor() { + ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.editor.fork_not_editable") + ctx.NotFound(nil) } - return treeNames, treePaths -} -func editFileCommon(ctx *context.Context, isNewFile bool) { - ctx.Data["PageIsEdit"] = true - ctx.Data["IsNewFile"] = isNewFile ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() + 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["IsEditingFileOnly"] = ctx.FormString("return_uri") != "" ctx.Data["ReturnURI"] = ctx.FormString("return_uri") -} - -func editFile(ctx *context.Context, isNewFile bool) { - editFileCommon(ctx, 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 - } - - // 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(nil) - return - } - - blob := entry.Blob() - if blob.Size() >= setting.UI.MaxDisplayFileSize { - ctx.NotFound(err) - return - } - - dataRc, err := blob.DataAsync() - if err != nil { - ctx.NotFound(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(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 username the new file. - treeNames = append(treeNames, fileName) - } - - ctx.Data["TreeNames"] = treeNames - ctx.Data["TreePaths"] = treePaths + // form fields ctx.Data["commit_summary"] = "" ctx.Data["commit_message"] = "" - ctx.Data["commit_choice"] = util.Iif(canCommit, frmCommitChoiceDirect, frmCommitChoiceNewBranch) - ctx.Data["new_branch_name"] = GetUniquePatchBranchName(ctx) + 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 - - ctx.Data["EditorconfigJson"] = GetEditorConfig(ctx, treePath) - - ctx.HTML(http.StatusOK, tplEditFile) + 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) { - editFileCommon(ctx, isNewFile) - ctx.Data["PageHasPosted"] = true - - canCommit := renderCommitRights(ctx) - treeNames, treePaths := getParentTreeFields(form.TreePath) - branchName := ctx.Repo.BranchName - if form.CommitChoice == frmCommitChoiceNewBranch { - branchName = form.NewBranchName - } - - ctx.Data["TreePath"] = form.TreePath - ctx.Data["TreeNames"] = treeNames - ctx.Data["TreePaths"] = treePaths - 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["EditorconfigJson"] = GetEditorConfig(ctx, form.TreePath) - +func prepareEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *preparedEditorCommitForm[T] { + form := web.GetForm(ctx).(T) if ctx.HasError() { - ctx.HTML(http.StatusOK, tplEditFile) - return + ctx.JSONError(ctx.GetErrMsg()) + return nil } - // Cannot commit to 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 - } + commonForm := form.GetCommitCommonForm() + commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath) - // 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) - } + 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 } - form.CommitMessage = strings.TrimSpace(form.CommitMessage) - if len(form.CommitMessage) > 0 { - message += "\n\n" + form.CommitMessage + 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 } - gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) - if !valid { - ctx.Data["Err_CommitEmail"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplEditFile, &form) - return + // 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 } - operation := "update" - if isNewFile { - operation = "create" + if !issues.CanMaintainerWriteToBranch(ctx, ctx.Repo.Permission, targetBranchName, ctx.Doer) { + ctx.NotFound(nil) + 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, - Author: gitCommitter, - Committer: gitCommitter, - }); 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.HTTPError(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.HTTPError(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.HTTPError(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) + // Committer user info + gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, commonForm.CommitEmail) + if !valid { + ctx.JSONError(ctx.Tr("repo.editor.invalid_commit_email")) + return nil + } + + 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) + ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", targetBranchName)) } - } 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.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.HTTPError(http.StatusInternalServerError, "file name to diff is invalid") - return + return &preparedEditorCommitForm[T]{ + form: form, + commonForm: commonForm, + CommitFormOptions: commitFormOptions, + OldBranchName: oldBranchName, + NewBranchName: targetBranchName, + GitCommitter: gitCommitter, } - - entry, err := ctx.Repo.Commit.GetTreeEntryByPath(treePath) - if err != nil { - ctx.HTTPError(http.StatusInternalServerError, "GetTreeEntryByPath: "+err.Error()) - return - } else if entry.IsDir() { - ctx.HTTPError(http.StatusUnprocessableEntity) - return - } - - diff, err := files_service.GetDiffPreview(ctx, ctx.Repo.Repository, ctx.Repo.BranchName, treePath, form.Content) - if err != nil { - ctx.HTTPError(http.StatusInternalServerError, "GetDiffPreview: "+err.Error()) - return - } - - if len(diff.Files) != 0 { - ctx.Data["File"] = diff.Files[0] - } - - 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 - } - - 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 +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 } - gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) - if !valid { - ctx.Data["Err_CommitEmail"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplDeleteFile, &form) - return + // 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, - Author: gitCommitter, - Committer: gitCommitter, - }); 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.HTTPError(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.HTTPError(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) + return buf, dataRc, fInfo } -// 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) -} - -// 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) + ctx.Data["FileSize"] = fInfo.fileSize + + // 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") + } + + if ctx.Data["NotEditableReason"] == nil { + buf, err := io.ReadAll(io.MultiReader(bytes.NewReader(prefetch), dataRc)) 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) + ctx.ServerError("ReadAll", err) return } - - // 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 + if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil { + ctx.Data["FileContent"] = string(buf) + } else { + ctx.Data["FileContent"] = content } } } - 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 - } + ctx.Data["EditorconfigJson"] = getContextRepoEditorConfig(ctx, ctx.Repo.TreePath) + ctx.HTML(http.StatusOK, tplEditFile) +} - gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) - if !valid { - ctx.Data["Err_CommitEmail"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplUploadFile, &form) +func EditFilePost(ctx *context.Context) { + editorAction := ctx.PathParam("editor_action") + isNewFile := editorAction == "_new" + parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) + if ctx.Written() { return } - 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, - Author: gitCommitter, - Committer: gitCommitter, - }); 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.HTTPError(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), 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) - } 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) - } - } 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) - } + defaultCommitMessage := util.Iif(isNewFile, ctx.Locale.TrString("repo.editor.add", parsed.form.TreePath), ctx.Locale.TrString("repo.editor.update", parsed.form.TreePath)) + + 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 } - 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") - } + _, 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 { + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) + return } - redirectForCommitChoice(ctx, form.CommitChoice, branchName, form.TreePath) + redirectForCommitChoice(ctx, parsed, 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 "" - } +// DeleteFile render delete file page +func DeleteFile(ctx *context.Context) { + prepareEditorCommitFormOptions(ctx, "_delete") + if ctx.Written() { + return } - return name + ctx.Data["PageIsDelete"] = true + ctx.HTML(http.StatusOK, tplDeleteFile) } -// 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.HTTPError(http.StatusInternalServerError, fmt.Sprintf("FormFile: %v", err)) +// DeleteFilePost response for deleting file +func DeleteFilePost(ctx *context.Context) { + parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx) + if ctx.Written() { 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) + 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.HTTPError(http.StatusBadRequest, err.Error()) + editorHandleFileOperationError(ctx, parsed.NewBranchName, err) return } - name := cleanUploadFileName(header.Filename) - if len(name) == 0 { - ctx.HTTPError(http.StatusInternalServerError, "Upload file name is invalid") - return - } + ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath)) + redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath) + redirectForCommitChoice(ctx, parsed, redirectTreePath) +} - upload, err := repo_model.NewUpload(ctx, name, buf, file) - if err != nil { - ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("NewUpload: %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("New file uploaded: %s", upload.UUID) - ctx.JSON(http.StatusOK, map[string]string{ - "uuid": upload.UUID, - }) + ctx.HTML(http.StatusOK, tplUploadFile) } -// 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) +func UploadFilePost(ctx *context.Context) { + parsed := prepareEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx) + if ctx.Written() { return } - if err := repo_model.DeleteUploadByUUID(ctx, form.File); err != nil { - ctx.HTTPError(http.StatusInternalServerError, fmt.Sprintf("DeleteUploadByUUID: %v", err)) + 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 } - - log.Trace("Upload file removed: %s", form.File) - ctx.Status(http.StatusNoContent) -} - -// 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 "" - } - } - 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 + 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 36e64bfee3..c2694e540f 100644 --- a/routers/web/repo/fork.go +++ b/routers/web/repo/fork.go @@ -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 { @@ -194,44 +189,50 @@ func ForkPost(ctx *context.Context) { } } - 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 f93d7fc66a..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 == "HEAD" || 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 ) @@ -303,17 +303,12 @@ 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) @@ -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) 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/issue.go b/routers/web/repo/issue.go index dbbe29a3c3..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" @@ -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) @@ -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) @@ -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 diff --git a/routers/web/repo/issue_comment.go b/routers/web/repo/issue_comment.go index 45463200f6..c2a7f6b682 100644 --- a/routers/web/repo/issue_comment.go +++ b/routers/web/repo/issue_comment.go @@ -8,6 +8,7 @@ import ( "fmt" "html/template" "net/http" + "strconv" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/renderhelper" @@ -96,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 @@ -239,21 +240,28 @@ func UpdateCommentContent(ctx *context.Context) { 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 { @@ -271,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) diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go index c2c208736c..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)) } } diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go index 62c0128f19..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.HTTPError(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 diff --git a/routers/web/repo/issue_label_test.go b/routers/web/repo/issue_label_test.go index 486c2e35a2..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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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 a65ae77795..fd34422cfc 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) } @@ -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, @@ -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) @@ -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) @@ -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 @@ -758,6 +767,10 @@ func Issues(ctx *context.Context) { } ctx.Data["Title"] = ctx.Tr("repo.pulls") ctx.Data["PageIsPullList"] = true + prepareRecentlyPushedNewBranches(ctx) + if ctx.Written() { + return + } } else { MustEnableIssues(ctx) if ctx.Written() { @@ -768,7 +781,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 9f52396414..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] { diff --git a/routers/web/repo/issue_stopwatch.go b/routers/web/repo/issue_stopwatch.go index 73e279e0a6..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 err := issues_model.CreateOrStopIssueStopwatch(c, c.Doer, issue); err != nil { - c.ServerError("CreateOrStopIssueStopwatch", err) + 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 showSuccessMessage { - c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) +// IssueStopStopwatch stops a stopwatch for the given issue. +func IssueStopStopwatch(c *context.Context) { + issue := GetActionIssue(c) + if c.Written() { + return } + 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("") } @@ -53,7 +65,7 @@ func CancelStopwatch(c *context.Context) { 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_view.go b/routers/web/repo/issue_view.go index b312f1260a..d4458ed19e 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "sort" + "strconv" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" @@ -31,6 +32,7 @@ import ( "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" @@ -271,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 { @@ -294,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(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. @@ -337,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 } @@ -351,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 } @@ -365,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 { @@ -412,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" @@ -594,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) @@ -670,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) @@ -727,6 +766,9 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue } 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) } @@ -792,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) @@ -838,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 @@ -948,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) @@ -958,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/milestone.go b/routers/web/repo/milestone.go index f1d0a857ea..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{ @@ -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 120b3469f6..0000000000 --- a/routers/web/repo/patch.go +++ /dev/null @@ -1,125 +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 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 - } - - gitCommitter, valid := WebGitOperationGetCommitChosenEmailIdentity(ctx, form.CommitEmail) - if !valid { - ctx.Data["Err_CommitEmail"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.invalid_commit_email"), tplPatchFile, &form) - return - } - - 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", ""), - Author: gitCommitter, - Committer: gitCommitter, - }) - 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 5b81a5e4d1..a57976b4ca 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -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 @@ -313,14 +310,14 @@ 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 := project_service.LoadIssuesFromProject(ctx, project, &issues_model.IssuesOptions{ RepoIDs: []int64{ctx.Repo.Repository.ID}, - LabelIDs: labelIDs, - AssigneeID: optional.Some(assigneeID), + LabelIDs: preparedLabelFilter.SelectedLabelIDs, + AssigneeID: assigneeID, }) if err != nil { ctx.ServerError("LoadIssuesOfColumns", err) @@ -381,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 { @@ -397,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) diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index e12798f93d..23402e3eb2 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" @@ -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,21 +191,19 @@ func GetPullDiffStats(ctx *context.Context) { pull := issue.PullRequest mergeBaseCommitID := GetMergedBaseCommitID(ctx, issue) - if mergeBaseCommitID == "" { - ctx.NotFound(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 } - diffShortStat, err := gitdiff.GetDiffShortStat(ctx.Repo.GitRepo, mergeBaseCommitID, headCommitID) if err != nil { - ctx.ServerError("GetDiffShortStat", err) + log.Error("Failed to GetDiffShortStat: %v, repo: %v", err, ctx.Repo.Repository.FullName()) return } @@ -291,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 @@ -358,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 @@ -454,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 @@ -749,7 +750,7 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi diffOptions.BeforeCommitID = startCommitID } - diff, err := gitdiff.GetDiffForRender(ctx, gitRepo, diffOptions, files...) + diff, err := gitdiff.GetDiffForRender(ctx, ctx.Repo.RepoLink, gitRepo, diffOptions, files...) if err != nil { ctx.ServerError("GetDiff", err) return @@ -759,12 +760,9 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi // 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. - shouldGetUserSpecificDiff := false - if !ctx.IsSigned || willShowSpecifiedCommit || willShowSpecifiedCommitRange { - // do nothing - } else { - shouldGetUserSpecificDiff = true - err = gitdiff.SyncUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diff, diffOptions, files...) + 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 @@ -823,18 +821,16 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi ctx.ServerError("GetDiffTree", err) return } - - filesViewedState := make(map[string]pull_model.ViewedState) - if shouldGetUserSpecificDiff { - // This sort of sucks because we already fetch this when getting the diff - review, err := pull_model.GetNewestReviewState(ctx, ctx.Doer.ID, issue.ID) - if err == nil && review != nil && review.UpdatedFiles != nil { - // If there wasn't an error and we have a review with updated files, use that - filesViewedState = review.UpdatedFiles - } + var filesViewedState map[string]pull_model.ViewedState + if reviewState != nil { + filesViewedState = reviewState.UpdatedFiles } - ctx.PageData["DiffFiles"] = transformDiffTreeForUI(diffTree, filesViewedState) + 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 @@ -991,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{ @@ -1063,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")) @@ -1071,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 ... @@ -1260,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 @@ -1286,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 } diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index fb92d24394..929e131d61 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -209,11 +209,12 @@ 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 { + 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 228eb0dbac..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.Repository.DefaultBranch); 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 553bdbf6e5..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" @@ -102,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() @@ -113,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 @@ -130,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 } @@ -378,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 @@ -534,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 @@ -580,7 +583,7 @@ func EditReleasePost(ctx *context.Context) { 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 diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 73baf683ed..828ec08a8a 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -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 { @@ -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 } @@ -305,11 +301,15 @@ func CreatePost(ctx *context.Context) { } func handleActionError(ctx *context.Context, err error) { - if errors.Is(err, user_model.ErrBlockedUser) { + switch { + case errors.Is(err, user_model.ErrBlockedUser): ctx.Flash.Error(ctx.Tr("repo.action.blocked_user")) - } else if errors.Is(err, util.ErrPermissionDenied) { + 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) - } else { + default: ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.PathParam("action")), err) } } @@ -461,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")), diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go index 655291d25c..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" @@ -44,10 +45,7 @@ func LFSFiles(ctx *context.Context) { 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) @@ -76,10 +74,7 @@ func LFSLocks(ctx *context.Context) { } 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 @@ -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(): diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go index 75de2ba1e7..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 } @@ -283,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) { @@ -332,7 +334,7 @@ func RenameBranchPost(ctx *context.Context) { if ctx.HasError() { ctx.Flash.Error(ctx.GetErrMsg()) - ctx.Redirect(fmt.Sprintf("%s/branches", ctx.Repo.RepoLink)) + ctx.Redirect(ctx.Repo.RepoLink + "/branches") return } @@ -341,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) } @@ -356,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 33692778d5..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 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/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 ac7eb768fa..0865d9d7c0 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -6,14 +6,12 @@ package setting import ( "errors" - "fmt" "net/http" "strings" "time" "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" @@ -37,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 ( @@ -48,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) { @@ -68,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 @@ -105,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 @@ -115,871 +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 !strings.EqualFold(repo.LowerName, 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 - pullMirror.LFS = form.LFS - pullMirror.LFSEndpoint = form.LFSEndpoint - if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil { - ctx.ServerError("UpdateMirror", err) + form.LFS = form.LFS && setting.LFS.StartServer + + 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) + pullMirror.LFS = form.LFS + pullMirror.LFSEndpoint = form.LFSEndpoint + if err := repo_model.UpdateMirror(ctx, pullMirror); err != nil { + ctx.ServerError("UpdateMirror", err) + return + } - ctx.Flash.Info(ctx.Tr("repo.settings.pull_mirror_sync_in_progress", repo.OriginalURL)) - ctx.Redirect(repo.Link() + "/settings") + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") +} - case "push-mirror-sync": - if !setting.Mirror.Enabled { - 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 + } - m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) - if m == nil { - ctx.NotFound(nil) - return - } + mirror_service.AddPullMirrorToQueue(repo.ID) - mirror_service.AddPushMirrorToQueue(m.ID) + ctx.Flash.Info(ctx.Tr("repo.settings.pull_mirror_sync_in_progress", repo.OriginalURL)) + ctx.Redirect(repo.Link() + "/settings") +} - ctx.Flash.Info(ctx.Tr("repo.settings.push_mirror_sync_in_progress", m.RemoteAddress)) - ctx.Redirect(repo.Link() + "/settings") +func handleSettingsPostPushMirrorSync(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository - case "push-mirror-update": - if !setting.Mirror.Enabled || repo.IsArchived { - ctx.NotFound(nil) - return - } + if !setting.Mirror.Enabled { + 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 + m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) + if m == nil { + 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 - } + mirror_service.AddPushMirrorToQueue(m.ID) - m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) - if m == nil { - ctx.NotFound(nil) - return - } + ctx.Flash.Info(ctx.Tr("repo.settings.push_mirror_sync_in_progress", m.RemoteAddress)) + 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") +func handleSettingsPostPushMirrorUpdate(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository - case "push-mirror-remove": - if !setting.Mirror.Enabled || repo.IsArchived { - ctx.NotFound(nil) - return - } + if !setting.Mirror.Enabled || repo.IsArchived { + 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 + // 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 - m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) - if m == nil { - 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 + } - if err := mirror_service.RemovePushMirrorRemote(ctx, m); err != nil { - ctx.ServerError("RemovePushMirrorRemote", err) - return - } + m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) + if m == nil { + ctx.NotFound(nil) + return + } - if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { - ctx.ServerError("DeletePushMirrorByID", err) - return - } + 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.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(repo.Link() + "/settings") +func handleSettingsPostPushMirrorRemove(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository - case "push-mirror-add": - if setting.Mirror.DisableNewPush || repo.IsArchived { - ctx.NotFound(nil) - return - } + if !setting.Mirror.Enabled || repo.IsArchived { + 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 + // 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 - } + m, _, _ := repo_model.GetPushMirrorByIDAndRepoID(ctx, form.PushMirrorID, repo.ID) + if m == nil { + ctx.NotFound(nil) + return + } - 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 err := mirror_service.RemovePushMirrorRemote(ctx, m); err != nil { + ctx.ServerError("RemovePushMirrorRemote", err) + return + } - remoteSuffix, err := util.CryptoRandomString(10) - if err != nil { - ctx.ServerError("RandomString", err) - return - } + if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: m.ID, RepoID: m.RepoID}); err != nil { + ctx.ServerError("DeletePushMirrorByID", err) + return + } - remoteAddress, err := util.SanitizeURL(form.PushMirrorAddress) - if err != nil { - ctx.Data["Err_PushMirrorAddress"] = true - handleSettingRemoteAddrError(ctx, err, form) - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") +} - 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 - } +func handleSettingsPostPushMirrorAdd(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoSettingForm) + repo := ctx.Repo.Repository - 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 setting.Mirror.DisableNewPush || repo.IsArchived { + ctx.NotFound(nil) + return + } - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(repo.Link() + "/settings") + // 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 - case "advanced": - var repoChanged bool - var units []repo_model.RepoUnit - var deleteUnitTypes []unit_model.Type + 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 + } - if repo.CloseIssuesViaCommitInAnyBranch != form.EnableCloseIssuesViaCommitInAnyBranch { - repo.CloseIssuesViaCommitInAnyBranch = form.EnableCloseIssuesViaCommitInAnyBranch - repoChanged = true - } + remoteSuffix, err := util.CryptoRandomString(10) + if err != nil { + ctx.ServerError("RandomString", err) + 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) - } + remoteAddress, err := util.SanitizeURL(form.PushMirrorAddress) + if err != nil { + ctx.Data["Err_PushMirrorAddress"] = true + handleSettingRemoteAddrError(ctx, err, form) + 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 - } + 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 + } - 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) - } + 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.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")) - } - } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") +} - 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) - } +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.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 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.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) - } + // 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 repo.CloseIssuesViaCommitInAnyBranch != form.EnableCloseIssuesViaCommitInAnyBranch { + repo.CloseIssuesViaCommitInAnyBranch = form.EnableCloseIssuesViaCommitInAnyBranch + repoChanged = true + } + + 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 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) + 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 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) + 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 !unit_model.TypeWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) + } + } - 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.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 len(units) == 0 { - ctx.Flash.Error(ctx.Tr("repo.settings.update_settings_no_unit")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + 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 err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil { - ctx.ServerError("UpdateRepositoryUnits", err) + 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 repoChanged { - if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { - ctx.ServerError("UpdateRepository", err) - return - } + 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) } - log.Trace("Repository advanced settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + if !unit_model.TypeIssues.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) + } + } - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + 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) + } - case "signing": - changed := false - trustModel := repo_model.ToTrustModel(form.TrustModel) - if trustModel != repo.TrustModel { - repo.TrustModel = trustModel - changed = true - } + 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) + } - if changed { - if err := repo_service.UpdateRepository(ctx, repo, false); err != nil { - ctx.ServerError("UpdateRepository", err) - return - } - } - log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + 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) + } - ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + 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) + } - case "admin": - if !ctx.Doer.IsAdmin { - ctx.HTTPError(http.StatusForbidden) - return - } + 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) + } - if repo.IsFsckEnabled != form.EnableHealthCheck { - repo.IsFsckEnabled = form.EnableHealthCheck - } + if len(units) == 0 { + ctx.Flash.Error(ctx.Tr("repo.settings.update_settings_no_unit")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") + return + } + 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) - log.Trace("Repository admin 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_index": - if !ctx.Doer.IsAdmin { - ctx.HTTPError(http.StatusForbidden) - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} - 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.HTTPError(http.StatusForbidden) - return - } - code.UpdateRepoIndexer(ctx.Repo.Repository) - default: - ctx.NotFound(nil) +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 signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) + } - log.Trace("Repository reindex for %s requested: %s/%s", form.RequestReindexType, ctx.Repo.Owner.Name, repo.Name) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} - ctx.Flash.Success(ctx.Tr("repo.settings.reindex_requested")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") +func handleSettingsPostAdmin(ctx *context.Context) { + if !ctx.Doer.IsAdmin { + ctx.HTTPError(http.StatusForbidden) + return + } - case "convert": - 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) + 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 !repo.IsMirror { - ctx.HTTPError(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) - 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()) +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 "convert_fork": - if !ctx.Repo.IsOwner() { - ctx.HTTPError(http.StatusNotFound) + switch form.RequestReindexType { + case "stats": + if err := stats.UpdateRepoIndexer(ctx.Repo.Repository); err != nil { + ctx.ServerError("UpdateStatsRepondexer", err) 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) + case "code": + if !setting.Indexer.RepoIndexerEnabled { + ctx.HTTPError(http.StatusForbidden) return } + code.UpdateRepoIndexer(ctx.Repo.Repository) + default: + ctx.NotFound(nil) + return + } - if !repo.IsFork { - ctx.HTTPError(http.StatusNotFound) - return - } + log.Trace("Repository reindex for %s requested: %s/%s", form.RequestReindexType, ctx.Repo.Owner.Name, repo.Name) - 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") - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.reindex_requested")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings") +} - 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 - } +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 + } - 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()) + if !repo.IsMirror { + ctx.HTTPError(http.StatusNotFound) + return + } + repo.IsMirror = false - case "transfer": - 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 := 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()) +} - 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 - } +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 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 - } - } + if !repo.IsFork { + ctx.HTTPError(http.StatusNotFound) + return + } - // Close the GitRepo if open - if ctx.Repo.GitRepo != nil { - ctx.Repo.GitRepo.Close() - ctx.Repo.GitRepo = nil - } + 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 + } - 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 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 + } - return - } + 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()) +} - 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") +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 + } - case "cancel_transfer": - if !ctx.Repo.IsOwner() { - ctx.HTTPError(http.StatusNotFound) + 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 + } - 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) - } + 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 } + } - if err := repo_service.CancelRepositoryTransfer(ctx, repoTransfer, ctx.Doer); err != nil { - ctx.ServerError("CancelRepositoryTransfer", err) - return + // Close the GitRepo if open + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + + 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) } - 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") + return + } - case "delete": - 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 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") +} - // Close the gitrepository before doing this. - if ctx.Repo.GitRepo != nil { - ctx.Repo.GitRepo.Close() - } +func handleSettingsPostCancelTransfer(ctx *context.Context) { + repo := ctx.Repo.Repository + if !ctx.Repo.IsOwner() { + ctx.HTTPError(http.StatusNotFound) + return + } - if err := repo_service.DeleteRepository(ctx, ctx.Doer, ctx.Repo.Repository, true); err != nil { - ctx.ServerError("DeleteRepository", 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 deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) + return + } - ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) - ctx.Redirect(ctx.Repo.Owner.DashboardLink()) + if err := repo_service.CancelRepositoryTransfer(ctx, repoTransfer, ctx.Doer); err != nil { + ctx.ServerError("CancelRepositoryTransfer", err) + return + } - case "delete-wiki": - 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 - } + 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") +} - 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) +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 + } - ctx.Flash.Success(ctx.Tr("repo.settings.wiki_deletion_success")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + // Close the gitrepository before doing this. + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + } - case "archive": - if !ctx.Repo.IsOwner() { - ctx.HTTPError(http.StatusForbidden) - 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 repo.IsMirror { - ctx.Flash.Error(ctx.Tr("repo.settings.archive.error_ismirror")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") - return - } + ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) + ctx.Redirect(ctx.Repo.Owner.DashboardLink()) +} - 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 - } +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 + } - 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) - } + 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.HTTPError(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 ad33dac514..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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + assert.Equal(t, http.StatusSeeOther, ctx.Resp.WrittenStatus()) assert.NotEmpty(t, ctx.Flash.ErrorMsg) } diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index d3151a86a2..f107449749 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -185,6 +185,7 @@ func ParseHookEvent(form forms.WebhookForm) *webhook_module.HookEvent { 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, @@ -197,7 +198,6 @@ type webhookParams struct { URL string ContentType webhook.HookContentType - Secret string HTTPMethod string WebhookForm forms.WebhookForm Meta any @@ -236,7 +236,7 @@ func createWebhook(ctx *context.Context, params webhookParams) { URL: params.URL, HTTPMethod: params.HTTPMethod, ContentType: params.ContentType, - Secret: params.Secret, + Secret: params.WebhookForm.Secret, HookEvent: ParseHookEvent(params.WebhookForm), IsActive: params.WebhookForm.Active, Type: params.Type, @@ -289,7 +289,7 @@ func editWebhook(ctx *context.Context, params webhookParams) { w.URL = params.URL w.ContentType = params.ContentType - w.Secret = params.Secret + w.Secret = params.WebhookForm.Secret w.HookEvent = ParseHookEvent(params.WebhookForm) w.IsActive = params.WebhookForm.Active w.HTTPMethod = params.HTTPMethod @@ -335,7 +335,6 @@ func giteaHookParams(ctx *context.Context) webhookParams { Type: webhook_module.GITEA, URL: form.PayloadURL, ContentType: contentType, - Secret: form.Secret, HTTPMethod: form.HTTPMethod, WebhookForm: form.WebhookForm, } @@ -363,7 +362,6 @@ func gogsHookParams(ctx *context.Context) webhookParams { Type: webhook_module.GOGS, URL: form.PayloadURL, ContentType: contentType, - Secret: form.Secret, WebhookForm: form.WebhookForm, } } diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go index ab74741e61..340b2bc091 100644 --- a/routers/web/repo/treelist.go +++ b/routers/web/repo/treelist.go @@ -4,10 +4,14 @@ 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" @@ -56,41 +60,94 @@ func isExcludedEntry(entry *git.TreeEntry) bool { return false } -type FileDiffFile struct { - Name string +// WebDiffFileItem is used by frontend, check the field names in frontend before changing +type WebDiffFileItem struct { + FullName string + DisplayName string NameHash string - IsSubmodule bool + DiffStatus string + EntryMode string IsViewed bool - Status string + Children []*WebDiffFileItem + FileIcon template.HTML } -// transformDiffTreeForUI transforms a DiffTree into a slice of FileDiffFile for UI rendering +// 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 transformDiffTreeForUI(diffTree *gitdiff.DiffTree, filesViewedState map[string]pull_model.ViewedState) []FileDiffFile { - files := make([]FileDiffFile, 0, len(diffTree.Files)) +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 { - nameHash := git.HashFilePathForWebUI(file.HeadPath) - isSubmodule := file.HeadMode == git.EntryModeCommit - isViewed := filesViewedState[file.HeadPath] == pull_model.Viewed - - files = append(files, FileDiffFile{ - Name: file.HeadPath, - NameHash: nameHash, - IsSubmodule: isSubmodule, - IsViewed: isViewed, - Status: file.Status, - }) + 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) } - return files + 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) { - results, err := files_service.GetTreeViewNodes(ctx, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormString("sub_path")) + renderedIconPool := fileicon.NewRenderedIconPool() + results, err := files_service.GetTreeViewNodes(ctx, ctx.Repo.RepoLink, 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}) + 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 6ed5801d10..e47bc56d08 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" @@ -58,60 +60,63 @@ const ( ) 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 { @@ -130,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) } @@ -252,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 { @@ -287,12 +305,13 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri defer cancel() } - files, latestCommit, err := allEntries.GetCommitsInfo(commitInfoCtx, ctx.Repo.Commit, ctx.Repo.TreePath) + files, latestCommit, err := allEntries.GetCommitsInfo(commitInfoCtx, ctx.Repo.RepoLink, ctx.Repo.Commit, ctx.Repo.TreePath) if err != nil { ctx.ServerError("GetCommitsInfo", err) return nil } ctx.Data["Files"] = files + prepareDirectoryFileIcons(ctx, files) for _, f := range files { if f.Commit == nil { ctx.Data["HasFilesWithoutLatestCommit"] = true @@ -381,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 4ce7a8e3a4..2d5bddd939 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -18,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 { @@ -87,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 d538406035..5482780c98 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -15,11 +15,13 @@ import ( "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" - 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" @@ -76,7 +78,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 } @@ -140,7 +142,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 @@ -193,56 +195,6 @@ func prepareUpstreamDivergingInfo(ctx *context.Context) { ctx.Data["UpstreamDivergingInfo"] = upstreamDivergingInfo } -func prepareRecentlyPushedNewBranches(ctx *context.Context) { - if ctx.Doer != nil { - if err := ctx.Repo.Repository.GetBaseRepo(ctx); err != nil { - ctx.ServerError("GetBaseRepo", err) - return - } - - opts := &git_model.FindRecentlyPushedNewBranchesOptions{ - Repo: ctx.Repo.Repository, - BaseRepo: ctx.Repo.Repository, - } - if ctx.Repo.Repository.IsFork { - opts.BaseRepo = ctx.Repo.Repository.BaseRepo - } - - baseRepoPerm, err := access_model.GetUserRepoPermission(ctx, opts.BaseRepo, ctx.Doer) - if err != nil { - ctx.ServerError("GetUserRepoPermission", err) - return - } - - if !opts.Repo.IsMirror && !opts.BaseRepo.IsMirror && - opts.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) && - baseRepoPerm.CanRead(unit_model.TypePullRequests) { - var finalBranches []*git_model.RecentlyPushedNewBranch - branches, err := git_model.FindRecentlyPushedNewBranches(ctx, ctx.Doer, opts) - if err != nil { - log.Error("FindRecentlyPushedNewBranches failed: %v", err) - } - - for _, branch := range branches { - divergingInfo, err := repo_service.GetBranchDivergingInfo(ctx, - branch.BranchRepo, branch.BranchName, // "base" repo for diverging info - opts.BaseRepo, opts.BaseRepo.DefaultBranch, // "head" repo for diverging info - ) - if err != nil { - log.Error("GetBranchDivergingInfo failed: %v", err) - continue - } - branchRepoHasNewCommits := divergingInfo.BaseHasNewCommits - baseRepoCommitsBehind := divergingInfo.HeadCommitsBehind - if branchRepoHasNewCommits || baseRepoCommitsBehind > 0 { - finalBranches = append(finalBranches, branch) - } - } - ctx.Data["RecentlyPushedNewBranches"] = finalBranches - } - } -} - func updateContextRepoEmptyAndStatus(ctx *context.Context, empty bool, status repo_model.RepositoryStatus) { if ctx.Repo.Repository.IsEmpty == empty && ctx.Repo.Repository.Status == status { return @@ -259,6 +211,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 { @@ -269,7 +225,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. @@ -302,12 +258,37 @@ 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) } } } @@ -343,11 +324,39 @@ func prepareHomeTreeSideBarSwitch(ctx *context.Context) { 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 +} + // Home render repository home page 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. @@ -356,10 +365,8 @@ func Home(ctx *context.Context) { return } - prepareHomeTreeSideBarSwitch(ctx) - 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 @@ -372,6 +379,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 { @@ -379,6 +388,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() 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/wiki.go b/routers/web/repo/wiki.go index 0f8e1223c6..a35b7b86e1 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -6,8 +6,7 @@ package repo import ( "bytes" - gocontext "context" - "fmt" + "html/template" "io" "net/http" "net/url" @@ -62,9 +61,9 @@ func MustEnableWiki(ctx *context.Context) { 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 +95,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 +104,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 +178,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 +202,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 +240,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 +270,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,53 +339,35 @@ 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"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository) if err != nil { - if wikiRepo != nil { - wikiRepo.Close() - } ctx.ServerError("ConvertFromGitCommit", err) return nil, nil } @@ -450,16 +376,11 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) 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) @@ -467,7 +388,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" @@ -491,17 +412,13 @@ func renderEditPage(ctx *context.Context) { 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 @@ -563,12 +480,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 } @@ -581,10 +493,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 @@ -604,13 +516,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 } @@ -622,7 +528,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 @@ -642,12 +548,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 @@ -667,7 +568,7 @@ func WikiPages(ctx *context.Context) { } allEntries.CustomSort(base.NaturalSortLess) - entries, _, err := allEntries.GetCommitsInfo(gocontext.Context(ctx), commit, treePath) + entries, _, err := allEntries.GetCommitsInfo(ctx, ctx.Repo.RepoLink, commit, treePath) if err != nil { ctx.ServerError("GetCommitsInfo", err) return @@ -701,13 +602,7 @@ 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(nil) diff --git a/routers/web/repo/wiki_test.go b/routers/web/repo/wiki_test.go index 99114c93e0..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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus(), "filepath: %s", filepath) + assert.Equal(t, http.StatusNotFound, ctx.Resp.WrittenStatus(), "filepath: %s", filepath) } else { - assert.EqualValues(t, http.StatusOK, ctx.Resp.WrittenStatus(), "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 444bd960db..648f8046a4 100644 --- a/routers/web/shared/actions/runners.go +++ b/routers/web/shared/actions/runners.go @@ -57,9 +57,8 @@ func getRunnersCtx(ctx *context.Context) (*runnersCtx, 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 &runnersCtx{ @@ -109,10 +108,7 @@ func Runners(ctx *context.Context) { return } - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) opts := actions_model.FindRunnerOptions{ ListOptions: db.ListOptions{ @@ -180,10 +176,7 @@ func RunnersEdit(ctx *context.Context) { return } - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) runnerID := ctx.PathParamInt64("runnerid") ownerID := rCtx.OwnerID @@ -198,7 +191,7 @@ func RunnersEdit(ctx *context.Context) { 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(err) return @@ -251,7 +244,7 @@ func RunnersEditPost(ctx *context.Context) { ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err) return } - if !runner.Editable(ownerID, repoID) { + if !runner.EditableInContext(ownerID, repoID) { ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner")) return } @@ -305,7 +298,7 @@ func RunnerDeletePost(ctx *context.Context) { return } - if !runner.Editable(rCtx.OwnerID, rCtx.RepoID) { + if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) { ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to delete this runner")) return } diff --git a/routers/web/shared/actions/variables.go b/routers/web/shared/actions/variables.go index 9cc1676d7b..a43c2c2690 100644 --- a/routers/web/shared/actions/variables.go +++ b/routers/web/shared/actions/variables.go @@ -49,9 +49,8 @@ func getVariablesCtx(ctx *context.Context) (*variablesCtx, 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 &variablesCtx{ 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 3d1795b42c..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 } diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go index c8b80ebb26..29f4e9520d 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -32,11 +32,11 @@ func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL 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/swagger_json.go b/routers/web/swagger_json.go index fc39b504a9..52f6beaf59 100644 --- a/routers/web/swagger_json.go +++ b/routers/web/swagger_json.go @@ -4,10 +4,15 @@ package web import ( + "html/template" + + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/context" ) // SwaggerV1Json render swagger v1 json func SwaggerV1Json(ctx *context.Context) { + ctx.Data["SwaggerAppVer"] = template.HTML(template.JSEscapeString(setting.AppVer)) + ctx.Data["SwaggerAppSubUrl"] = setting.AppSubURL // it is JS-safe ctx.JSONTemplate("swagger/v1_json") } diff --git a/routers/web/user/code.go b/routers/web/user/code.go index f9aa58b877..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 } @@ -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 8e030a62a2..b53a3daedb 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -119,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, @@ -137,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) } @@ -177,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 @@ -198,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) @@ -243,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 @@ -462,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, @@ -501,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: @@ -521,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, @@ -618,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 { @@ -699,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." } @@ -792,9 +789,9 @@ func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMod 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: @@ -816,8 +813,8 @@ func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMod // Below stats are for the left sidebar opts = opts.Copy(func(o *issue_indexer.SearchOptions) { - o.AssigneeID = nil - o.PosterID = nil + o.AssigneeID = "" + o.PosterID = "" o.MentionID = nil o.ReviewRequestedID = nil o.ReviewedID = nil @@ -827,11 +824,11 @@ func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMod 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 b2c8ad98ba..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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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.WrittenStatus()) + 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..aaf9d435c0 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -4,11 +4,8 @@ package user import ( - goctx "context" - "errors" "fmt" "net/http" - "net/url" "strings" activities_model "code.gitea.io/gitea/models/activities" @@ -35,84 +32,42 @@ 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 +// Notifications is the notification list page func Notifications(ctx *context.Context) { - getNotifications(ctx) + prepareUserNotificationsData(ctx) if ctx.Written() { return } if ctx.FormBool("div-only") { - ctx.Data["SequenceNumber"] = ctx.FormString("sequence-number") ctx.HTML(http.StatusOK, tplNotificationDiv) return } ctx.HTML(http.StatusOK, tplNotification) } -func getNotifications(ctx *context.Context) { - var ( - keyword = ctx.FormTrim("q") - status activities_model.NotificationStatus - page = ctx.FormInt("page") - perPage = ctx.FormInt("perPage") - ) - if page < 1 { - page = 1 - } - if perPage < 1 { - perPage = 20 - } - - switch keyword { - case "read": - status = activities_model.NotificationStatusRead - default: - status = activities_model.NotificationStatusUnread - } +func prepareUserNotificationsData(ctx *context.Context) { + pageType := ctx.FormString("type", ctx.FormString("q")) // "q" is the legacy query parameter for "page type" + page := max(1, ctx.FormInt("page")) + perPage := util.IfZero(ctx.FormInt("perPage"), 20) // this value is never used or exposed .... + queryStatus := util.Iif(pageType == "read", activities_model.NotificationStatusRead, activities_model.NotificationStatusUnread) total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ UserID: ctx.Doer.ID, - Status: []activities_model.NotificationStatus{status}, + Status: []activities_model.NotificationStatus{queryStatus}, }) if err != nil { ctx.ServerError("ErrGetNotificationCount", err) return } - // redirect to last page if request page is more than total pages pager := context.NewPagination(int(total), perPage, page, 5) if pager.Paginater.Current() < page { - ctx.Redirect(fmt.Sprintf("%s/notifications?q=%s&page=%d", setting.AppSubURL, url.QueryEscape(ctx.FormString("q")), pager.Paginater.Current())) - return + // use the last page if the requested page is more than total pages + page = pager.Paginater.Current() + pager = context.NewPagination(int(total), perPage, page, 5) } - statuses := []activities_model.NotificationStatus{status, activities_model.NotificationStatusPinned} + statuses := []activities_model.NotificationStatus{queryStatus, activities_model.NotificationStatusPinned} nls, err := db.Find[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ ListOptions: db.ListOptions{ PageSize: perPage, @@ -169,51 +124,37 @@ func getNotifications(ctx *context.Context) { } ctx.Data["Title"] = ctx.Tr("notifications") - ctx.Data["Keyword"] = keyword - ctx.Data["Status"] = status + ctx.Data["PageType"] = pageType ctx.Data["Notifications"] = notifications - + ctx.Data["Link"] = setting.AppSubURL + "/notifications" + ctx.Data["SequenceNumber"] = ctx.FormString("sequence-number") pager.AddParamFromRequest(ctx.Req) ctx.Data["Page"] = pager } // NotificationStatusPost is a route for changing the status of a notification func NotificationStatusPost(ctx *context.Context) { - var ( - notificationID = ctx.FormInt64("notification_id") - statusStr = ctx.FormString("status") - status activities_model.NotificationStatus - ) - - switch statusStr { - case "read": - status = activities_model.NotificationStatusRead - case "unread": - status = activities_model.NotificationStatusUnread - case "pinned": - status = activities_model.NotificationStatusPinned + notificationID := ctx.FormInt64("notification_id") + var newStatus activities_model.NotificationStatus + switch ctx.FormString("notification_action") { + case "mark_as_read": + newStatus = activities_model.NotificationStatusRead + case "mark_as_unread": + newStatus = activities_model.NotificationStatusUnread + case "pin": + newStatus = activities_model.NotificationStatusPinned default: - ctx.ServerError("InvalidNotificationStatus", errors.New("Invalid notification status")) - return + return // ignore user's invalid input } - - if _, err := activities_model.SetNotificationStatus(ctx, notificationID, ctx.Doer, status); err != nil { + if _, err := activities_model.SetNotificationStatus(ctx, notificationID, ctx.Doer, newStatus); err != nil { ctx.ServerError("SetNotificationStatus", err) return } - if !ctx.FormBool("noredirect") { - url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, url.QueryEscape(ctx.FormString("page"))) - ctx.Redirect(url, http.StatusSeeOther) - } - - getNotifications(ctx) + prepareUserNotificationsData(ctx) if ctx.Written() { return } - ctx.Data["Link"] = setting.AppSubURL + "/notifications" - ctx.Data["SequenceNumber"] = ctx.Req.PostFormValue("sequence-number") - ctx.HTML(http.StatusOK, tplNotificationDiv) } @@ -230,10 +171,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 +252,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 +265,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 +296,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 +344,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 c01bc96e2b..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) } @@ -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,19 +328,16 @@ 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 { @@ -324,10 +348,7 @@ func ListPackageVersions(ctx *context.Context) { 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) } @@ -503,9 +513,9 @@ func DownloadPackageFile(ctx *context.Context) { 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 39f066a53c..d7052914b6 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -63,27 +63,22 @@ func userProfile(ctx *context.Context) { 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 } @@ -328,9 +335,11 @@ func ActionUserFollow(ctx *context.Context) { 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() { 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 94577832a9..6b17da50e5 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" @@ -36,15 +35,14 @@ 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(fmt.Errorf("account setting are not allowed to be changed")) + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials, setting.UserFeatureDeletion) { + ctx.NotFound(errors.New("account setting are not allowed to be changed")) return } ctx.Data["Title"] = ctx.Tr("settings.account") ctx.Data["PageIsSettingsAccount"] = true ctx.Data["Email"] = ctx.Doer.Email - ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail loadAccountData(ctx) @@ -54,7 +52,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(fmt.Errorf("password setting is not allowed to be changed")) + ctx.NotFound(errors.New("password setting is not allowed to be changed")) return } @@ -62,7 +60,6 @@ func AccountPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsAccount"] = true ctx.Data["Email"] = ctx.Doer.Email - ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail if ctx.HasError() { loadAccountData(ctx) @@ -105,7 +102,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(fmt.Errorf("emails are not allowed to be changed")) + ctx.NotFound(errors.New("emails are not allowed to be changed")) return } @@ -113,7 +110,6 @@ func EmailPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsAccount"] = true ctx.Data["Email"] = ctx.Doer.Email - ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail // Make email address primary. if ctx.FormString("_method") == "PRIMARY" { @@ -173,30 +169,6 @@ func EmailPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/account") return } - // Set Email Notification Preference - if ctx.FormString("_method") == "NOTIFICATION" { - preference := ctx.FormString("preference") - if !(preference == user_model.EmailNotificationsEnabled || - preference == user_model.EmailNotificationsOnMention || - preference == user_model.EmailNotificationsDisabled || - preference == user_model.EmailNotificationsAndYourOwn) { - log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name) - ctx.ServerError("SetEmailPreference", errors.New("option unrecognized")) - return - } - opts := &user.UpdateOptions{ - EmailNotificationsPreference: optional.Some(preference), - } - if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil { - log.Error("Set Email Notifications failed: %v", err) - ctx.ServerError("UpdateUser", err) - return - } - log.Trace("Email notifications preference made %s: %s", preference, ctx.Doer.Name) - ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success")) - ctx.Redirect(setting.AppSubURL + "/user/settings/account") - return - } if ctx.HasError() { loadAccountData(ctx) @@ -239,7 +211,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(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")) @@ -268,7 +240,6 @@ func DeleteAccount(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsAccount"] = true ctx.Data["Email"] = ctx.Doer.Email - ctx.Data["EnableNotifyMail"] = setting.Service.EnableNotifyMail if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil { switch { @@ -343,7 +314,6 @@ func loadAccountData(ctx *context.Context) { emails[i] = &email } ctx.Data["Emails"] = emails - ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference ctx.Data["ActivationsPending"] = pendingActivation ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) diff --git a/routers/web/user/setting/account_test.go b/routers/web/user/setting/account_test.go index 13caa33771..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.WrittenStatus()) + 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 1f6c97a5cc..9c43ddd3ea 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -43,8 +43,9 @@ func ApplicationsPost(ctx *context.Context) { _ = ctx.Req.ParseForm() var scopeNames []string + const accessTokenScopePrefix = "scope-" for k, v := range ctx.Req.Form { - if strings.HasPrefix(k, "scope-") { + if strings.HasPrefix(k, accessTokenScopePrefix) { scopeNames = append(scopeNames, v...) } } @@ -54,7 +55,7 @@ func ApplicationsPost(ctx *context.Context) { ctx.ServerError("GetScope", err) return } - if scope == "" || scope == auth_model.AccessTokenScopePublicOnly { + if !scope.HasPermissionScope() { ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true) } diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go index 17e32f5403..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(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(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(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(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(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(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/notifications.go b/routers/web/user/setting/notifications.go new file mode 100644 index 0000000000..16e58a0481 --- /dev/null +++ b/routers/web/user/setting/notifications.go @@ -0,0 +1,62 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "errors" + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/user" +) + +const tplSettingsNotifications templates.TplName = "user/settings/notifications" + +// Notifications render user's notifications settings +func Notifications(ctx *context.Context) { + if !setting.Service.EnableNotifyMail { + ctx.NotFound(nil) + return + } + + ctx.Data["Title"] = ctx.Tr("notifications") + ctx.Data["PageIsSettingsNotifications"] = true + ctx.Data["EmailNotificationsPreference"] = ctx.Doer.EmailNotificationsPreference + + ctx.HTML(http.StatusOK, tplSettingsNotifications) +} + +// NotificationsEmailPost set user's email notification preference +func NotificationsEmailPost(ctx *context.Context) { + if !setting.Service.EnableNotifyMail { + ctx.NotFound(nil) + return + } + + preference := ctx.FormString("preference") + if !(preference == user_model.EmailNotificationsEnabled || + preference == user_model.EmailNotificationsOnMention || + preference == user_model.EmailNotificationsDisabled || + preference == user_model.EmailNotificationsAndYourOwn) { + log.Error("Email notifications preference change returned unrecognized option %s: %s", preference, ctx.Doer.Name) + ctx.ServerError("SetEmailPreference", errors.New("option unrecognized")) + return + } + opts := &user.UpdateOptions{ + EmailNotificationsPreference: optional.Some(preference), + } + if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil { + log.Error("Set Email Notifications failed: %v", err) + ctx.ServerError("UpdateUser", err) + return + } + log.Trace("Email notifications preference made %s: %s", preference, ctx.Doer.Name) + ctx.Flash.Success(ctx.Tr("settings.email_preference_set_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/notifications") +} diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go index d4da468a85..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 } } diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 7577036a55..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 diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go index e5315efc74..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" @@ -163,6 +164,7 @@ func EnrollTwoFactor(ctx *context.Context) { 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 { @@ -194,6 +196,7 @@ func EnrollTwoFactorPost(ctx *context.Context) { 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/webauthn.go b/routers/web/user/setting/security/webauthn.go index 63721343df..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" @@ -120,7 +121,7 @@ func WebauthnRegisterPost(ctx *context.Context) { return } _ = ctx.Session.Delete("webauthnName") - + _ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true) ctx.JSON(http.StatusCreated, cred) } diff --git a/routers/web/web.go b/routers/web/web.go index f4bd3ef4bc..b9c7013f63 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -178,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 @@ -280,28 +280,26 @@ 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.PageGlobalData) - 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() @@ -597,6 +595,10 @@ func registerRoutes(m *web.Router) { m.Post("/hidden_comments", user_setting.UpdateUserHiddenComments) m.Post("/theme", web.Bind(forms.UpdateThemeForm{}), user_setting.UpdateUIThemePost) }) + m.Group("/notifications", func() { + m.Get("", user_setting.Notifications) + m.Post("/email", user_setting.NotificationsEmailPost) + }) m.Group("/security", func() { m.Get("", security.Security) m.Group("/two_factor", func() { @@ -684,7 +686,7 @@ func registerRoutes(m *web.Router) { m.Get("", user_setting.BlockedUsers) m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost) }) - }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "EnablePackages", setting.Packages.Enabled)) + }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "EnablePackages", setting.Packages.Enabled, "EnableNotifyMail", setting.Service.EnableNotifyMail)) m.Group("/user", func() { m.Get("/activate", auth.Activate) @@ -856,13 +858,13 @@ 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(nil) return } - case ctx.ContextUser.Visibility == structs.VisibleTypeLimited: + case structs.VisibleTypeLimited: if ctx.Doer == nil { ctx.NotFound(nil) return @@ -966,7 +968,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) @@ -1014,6 +1017,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) @@ -1031,7 +1035,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() { @@ -1080,6 +1084,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) @@ -1185,6 +1191,7 @@ func registerRoutes(m *web.Router) { 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 @@ -1210,7 +1217,7 @@ func registerRoutes(m *web.Router) { 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 // end "/{username}/{reponame}": view milestone, label, issue, pull, etc @@ -1224,9 +1231,9 @@ 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) @@ -1250,7 +1257,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) }) }) @@ -1289,7 +1297,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). @@ -1298,7 +1306,7 @@ 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: many "pulls" requests are sent to "issues" endpoints incorrectly, need to move these routes to the proper place m.Group("/issues", func() { @@ -1310,26 +1318,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.Post("/_preview/*", web.Bind(forms.EditPreviewDiffForm{}), repo.DiffPreviewPost) - 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.Combo("/_delete/*").Get(repo.DeleteFile). - Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) - m.Combo("/_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(), repo.WebGitOperationCommonData) + // "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{}), 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()) @@ -1376,7 +1396,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) @@ -1402,7 +1422,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() { @@ -1444,8 +1464,10 @@ 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}", reqRepoActionsWriter, actions.ArtifactsDeleteView) m.Post("/rerun", reqRepoActionsWriter, actions.Rerun) @@ -1489,7 +1511,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) }, @@ -1504,20 +1526,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) @@ -1604,7 +1627,7 @@ func registerRoutes(m *web.Router) { 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) @@ -1640,7 +1663,9 @@ 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("/mail-preview", devtest.MailPreview) + m.Any("/mail-preview/*", devtest.MailPreviewRender) + 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) }) diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go index afcfdc8252..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" @@ -85,10 +86,10 @@ func WebfingerQuery(ctx *context.Context) { 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", |