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  }