fix(avatar): use sha256 and inline the federated avatar lookup (#38843)

- Hash emails with sha256. Gravatar moved to sha256, and both it and
libravatar.org serve the same image for either hash.
- Drop `strk.kbt.io/projects/go/libravatar` for a 46 line inline SRV
lookup. It could not bound or cancel its DNS query and panicked on an
unexpected resolver error. The replacement carries the request context
and a 3s timeout.
- Fix federated avatars querying DNS for every avatar on every render.
`loadAvatarSetting` compared a cache field that was never assigned, so
each call rebuilt the resolver and dropped its cache. That cache is
gone, both settings are read where they are used.
- Migration 348 recreates `email_hash` with a 64 char hash column and a
`hash_type` column, so a later algorithm change can tell old rows apart.
The MD5 rows are unreachable and their `UNIQUE` email index would reject
the SHA256 replacements.
- Fix a re-saved avatar form replacing an uploaded avatar with a random
one.
- Remove the `duoshuo` `GRAVATAR_SOURCE` alias, that service shut down
in 2017.
- Remove dead i18n key.

Fixes: https://github.com/go-gitea/gitea/issues/34284
Fixes: https://github.com/go-gitea/gitea/issues/28110
Docs: https://gitea.com/gitea/docs/pulls/499
Signed-off-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-08-11 01:13:28 +02:00
committed by GitHub
parent 52d0e18dac
commit e3ee28f15b
17 changed files with 221 additions and 216 deletions
-5
View File
@@ -1284,11 +1284,6 @@
"path": "mvdan.cc/xurls/v2/LICENSE", "path": "mvdan.cc/xurls/v2/LICENSE",
"licenseText": "Copyright (c) 2015, Daniel Martí. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of the copyright holder nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" "licenseText": "Copyright (c) 2015, Daniel Martí. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of the copyright holder nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
}, },
{
"name": "strk.kbt.io/projects/go/libravatar",
"path": "strk.kbt.io/projects/go/libravatar/LICENSE",
"licenseText": "Copyright (c) 2016 Sandro Santilli \u003cstrk@kbt.io\u003e\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
},
{ {
"name": "xorm.io/builder", "name": "xorm.io/builder",
"path": "xorm.io/builder/LICENSE", "path": "xorm.io/builder/LICENSE",
+1 -2
View File
@@ -2038,8 +2038,7 @@ LEVEL = Info
;; If the uploaded file is not larger than this byte size, the image will be used as is, without resizing/converting. ;; If the uploaded file is not larger than this byte size, the image will be used as is, without resizing/converting.
;AVATAR_MAX_ORIGIN_SIZE = 262144 ;AVATAR_MAX_ORIGIN_SIZE = 262144
;; ;;
;; Chinese users can choose "duoshuo" ;; "gravatar", "libravatar", or any other Gravatar-compatible source, like: https://cn.gravatar.com/avatar/
;; or a custom avatar source, like: http://cn.gravatar.com/avatar/
;GRAVATAR_SOURCE = gravatar ;GRAVATAR_SOURCE = gravatar
;; ;;
;; Deprecated, see Web UI Admin Panel -> Config -> Settings ;; Deprecated, see Web UI Admin Panel -> Config -> Settings
-1
View File
@@ -116,7 +116,6 @@ require (
gopkg.in/ini.v1 v1.67.3 gopkg.in/ini.v1 v1.67.3
modernc.org/sqlite v1.56.0 modernc.org/sqlite v1.56.0
mvdan.cc/xurls/v2 v2.6.0 mvdan.cc/xurls/v2 v2.6.0
strk.kbt.io/projects/go/libravatar v0.0.0-20260301104140-add494e31dab
xorm.io/builder v0.3.13 xorm.io/builder v0.3.13
xorm.io/xorm v1.4.1 xorm.io/xorm v1.4.1
) )
-2
View File
@@ -951,8 +951,6 @@ mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0= pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0=
pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU= pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
strk.kbt.io/projects/go/libravatar v0.0.0-20260301104140-add494e31dab h1:3IZDVyI8uBmZko6pwm39f7mE0aTY7LViNdQHMeH7U60=
strk.kbt.io/projects/go/libravatar v0.0.0-20260301104140-add494e31dab/go.mod h1:FJGmPh3vz9jSos1L/F91iAgnC/aejc0wIIrF2ZwJxdY=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8= xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8=
+1
View File
@@ -421,6 +421,7 @@ func prepareMigrationTasks() []*migration {
newMigration(345, "Add block on CODEOWNERS reviews branch protection", v28.AddBlockOnCodeownerReviews), newMigration(345, "Add block on CODEOWNERS reviews branch protection", v28.AddBlockOnCodeownerReviews),
newMigration(346, "Add license_path column to repo_license and backfill", v28.AddLicensePathToRepoLicense), newMigration(346, "Add license_path column to repo_license and backfill", v28.AddLicensePathToRepoLicense),
newMigration(347, "Add watch options", v28.AddWatchOptions), newMigration(347, "Add watch options", v28.AddWatchOptions),
newMigration(348, "Recreate email_hash table for SHA256 avatar hashes", v28.RecreateEmailHashTable),
} }
return preparedMigrations return preparedMigrations
} }
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v28
import (
"context"
"gitea.dev/modelmigration/base"
)
func RecreateEmailHashTable(_ context.Context, x base.EngineMigration) error {
// the rows are unreachable MD5 hashes and their UNIQUE email index would reject the SHA256 replacements
if err := x.DropTables("email_hash"); err != nil {
return err
}
type EmailHash struct {
Hash string `xorm:"pk varchar(64)"`
Email string `xorm:"UNIQUE(email_hashtype) NOT NULL"`
HashType string `xorm:"UNIQUE(email_hashtype) NOT NULL varchar(16)"`
}
return x.Sync(new(EmailHash))
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v28
import (
"strings"
"testing"
"gitea.dev/modelmigration/migrationtest"
"github.com/stretchr/testify/require"
)
func TestRecreateEmailHashTable(t *testing.T) {
type EmailHash struct {
Hash string `xorm:"pk varchar(32)"`
Email string `xorm:"UNIQUE NOT NULL"`
}
x, deferable := migrationtest.PrepareTestEnv(t, 0, new(EmailHash))
defer deferable()
if x == nil || t.Failed() {
return
}
_, err := x.Insert(&EmailHash{Hash: strings.Repeat("a", 32), Email: "gitea@example.com"})
require.NoError(t, err)
require.NoError(t, RecreateEmailHashTable(t.Context(), x))
count, err := x.Count(new(EmailHash))
require.NoError(t, err)
require.EqualValues(t, 0, count, "the unreachable MD5 rows must be gone")
_, err = x.Exec("INSERT INTO email_hash (hash, email, hash_type) VALUES (?, ?, ?)", strings.Repeat("b", 64), "gitea@example.com", "sha256")
require.NoError(t, err, "the new schema must hold a SHA256 hash")
}
+54 -138
View File
@@ -5,21 +5,18 @@ package avatars
import ( import (
"context" "context"
"crypto/md5"
"encoding/hex"
"fmt"
"net/url" "net/url"
"path" "path"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/modules/avatar"
"gitea.dev/modules/base"
"gitea.dev/modules/cache" "gitea.dev/modules/cache"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"strk.kbt.io/projects/go/libravatar"
"xorm.io/builder" "xorm.io/builder"
) )
@@ -30,77 +27,38 @@ const (
DefaultAvatarPixelSize = 28 DefaultAvatarPixelSize = 28
) )
// EmailHash represents a pre-generated hash map (mainly used by LibravatarURL, it queries email server's DNS records) const emailHashType = "sha256" // so a later algorithm change can tell old rows apart
// EmailHash keeps the email out of the rendered page
type EmailHash struct { type EmailHash struct {
Hash string `xorm:"pk varchar(32)"` Hash string `xorm:"pk varchar(64)"`
Email string `xorm:"UNIQUE NOT NULL"` Email string `xorm:"UNIQUE(email_hashtype) NOT NULL"`
HashType string `xorm:"UNIQUE(email_hashtype) NOT NULL varchar(16)"`
} }
func init() { func init() {
db.RegisterModel(new(EmailHash)) db.RegisterModel(new(EmailHash))
} }
type avatarSettingStruct struct {
defaultAvatarLink string
gravatarSource string
gravatarSourceURL *url.URL
libravatar *libravatar.Libravatar
}
var avatarSettingAtomic atomic.Pointer[avatarSettingStruct]
func loadAvatarSetting() (*avatarSettingStruct, error) {
s := avatarSettingAtomic.Load()
if s == nil || s.gravatarSource != setting.GravatarSource {
s = &avatarSettingStruct{}
u, err := url.Parse(setting.AppSubURL)
if err != nil {
return nil, fmt.Errorf("unable to parse AppSubURL: %w", err)
}
u.Path = path.Join(u.Path, "/assets/img/avatar_default.png")
s.defaultAvatarLink = u.String()
s.gravatarSourceURL, err = url.Parse(setting.GravatarSource)
if err != nil {
return nil, fmt.Errorf("unable to parse GravatarSource %q: %w", setting.GravatarSource, err)
}
s.libravatar = libravatar.New()
if s.gravatarSourceURL.Scheme == "https" {
s.libravatar.SetUseHTTPS(true)
s.libravatar.SetSecureFallbackHost(s.gravatarSourceURL.Host)
} else {
s.libravatar.SetUseHTTPS(false)
s.libravatar.SetFallbackHost(s.gravatarSourceURL.Host)
}
avatarSettingAtomic.Store(s)
}
return s, nil
}
// DefaultAvatarLink the default avatar link // DefaultAvatarLink the default avatar link
func DefaultAvatarLink() string { func DefaultAvatarLink() string {
a, err := loadAvatarSetting() return setting.AppSubURL + "/assets/img/avatar_default.png"
if err != nil {
log.Error("Failed to loadAvatarSetting: %v", err)
return ""
}
return a.defaultAvatarLink
} }
// HashEmail hashes email address to MD5 string. https://en.gravatar.com/site/implement/hash/ // HashEmail hashes an email address the way avatar services address it. https://docs.gravatar.com/api/avatars/images/
func HashEmail(email string) string { func HashEmail(email string) string {
m := md5.New() return base.EncodeSha256(strings.ToLower(strings.TrimSpace(email)))
_, _ = m.Write([]byte(strings.ToLower(strings.TrimSpace(email))))
return hex.EncodeToString(m.Sum(nil))
} }
// GetEmailForHash converts a provided md5sum to the email func emailHashCacheKey(hash string) string {
func GetEmailForHash(ctx context.Context, md5Sum string) (string, error) { return cache.SafeCacheKey("Avatar", hash)
return cache.GetString("Avatar:"+md5Sum, func() (string, error) { }
emailHash, has, err := db.Get[EmailHash](ctx, builder.Eq{"`hash`": strings.ToLower(strings.TrimSpace(md5Sum))})
// GetEmailForHash converts a provided hash to the email
func GetEmailForHash(ctx context.Context, hash string) (string, error) {
hash = strings.ToLower(strings.TrimSpace(hash))
return cache.GetString(emailHashCacheKey(hash), func() (string, error) {
emailHash, has, err := db.Get[EmailHash](ctx, builder.Eq{"`hash`": hash})
if err != nil { if err != nil {
return "", err return "", err
} else if !has { } else if !has {
@@ -110,50 +68,19 @@ func GetEmailForHash(ctx context.Context, md5Sum string) (string, error) {
}) })
} }
// LibravatarURL returns the URL for the given email. Slow due to the DNS lookup. // saveEmailHash returns the hash and stores the pair for GetEmailForHash
// This function should only be called if a federated avatar service is enabled.
func LibravatarURL(email string) (*url.URL, error) {
a, err := loadAvatarSetting()
if err != nil {
return nil, err
}
urlStr, err := a.libravatar.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
}
// saveEmailHash returns an avatar link for a provided email,
// the email and hash are saved into database, which will be used by GetEmailForHash later
func saveEmailHash(ctx context.Context, email string) string { func saveEmailHash(ctx context.Context, email string) string {
lowerEmail := strings.ToLower(strings.TrimSpace(email)) lowerEmail := strings.ToLower(strings.TrimSpace(email))
emailHash := HashEmail(lowerEmail) emailHash := HashEmail(lowerEmail)
_, _ = cache.GetString("Avatar:"+emailHash, func() (string, error) { // a key of its own, GetEmailForHash caches an unknown hash as empty
emailHash := &EmailHash{ _, _ = cache.GetString(cache.SafeCacheKey("AvatarStored", emailHash), func() (string, error) {
Email: lowerEmail, // the check keeps a duplicate key error out of a transaction the caller may hold
Hash: emailHash, has, err := db.Exist[EmailHash](ctx, builder.Eq{"`hash`": emailHash})
if err == nil && !has {
_, err = db.GetEngine(ctx).Insert(&EmailHash{Email: lowerEmail, Hash: emailHash, HashType: emailHashType})
cache.Remove(emailHashCacheKey(emailHash)) // a lookup may have cached it as unknown
} }
// OK we're going to open a session just because I think that that might hide away any problems with postgres reporting errors return lowerEmail, err // an error must leave the hash unmarked
if err := db.WithTx(ctx, func(ctx context.Context) error {
has, err := db.GetEngine(ctx).Where("email = ? AND hash = ?", emailHash.Email, emailHash.Hash).Get(new(EmailHash))
if has || err != nil {
// Seriously we don't care about any DB problems just return the lowerEmail - we expect the transaction to fail most of the time
return nil
}
_, _ = db.GetEngine(ctx).Insert(emailHash)
return nil
}); err != nil {
// Seriously we don't care about any DB problems just return the lowerEmail - we expect the transaction to fail most of the time
return lowerEmail, nil
}
return lowerEmail, nil
}) })
return emailHash return emailHash
} }
@@ -174,59 +101,48 @@ func GenerateUserAvatarImageLink(userAvatar string, size int) string {
return setting.AppSubURL + "/avatars/" + url.PathEscape(userAvatar) return setting.AppSubURL + "/avatars/" + url.PathEscape(userAvatar)
} }
// generateRecognizedAvatarURL generate a recognized avatar (Gravatar/Libravatar) URL, it modifies the URL so the parameter is passed by a copy func generateSourceAvatarURL(source url.URL, email string, size int) string {
func generateRecognizedAvatarURL(u url.URL, size int) string { source.Path = path.Join(source.Path, HashEmail(email))
urlQuery := u.Query() urlQuery := source.Query()
urlQuery.Set("d", "identicon") urlQuery.Set("d", "identicon")
if size > 0 { if size > 0 {
urlQuery.Set("s", strconv.Itoa(size)) urlQuery.Set("s", strconv.Itoa(size))
} }
u.RawQuery = urlQuery.Encode() source.RawQuery = urlQuery.Encode()
return u.String() return source.String()
} }
// generateEmailAvatarLink returns a email avatar link. // generateEmailAvatarLink returns a email avatar link, a final link may query DNS
// if final is true, it may use a slow path (eg: query DNS).
// if final is false, it always uses a fast path.
func generateEmailAvatarLink(ctx context.Context, email string, size int, final bool) string { func generateEmailAvatarLink(ctx context.Context, email string, size int, final bool) string {
email = strings.TrimSpace(email) email = strings.TrimSpace(email)
if email == "" { if email == "" {
return DefaultAvatarLink() return DefaultAvatarLink()
} }
avatarSetting, err := loadAvatarSetting() federated := setting.Config().Picture.EnableFederatedAvatar.Value(ctx)
if err != nil { if federated && !final {
// return a 302 link, so page rendering never waits for the DNS query
link := setting.AppSubURL + "/avatar/" + url.PathEscape(saveEmailHash(ctx, email))
if size > 0 {
link += "?size=" + strconv.Itoa(size)
}
return link
}
if !federated && setting.Config().Picture.DisableGravatar.Value(ctx) {
return DefaultAvatarLink() return DefaultAvatarLink()
} }
enableFederatedAvatar := setting.Config().Picture.EnableFederatedAvatar.Value(ctx) source, err := url.Parse(setting.GravatarSource)
if enableFederatedAvatar { if err != nil {
emailHash := saveEmailHash(ctx, email) log.Error("unable to parse GravatarSource %q: %v", setting.GravatarSource, err)
if final { return DefaultAvatarLink()
// for final link, we can spend more time on slow external query
var avatarURL *url.URL
if avatarURL, err = LibravatarURL(email); err != nil {
return DefaultAvatarLink()
}
return generateRecognizedAvatarURL(*avatarURL, size)
}
// for non-final link, we should return fast (use a 302 redirection link)
urlStr := setting.AppSubURL + "/avatar/" + url.PathEscape(emailHash)
if size > 0 {
urlStr += "?size=" + strconv.Itoa(size)
}
return urlStr
} }
if federated {
disableGravatar := setting.Config().Picture.DisableGravatar.Value(ctx) if host := avatar.LookupFederatedHost(ctx, email, source.Scheme == "https"); host != "" {
if !disableGravatar { source.Host, source.Path = host, "/avatar"
// copy GravatarSourceURL, because we will modify its Path. }
avatarURLCopy := *avatarSetting.gravatarSourceURL
avatarURLCopy.Path = path.Join(avatarURLCopy.Path, HashEmail(email))
return generateRecognizedAvatarURL(avatarURLCopy, size)
} }
return generateSourceAvatarURL(*source, email, size)
return DefaultAvatarLink()
} }
// GenerateEmailAvatarFastLink returns a avatar link (fast, the link may be a delegated one: "/avatar/${hash}") // GenerateEmailAvatarFastLink returns a avatar link (fast, the link may be a delegated one: "/avatar/${hash}")
+30 -36
View File
@@ -4,54 +4,48 @@
package avatars_test package avatars_test
import ( import (
"strconv"
"testing" "testing"
avatars_model "gitea.dev/models/avatars" avatars_model "gitea.dev/models/avatars"
system_model "gitea.dev/models/system" system_model "gitea.dev/models/system"
"gitea.dev/models/unittest"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/setting/config" "gitea.dev/modules/setting/config"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
const gravatarSource = "https://secure.gravatar.com/avatar/" func TestEmailAvatarLink(t *testing.T) {
const email = "gitea@example.com"
func disableGravatar(t *testing.T) { const emailHash = "72af1071d72449afe29e816060e78e78fd85829ba6e2497aa1d4eedd3c9dc611"
err := system_model.SetSettings(t.Context(), map[string]string{setting.Config().Picture.EnableFederatedAvatar.DynKey(): "false"})
assert.NoError(t, err)
err = system_model.SetSettings(t.Context(), map[string]string{setting.Config().Picture.DisableGravatar.DynKey(): "true"})
assert.NoError(t, err)
}
func enableGravatar(t *testing.T) {
err := system_model.SetSettings(t.Context(), map[string]string{setting.Config().Picture.DisableGravatar.DynKey(): "false"})
assert.NoError(t, err)
setting.GravatarSource = gravatarSource
}
func TestHashEmail(t *testing.T) {
assert.Equal(t,
"d41d8cd98f00b204e9800998ecf8427e",
avatars_model.HashEmail(""),
)
assert.Equal(t,
"353cbad9b58e69c96154ad99f92bedc7",
avatars_model.HashEmail("gitea@example.com"),
)
}
func TestSizedAvatarLink(t *testing.T) {
setting.AppSubURL = "/testsuburl" setting.AppSubURL = "/testsuburl"
setting.GravatarSource = "https://secure.gravatar.com/avatar/"
disableGravatar(t) assert.Equal(t, emailHash, avatars_model.HashEmail(" Gitea@Example.com "))
config.GetDynGetter().InvalidateCache()
setAvatarConfig := func(disableGravatar, enableFederatedAvatar bool) {
assert.NoError(t, system_model.SetSettings(t.Context(), map[string]string{
setting.Config().Picture.DisableGravatar.DynKey(): strconv.FormatBool(disableGravatar),
setting.Config().Picture.EnableFederatedAvatar.DynKey(): strconv.FormatBool(enableFederatedAvatar),
}))
config.GetDynGetter().InvalidateCache()
}
setAvatarConfig(true, false)
assert.Equal(t, "/testsuburl/assets/img/avatar_default.png", assert.Equal(t, "/testsuburl/assets/img/avatar_default.png",
avatars_model.GenerateEmailAvatarFastLink(t.Context(), "gitea@example.com", 100)) avatars_model.GenerateEmailAvatarFastLink(t.Context(), email, 100))
enableGravatar(t) setAvatarConfig(false, false)
config.GetDynGetter().InvalidateCache() assert.Equal(t, "https://secure.gravatar.com/avatar/"+emailHash+"?d=identicon&s=100",
assert.Equal(t, avatars_model.GenerateEmailAvatarFastLink(t.Context(), email, 100))
"https://secure.gravatar.com/avatar/353cbad9b58e69c96154ad99f92bedc7?d=identicon&s=100",
avatars_model.GenerateEmailAvatarFastLink(t.Context(), "gitea@example.com", 100), // the DNS query waits until the browser follows the link
) setAvatarConfig(false, true)
assert.Equal(t, "/testsuburl/avatar/"+emailHash+"?size=100",
avatars_model.GenerateEmailAvatarFastLink(t.Context(), email, 100))
storedEmail, err := avatars_model.GetEmailForHash(t.Context(), emailHash)
assert.NoError(t, err)
assert.Equal(t, email, storedEmail)
assert.Equal(t, "sha256", unittest.AssertExistsAndLoadBean(t, &avatars_model.EmailHash{Hash: emailHash}, unittest.OrderBy("hash")).HashType)
} }
+6 -13
View File
@@ -16,6 +16,7 @@ import (
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/storage" "gitea.dev/modules/storage"
"gitea.dev/modules/util"
) )
// CustomAvatarRelativePath returns user custom avatar relative path. // CustomAvatarRelativePath returns user custom avatar relative path.
@@ -25,21 +26,13 @@ func (u *User) CustomAvatarRelativePath() string {
// GenerateRandomAvatar generates a random avatar for user. // GenerateRandomAvatar generates a random avatar for user.
func GenerateRandomAvatar(ctx context.Context, u *User) error { func GenerateRandomAvatar(ctx context.Context, u *User) error {
seed := u.Email seed := []byte(util.IfZero(u.Email, u.Name))
if len(seed) == 0 { u.Avatar = avatar.HashAvatar(u.ID, seed)
seed = u.Name
}
img := avatar.RandomImageDefaultSize([]byte(seed)) // a failed Stat usually means the file is not there yet
if _, err := storage.Avatars.Stat(u.CustomAvatarRelativePath()); err != nil {
u.Avatar = avatars.HashEmail(seed)
_, err := storage.Avatars.Stat(u.CustomAvatarRelativePath())
if err != nil {
// If unable to Stat the avatar file (usually it means non-existing), then try to save a new one
// Don't share the images so that we can delete them easily
if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error { if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error {
return png.Encode(w, img) return png.Encode(w, avatar.RandomImageDefaultSize(seed))
}); err != nil { }); err != nil {
return fmt.Errorf("failed to save avatar %s: %w", u.CustomAvatarRelativePath(), err) return fmt.Errorf("failed to save avatar %s: %w", u.CustomAvatarRelativePath(), err)
} }
+7
View File
@@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"image" "image"
"image/png" "image/png"
"net"
"os" "os"
"testing" "testing"
@@ -136,3 +137,9 @@ func BenchmarkRandomImage(b *testing.B) {
} }
}) })
} }
func TestSrvHost(t *testing.T) {
assert.Equal(t, "avatars.example.com", srvHost(&net.SRV{Target: "avatars.example.com.", Port: 443}, 443))
assert.Equal(t, "avatars.example.com:8443", srvHost(&net.SRV{Target: "avatars.example.com.", Port: 8443}, 443))
assert.Empty(t, srvHost(&net.SRV{Target: ".", Port: 443}, 443))
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package avatar
import (
"context"
"errors"
"net"
"strconv"
"strings"
"time"
"gitea.dev/modules/cache"
)
// LookupFederatedHost returns the avatar host from the email domain's SRV record. https://wiki.libravatar.org/api/
func LookupFederatedHost(ctx context.Context, email string, secure bool) string {
at := strings.LastIndexByte(email, '@')
if at < 0 {
return ""
}
domain := strings.ToLower(email[at+1:])
service, defaultPort := "avatars", uint16(80)
if secure {
service, defaultPort = "avatars-sec", 443
}
host, _ := cache.GetString(cache.SafeCacheKey("Avatar:SRV:"+service, domain), func() (string, error) {
lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second) // a slow resolver must not stall the request
defer cancel()
// LookupSRV already sorts by priority and randomizes by weight (RFC 2782)
_, records, err := net.DefaultResolver.LookupSRV(lookupCtx, service, "tcp", domain)
var dnsErr *net.DNSError
if err != nil && !(errors.As(err, &dnsErr) && dnsErr.IsNotFound) {
return "", err // a timeout or a cancelled request must not cache as "no record"
}
if err != nil || len(records) == 0 {
return "", nil
}
return srvHost(records[0], defaultPort), nil
})
return host
}
// a target of "." means the service is unavailable (RFC 2782)
func srvHost(record *net.SRV, defaultPort uint16) string {
target := strings.TrimSuffix(record.Target, ".")
if target == "" || record.Port == defaultPort {
return target
}
return net.JoinHostPort(target, strconv.Itoa(int(record.Port)))
}
-2
View File
@@ -53,8 +53,6 @@ func loadAvatarsFrom(rootCfg ConfigProvider) error {
Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(2) Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(2)
switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source { switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source {
case "duoshuo":
GravatarSource = "http://gravatar.duoshuo.com/avatar/"
case "gravatar": case "gravatar":
GravatarSource = "https://secure.gravatar.com/avatar/" GravatarSource = "https://secure.gravatar.com/avatar/"
case "libravatar": case "libravatar":
-1
View File
@@ -711,7 +711,6 @@
"settings.keep_activity_private": "Hide Activity from profile page", "settings.keep_activity_private": "Hide Activity from profile page",
"settings.keep_activity_private_popup": "Makes the activity visible only for you and the admins", "settings.keep_activity_private_popup": "Makes the activity visible only for you and the admins",
"settings.lookup_avatar_by_mail": "Look Up Avatar by Email Address", "settings.lookup_avatar_by_mail": "Look Up Avatar by Email Address",
"settings.federated_avatar_lookup": "Federated Avatar Lookup",
"settings.enable_custom_avatar": "Use Custom Avatar", "settings.enable_custom_avatar": "Use Custom Avatar",
"settings.choose_new_avatar": "Choose new avatar", "settings.choose_new_avatar": "Choose new avatar",
"settings.update_avatar": "Update Avatar", "settings.update_avatar": "Update Avatar",
-1
View File
@@ -58,7 +58,6 @@ func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error {
// SettingsAvatar save new POSTed repository avatar // SettingsAvatar save new POSTed repository avatar
func SettingsAvatar(ctx *context.Context) { func SettingsAvatar(ctx *context.Context) {
form := web.GetForm[*forms.AvatarForm](ctx) form := web.GetForm[*forms.AvatarForm](ctx)
form.Source = forms.AvatarLocal
if err := UpdateAvatarSetting(ctx, *form); err != nil { if err := UpdateAvatarSetting(ctx, *form); err != nil {
ctx.Flash.Error(err.Error()) ctx.Flash.Error(err.Error())
} else { } else {
+1 -6
View File
@@ -14,7 +14,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.dev/models/avatars"
"gitea.dev/models/db" "gitea.dev/models/db"
"gitea.dev/models/organization" "gitea.dev/models/organization"
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
@@ -127,11 +126,7 @@ func ProfilePost(ctx *context.Context) {
func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *user_model.User) error { func UpdateAvatarSetting(ctx *context.Context, form *forms.AvatarForm, ctxUser *user_model.User) error {
ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal
if len(form.Gravatar) > 0 { if len(form.Gravatar) > 0 {
if form.Avatar != nil { ctxUser.Avatar = "" // UploadAvatar sets the real storage path when a file is uploaded
ctxUser.Avatar = avatars.HashEmail(form.Gravatar)
} else {
ctxUser.Avatar = ""
}
ctxUser.AvatarEmail = form.Gravatar ctxUser.AvatarEmail = form.Gravatar
} }
+4 -9
View File
@@ -239,18 +239,13 @@ func (f *UpdateLanguageForm) Validate(req *http.Request, errs binding.Errors) bi
return middleware.Validate(ctx, errs, f) return middleware.Validate(ctx, errs, f)
} }
// Avatar types const AvatarLocal = "local" // the AvatarForm.Source value that selects an uploaded avatar
const (
AvatarLocal string = "local"
AvatarByMail string = "bymail"
)
// AvatarForm form for changing avatar // AvatarForm form for changing avatar
type AvatarForm struct { type AvatarForm struct {
Source string Source string
Avatar *multipart.FileHeader Avatar *multipart.FileHeader
Gravatar string `binding:"OmitEmpty;Email;MaxSize(254)"` Gravatar string `binding:"OmitEmpty;Email;MaxSize(254)"`
Federavatar bool
} }
// Validate validates the fields // Validate validates the fields