diff --git a/models/avatar.go b/models/avatar.go new file mode 100644 index 0000000000..311d714629 --- /dev/null +++ b/models/avatar.go @@ -0,0 +1,48 @@ +// 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 ( + "crypto/md5" + "fmt" + "net/url" + "strings" + + "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/setting" +) + +// EmailHash represents a pre-generated hash map +type EmailHash struct { + Hash string `xorm:"pk varchar(32)"` + Email string `xorm:"UNIQUE NOT NULL"` +} + +// GetEmailForHash converts a provided md5sum to the email +func GetEmailForHash(md5Sum string) (string, error) { + return cache.GetString("Avatar:"+md5Sum, func() (string, error) { + emailHash := EmailHash{ + Hash: strings.ToLower(strings.TrimSpace(md5Sum)), + } + + _, err := x.Get(&emailHash) + return emailHash.Email, err + }) +} + +// AvatarLink returns an avatar link for a provided email +func AvatarLink(email string) string { + lowerEmail := strings.ToLower(strings.TrimSpace(email)) + sum := fmt.Sprintf("%x", md5.Sum([]byte(lowerEmail))) + _, _ = cache.GetString("Avatar:"+sum, func() (string, error) { + emailHash := &EmailHash{ + Email: lowerEmail, + Hash: sum, + } + _, _ = x.Insert(emailHash) + return lowerEmail, nil + }) + return setting.AppSubURL + "/avatar/" + url.PathEscape(sum) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c554121e85..3f18a18c6d 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -198,6 +198,8 @@ var migrations = []Migration{ NewMigration("Add IsSystemWebhook column to webhooks table", addSystemWebhookColumn), // v132 -> v133 NewMigration("Add Branch Protection Protected Files Column", addBranchProtectionProtectedFilesColumn), + // v133 -> v134 + NewMigration("Add EmailHash Table", addEmailHashTable), } // Migrate database to current version diff --git a/models/migrations/v133.go b/models/migrations/v133.go new file mode 100644 index 0000000000..ea0411d470 --- /dev/null +++ b/models/migrations/v133.go @@ -0,0 +1,16 @@ +// 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 migrations + +import "xorm.io/xorm" + +func addEmailHashTable(x *xorm.Engine) error { + // EmailHash represents a pre-generated hash map + type EmailHash struct { + Hash string `xorm:"pk varchar(32)"` + Email string `xorm:"UNIQUE NOT NULL"` + } + return x.Sync2(new(EmailHash)) +} diff --git a/models/models.go b/models/models.go index d2872422e3..3bf7713955 100644 --- a/models/models.go +++ b/models/models.go @@ -124,6 +124,7 @@ func init() { new(OAuth2Grant), new(Task), new(LanguageStat), + new(EmailHash), ) gonicNames := []string{"SSL", "UID"} diff --git a/modules/base/tool.go b/modules/base/tool.go index 86606c8bee..157bd9bc3d 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -193,11 +193,32 @@ func SizedAvatarLink(email string, size int) string { return avatarURL.String() } -// AvatarLink returns relative avatar link to the site domain by given email, -// which includes app sub-url as prefix. However, it is possible -// to return full URL if user enables Gravatar-like service. -func AvatarLink(email string) string { - return SizedAvatarLink(email, DefaultAvatarSize) +// SizedAvatarLinkWithDomain returns a sized link to the avatar for the given email +// address. +func SizedAvatarLinkWithDomain(email string, size int) string { + var avatarURL *url.URL + if setting.EnableFederatedAvatar && setting.LibravatarService != nil { + var err error + avatarURL, err = libravatarURL(email) + if err != nil { + return DefaultAvatarLink() + } + } else if !setting.DisableGravatar { + // copy GravatarSourceURL, because we will modify its Path. + copyOfGravatarSourceURL := *setting.GravatarSourceURL + avatarURL = ©OfGravatarSourceURL + avatarURL.Path = path.Join(avatarURL.Path, HashEmail(email)) + } else { + return DefaultAvatarLink() + } + + vals := avatarURL.Query() + vals.Set("d", "identicon") + if size != DefaultAvatarSize { + vals.Set("s", strconv.Itoa(size)) + } + avatarURL.RawQuery = vals.Encode() + return avatarURL.String() } // FileSize calculates the file size and generate user-friendly string. diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go index 075b5ed817..9c1a79e3f2 100644 --- a/modules/base/tool_test.go +++ b/modules/base/tool_test.go @@ -90,17 +90,6 @@ func TestSizedAvatarLink(t *testing.T) { ) } -func TestAvatarLink(t *testing.T) { - disableGravatar() - assert.Equal(t, "/img/avatar_default.png", AvatarLink("gitea@example.com")) - - enableGravatar(t) - assert.Equal(t, - "https://secure.gravatar.com/avatar/353cbad9b58e69c96154ad99f92bedc7?d=identicon", - AvatarLink("gitea@example.com"), - ) -} - func TestFileSize(t *testing.T) { var size int64 = 512 assert.Equal(t, "512 B", FileSize(size)) diff --git a/modules/cache/cache.go b/modules/cache/cache.go index e3a905e3fa..859f4a4b47 100644 --- a/modules/cache/cache.go +++ b/modules/cache/cache.go @@ -41,6 +41,34 @@ func NewContext() error { return err } +// GetString returns the key value from cache with callback when no key exists in cache +func GetString(key string, getFunc func() (string, error)) (string, error) { + if conn == nil || setting.CacheService.TTL == 0 { + return getFunc() + } + if !conn.IsExist(key) { + var ( + value string + err error + ) + if value, err = getFunc(); err != nil { + return value, err + } + err = conn.Put(key, value, int64(setting.CacheService.TTL.Seconds())) + if err != nil { + return "", err + } + } + value := conn.Get(key) + if v, ok := value.(string); ok { + return v, nil + } + if v, ok := value.(fmt.Stringer); ok { + return v.String(), nil + } + return fmt.Sprintf("%s", conn.Get(key)), nil +} + // GetInt returns key value from cache with callback when no key exists in cache func GetInt(key string, getFunc func() (int, error)) (int, error) { if conn == nil || setting.CacheService.TTL == 0 { diff --git a/modules/repository/commits.go b/modules/repository/commits.go index 7345aaae24..e02f3d11ca 100644 --- a/modules/repository/commits.go +++ b/modules/repository/commits.go @@ -10,7 +10,6 @@ import ( "time" "code.gitea.io/gitea/models" - "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" @@ -124,7 +123,7 @@ func (pc *PushCommits) AvatarLink(email string) string { var err error u, err = models.GetUserByEmail(email) if err != nil { - pc.avatars[email] = base.AvatarLink(email) + pc.avatars[email] = models.AvatarLink(email) if !models.IsErrUserNotExist(err) { log.Error("GetUserByEmail: %v", err) return "" diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go index 2f61ce3329..cb00e19c2e 100644 --- a/modules/repository/commits_test.go +++ b/modules/repository/commits_test.go @@ -6,6 +6,8 @@ package repository import ( "container/list" + "crypto/md5" + "fmt" "testing" "time" @@ -114,7 +116,7 @@ func TestPushCommits_AvatarLink(t *testing.T) { pushCommits.AvatarLink("user2@example.com")) assert.Equal(t, - "https://secure.gravatar.com/avatar/19ade630b94e1e0535b3df7387434154?d=identicon", + "/avatar/"+fmt.Sprintf("%x", md5.Sum([]byte("nonexistent@example.com"))), pushCommits.AvatarLink("nonexistent@example.com")) } diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 9d3206934e..b5b4987427 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -85,7 +85,7 @@ func NewFuncMap() []template.FuncMap { "AllowedReactions": func() []string { return setting.UI.Reactions }, - "AvatarLink": base.AvatarLink, + "AvatarLink": models.AvatarLink, "Safe": Safe, "SafeJS": SafeJS, "Str2html": Str2html, diff --git a/routers/repo/blame.go b/routers/repo/blame.go index f5a2a548e3..beed59ea97 100644 --- a/routers/repo/blame.go +++ b/routers/repo/blame.go @@ -230,7 +230,7 @@ func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames m } avatar = fmt.Sprintf(``, setting.AppSubURL, url.PathEscape(commit.User.Name), commit.User.RelAvatarLink(), html.EscapeString(authorName)) } else { - avatar = fmt.Sprintf(``, html.EscapeString(base.AvatarLink(commit.Author.Email)), html.EscapeString(commit.Author.Name)) + avatar = fmt.Sprintf(``, html.EscapeString(models.AvatarLink(commit.Author.Email)), html.EscapeString(commit.Author.Name)) } commitInfo.WriteString(fmt.Sprintf(`
%s
%s
`, attr, avatar, repoLink, part.Sha, html.EscapeString(commit.CommitMessage), commitSince)) } else { diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 093edcd920..459aa4d09f 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -417,6 +417,8 @@ func RegisterRoutes(m *macaron.Macaron) { }) // ***** END: User ***** + m.Get("/avatar/:hash", user.AvatarByEmailHash) + adminReq := context.Toggle(&context.ToggleOptions{SignInRequired: true, AdminRequired: true}) // ***** START: Admin ***** diff --git a/routers/user/avatar.go b/routers/user/avatar.go index 045206c50a..32d05f03cc 100644 --- a/routers/user/avatar.go +++ b/routers/user/avatar.go @@ -5,10 +5,12 @@ package user import ( + "errors" "strconv" "strings" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" ) @@ -41,3 +43,26 @@ func Avatar(ctx *context.Context) { ctx.Redirect(user.RealSizedAvatarLink(size)) } + +// AvatarByEmailHash redirects the browser to the appropriate Avatar link +func AvatarByEmailHash(ctx *context.Context) { + hash := ctx.Params(":hash") + if len(hash) == 0 { + ctx.ServerError("invalid avatar hash", errors.New("hash cannot be empty")) + return + } + email, err := models.GetEmailForHash(hash) + if err != nil { + ctx.ServerError("invalid avatar hash", err) + return + } + if len(email) == 0 { + ctx.Redirect(base.DefaultAvatarLink()) + return + } + size := ctx.QueryInt("size") + if size == 0 { + size = base.DefaultAvatarSize + } + ctx.Redirect(base.SizedAvatarLinkWithDomain(email, size)) +}