code.gitea.io/gitea@v1.22.3/models/avatars/avatar.go (about) 1 // Copyright 2021 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package avatars 5 6 import ( 7 "context" 8 "crypto/md5" 9 "encoding/hex" 10 "fmt" 11 "net/url" 12 "path" 13 "strconv" 14 "strings" 15 "sync/atomic" 16 17 "code.gitea.io/gitea/models/db" 18 "code.gitea.io/gitea/modules/cache" 19 "code.gitea.io/gitea/modules/log" 20 "code.gitea.io/gitea/modules/setting" 21 22 "strk.kbt.io/projects/go/libravatar" 23 ) 24 25 const ( 26 // DefaultAvatarClass is the default class of a rendered avatar 27 DefaultAvatarClass = "ui avatar tw-align-middle" 28 // DefaultAvatarPixelSize is the default size in pixels of a rendered avatar 29 DefaultAvatarPixelSize = 28 30 ) 31 32 // EmailHash represents a pre-generated hash map (mainly used by LibravatarURL, it queries email server's DNS records) 33 type EmailHash struct { 34 Hash string `xorm:"pk varchar(32)"` 35 Email string `xorm:"UNIQUE NOT NULL"` 36 } 37 38 func init() { 39 db.RegisterModel(new(EmailHash)) 40 } 41 42 type avatarSettingStruct struct { 43 defaultAvatarLink string 44 gravatarSource string 45 gravatarSourceURL *url.URL 46 libravatar *libravatar.Libravatar 47 } 48 49 var avatarSettingAtomic atomic.Pointer[avatarSettingStruct] 50 51 func loadAvatarSetting() (*avatarSettingStruct, error) { 52 s := avatarSettingAtomic.Load() 53 if s == nil || s.gravatarSource != setting.GravatarSource { 54 s = &avatarSettingStruct{} 55 u, err := url.Parse(setting.AppSubURL) 56 if err != nil { 57 return nil, fmt.Errorf("unable to parse AppSubURL: %w", err) 58 } 59 60 u.Path = path.Join(u.Path, "/assets/img/avatar_default.png") 61 s.defaultAvatarLink = u.String() 62 63 s.gravatarSourceURL, err = url.Parse(setting.GravatarSource) 64 if err != nil { 65 return nil, fmt.Errorf("unable to parse GravatarSource %q: %w", setting.GravatarSource, err) 66 } 67 68 s.libravatar = libravatar.New() 69 if s.gravatarSourceURL.Scheme == "https" { 70 s.libravatar.SetUseHTTPS(true) 71 s.libravatar.SetSecureFallbackHost(s.gravatarSourceURL.Host) 72 } else { 73 s.libravatar.SetUseHTTPS(false) 74 s.libravatar.SetFallbackHost(s.gravatarSourceURL.Host) 75 } 76 77 avatarSettingAtomic.Store(s) 78 } 79 return s, nil 80 } 81 82 // DefaultAvatarLink the default avatar link 83 func DefaultAvatarLink() string { 84 a, err := loadAvatarSetting() 85 if err != nil { 86 log.Error("Failed to loadAvatarSetting: %v", err) 87 return "" 88 } 89 return a.defaultAvatarLink 90 } 91 92 // HashEmail hashes email address to MD5 string. https://en.gravatar.com/site/implement/hash/ 93 func HashEmail(email string) string { 94 m := md5.New() 95 _, _ = m.Write([]byte(strings.ToLower(strings.TrimSpace(email)))) 96 return hex.EncodeToString(m.Sum(nil)) 97 } 98 99 // GetEmailForHash converts a provided md5sum to the email 100 func GetEmailForHash(ctx context.Context, md5Sum string) (string, error) { 101 return cache.GetString("Avatar:"+md5Sum, func() (string, error) { 102 emailHash := EmailHash{ 103 Hash: strings.ToLower(strings.TrimSpace(md5Sum)), 104 } 105 106 _, err := db.GetEngine(ctx).Get(&emailHash) 107 return emailHash.Email, err 108 }) 109 } 110 111 // LibravatarURL returns the URL for the given email. Slow due to the DNS lookup. 112 // This function should only be called if a federated avatar service is enabled. 113 func LibravatarURL(email string) (*url.URL, error) { 114 a, err := loadAvatarSetting() 115 if err != nil { 116 return nil, err 117 } 118 urlStr, err := a.libravatar.FromEmail(email) 119 if err != nil { 120 log.Error("LibravatarService.FromEmail(email=%s): error %v", email, err) 121 return nil, err 122 } 123 u, err := url.Parse(urlStr) 124 if err != nil { 125 log.Error("Failed to parse libravatar url(%s): error %v", urlStr, err) 126 return nil, err 127 } 128 return u, nil 129 } 130 131 // saveEmailHash returns an avatar link for a provided email, 132 // the email and hash are saved into database, which will be used by GetEmailForHash later 133 func saveEmailHash(ctx context.Context, email string) string { 134 lowerEmail := strings.ToLower(strings.TrimSpace(email)) 135 emailHash := HashEmail(lowerEmail) 136 _, _ = cache.GetString("Avatar:"+emailHash, func() (string, error) { 137 emailHash := &EmailHash{ 138 Email: lowerEmail, 139 Hash: emailHash, 140 } 141 // OK we're going to open a session just because I think that that might hide away any problems with postgres reporting errors 142 if err := db.WithTx(ctx, func(ctx context.Context) error { 143 has, err := db.GetEngine(ctx).Where("email = ? AND hash = ?", emailHash.Email, emailHash.Hash).Get(new(EmailHash)) 144 if has || err != nil { 145 // Seriously we don't care about any DB problems just return the lowerEmail - we expect the transaction to fail most of the time 146 return nil 147 } 148 _, _ = db.GetEngine(ctx).Insert(emailHash) 149 return nil 150 }); err != nil { 151 // Seriously we don't care about any DB problems just return the lowerEmail - we expect the transaction to fail most of the time 152 return lowerEmail, nil 153 } 154 return lowerEmail, nil 155 }) 156 return emailHash 157 } 158 159 // GenerateUserAvatarFastLink returns a fast link (302) to the user's avatar: "/user/avatar/${User.Name}/${size}" 160 func GenerateUserAvatarFastLink(userName string, size int) string { 161 if size < 0 { 162 size = 0 163 } 164 return setting.AppSubURL + "/user/avatar/" + url.PathEscape(userName) + "/" + strconv.Itoa(size) 165 } 166 167 // GenerateUserAvatarImageLink returns a link for `User.Avatar` image file: "/avatars/${User.Avatar}" 168 func GenerateUserAvatarImageLink(userAvatar string, size int) string { 169 if size > 0 { 170 return setting.AppSubURL + "/avatars/" + url.PathEscape(userAvatar) + "?size=" + strconv.Itoa(size) 171 } 172 return setting.AppSubURL + "/avatars/" + url.PathEscape(userAvatar) 173 } 174 175 // generateRecognizedAvatarURL generate a recognized avatar (Gravatar/Libravatar) URL, it modifies the URL so the parameter is passed by a copy 176 func generateRecognizedAvatarURL(u url.URL, size int) string { 177 urlQuery := u.Query() 178 urlQuery.Set("d", "identicon") 179 if size > 0 { 180 urlQuery.Set("s", strconv.Itoa(size)) 181 } 182 u.RawQuery = urlQuery.Encode() 183 return u.String() 184 } 185 186 // generateEmailAvatarLink returns a email avatar link. 187 // if final is true, it may use a slow path (eg: query DNS). 188 // if final is false, it always uses a fast path. 189 func generateEmailAvatarLink(ctx context.Context, email string, size int, final bool) string { 190 email = strings.TrimSpace(email) 191 if email == "" { 192 return DefaultAvatarLink() 193 } 194 195 avatarSetting, err := loadAvatarSetting() 196 if err != nil { 197 return DefaultAvatarLink() 198 } 199 200 enableFederatedAvatar := setting.Config().Picture.EnableFederatedAvatar.Value(ctx) 201 if enableFederatedAvatar { 202 emailHash := saveEmailHash(ctx, email) 203 if final { 204 // for final link, we can spend more time on slow external query 205 var avatarURL *url.URL 206 if avatarURL, err = LibravatarURL(email); err != nil { 207 return DefaultAvatarLink() 208 } 209 return generateRecognizedAvatarURL(*avatarURL, size) 210 } 211 // for non-final link, we should return fast (use a 302 redirection link) 212 urlStr := setting.AppSubURL + "/avatar/" + url.PathEscape(emailHash) 213 if size > 0 { 214 urlStr += "?size=" + strconv.Itoa(size) 215 } 216 return urlStr 217 } 218 219 disableGravatar := setting.Config().Picture.DisableGravatar.Value(ctx) 220 if !disableGravatar { 221 // copy GravatarSourceURL, because we will modify its Path. 222 avatarURLCopy := *avatarSetting.gravatarSourceURL 223 avatarURLCopy.Path = path.Join(avatarURLCopy.Path, HashEmail(email)) 224 return generateRecognizedAvatarURL(avatarURLCopy, size) 225 } 226 227 return DefaultAvatarLink() 228 } 229 230 // GenerateEmailAvatarFastLink returns a avatar link (fast, the link may be a delegated one: "/avatar/${hash}") 231 func GenerateEmailAvatarFastLink(ctx context.Context, email string, size int) string { 232 return generateEmailAvatarLink(ctx, email, size, false) 233 } 234 235 // GenerateEmailAvatarFinalLink returns a avatar final link (maybe slow) 236 func GenerateEmailAvatarFinalLink(ctx context.Context, email string, size int) string { 237 return generateEmailAvatarLink(ctx, email, size, true) 238 }