diff --git a/models/action.go b/models/action.go index f4d163afa..ca186033a 100644 --- a/models/action.go +++ b/models/action.go @@ -140,12 +140,6 @@ func (a *Action) GetDisplayNameTitle() string { return a.GetActFullName() } -// GetActAvatar the action's user's avatar link -func (a *Action) GetActAvatar() string { - a.loadActUser() - return a.ActUser.RelAvatarLink() -} - // GetRepoUserName returns the name of the action repository owner. func (a *Action) GetRepoUserName() string { a.loadRepo() diff --git a/models/avatar.go b/models/avatar.go index c9ba2961e..ac260fbd9 100644 --- a/models/avatar.go +++ b/models/avatar.go @@ -8,9 +8,13 @@ import ( "crypto/md5" "fmt" "net/url" + "path" + "strconv" "strings" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/cache" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" ) @@ -20,6 +24,28 @@ type EmailHash struct { Email string `xorm:"UNIQUE NOT NULL"` } +// DefaultAvatarLink the default avatar link +func DefaultAvatarLink() string { + u, err := url.Parse(setting.AppSubURL) + if err != nil { + log.Error("GetUserByEmail: %v", err) + return "" + } + + u.Path = path.Join(u.Path, "/img/avatar_default.png") + return u.String() +} + +// DefaultAvatarSize is a sentinel value for the default avatar size, as +// determined by the avatar-hosting service. +const DefaultAvatarSize = -1 + +// HashEmail hashes email address to MD5 string. +// https://en.gravatar.com/site/implement/hash/ +func HashEmail(email string) string { + return base.EncodeMD5(strings.ToLower(strings.TrimSpace(email))) +} + // GetEmailForHash converts a provided md5sum to the email func GetEmailForHash(md5Sum string) (string, error) { return cache.GetString("Avatar:"+md5Sum, func() (string, error) { @@ -32,8 +58,24 @@ func GetEmailForHash(md5Sum string) (string, error) { }) } -// AvatarLink returns an avatar link for a provided email -func AvatarLink(email string) string { +// LibravatarURL returns the URL for the given email. This function should only +// be called if a federated avatar service is enabled. +func LibravatarURL(email string) (*url.URL, error) { + urlStr, err := setting.LibravatarService.FromEmail(email) + if err != nil { + log.Error("LibravatarService.FromEmail(email=%s): error %v", email, err) + return nil, err + } + u, err := url.Parse(urlStr) + if err != nil { + log.Error("Failed to parse libravatar url(%s): error %v", urlStr, err) + return nil, err + } + return u, nil +} + +// HashedAvatarLink returns an avatar link for a provided email +func HashedAvatarLink(email string) string { lowerEmail := strings.ToLower(strings.TrimSpace(email)) sum := fmt.Sprintf("%x", md5.Sum([]byte(lowerEmail))) _, _ = cache.GetString("Avatar:"+sum, func() (string, error) { @@ -57,3 +99,34 @@ func AvatarLink(email string) string { }) return setting.AppSubURL + "/avatar/" + url.PathEscape(sum) } + +// MakeFinalAvatarURL constructs the final avatar URL string +func MakeFinalAvatarURL(u *url.URL, size int) string { + vals := u.Query() + vals.Set("d", "identicon") + if size != DefaultAvatarSize { + vals.Set("s", strconv.Itoa(size)) + } + u.RawQuery = vals.Encode() + return u.String() +} + +// SizedAvatarLink returns a sized link to the avatar for the given email address. +func SizedAvatarLink(email string, size int) string { + var avatarURL *url.URL + if setting.EnableFederatedAvatar && setting.LibravatarService != nil { + // This is the slow path that would need to call LibravatarURL() which + // does DNS lookups. Avoid it by issuing a redirect so we don't block + // the template render with network requests. + return HashedAvatarLink(email) + } 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() + } + + return MakeFinalAvatarURL(avatarURL, size) +} diff --git a/models/avatar_test.go b/models/avatar_test.go new file mode 100644 index 000000000..89540705a --- /dev/null +++ b/models/avatar_test.go @@ -0,0 +1,52 @@ +// 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 ( + "net/url" + "testing" + + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" +) + +const gravatarSource = "https://secure.gravatar.com/avatar/" + +func disableGravatar() { + setting.EnableFederatedAvatar = false + setting.LibravatarService = nil + setting.DisableGravatar = true +} + +func enableGravatar(t *testing.T) { + setting.DisableGravatar = false + var err error + setting.GravatarSourceURL, err = url.Parse(gravatarSource) + assert.NoError(t, err) +} + +func TestHashEmail(t *testing.T) { + assert.Equal(t, + "d41d8cd98f00b204e9800998ecf8427e", + HashEmail(""), + ) + assert.Equal(t, + "353cbad9b58e69c96154ad99f92bedc7", + HashEmail("gitea@example.com"), + ) +} + +func TestSizedAvatarLink(t *testing.T) { + disableGravatar() + assert.Equal(t, "/suburl/img/avatar_default.png", + SizedAvatarLink("gitea@example.com", 100)) + + enableGravatar(t) + assert.Equal(t, + "https://secure.gravatar.com/avatar/353cbad9b58e69c96154ad99f92bedc7?d=identicon&s=100", + SizedAvatarLink("gitea@example.com", 100), + ) +} diff --git a/models/user_avatar.go b/models/user_avatar.go index 2f9db5c2e..50c1c99c5 100644 --- a/models/user_avatar.go +++ b/models/user_avatar.go @@ -13,7 +13,6 @@ import ( "strings" "code.gitea.io/gitea/modules/avatar" - "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" @@ -41,7 +40,7 @@ func (u *User) generateRandomAvatar(e Engine) error { } if u.Avatar == "" { - u.Avatar = base.HashEmail(u.AvatarEmail) + u.Avatar = HashEmail(u.AvatarEmail) } if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error { @@ -76,13 +75,13 @@ func (u *User) SizedRelAvatarLink(size int) string { // func (u *User) RealSizedAvatarLink(size int) string { if u.ID == -1 { - return base.DefaultAvatarLink() + return DefaultAvatarLink() } switch { case u.UseCustomAvatar: if u.Avatar == "" { - return base.DefaultAvatarLink() + return DefaultAvatarLink() } return setting.AppSubURL + "/avatars/" + u.Avatar case setting.DisableGravatar, setting.OfflineMode: @@ -94,14 +93,14 @@ func (u *User) RealSizedAvatarLink(size int) string { return setting.AppSubURL + "/avatars/" + u.Avatar } - return base.SizedAvatarLink(u.AvatarEmail, size) + return SizedAvatarLink(u.AvatarEmail, size) } // RelAvatarLink returns a relative link to the user's avatar. The link // may either be a sub-URL to this site, or a full URL to an external avatar // service. func (u *User) RelAvatarLink() string { - return u.SizedRelAvatarLink(base.DefaultAvatarSize) + return u.SizedRelAvatarLink(DefaultAvatarSize) } // AvatarLink returns user avatar absolute link. diff --git a/modules/auth/sso/sspi_windows.go b/modules/auth/sso/sspi_windows.go index 00f15d97b..62013737c 100644 --- a/modules/auth/sso/sspi_windows.go +++ b/modules/auth/sso/sspi_windows.go @@ -168,7 +168,7 @@ func (s *SSPI) newUser(ctx *macaron.Context, username string, cfg *models.SSPICo IsActive: cfg.AutoActivateUsers, Language: cfg.DefaultLanguage, UseCustomAvatar: true, - Avatar: base.DefaultAvatarLink(), + Avatar: models.DefaultAvatarLink(), EmailNotificationsPreference: models.EmailNotificationsDisabled, } if err := models.CreateUser(user); err != nil { diff --git a/modules/base/tool.go b/modules/base/tool.go index a21fd9b0f..2cc09fb25 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -12,9 +12,7 @@ import ( "encoding/hex" "fmt" "net/http" - "net/url" "os" - "path" "path/filepath" "runtime" "strconv" @@ -134,93 +132,6 @@ func CreateTimeLimitCode(data string, minutes int, startInf interface{}) string return code } -// HashEmail hashes email address to MD5 string. -// https://en.gravatar.com/site/implement/hash/ -func HashEmail(email string) string { - return EncodeMD5(strings.ToLower(strings.TrimSpace(email))) -} - -// DefaultAvatarLink the default avatar link -func DefaultAvatarLink() string { - return setting.AppSubURL + "/img/avatar_default.png" -} - -// DefaultAvatarSize is a sentinel value for the default avatar size, as -// determined by the avatar-hosting service. -const DefaultAvatarSize = -1 - -// libravatarURL returns the URL for the given email. This function should only -// be called if a federated avatar service is enabled. -func libravatarURL(email string) (*url.URL, error) { - urlStr, err := setting.LibravatarService.FromEmail(email) - if err != nil { - log.Error("LibravatarService.FromEmail(email=%s): error %v", email, err) - return nil, err - } - u, err := url.Parse(urlStr) - if err != nil { - log.Error("Failed to parse libravatar url(%s): error %v", urlStr, err) - return nil, err - } - return u, nil -} - -// SizedAvatarLink returns a sized link to the avatar for the given email -// address. -func SizedAvatarLink(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() -} - -// 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. func FileSize(s int64) string { return humanize.IBytes(uint64(s)) diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go index f765fd0db..0c5bd6657 100644 --- a/modules/base/tool_test.go +++ b/modules/base/tool_test.go @@ -5,11 +5,8 @@ package base import ( - "net/url" "testing" - "code.gitea.io/gitea/modules/setting" - "github.com/stretchr/testify/assert" ) @@ -56,44 +53,6 @@ func TestBasicAuthEncode(t *testing.T) { // TODO: Test VerifyTimeLimitCode() // TODO: Test CreateTimeLimitCode() -func TestHashEmail(t *testing.T) { - assert.Equal(t, - "d41d8cd98f00b204e9800998ecf8427e", - HashEmail(""), - ) - assert.Equal(t, - "353cbad9b58e69c96154ad99f92bedc7", - HashEmail("gitea@example.com"), - ) -} - -const gravatarSource = "https://secure.gravatar.com/avatar/" - -func disableGravatar() { - setting.EnableFederatedAvatar = false - setting.LibravatarService = nil - setting.DisableGravatar = true -} - -func enableGravatar(t *testing.T) { - setting.DisableGravatar = false - var err error - setting.GravatarSourceURL, err = url.Parse(gravatarSource) - assert.NoError(t, err) -} - -func TestSizedAvatarLink(t *testing.T) { - disableGravatar() - assert.Equal(t, "/img/avatar_default.png", - SizedAvatarLink("gitea@example.com", 100)) - - enableGravatar(t) - assert.Equal(t, - "https://secure.gravatar.com/avatar/353cbad9b58e69c96154ad99f92bedc7?d=identicon&s=100", - SizedAvatarLink("gitea@example.com", 100), - ) -} - func TestFileSize(t *testing.T) { var size int64 = 512 assert.Equal(t, "512 B", FileSize(size)) diff --git a/modules/repository/commits.go b/modules/repository/commits.go index e02f3d11c..fd8b8d927 100644 --- a/modules/repository/commits.go +++ b/modules/repository/commits.go @@ -123,7 +123,7 @@ func (pc *PushCommits) AvatarLink(email string) string { var err error u, err = models.GetUserByEmail(email) if err != nil { - pc.avatars[email] = models.AvatarLink(email) + pc.avatars[email] = models.HashedAvatarLink(email) if !models.IsErrUserNotExist(err) { log.Error("GetUserByEmail: %v", err) return "" diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 8b9639752..5af1addb6 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -88,7 +88,6 @@ func NewFuncMap() []template.FuncMap { "AllowedReactions": func() []string { return setting.UI.Reactions }, - "AvatarLink": models.AvatarLink, "Safe": Safe, "SafeJS": SafeJS, "Str2html": Str2html, @@ -339,7 +338,9 @@ func NewFuncMap() []template.FuncMap { } return false }, - "svg": SVG, + "svg": SVG, + "avatar": Avatar, + "avatarByEmail": AvatarByEmail, "SortArrow": func(normSort, revSort, urlSort string, isDefault bool) template.HTML { // if needed if len(normSort) == 0 || len(urlSort) == 0 { @@ -499,18 +500,38 @@ func NewTextFuncMap() []texttmpl.FuncMap { var widthRe = regexp.MustCompile(`width="[0-9]+?"`) var heightRe = regexp.MustCompile(`height="[0-9]+?"`) -// SVG render icons - arguments icon name (string), size (int), class (string) -func SVG(icon string, others ...interface{}) template.HTML { - size := 16 +func parseOthers(defaultSize int, defaultClass string, others ...interface{}) (int, string) { + size := defaultSize if len(others) > 0 && others[0].(int) != 0 { size = others[0].(int) } - class := "" + class := defaultClass if len(others) > 1 && others[1].(string) != "" { - class = others[1].(string) + if defaultClass == "" { + class = others[1].(string) + } else { + class = defaultClass + " " + others[1].(string) + } + } + + return size, class +} + +func avatarHTML(src string, size int, class string, name string) template.HTML { + sizeStr := fmt.Sprintf(`%d`, size) + + if name == "" { + name = "avatar" } + return template.HTML(``) +} + +// SVG render icons - arguments icon name (string), size (int), class (string) +func SVG(icon string, others ...interface{}) template.HTML { + size, class := parseOthers(16, "", others...) + if svgStr, ok := svg.SVGs[icon]; ok { if size != 16 { svgStr = widthRe.ReplaceAllString(svgStr, fmt.Sprintf(`width="%d"`, size)) @@ -524,6 +545,38 @@ func SVG(icon string, others ...interface{}) template.HTML { return template.HTML("") } +// Avatar renders user and repo avatars. args: user/repo, size (int), class (string) +func Avatar(item interface{}, others ...interface{}) template.HTML { + size, class := parseOthers(28, "ui avatar image", others...) + if user, ok := item.(*models.User); ok { + src := user.RealSizedAvatarLink(size * 2) // request double size for finer rendering + if src != "" { + return avatarHTML(src, size, class, user.DisplayName()) + } + } + + if repo, ok := item.(*models.Repository); ok { + src := repo.RelAvatarLink() + if src != "" { + return avatarHTML(src, size, class, repo.FullName()) + } + } + + return template.HTML("") +} + +// AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string) +func AvatarByEmail(email string, name string, others ...interface{}) template.HTML { + size, class := parseOthers(28, "ui avatar image", others...) + src := models.SizedAvatarLink(email, size*2) // request double size for finer rendering + + if src != "" { + return avatarHTML(src, size, class, name) + } + + return template.HTML("") +} + // Safe render raw as HTML func Safe(raw string) template.HTML { return template.HTML(raw) diff --git a/routers/repo/blame.go b/routers/repo/blame.go index 812c55ea4..bd1131098 100644 --- a/routers/repo/blame.go +++ b/routers/repo/blame.go @@ -10,7 +10,6 @@ import ( "fmt" "html" gotemplate "html/template" - "net/url" "strings" "code.gitea.io/gitea/models" @@ -19,7 +18,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" ) @@ -209,17 +208,15 @@ func renderBlame(ctx *context.Context, blameParts []git.BlamePart, commitNames m commit := commitNames[part.Sha] if index == 0 { // User avatar image - avatar := "" commitSince := timeutil.TimeSinceUnix(timeutil.TimeStamp(commit.Author.When.Unix()), ctx.Data["Lang"].(string)) + + var avatar string if commit.User != nil { - authorName := commit.Author.Name - if len(commit.User.FullName) > 0 { - authorName = commit.User.FullName - } - avatar = fmt.Sprintf(``, setting.AppSubURL, url.PathEscape(commit.User.Name), commit.User.RelAvatarLink(), html.EscapeString(authorName)) + avatar = string(templates.Avatar(commit.User, 18, "mr-3")) } else { - avatar = fmt.Sprintf(``, html.EscapeString(models.AvatarLink(commit.Author.Email)), html.EscapeString(commit.Author.Name)) + avatar = string(templates.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "mr-3")) } + commitInfo.WriteString(fmt.Sprintf(`
%s
%s
`, attr, avatar, repoLink, part.Sha, html.EscapeString(commit.CommitMessage), commitSince)) } else { commitInfo.WriteString(fmt.Sprintf(`
`, attr)) diff --git a/routers/user/avatar.go b/routers/user/avatar.go index 32d05f03c..c3ece4908 100644 --- a/routers/user/avatar.go +++ b/routers/user/avatar.go @@ -6,11 +6,11 @@ package user import ( "errors" + "net/url" "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" ) @@ -46,23 +46,38 @@ func Avatar(ctx *context.Context) { // AvatarByEmailHash redirects the browser to the appropriate Avatar link func AvatarByEmailHash(ctx *context.Context) { + var err error + 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) + + var email string + email, err = models.GetEmailForHash(hash) if err != nil { ctx.ServerError("invalid avatar hash", err) return } if len(email) == 0 { - ctx.Redirect(base.DefaultAvatarLink()) + ctx.Redirect(models.DefaultAvatarLink()) return } size := ctx.QueryInt("size") if size == 0 { - size = base.DefaultAvatarSize + size = models.DefaultAvatarSize } - ctx.Redirect(base.SizedAvatarLinkWithDomain(email, size)) + + var avatarURL *url.URL + avatarURL, err = models.LibravatarURL(email) + if err != nil { + avatarURL, err = url.Parse(models.DefaultAvatarLink()) + if err != nil { + ctx.ServerError("invalid default avatar url", err) + return + } + } + + ctx.Redirect(models.MakeFinalAvatarURL(avatarURL, size)) } diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 1b102b11d..979e4d548 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -47,7 +47,7 @@