buddy_icon.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. package handlers
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "net/url"
  8. "github.com/mk6i/open-oscar-server/state"
  9. "github.com/mk6i/open-oscar-server/wire"
  10. )
  11. // ErrNoBuddyIcon indicates that a user has not set a buddy icon.
  12. var ErrNoBuddyIcon = errors.New("no buddy icon")
  13. // BARTService retrieves BART (Buddy Art) assets by hash.
  14. type BARTService interface {
  15. RetrieveItem(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x04_BARTDownloadQuery) (wire.SNACMessage, error)
  16. }
  17. // BuddyIconRetriever resolves a user's buddy icon reference. References live in
  18. // the feedbag rather than on the session, so they resolve for offline users too.
  19. type BuddyIconRetriever interface {
  20. BuddyIconMetadata(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error)
  21. }
  22. // BuddyIconSource resolves buddy icons, both as URLs to publish to the web
  23. // client and as the image bytes those URLs serve.
  24. //
  25. // The client never derives an icon URL: it renders whatever string the server
  26. // puts in a user's buddyIcon field, falling back to a blank-person placeholder
  27. // when the field is absent.
  28. type BuddyIconSource struct {
  29. IconRetriever BuddyIconRetriever
  30. BARTService BARTService
  31. Logger *slog.Logger
  32. }
  33. // URL returns the absolute, content-addressed URL that screenName's buddy icon
  34. // is served from, or an empty string if the user has no icon.
  35. //
  36. // The icon hash is part of the URL so that the URL changes whenever the user
  37. // changes their icon. Browsers cache icons by URL, and the client refetches a
  38. // user's large icon only when it observes buddyIconUrl change.
  39. func (s BuddyIconSource) URL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
  40. // The client loads icons from a different origin than the page it runs on,
  41. // so a URL is only publishable if it can be made absolute. Callers that have
  42. // no origin to build against pass an empty baseURL to opt out.
  43. if baseURL == "" {
  44. return ""
  45. }
  46. id, err := s.iconID(ctx, screenName)
  47. if err != nil {
  48. if !errors.Is(err, ErrNoBuddyIcon) {
  49. s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
  50. "screenName", screenName.String(), "err", err.Error())
  51. }
  52. return ""
  53. }
  54. return iconURL(baseURL, screenName, id.Hash)
  55. }
  56. // PublishedURL returns a buddyIcon URL that is always non-empty when baseURL is
  57. // set: the content-addressed URL when the user has an icon, otherwise a hash-less
  58. // URL that resolves to the blank placeholder.
  59. //
  60. // Callers that publish icons into buddy-list, presence, or myInfo payloads use
  61. // this so the client always receives a URL. The web client's shallow user-object
  62. // merge never drops a stale buddyIconUrl on its own, so a user who clears their
  63. // icon only stops rendering it once a *different* URL arrives; the hash-less
  64. // placeholder URL is that different URL.
  65. func (s BuddyIconSource) PublishedURL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
  66. if baseURL == "" {
  67. return ""
  68. }
  69. id, err := s.iconID(ctx, screenName)
  70. switch {
  71. case errors.Is(err, ErrNoBuddyIcon):
  72. // No icon set: publish the hash-less placeholder rather than nothing, so
  73. // a cleared icon propagates to the client.
  74. return iconURL(baseURL, screenName, nil)
  75. case err != nil:
  76. s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
  77. "screenName", screenName.String(), "err", err.Error())
  78. return ""
  79. }
  80. return iconURL(baseURL, screenName, id.Hash)
  81. }
  82. // URLForHash formats a buddyIcon URL for a hash already known to the caller,
  83. // skipping the metadata lookup that URL/PublishedURL do. The event pump uses this
  84. // on presence broadcasts, whose SNAC already carries the buddy's icon hash (TLV
  85. // wire.OServiceUserInfoBARTInfo).
  86. //
  87. // A non-empty hash yields the content-addressed URL; a nil/empty hash yields the
  88. // hash-less placeholder URL (which serves the blank icon), so a buddy who cleared
  89. // or never set an icon still gets a non-empty URL the client's shallow merge can
  90. // act on. An empty baseURL (no origin to build against) yields "".
  91. func (s BuddyIconSource) URLForHash(baseURL string, screenName state.IdentScreenName, hash []byte) string {
  92. if baseURL == "" {
  93. return ""
  94. }
  95. return iconURL(baseURL, screenName, hash)
  96. }
  97. // iconURL formats the expressions endpoint URL for screenName's icon. A non-empty
  98. // hash is content-addressed and cacheable; an empty hash yields the placeholder
  99. // form that serves the blank icon.
  100. func iconURL(baseURL string, screenName state.IdentScreenName, hash []byte) string {
  101. if len(hash) == 0 {
  102. return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon",
  103. baseURL, url.QueryEscape(screenName.String()))
  104. }
  105. return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon&bartId=%x",
  106. baseURL, url.QueryEscape(screenName.String()), hash)
  107. }
  108. // Image returns the image bytes of screenName's current buddy icon. It returns
  109. // ErrNoBuddyIcon if the user has no icon set.
  110. func (s BuddyIconSource) Image(ctx context.Context, screenName state.IdentScreenName) ([]byte, error) {
  111. id, err := s.iconID(ctx, screenName)
  112. if err != nil {
  113. return nil, err
  114. }
  115. return s.ImageForHash(ctx, screenName, id.Hash)
  116. }
  117. // ImageForHash returns the bytes of the BART asset identified by hash for
  118. // screenName, independent of the user's current icon reference. This lets a
  119. // content-addressed URL resolve to the exact image its hash names, so a URL that
  120. // was cached as immutable never resolves to a different image later. It returns
  121. // ErrNoBuddyIcon if no asset with that hash exists. Passing the clear-icon hash
  122. // yields the blank placeholder image.
  123. func (s BuddyIconSource) ImageForHash(ctx context.Context, screenName state.IdentScreenName, hash []byte) ([]byte, error) {
  124. msg, err := s.BARTService.RetrieveItem(ctx, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
  125. ScreenName: screenName.String(),
  126. BARTID: wire.BARTID{
  127. Type: wire.BARTTypesBuddyIcon,
  128. BARTInfo: wire.BARTInfo{Hash: hash},
  129. },
  130. })
  131. if err != nil {
  132. return nil, fmt.Errorf("RetrieveItem: %w", err)
  133. }
  134. reply, ok := msg.Body.(wire.SNAC_0x10_0x05_BARTDownloadReply)
  135. if !ok {
  136. return nil, fmt.Errorf("unexpected BART reply body type %T", msg.Body)
  137. }
  138. if len(reply.Data) == 0 {
  139. return nil, ErrNoBuddyIcon
  140. }
  141. return reply.Data, nil
  142. }
  143. // iconID looks up a user's buddy icon reference, translating "no icon" and
  144. // "icon cleared" into ErrNoBuddyIcon.
  145. func (s BuddyIconSource) iconID(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) {
  146. id, err := s.IconRetriever.BuddyIconMetadata(ctx, screenName)
  147. if err != nil {
  148. return nil, fmt.Errorf("BuddyIconMetadata: %w", err)
  149. }
  150. if id == nil || id.HasClearIconHash() || len(id.Hash) == 0 {
  151. return nil, ErrNoBuddyIcon
  152. }
  153. return id, nil
  154. }