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:
parent
a3f90948d8
commit
e6baa656f7
13 changed files with 154 additions and 21 deletions
48
models/avatar.go
Normal file
48
models/avatar.go
Normal 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)
|
||||||
|
}
|
|
@ -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
16
models/migrations/v133.go
Normal 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))
|
||||||
|
}
|
|
@ -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"}
|
||||||
|
|
|
@ -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 = ©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.
|
// FileSize calculates the file size and generate user-friendly string.
|
||||||
|
|
|
@ -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))
|
||||||
|
|
28
modules/cache/cache.go
vendored
28
modules/cache/cache.go
vendored
|
@ -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 {
|
||||||
|
|
|
@ -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 ""
|
||||||
|
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 *****
|
||||||
|
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
Reference in a new issue