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>tags/v1.13.0-dev
@@ -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 | ||||
@@ -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, | |||||
// 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. | // 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)) | ||||
@@ -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)) | |||||
} |