diff --git a/assets/go-licenses.json b/assets/go-licenses.json index 0304d9e0871..c20022dfbdd 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -1284,11 +1284,6 @@ "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" }, - { - "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", "path": "xorm.io/builder/LICENSE", diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 0eb33002153..0ec2072c0ff 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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. ;AVATAR_MAX_ORIGIN_SIZE = 262144 ;; -;; Chinese users can choose "duoshuo" -;; or a custom avatar source, like: http://cn.gravatar.com/avatar/ +;; "gravatar", "libravatar", or any other Gravatar-compatible source, like: https://cn.gravatar.com/avatar/ ;GRAVATAR_SOURCE = gravatar ;; ;; Deprecated, see Web UI Admin Panel -> Config -> Settings diff --git a/go.mod b/go.mod index 95c7790a00a..4e2ad06a575 100644 --- a/go.mod +++ b/go.mod @@ -116,7 +116,6 @@ require ( gopkg.in/ini.v1 v1.67.3 modernc.org/sqlite v1.56.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/xorm v1.4.1 ) diff --git a/go.sum b/go.sum index 8c5f60a929c..60ee210c5d4 100644 --- a/go.sum +++ b/go.sum @@ -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= pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0= 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/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8= diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 28be7645c51..9b2a793db74 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -421,6 +421,7 @@ func prepareMigrationTasks() []*migration { 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(347, "Add watch options", v28.AddWatchOptions), + newMigration(348, "Recreate email_hash table for SHA256 avatar hashes", v28.RecreateEmailHashTable), } return preparedMigrations } diff --git a/modelmigration/v28/v348.go b/modelmigration/v28/v348.go new file mode 100644 index 00000000000..85d2e1234e2 --- /dev/null +++ b/modelmigration/v28/v348.go @@ -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)) +} diff --git a/modelmigration/v28/v348_test.go b/modelmigration/v28/v348_test.go new file mode 100644 index 00000000000..a6d5ae90521 --- /dev/null +++ b/modelmigration/v28/v348_test.go @@ -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") +} diff --git a/models/avatars/avatar.go b/models/avatars/avatar.go index 59300103a76..73fd4438384 100644 --- a/models/avatars/avatar.go +++ b/models/avatars/avatar.go @@ -5,21 +5,18 @@ package avatars import ( "context" - "crypto/md5" - "encoding/hex" - "fmt" "net/url" "path" "strconv" "strings" - "sync/atomic" "gitea.dev/models/db" + "gitea.dev/modules/avatar" + "gitea.dev/modules/base" "gitea.dev/modules/cache" "gitea.dev/modules/log" "gitea.dev/modules/setting" - "strk.kbt.io/projects/go/libravatar" "xorm.io/builder" ) @@ -30,77 +27,38 @@ const ( 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 { - Hash string `xorm:"pk varchar(32)"` - Email string `xorm:"UNIQUE NOT NULL"` + Hash string `xorm:"pk varchar(64)"` + Email string `xorm:"UNIQUE(email_hashtype) NOT NULL"` + HashType string `xorm:"UNIQUE(email_hashtype) NOT NULL varchar(16)"` } func init() { 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 func DefaultAvatarLink() string { - a, err := loadAvatarSetting() - if err != nil { - log.Error("Failed to loadAvatarSetting: %v", err) - return "" - } - return a.defaultAvatarLink + return setting.AppSubURL + "/assets/img/avatar_default.png" } -// 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 { - m := md5.New() - _, _ = m.Write([]byte(strings.ToLower(strings.TrimSpace(email)))) - return hex.EncodeToString(m.Sum(nil)) + return base.EncodeSha256(strings.ToLower(strings.TrimSpace(email))) } -// GetEmailForHash converts a provided md5sum to the email -func GetEmailForHash(ctx context.Context, md5Sum string) (string, error) { - return cache.GetString("Avatar:"+md5Sum, func() (string, error) { - emailHash, has, err := db.Get[EmailHash](ctx, builder.Eq{"`hash`": strings.ToLower(strings.TrimSpace(md5Sum))}) +func emailHashCacheKey(hash string) string { + return cache.SafeCacheKey("Avatar", hash) +} + +// 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 { return "", err } 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. -// 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 +// saveEmailHash returns the hash and stores the pair for GetEmailForHash func saveEmailHash(ctx context.Context, email string) string { lowerEmail := strings.ToLower(strings.TrimSpace(email)) emailHash := HashEmail(lowerEmail) - _, _ = cache.GetString("Avatar:"+emailHash, func() (string, error) { - emailHash := &EmailHash{ - Email: lowerEmail, - Hash: emailHash, + // a key of its own, GetEmailForHash caches an unknown hash as empty + _, _ = cache.GetString(cache.SafeCacheKey("AvatarStored", emailHash), func() (string, error) { + // the check keeps a duplicate key error out of a transaction the caller may hold + 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 - 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 lowerEmail, err // an error must leave the hash unmarked }) return emailHash } @@ -174,59 +101,48 @@ func GenerateUserAvatarImageLink(userAvatar string, size int) string { 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 generateRecognizedAvatarURL(u url.URL, size int) string { - urlQuery := u.Query() +func generateSourceAvatarURL(source url.URL, email string, size int) string { + source.Path = path.Join(source.Path, HashEmail(email)) + urlQuery := source.Query() urlQuery.Set("d", "identicon") if size > 0 { urlQuery.Set("s", strconv.Itoa(size)) } - u.RawQuery = urlQuery.Encode() - return u.String() + source.RawQuery = urlQuery.Encode() + return source.String() } -// generateEmailAvatarLink returns a email avatar link. -// if final is true, it may use a slow path (eg: query DNS). -// if final is false, it always uses a fast path. +// generateEmailAvatarLink returns a email avatar link, a final link may query DNS func generateEmailAvatarLink(ctx context.Context, email string, size int, final bool) string { email = strings.TrimSpace(email) if email == "" { return DefaultAvatarLink() } - avatarSetting, err := loadAvatarSetting() - if err != nil { + federated := setting.Config().Picture.EnableFederatedAvatar.Value(ctx) + 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() } - enableFederatedAvatar := setting.Config().Picture.EnableFederatedAvatar.Value(ctx) - if enableFederatedAvatar { - emailHash := saveEmailHash(ctx, email) - if final { - // 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 + source, err := url.Parse(setting.GravatarSource) + if err != nil { + log.Error("unable to parse GravatarSource %q: %v", setting.GravatarSource, err) + return DefaultAvatarLink() } - - disableGravatar := setting.Config().Picture.DisableGravatar.Value(ctx) - if !disableGravatar { - // copy GravatarSourceURL, because we will modify its Path. - avatarURLCopy := *avatarSetting.gravatarSourceURL - avatarURLCopy.Path = path.Join(avatarURLCopy.Path, HashEmail(email)) - return generateRecognizedAvatarURL(avatarURLCopy, size) + if federated { + if host := avatar.LookupFederatedHost(ctx, email, source.Scheme == "https"); host != "" { + source.Host, source.Path = host, "/avatar" + } } - - return DefaultAvatarLink() + return generateSourceAvatarURL(*source, email, size) } // GenerateEmailAvatarFastLink returns a avatar link (fast, the link may be a delegated one: "/avatar/${hash}") diff --git a/models/avatars/avatar_test.go b/models/avatars/avatar_test.go index cfc578d27b9..54464c6e4b2 100644 --- a/models/avatars/avatar_test.go +++ b/models/avatars/avatar_test.go @@ -4,54 +4,48 @@ package avatars_test import ( + "strconv" "testing" avatars_model "gitea.dev/models/avatars" system_model "gitea.dev/models/system" + "gitea.dev/models/unittest" "gitea.dev/modules/setting" "gitea.dev/modules/setting/config" "github.com/stretchr/testify/assert" ) -const gravatarSource = "https://secure.gravatar.com/avatar/" - -func disableGravatar(t *testing.T) { - 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) { +func TestEmailAvatarLink(t *testing.T) { + const email = "gitea@example.com" + const emailHash = "72af1071d72449afe29e816060e78e78fd85829ba6e2497aa1d4eedd3c9dc611" setting.AppSubURL = "/testsuburl" + setting.GravatarSource = "https://secure.gravatar.com/avatar/" - disableGravatar(t) - config.GetDynGetter().InvalidateCache() + assert.Equal(t, emailHash, avatars_model.HashEmail(" Gitea@Example.com ")) + + 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", - avatars_model.GenerateEmailAvatarFastLink(t.Context(), "gitea@example.com", 100)) + avatars_model.GenerateEmailAvatarFastLink(t.Context(), email, 100)) - enableGravatar(t) - config.GetDynGetter().InvalidateCache() - assert.Equal(t, - "https://secure.gravatar.com/avatar/353cbad9b58e69c96154ad99f92bedc7?d=identicon&s=100", - avatars_model.GenerateEmailAvatarFastLink(t.Context(), "gitea@example.com", 100), - ) + setAvatarConfig(false, false) + assert.Equal(t, "https://secure.gravatar.com/avatar/"+emailHash+"?d=identicon&s=100", + avatars_model.GenerateEmailAvatarFastLink(t.Context(), email, 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) } diff --git a/models/user/avatar.go b/models/user/avatar.go index 13f001e3ba2..5b4d1cef41a 100644 --- a/models/user/avatar.go +++ b/models/user/avatar.go @@ -16,6 +16,7 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/setting" "gitea.dev/modules/storage" + "gitea.dev/modules/util" ) // CustomAvatarRelativePath returns user custom avatar relative path. @@ -25,21 +26,13 @@ func (u *User) CustomAvatarRelativePath() string { // GenerateRandomAvatar generates a random avatar for user. func GenerateRandomAvatar(ctx context.Context, u *User) error { - seed := u.Email - if len(seed) == 0 { - seed = u.Name - } + seed := []byte(util.IfZero(u.Email, u.Name)) + u.Avatar = avatar.HashAvatar(u.ID, seed) - img := avatar.RandomImageDefaultSize([]byte(seed)) - - 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 + // a failed Stat usually means the file is not there yet + if _, err := storage.Avatars.Stat(u.CustomAvatarRelativePath()); err != nil { 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 { return fmt.Errorf("failed to save avatar %s: %w", u.CustomAvatarRelativePath(), err) } diff --git a/modules/avatar/avatar_test.go b/modules/avatar/avatar_test.go index 8e2aae43feb..f2e2879e0ab 100644 --- a/modules/avatar/avatar_test.go +++ b/modules/avatar/avatar_test.go @@ -7,6 +7,7 @@ import ( "bytes" "image" "image/png" + "net" "os" "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)) +} diff --git a/modules/avatar/federated.go b/modules/avatar/federated.go new file mode 100644 index 00000000000..27d3778cf68 --- /dev/null +++ b/modules/avatar/federated.go @@ -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))) +} diff --git a/modules/setting/picture.go b/modules/setting/picture.go index d20a110b6c4..d346ac22381 100644 --- a/modules/setting/picture.go +++ b/modules/setting/picture.go @@ -53,8 +53,6 @@ func loadAvatarsFrom(rootCfg ConfigProvider) error { Avatar.RenderedSizeFactor = sec.Key("AVATAR_RENDERED_SIZE_FACTOR").MustInt(2) switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source { - case "duoshuo": - GravatarSource = "http://gravatar.duoshuo.com/avatar/" case "gravatar": GravatarSource = "https://secure.gravatar.com/avatar/" case "libravatar": diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 25640be60b7..bc376bcc435 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -711,7 +711,6 @@ "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.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.choose_new_avatar": "Choose new avatar", "settings.update_avatar": "Update Avatar", diff --git a/routers/web/repo/setting/avatar.go b/routers/web/repo/setting/avatar.go index b66f31bd650..11ae4fe07c2 100644 --- a/routers/web/repo/setting/avatar.go +++ b/routers/web/repo/setting/avatar.go @@ -58,7 +58,6 @@ func UpdateAvatarSetting(ctx *context.Context, form forms.AvatarForm) error { // SettingsAvatar save new POSTed repository avatar func SettingsAvatar(ctx *context.Context) { form := web.GetForm[*forms.AvatarForm](ctx) - form.Source = forms.AvatarLocal if err := UpdateAvatarSetting(ctx, *form); err != nil { ctx.Flash.Error(err.Error()) } else { diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index d52ecb69d7a..876dbb29614 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -14,7 +14,6 @@ import ( "path/filepath" "strings" - "gitea.dev/models/avatars" "gitea.dev/models/db" "gitea.dev/models/organization" 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 { ctxUser.UseCustomAvatar = form.Source == forms.AvatarLocal if len(form.Gravatar) > 0 { - if form.Avatar != nil { - ctxUser.Avatar = avatars.HashEmail(form.Gravatar) - } else { - ctxUser.Avatar = "" - } + ctxUser.Avatar = "" // UploadAvatar sets the real storage path when a file is uploaded ctxUser.AvatarEmail = form.Gravatar } diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 32dfda2ef3b..012345fd5ee 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -239,18 +239,13 @@ func (f *UpdateLanguageForm) Validate(req *http.Request, errs binding.Errors) bi return middleware.Validate(ctx, errs, f) } -// Avatar types -const ( - AvatarLocal string = "local" - AvatarByMail string = "bymail" -) +const AvatarLocal = "local" // the AvatarForm.Source value that selects an uploaded avatar // AvatarForm form for changing avatar type AvatarForm struct { - Source string - Avatar *multipart.FileHeader - Gravatar string `binding:"OmitEmpty;Email;MaxSize(254)"` - Federavatar bool + Source string + Avatar *multipart.FileHeader + Gravatar string `binding:"OmitEmpty;Email;MaxSize(254)"` } // Validate validates the fields