make avatar lookup occur at image request (#10540)

speed up page generation by making avatar lookup occur at the browser
not at page generation

* Protect against evil email address ".."

* hash the complete email address

Signed-off-by: Andrew Thornton <art27@cantab.net>

Co-Authored-By: Lauris BH <lauris@nix.lv>
This commit is contained in:
zeripath 2020-03-27 12:34:39 +00:00 committed by GitHub
parent a3f90948d8
commit e6baa656f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 154 additions and 21 deletions

48
models/avatar.go Normal file
View File

@ -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)
}

View File

@ -198,6 +198,8 @@ var migrations = []Migration{
NewMigration("Add IsSystemWebhook column to webhooks table", addSystemWebhookColumn), NewMigration("Add IsSystemWebhook column to webhooks table", addSystemWebhookColumn),
// v132 -> v133 // v132 -> v133
NewMigration("Add Branch Protection Protected Files Column", addBranchProtectionProtectedFilesColumn), NewMigration("Add Branch Protection Protected Files Column", addBranchProtectionProtectedFilesColumn),
// v133 -> v134
NewMigration("Add EmailHash Table", addEmailHashTable),
} }
// Migrate database to current version // Migrate database to current version

16
models/migrations/v133.go Normal file
View File

@ -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))
}

View File

@ -124,6 +124,7 @@ func init() {
new(OAuth2Grant), new(OAuth2Grant),
new(Task), new(Task),
new(LanguageStat), new(LanguageStat),
new(EmailHash),
) )
gonicNames := []string{"SSL", "UID"} gonicNames := []string{"SSL", "UID"}

View File

@ -193,11 +193,32 @@ func SizedAvatarLink(email string, size int) string {
return avatarURL.String() return avatarURL.String()
} }
// AvatarLink returns relative avatar link to the site domain by given email, // SizedAvatarLinkWithDomain returns a sized link to the avatar for the given email
// which includes app sub-url as prefix. However, it is possible // address.
// to return full URL if user enables Gravatar-like service. func SizedAvatarLinkWithDomain(email string, size int) string {
func AvatarLink(email string) string { var avatarURL *url.URL
return SizedAvatarLink(email, DefaultAvatarSize) 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 = &copyOfGravatarSourceURL
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. // FileSize calculates the file size and generate user-friendly string.

View File

@ -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) { func TestFileSize(t *testing.T) {
var size int64 = 512 var size int64 = 512
assert.Equal(t, "512 B", FileSize(size)) assert.Equal(t, "512 B", FileSize(size))

View File

@ -41,6 +41,34 @@ func NewContext() error {
return err 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 // GetInt returns key value from cache with callback when no key exists in cache
func GetInt(key string, getFunc func() (int, error)) (int, error) { func GetInt(key string, getFunc func() (int, error)) (int, error) {
if conn == nil || setting.CacheService.TTL == 0 { if conn == nil || setting.CacheService.TTL == 0 {

View File

@ -10,7 +10,6 @@ import (
"time" "time"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
@ -124,7 +123,7 @@ func (pc *PushCommits) AvatarLink(email string) string {
var err error var err error
u, err = models.GetUserByEmail(email) u, err = models.GetUserByEmail(email)
if err != nil { if err != nil {
pc.avatars[email] = base.AvatarLink(email) pc.avatars[email] = models.AvatarLink(email)
if !models.IsErrUserNotExist(err) { if !models.IsErrUserNotExist(err) {
log.Error("GetUserByEmail: %v", err) log.Error("GetUserByEmail: %v", err)
return "" return ""

View File

@ -6,6 +6,8 @@ package repository
import ( import (
"container/list" "container/list"
"crypto/md5"
"fmt"
"testing" "testing"
"time" "time"
@ -114,7 +116,7 @@ func TestPushCommits_AvatarLink(t *testing.T) {
pushCommits.AvatarLink("user2@example.com")) pushCommits.AvatarLink("user2@example.com"))
assert.Equal(t, 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")) pushCommits.AvatarLink("nonexistent@example.com"))
} }

View File

@ -85,7 +85,7 @@ func NewFuncMap() []template.FuncMap {
"AllowedReactions": func() []string { "AllowedReactions": func() []string {
return setting.UI.Reactions return setting.UI.Reactions
}, },
"AvatarLink": base.AvatarLink, "AvatarLink": models.AvatarLink,
"Safe": Safe, "Safe": Safe,
"SafeJS": SafeJS, "SafeJS": SafeJS,
"Str2html": Str2html, "Str2html": Str2html,

View File

@ -230,7 +230,7 @@ func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames m
} }
avatar = fmt.Sprintf(`<a href="%s/%s"><img class="ui avatar image" src="%s" title="%s" alt=""/></a>`, setting.AppSubURL, url.PathEscape(commit.User.Name), commit.User.RelAvatarLink(), html.EscapeString(authorName)) avatar = fmt.Sprintf(`<a href="%s/%s"><img class="ui avatar image" src="%s" title="%s" alt=""/></a>`, setting.AppSubURL, url.PathEscape(commit.User.Name), commit.User.RelAvatarLink(), html.EscapeString(authorName))
} else { } else {
avatar = fmt.Sprintf(`<img class="ui avatar image" src="%s" title="%s"/>`, html.EscapeString(base.AvatarLink(commit.Author.Email)), html.EscapeString(commit.Author.Name)) avatar = fmt.Sprintf(`<img class="ui avatar image" src="%s" title="%s"/>`, html.EscapeString(models.AvatarLink(commit.Author.Email)), html.EscapeString(commit.Author.Name))
} }
commitInfo.WriteString(fmt.Sprintf(`<div class="blame-info%s"><div class="blame-data"><div class="blame-avatar">%s</div><div class="blame-message"><a href="%s/commit/%s" title="%[5]s">%[5]s</a></div><div class="blame-time">%s</div></div></div>`, attr, avatar, repoLink, part.Sha, html.EscapeString(commit.CommitMessage), commitSince)) commitInfo.WriteString(fmt.Sprintf(`<div class="blame-info%s"><div class="blame-data"><div class="blame-avatar">%s</div><div class="blame-message"><a href="%s/commit/%s" title="%[5]s">%[5]s</a></div><div class="blame-time">%s</div></div></div>`, attr, avatar, repoLink, part.Sha, html.EscapeString(commit.CommitMessage), commitSince))
} else { } else {

View File

@ -417,6 +417,8 @@ func RegisterRoutes(m *macaron.Macaron) {
}) })
// ***** END: User ***** // ***** END: User *****
m.Get("/avatar/:hash", user.AvatarByEmailHash)
adminReq := context.Toggle(&context.ToggleOptions{SignInRequired: true, AdminRequired: true}) adminReq := context.Toggle(&context.ToggleOptions{SignInRequired: true, AdminRequired: true})
// ***** START: Admin ***** // ***** START: Admin *****

View File

@ -5,10 +5,12 @@
package user package user
import ( import (
"errors"
"strconv" "strconv"
"strings" "strings"
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
) )
@ -41,3 +43,26 @@ func Avatar(ctx *context.Context) {
ctx.Redirect(user.RealSizedAvatarLink(size)) 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))
}