expressions.go 4.0 KB

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