4
0

expressions.go 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. package handlers
  2. import (
  3. "encoding/hex"
  4. "errors"
  5. "log/slog"
  6. "net/http"
  7. "github.com/mk6i/open-oscar-server/state"
  8. "github.com/mk6i/open-oscar-server/wire"
  9. )
  10. // ExpressionsData lists the expressions (buddy icons, etc.) a user publishes.
  11. type ExpressionsData struct {
  12. Expressions []Expression `json:"expressions" xml:"expressions>expression"`
  13. }
  14. // Expression is one published asset.
  15. type Expression struct {
  16. Type string `json:"type" xml:"type"`
  17. URL string `json:"url" xml:"url"`
  18. }
  19. // ExpressionsHandler handles Web AIM API expressions/buddy icon endpoints.
  20. type ExpressionsHandler struct {
  21. IconSource BuddyIconSource
  22. Logger *slog.Logger
  23. }
  24. // NewExpressionsHandler creates a new ExpressionsHandler.
  25. func NewExpressionsHandler(iconSource BuddyIconSource, logger *slog.Logger) *ExpressionsHandler {
  26. return &ExpressionsHandler{
  27. IconSource: iconSource,
  28. Logger: logger,
  29. }
  30. }
  31. // Get handles GET /expressions/get requests for buddy icons and expressions.
  32. //
  33. // The AIM client calls this endpoint two different ways:
  34. //
  35. // - With type=buddyIcon it fetches the image itself. The buddyIcon URL
  36. // published in presence, buddylist and myInfo payloads points here, and the
  37. // client renders it directly as an <img> source.
  38. // - With no type it asks for the user's expressions as JSON, then scans the
  39. // returned array for the entry typed bigBuddyIcon and uses its url to render
  40. // hovercards and other large views. We have only one icon per user, so it is
  41. // offered as both.
  42. func (h *ExpressionsHandler) Get(w http.ResponseWriter, r *http.Request) {
  43. ctx := r.Context()
  44. target := r.URL.Query().Get("t")
  45. if target == "" {
  46. SendError(w, r, http.StatusBadRequest, "missing target")
  47. return
  48. }
  49. screenName := state.NewIdentScreenName(target)
  50. switch r.URL.Query().Get("type") {
  51. case "buddyIcon", "bigBuddyIcon":
  52. h.serveIcon(w, r, screenName)
  53. return
  54. }
  55. iconURL := h.IconSource.URL(ctx, baseURLFromRequest(r), screenName)
  56. // f=redirect asks for the icon itself rather than a description of it.
  57. if r.URL.Query().Get("f") == "redirect" {
  58. if iconURL == "" {
  59. w.WriteHeader(http.StatusNotFound)
  60. return
  61. }
  62. http.Redirect(w, r, iconURL, http.StatusFound)
  63. return
  64. }
  65. expressions := []Expression{}
  66. if iconURL != "" {
  67. expressions = append(expressions, Expression{Type: "bigBuddyIcon", URL: iconURL})
  68. }
  69. resp := BaseResponse{}
  70. resp.Response.StatusCode = 200
  71. resp.Response.StatusText = "OK"
  72. resp.Response.Data = &ExpressionsData{Expressions: expressions}
  73. SendResponse(w, r, resp, h.Logger)
  74. }
  75. // serveIcon writes a user's buddy icon image.
  76. //
  77. // A bartId names an exact image by content hash, so the endpoint serves that
  78. // image regardless of the user's current icon and lets browsers cache it
  79. // forever. Without a bartId it serves whatever the user's icon is now — which
  80. // keeps changing — so that response is not cacheable, and a user with no icon
  81. // gets the blank placeholder so the client's <img> still renders.
  82. func (h *ExpressionsHandler) serveIcon(w http.ResponseWriter, r *http.Request, screenName state.IdentScreenName) {
  83. ctx := r.Context()
  84. var (
  85. icon []byte
  86. err error
  87. immutable bool
  88. )
  89. if raw := r.URL.Query().Get("bartId"); raw != "" {
  90. hash, decodeErr := hex.DecodeString(raw)
  91. if decodeErr != nil || len(hash) == 0 {
  92. http.Error(w, "invalid bartId", http.StatusBadRequest)
  93. return
  94. }
  95. icon, err = h.IconSource.ImageForHash(ctx, screenName, hash)
  96. immutable = true
  97. } else {
  98. icon, err = h.IconSource.Image(ctx, screenName)
  99. if errors.Is(err, ErrNoBuddyIcon) {
  100. // Serve the blank placeholder rather than 404 so a cleared icon still
  101. // renders something and the client stops showing the previous one.
  102. icon, err = h.IconSource.ImageForHash(ctx, screenName, wire.GetClearIconHash())
  103. }
  104. }
  105. switch {
  106. case errors.Is(err, ErrNoBuddyIcon):
  107. // The client swaps in its own placeholder when the icon fails to load.
  108. http.Error(w, "icon not found", http.StatusNotFound)
  109. return
  110. case err != nil:
  111. h.Logger.ErrorContext(ctx, "failed to retrieve buddy icon",
  112. "screenName", screenName.String(), "err", err.Error())
  113. http.Error(w, "internal server error", http.StatusInternalServerError)
  114. return
  115. }
  116. if immutable {
  117. w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
  118. } else {
  119. w.Header().Set("Cache-Control", "no-cache")
  120. }
  121. w.Header().Set("Content-Type", http.DetectContentType(icon))
  122. _, _ = w.Write(icon)
  123. }