mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	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:
		
							
								
								
									
										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 New Issue
	
	Block a user