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