diff --git a/models/error.go b/models/error.go index 7f1eda1b14..fc161ed806 100644 --- a/models/error.go +++ b/models/error.go @@ -146,6 +146,21 @@ func (err ErrUserNotExist) Error() string { return fmt.Sprintf("user does not exist [uid: %d, name: %s, keyid: %d]", err.UID, err.Name, err.KeyID) } +// ErrUserRedirectNotExist represents a "UserRedirectNotExist" kind of error. +type ErrUserRedirectNotExist struct { + Name string +} + +// IsErrUserRedirectNotExist check if an error is an ErrUserRedirectNotExist. +func IsErrUserRedirectNotExist(err error) bool { + _, ok := err.(ErrUserRedirectNotExist) + return ok +} + +func (err ErrUserRedirectNotExist) Error() string { + return fmt.Sprintf("user redirect does not exist [name: %s]", err.Name) +} + // ErrUserProhibitLogin represents a "ErrUserProhibitLogin" kind of error. type ErrUserProhibitLogin struct { UID int64 diff --git a/models/fixtures/user_redirect.yml b/models/fixtures/user_redirect.yml new file mode 100644 index 0000000000..8ff7993398 --- /dev/null +++ b/models/fixtures/user_redirect.yml @@ -0,0 +1,4 @@ +- + id: 1 + lower_name: olduser1 + redirect_user_id: 1 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index f62bba2a71..3227f6f754 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -279,6 +279,8 @@ var migrations = []Migration{ NewMigration("Convert hook task type from char(16) to varchar(16) and trim the column", convertHookTaskTypeToVarcharAndTrim), // v166 -> v167 NewMigration("Where Password is Valid with Empty String delete it", recalculateUserEmptyPWD), + // v167 -> v168 + NewMigration("Add user redirect", addUserRedirect), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v167.go b/models/migrations/v167.go new file mode 100644 index 0000000000..fd91f226ab --- /dev/null +++ b/models/migrations/v167.go @@ -0,0 +1,24 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package migrations + +import ( + "fmt" + + "xorm.io/xorm" +) + +func addUserRedirect(x *xorm.Engine) (err error) { + type UserRedirect struct { + ID int64 `xorm:"pk autoincr"` + LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"` + RedirectUserID int64 + } + + if err := x.Sync2(new(UserRedirect)); err != nil { + return fmt.Errorf("Sync2: %v", err) + } + return nil +} diff --git a/models/models.go b/models/models.go index 2ace1ea6dd..1bfe75b7fc 100644 --- a/models/models.go +++ b/models/models.go @@ -128,6 +128,7 @@ func init() { new(Task), new(LanguageStat), new(EmailHash), + new(UserRedirect), new(Project), new(ProjectBoard), new(ProjectIssue), diff --git a/models/org.go b/models/org.go index f45c9af7a7..ee867eec88 100644 --- a/models/org.go +++ b/models/org.go @@ -171,6 +171,10 @@ func CreateOrganization(org, owner *User) (err error) { return err } + if err = deleteUserRedirect(sess, org.Name); err != nil { + return err + } + if _, err = sess.Insert(org); err != nil { return fmt.Errorf("insert organization: %v", err) } diff --git a/models/repo.go b/models/repo.go index b11671e1fc..62d64fbee9 100644 --- a/models/repo.go +++ b/models/repo.go @@ -1312,8 +1312,8 @@ func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error return fmt.Errorf("delete repo redirect: %v", err) } - if err := NewRepoRedirect(DBContext{sess}, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil { - return fmt.Errorf("NewRepoRedirect: %v", err) + if err := newRepoRedirect(sess, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil { + return fmt.Errorf("newRepoRedirect: %v", err) } return sess.Commit() @@ -1361,12 +1361,7 @@ func ChangeRepositoryName(doer *User, repo *Repository, newRepoName string) (err return fmt.Errorf("sess.Begin: %v", err) } - // If there was previously a redirect at this location, remove it. - if err = deleteRepoRedirect(sess, repo.OwnerID, newRepoName); err != nil { - return fmt.Errorf("delete repo redirect: %v", err) - } - - if err := NewRepoRedirect(DBContext{sess}, repo.Owner.ID, repo.ID, oldRepoName, newRepoName); err != nil { + if err := newRepoRedirect(sess, repo.Owner.ID, repo.ID, oldRepoName, newRepoName); err != nil { return err } diff --git a/models/repo_redirect.go b/models/repo_redirect.go index 182b6b41a2..afbfeb499e 100644 --- a/models/repo_redirect.go +++ b/models/repo_redirect.go @@ -28,16 +28,16 @@ func LookupRepoRedirect(ownerID int64, repoName string) (int64, error) { return redirect.RedirectRepoID, nil } -// NewRepoRedirect create a new repo redirect -func NewRepoRedirect(ctx DBContext, ownerID, repoID int64, oldRepoName, newRepoName string) error { +// newRepoRedirect create a new repo redirect +func newRepoRedirect(e Engine, ownerID, repoID int64, oldRepoName, newRepoName string) error { oldRepoName = strings.ToLower(oldRepoName) newRepoName = strings.ToLower(newRepoName) - if err := deleteRepoRedirect(ctx.e, ownerID, newRepoName); err != nil { + if err := deleteRepoRedirect(e, ownerID, newRepoName); err != nil { return err } - if _, err := ctx.e.Insert(&RepoRedirect{ + if _, err := e.Insert(&RepoRedirect{ OwnerID: ownerID, LowerName: oldRepoName, RedirectRepoID: repoID, diff --git a/models/repo_redirect_test.go b/models/repo_redirect_test.go index 44ec2b4e94..4c3184a0fd 100644 --- a/models/repo_redirect_test.go +++ b/models/repo_redirect_test.go @@ -26,7 +26,7 @@ func TestNewRepoRedirect(t *testing.T) { assert.NoError(t, PrepareTestDatabase()) repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) - assert.NoError(t, NewRepoRedirect(DefaultDBContext(), repo.OwnerID, repo.ID, repo.Name, "newreponame")) + assert.NoError(t, newRepoRedirect(x, repo.OwnerID, repo.ID, repo.Name, "newreponame")) AssertExistsAndLoadBean(t, &RepoRedirect{ OwnerID: repo.OwnerID, @@ -45,7 +45,7 @@ func TestNewRepoRedirect2(t *testing.T) { assert.NoError(t, PrepareTestDatabase()) repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) - assert.NoError(t, NewRepoRedirect(DefaultDBContext(), repo.OwnerID, repo.ID, repo.Name, "oldrepo1")) + assert.NoError(t, newRepoRedirect(x, repo.OwnerID, repo.ID, repo.Name, "oldrepo1")) AssertExistsAndLoadBean(t, &RepoRedirect{ OwnerID: repo.OwnerID, @@ -64,7 +64,7 @@ func TestNewRepoRedirect3(t *testing.T) { assert.NoError(t, PrepareTestDatabase()) repo := AssertExistsAndLoadBean(t, &Repository{ID: 2}).(*Repository) - assert.NoError(t, NewRepoRedirect(DefaultDBContext(), repo.OwnerID, repo.ID, repo.Name, "newreponame")) + assert.NoError(t, newRepoRedirect(x, repo.OwnerID, repo.ID, repo.Name, "newreponame")) AssertExistsAndLoadBean(t, &RepoRedirect{ OwnerID: repo.OwnerID, diff --git a/models/user.go b/models/user.go index 746608aaa4..8147c9f626 100644 --- a/models/user.go +++ b/models/user.go @@ -863,6 +863,10 @@ func CreateUser(u *User) (err error) { return ErrUserAlreadyExist{u.Name} } + if err = deleteUserRedirect(sess, u.Name); err != nil { + return err + } + u.Email = strings.ToLower(u.Email) isExist, err = sess. Where("email=?", u.Email). @@ -973,6 +977,7 @@ func VerifyActiveEmailCode(code, email string) *EmailAddress { // ChangeUserName changes all corresponding setting from old user name to new one. func ChangeUserName(u *User, newUserName string) (err error) { + oldUserName := u.Name if err = IsUsableUsername(newUserName); err != nil { return err } @@ -990,16 +995,28 @@ func ChangeUserName(u *User, newUserName string) (err error) { return ErrUserAlreadyExist{newUserName} } - if _, err = sess.Exec("UPDATE `repository` SET owner_name=? WHERE owner_name=?", newUserName, u.Name); err != nil { + if _, err = sess.Exec("UPDATE `repository` SET owner_name=? WHERE owner_name=?", newUserName, oldUserName); err != nil { return fmt.Errorf("Change repo owner name: %v", err) } // Do not fail if directory does not exist - if err = os.Rename(UserPath(u.Name), UserPath(newUserName)); err != nil && !os.IsNotExist(err) { + if err = os.Rename(UserPath(oldUserName), UserPath(newUserName)); err != nil && !os.IsNotExist(err) { return fmt.Errorf("Rename user directory: %v", err) } - return sess.Commit() + if err = newUserRedirect(sess, u.ID, oldUserName, newUserName); err != nil { + return err + } + + if err = sess.Commit(); err != nil { + if err2 := os.Rename(UserPath(newUserName), UserPath(oldUserName)); err2 != nil && !os.IsNotExist(err2) { + log.Critical("Unable to rollback directory change during failed username change from: %s to: %s. DB Error: %v. Filesystem Error: %v", oldUserName, newUserName, err, err2) + return fmt.Errorf("failed to rollback directory change during failed username change from: %s to: %s. DB Error: %w. Filesystem Error: %v", oldUserName, newUserName, err, err2) + } + return err + } + + return nil } // checkDupEmail checks whether there are the same email with the user diff --git a/models/user_redirect.go b/models/user_redirect.go new file mode 100644 index 0000000000..1da8b44088 --- /dev/null +++ b/models/user_redirect.go @@ -0,0 +1,52 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import "strings" + +// UserRedirect represents that a user name should be redirected to another +type UserRedirect struct { + ID int64 `xorm:"pk autoincr"` + LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"` + RedirectUserID int64 // userID to redirect to +} + +// LookupUserRedirect look up userID if a user has a redirect name +func LookupUserRedirect(userName string) (int64, error) { + userName = strings.ToLower(userName) + redirect := &UserRedirect{LowerName: userName} + if has, err := x.Get(redirect); err != nil { + return 0, err + } else if !has { + return 0, ErrUserRedirectNotExist{Name: userName} + } + return redirect.RedirectUserID, nil +} + +// newUserRedirect create a new user redirect +func newUserRedirect(e Engine, ID int64, oldUserName, newUserName string) error { + oldUserName = strings.ToLower(oldUserName) + newUserName = strings.ToLower(newUserName) + + if err := deleteUserRedirect(e, newUserName); err != nil { + return err + } + + if _, err := e.Insert(&UserRedirect{ + LowerName: oldUserName, + RedirectUserID: ID, + }); err != nil { + return err + } + return nil +} + +// deleteUserRedirect delete any redirect from the specified user name to +// anything else +func deleteUserRedirect(e Engine, userName string) error { + userName = strings.ToLower(userName) + _, err := e.Delete(&UserRedirect{LowerName: userName}) + return err +} diff --git a/models/user_redirect_test.go b/models/user_redirect_test.go new file mode 100644 index 0000000000..791c920bcf --- /dev/null +++ b/models/user_redirect_test.go @@ -0,0 +1,69 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLookupUserRedirect(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + + userID, err := LookupUserRedirect("olduser1") + assert.NoError(t, err) + assert.EqualValues(t, 1, userID) + + _, err = LookupUserRedirect("doesnotexist") + assert.True(t, IsErrUserRedirectNotExist(err)) +} + +func TestNewUserRedirect(t *testing.T) { + // redirect to a completely new name + assert.NoError(t, PrepareTestDatabase()) + + user := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User) + assert.NoError(t, newUserRedirect(x, user.ID, user.Name, "newusername")) + + AssertExistsAndLoadBean(t, &UserRedirect{ + LowerName: user.LowerName, + RedirectUserID: user.ID, + }) + AssertExistsAndLoadBean(t, &UserRedirect{ + LowerName: "olduser1", + RedirectUserID: user.ID, + }) +} + +func TestNewUserRedirect2(t *testing.T) { + // redirect to previously used name + assert.NoError(t, PrepareTestDatabase()) + + user := AssertExistsAndLoadBean(t, &User{ID: 1}).(*User) + assert.NoError(t, newUserRedirect(x, user.ID, user.Name, "olduser1")) + + AssertExistsAndLoadBean(t, &UserRedirect{ + LowerName: user.LowerName, + RedirectUserID: user.ID, + }) + AssertNotExistsBean(t, &UserRedirect{ + LowerName: "olduser1", + RedirectUserID: user.ID, + }) +} + +func TestNewUserRedirect3(t *testing.T) { + // redirect for a previously-unredirected user + assert.NoError(t, PrepareTestDatabase()) + + user := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) + assert.NoError(t, newUserRedirect(x, user.ID, user.Name, "newusername")) + + AssertExistsAndLoadBean(t, &UserRedirect{ + LowerName: user.LowerName, + RedirectUserID: user.ID, + }) +} diff --git a/modules/context/context.go b/modules/context/context.go index 1ee31e0ebb..e4121649ae 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -90,6 +90,26 @@ func (ctx *Context) IsUserRepoReaderAny() bool { return ctx.Repo.HasAccess() } +// RedirectToUser redirect to a differently-named user +func RedirectToUser(ctx *Context, userName string, redirectUserID int64) { + user, err := models.GetUserByID(redirectUserID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + redirectPath := strings.Replace( + ctx.Req.URL.Path, + userName, + user.Name, + 1, + ) + if ctx.Req.URL.RawQuery != "" { + redirectPath += "?" + ctx.Req.URL.RawQuery + } + ctx.Redirect(path.Join(setting.AppSubURL, redirectPath)) +} + // HasAPIError returns true if error occurs in form validation. func (ctx *Context) HasAPIError() bool { hasErr, ok := ctx.Data["HasError"] diff --git a/modules/context/org.go b/modules/context/org.go index 9b87fba9fd..f61a39c666 100644 --- a/modules/context/org.go +++ b/modules/context/org.go @@ -54,7 +54,14 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { ctx.Org.Organization, err = models.GetUserByName(orgName) if err != nil { if models.IsErrUserNotExist(err) { - ctx.NotFound("GetUserByName", err) + redirectUserID, err := models.LookupUserRedirect(orgName) + if err == nil { + RedirectToUser(ctx, orgName, redirectUserID) + } else if models.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetUserByName", err) + } else { + ctx.ServerError("LookupUserRedirect", err) + } } else { ctx.ServerError("GetUserByName", err) } diff --git a/modules/context/repo.go b/modules/context/repo.go index 2aee6caca4..63cb02dc06 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -411,11 +411,18 @@ func RepoAssignment() macaron.Handler { owner, err = models.GetUserByName(userName) if err != nil { if models.IsErrUserNotExist(err) { - if ctx.Query("go-get") == "1" { - EarlyResponseForGoGetMeta(ctx) - return + redirectUserID, err := models.LookupUserRedirect(userName) + if err == nil { + RedirectToUser(ctx, userName, redirectUserID) + } else if models.IsErrUserRedirectNotExist(err) { + if ctx.Query("go-get") == "1" { + EarlyResponseForGoGetMeta(ctx) + return + } + ctx.NotFound("GetUserByName", nil) + } else { + ctx.ServerError("LookupUserRedirect", err) } - ctx.NotFound("GetUserByName", nil) } else { ctx.ServerError("GetUserByName", err) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 770670ec1f..6c9604bf8b 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -450,6 +450,7 @@ update_language_not_found = Language '%s' is not available. update_profile_success = Your profile has been updated. change_username = Your username has been changed. change_username_prompt = Note: username changes also change your account URL. +change_username_redirect_prompt = The old username will redirect until it is claimed. continue = Continue cancel = Cancel language = Language @@ -1941,6 +1942,7 @@ settings.visibility.private_shortname = Private settings.update_settings = Update Settings settings.update_setting_success = Organization settings have been updated. settings.change_orgname_prompt = Note: changing the organization name also changes the organization's URL. +settings.change_orgname_redirect_prompt = The old name will redirect until it is claimed. settings.update_avatar_success = The organization's avatar has been updated. settings.delete = Delete Organization settings.delete_account = Delete This Organization diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 02ad8ab360..876f48ca5c 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -134,7 +134,13 @@ func repoAssignment() macaron.Handler { owner, err = models.GetUserByName(userName) if err != nil { if models.IsErrUserNotExist(err) { - ctx.NotFound() + if redirectUserID, err := models.LookupUserRedirect(userName); err == nil { + context.RedirectToUser(ctx.Context, userName, redirectUserID) + } else if models.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetUserByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) + } } else { ctx.Error(http.StatusInternalServerError, "GetUserByName", err) } @@ -393,7 +399,14 @@ func orgAssignment(args ...bool) macaron.Handler { ctx.Org.Organization, err = models.GetOrgByName(ctx.Params(":org")) if err != nil { if models.IsErrOrgNotExist(err) { - ctx.NotFound() + redirectUserID, err := models.LookupUserRedirect(ctx.Params(":org")) + if err == nil { + context.RedirectToUser(ctx.Context, ctx.Params(":org"), redirectUserID) + } else if models.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetOrgByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) + } } else { ctx.Error(http.StatusInternalServerError, "GetOrgByName", err) } diff --git a/routers/api/v1/user/helper.go b/routers/api/v1/user/helper.go new file mode 100644 index 0000000000..fcdac257ed --- /dev/null +++ b/routers/api/v1/user/helper.go @@ -0,0 +1,36 @@ +// Copyright 2021 The Gitea Authors. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" +) + +// GetUserByParamsName get user by name +func GetUserByParamsName(ctx *context.APIContext, name string) *models.User { + username := ctx.Params(name) + user, err := models.GetUserByName(username) + if err != nil { + if models.IsErrUserNotExist(err) { + if redirectUserID, err := models.LookupUserRedirect(username); err == nil { + context.RedirectToUser(ctx.Context, username, redirectUserID) + } else { + ctx.NotFound("GetUserByName", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return nil + } + return user +} + +// GetUserByParams returns user whose name is presented in URL (":username"). +func GetUserByParams(ctx *context.APIContext) *models.User { + return GetUserByParamsName(ctx, ":username") +} diff --git a/routers/api/v1/user/key.go b/routers/api/v1/user/key.go index 8069660653..fa16df1836 100644 --- a/routers/api/v1/user/key.go +++ b/routers/api/v1/user/key.go @@ -39,25 +39,6 @@ func appendPrivateInformation(apiKey *api.PublicKey, key *models.PublicKey, defa return apiKey, nil } -// GetUserByParamsName get user by name -func GetUserByParamsName(ctx *context.APIContext, name string) *models.User { - user, err := models.GetUserByName(ctx.Params(name)) - if err != nil { - if models.IsErrUserNotExist(err) { - ctx.NotFound() - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err) - } - return nil - } - return user -} - -// GetUserByParams returns user whose name is presented in URL paramenter. -func GetUserByParams(ctx *context.APIContext) *models.User { - return GetUserByParamsName(ctx, ":username") -} - func composePublicKeysAPILink() string { return setting.AppURL + "api/v1/user/keys/" } diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index b860219e62..ecc149fe52 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -107,13 +107,8 @@ func GetInfo(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - u, err := models.GetUserByName(ctx.Params(":username")) - if err != nil { - if models.IsErrUserNotExist(err) { - ctx.NotFound() - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err) - } + u := GetUserByParams(ctx) + if ctx.Written() { return } @@ -153,14 +148,8 @@ func GetUserHeatmapData(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - // Get the user to throw an error if it does not exist - user, err := models.GetUserByName(ctx.Params(":username")) - if err != nil { - if models.IsErrUserNotExist(err) { - ctx.Status(http.StatusNotFound) - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err) - } + user := GetUserByParams(ctx) + if ctx.Written() { return } diff --git a/routers/repo/http.go b/routers/repo/http.go index d4464ec62e..3de45698e8 100644 --- a/routers/repo/http.go +++ b/routers/repo/http.go @@ -102,8 +102,15 @@ func HTTP(ctx *context.Context) { owner, err := models.GetUserByName(username) if err != nil { - log.Error("Attempted access of unknown user from %s", ctx.RemoteAddr()) - ctx.NotFoundOrServerError("GetUserByName", models.IsErrUserNotExist, err) + if models.IsErrUserNotExist(err) { + if redirectUserID, err := models.LookupUserRedirect(username); err == nil { + context.RedirectToUser(ctx, username, redirectUserID) + } else { + ctx.NotFound("GetUserByName", err) + } + } else { + ctx.ServerError("GetUserByName", err) + } return } if !owner.IsOrganization() && !owner.IsActive { diff --git a/routers/user/profile.go b/routers/user/profile.go index bd5b359272..e19407baa7 100644 --- a/routers/user/profile.go +++ b/routers/user/profile.go @@ -23,7 +23,11 @@ func GetUserByName(ctx *context.Context, name string) *models.User { user, err := models.GetUserByName(name) if err != nil { if models.IsErrUserNotExist(err) { - ctx.NotFound("GetUserByName", nil) + if redirectUserID, err := models.LookupUserRedirect(name); err == nil { + context.RedirectToUser(ctx, name, redirectUserID) + } else { + ctx.NotFound("GetUserByName", err) + } } else { ctx.ServerError("GetUserByName", err) } diff --git a/templates/org/settings/options.tmpl b/templates/org/settings/options.tmpl index 348f8cb533..09bf5e0caa 100644 --- a/templates/org/settings/options.tmpl +++ b/templates/org/settings/options.tmpl @@ -13,7 +13,10 @@