expressions_handler.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. package webapi
  2. import (
  3. "context"
  4. "encoding/hex"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "math/rand"
  10. "net/http"
  11. "net/url"
  12. "github.com/mk6i/open-oscar-server/state"
  13. "github.com/mk6i/open-oscar-server/wire"
  14. )
  15. // ExpressionsData lists the expressions (buddy icons, etc.) a user publishes.
  16. type ExpressionsData struct {
  17. Expressions []Expression `json:"expressions" xml:"expressions>expression"`
  18. }
  19. // Expression is one published asset.
  20. type Expression struct {
  21. Type string `json:"type" xml:"type"`
  22. URL string `json:"url" xml:"url"`
  23. }
  24. const bartUploadMaxBytes = 64 << 10
  25. // ExpressionsHandler handles Web AIM API expressions/buddy icon endpoints.
  26. type ExpressionsHandler struct {
  27. IconSource BuddyIconSource
  28. BARTService BARTService
  29. FeedbagService FeedbagService
  30. Logger *slog.Logger
  31. }
  32. // NewExpressionsHandler creates a new ExpressionsHandler.
  33. func NewExpressionsHandler(
  34. iconSource BuddyIconSource,
  35. bartService BARTService,
  36. feedbagService FeedbagService,
  37. logger *slog.Logger,
  38. ) *ExpressionsHandler {
  39. return &ExpressionsHandler{
  40. IconSource: iconSource,
  41. BARTService: bartService,
  42. FeedbagService: feedbagService,
  43. Logger: logger,
  44. }
  45. }
  46. // Get handles GET /expressions/get requests for buddy icons and expressions.
  47. //
  48. // The AIM client calls this endpoint two different ways:
  49. //
  50. // - With type=buddyIcon it fetches the image itself. The buddyIcon URL
  51. // published in presence, buddylist and myInfo payloads points here, and the
  52. // client renders it directly as an <img> source.
  53. // - With no type it asks for the user's expressions as JSON, then scans the
  54. // returned array for the entry typed bigBuddyIcon and uses its url to render
  55. // hovercards and other large views. We have only one icon per user, so it is
  56. // offered as both.
  57. func (h *ExpressionsHandler) Get(w http.ResponseWriter, r *http.Request) {
  58. ctx := r.Context()
  59. target := r.URL.Query().Get("t")
  60. if target == "" {
  61. SendError(w, r, http.StatusBadRequest, "missing target")
  62. return
  63. }
  64. screenName := state.NewIdentScreenName(target)
  65. switch r.URL.Query().Get("type") {
  66. case "buddyIcon", "bigBuddyIcon":
  67. h.serveIcon(w, r, screenName)
  68. return
  69. }
  70. iconURL := h.IconSource.URL(ctx, baseURLFromRequest(r), screenName)
  71. // f=redirect asks for the icon itself rather than a description of it.
  72. if r.URL.Query().Get("f") == "redirect" {
  73. if iconURL == "" {
  74. w.WriteHeader(http.StatusNotFound)
  75. return
  76. }
  77. http.Redirect(w, r, iconURL, http.StatusFound)
  78. return
  79. }
  80. expressions := []Expression{}
  81. if iconURL != "" {
  82. expressions = append(expressions, Expression{Type: "bigBuddyIcon", URL: iconURL})
  83. }
  84. SendOK(w, r, &ExpressionsData{Expressions: expressions}, h.Logger)
  85. }
  86. // serveIcon writes a user's buddy icon image.
  87. //
  88. // A bartId names an exact image by content hash, so the endpoint serves that
  89. // image regardless of the user's current icon and lets browsers cache it
  90. // forever. Without a bartId it serves whatever the user's icon is now — which
  91. // keeps changing — so that response is not cacheable, and a user with no icon
  92. // gets the blank placeholder so the client's <img> still renders.
  93. func (h *ExpressionsHandler) serveIcon(w http.ResponseWriter, r *http.Request, screenName state.IdentScreenName) {
  94. ctx := r.Context()
  95. var (
  96. icon []byte
  97. err error
  98. immutable bool
  99. )
  100. if raw := r.URL.Query().Get("bartId"); raw != "" {
  101. hash, decodeErr := hex.DecodeString(raw)
  102. if decodeErr != nil || len(hash) == 0 {
  103. http.Error(w, "invalid bartId", http.StatusBadRequest)
  104. return
  105. }
  106. icon, err = h.IconSource.ImageForHash(ctx, screenName, hash)
  107. immutable = true
  108. } else {
  109. icon, err = h.IconSource.Image(ctx, screenName)
  110. if errors.Is(err, ErrNoBuddyIcon) {
  111. // Serve the blank placeholder rather than 404 so a cleared icon still
  112. // renders something and the client stops showing the previous one.
  113. icon, err = h.IconSource.ImageForHash(ctx, screenName, wire.GetClearIconHash())
  114. }
  115. }
  116. switch {
  117. case errors.Is(err, ErrNoBuddyIcon):
  118. // The client swaps in its own placeholder when the icon fails to load.
  119. http.Error(w, "icon not found", http.StatusNotFound)
  120. return
  121. case err != nil:
  122. h.Logger.ErrorContext(ctx, "failed to retrieve buddy icon",
  123. "screenName", screenName.String(), "err", err.Error())
  124. http.Error(w, "internal server error", http.StatusInternalServerError)
  125. return
  126. }
  127. if immutable {
  128. w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
  129. } else {
  130. w.Header().Set("Cache-Control", "no-cache")
  131. }
  132. w.Header().Set("Content-Type", http.DetectContentType(icon))
  133. _, _ = w.Write(icon)
  134. }
  135. // UploadData is the payload of an expressions/upload response. The id is the
  136. // BART type followed by the asset's content hash; setExpression takes the same
  137. // string with the leading four hex digits (the type) stripped off.
  138. type UploadData struct {
  139. ID string `json:"id" xml:"id"`
  140. }
  141. // Upload handles POST /expressions/upload, which stores a buddy icon.
  142. func (h *ExpressionsHandler) Upload(w http.ResponseWriter, r *http.Request, session *Session) {
  143. defer session.InvalidateFeedbag()
  144. ctx := r.Context()
  145. var bartType uint16
  146. switch t := r.URL.Query().Get("type"); t {
  147. case "buddyIcon", "bigBuddyIcon", "largeBuddyIcon":
  148. bartType = wire.BARTTypesBuddyIcon
  149. case "":
  150. SendErrorDetail(w, r, http.StatusBadRequest, statusMissingParameter, 0,
  151. "required parameter 'type' is missing")
  152. return
  153. default:
  154. SendErrorDetail(w, r, http.StatusBadRequest, statusParameterError, 0,
  155. "unsupported expression type")
  156. return
  157. }
  158. // One byte past the cap distinguishes "exactly at the limit" from "over it".
  159. image, err := io.ReadAll(io.LimitReader(r.Body, bartUploadMaxBytes+1))
  160. if err != nil {
  161. h.Logger.ErrorContext(ctx, "failed to read upload body", "err", err.Error())
  162. SendError(w, r, http.StatusBadRequest, "failed to read request body")
  163. return
  164. }
  165. switch {
  166. case len(image) == 0:
  167. SendErrorDetail(w, r, http.StatusBadRequest, statusParameterError, 0, "empty image")
  168. return
  169. case len(image) > bartUploadMaxBytes:
  170. SendErrorDetail(w, r, http.StatusBadRequest, statusParameterError, 0, "image too large")
  171. return
  172. }
  173. uploadReply, err := h.BARTService.UpsertItem(ctx, session.OSCARSession,
  174. wire.SNACFrame{FoodGroup: wire.BART, SubGroup: wire.BARTUploadQuery},
  175. wire.SNAC_0x10_0x02_BARTUploadQuery{Type: bartType, Data: image})
  176. if err != nil {
  177. h.Logger.ErrorContext(ctx, "failed to store BART item", "err", err.Error())
  178. SendError(w, r, http.StatusInternalServerError, "failed to store expression")
  179. return
  180. }
  181. body, ok := uploadReply.Body.(wire.SNAC_0x10_0x03_BARTUploadReply)
  182. if !ok {
  183. h.Logger.ErrorContext(ctx, "unexpected BART upload reply",
  184. "type", fmt.Sprintf("%T", uploadReply.Body))
  185. SendError(w, r, http.StatusInternalServerError, "failed to store expression")
  186. return
  187. }
  188. if body.Code != wire.BARTReplyCodesSuccess {
  189. h.Logger.ErrorContext(ctx, "BART store rejected upload", "code", body.Code)
  190. SendError(w, r, http.StatusInternalServerError, "failed to store expression")
  191. return
  192. }
  193. if err := h.publishIcon(ctx, session.OSCARSession, bartType, body.ID.Hash); err != nil {
  194. h.Logger.ErrorContext(ctx, "failed to publish buddy icon",
  195. "screenName", session.OSCARSession.IdentScreenName().String(), "err", err.Error())
  196. SendError(w, r, http.StatusInternalServerError, "failed to publish expression")
  197. return
  198. }
  199. h.Logger.InfoContext(ctx, "stored buddy icon",
  200. "screenName", session.OSCARSession.IdentScreenName().String(),
  201. "bytes", len(image),
  202. "hash", fmt.Sprintf("%x", body.ID.Hash))
  203. SendOK(w, r, &UploadData{ID: fmt.Sprintf("%04x%x", bartType, body.ID.Hash)}, h.Logger)
  204. }
  205. // publishIcon points the user's feedbag BART reference at hash, which is what
  206. // makes the icon visible to buddies and to expressions/get.
  207. //
  208. // Upserting the item runs the same path an OSCAR client takes when it sets an
  209. // icon: the feedbag service sees the asset already in the BART store, stamps it
  210. // on the session and broadcasts the change to buddies.
  211. func (h *ExpressionsHandler) publishIcon(
  212. ctx context.Context,
  213. instance *state.SessionInstance,
  214. bartType uint16,
  215. hash []byte,
  216. ) error {
  217. msg, err := h.FeedbagService.Query(ctx, instance,
  218. wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery})
  219. if err != nil {
  220. return err
  221. }
  222. reply, ok := msg.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  223. if !ok {
  224. return fmt.Errorf("unexpected feedbag reply %T", msg.Body)
  225. }
  226. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  227. item, inserted := fl.SetIcon(bartType, hash)
  228. subGroup := wire.FeedbagUpdateItem
  229. if inserted {
  230. subGroup = wire.FeedbagInsertItem
  231. }
  232. _, err = h.FeedbagService.UpsertItem(ctx, instance,
  233. wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: subGroup},
  234. []wire.FeedbagItem{item})
  235. return err
  236. }
  237. // ErrNoBuddyIcon indicates that a user has not set a buddy icon.
  238. var ErrNoBuddyIcon = errors.New("no buddy icon")
  239. // BuddyIconSource resolves buddy icons, both as URLs to publish to the web
  240. // client and as the image bytes those URLs serve.
  241. //
  242. // The client never derives an icon URL: it renders whatever string the server
  243. // puts in a user's buddyIcon field, falling back to a blank-person placeholder
  244. // when the field is absent.
  245. type BuddyIconSource struct {
  246. IconRetriever BuddyIconRetriever
  247. BARTService BARTService
  248. Logger *slog.Logger
  249. }
  250. // URL returns the absolute, content-addressed URL that screenName's buddy icon
  251. // is served from, or an empty string if the user has no icon.
  252. //
  253. // The icon hash is part of the URL so that the URL changes whenever the user
  254. // changes their icon. Browsers cache icons by URL, and the client refetches a
  255. // user's large icon only when it observes buddyIconUrl change.
  256. func (s BuddyIconSource) URL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
  257. // The client loads icons from a different origin than the page it runs on,
  258. // so a URL is only publishable if it can be made absolute. Callers that have
  259. // no origin to build against pass an empty baseURL to opt out.
  260. if baseURL == "" {
  261. return ""
  262. }
  263. id, err := s.iconID(ctx, screenName)
  264. if err != nil {
  265. if !errors.Is(err, ErrNoBuddyIcon) {
  266. s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
  267. "screenName", screenName.String(), "err", err.Error())
  268. }
  269. return ""
  270. }
  271. return iconURL(baseURL, screenName, id.Hash)
  272. }
  273. // PublishedURL returns a buddyIcon URL that is always non-empty when baseURL is
  274. // set: the content-addressed URL when the user has an icon, otherwise a hash-less
  275. // URL that resolves to the blank placeholder.
  276. //
  277. // Callers that publish icons into buddy-list, presence, or myInfo payloads use
  278. // this so the client always receives a URL. The web client's shallow user-object
  279. // merge never drops a stale buddyIconUrl on its own, so a user who clears their
  280. // icon only stops rendering it once a *different* URL arrives; the hash-less
  281. // placeholder URL is that different URL.
  282. func (s BuddyIconSource) PublishedURL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
  283. if baseURL == "" {
  284. return ""
  285. }
  286. id, err := s.iconID(ctx, screenName)
  287. switch {
  288. case errors.Is(err, ErrNoBuddyIcon):
  289. // No icon set: publish the hash-less placeholder rather than nothing, so
  290. // a cleared icon propagates to the client.
  291. return iconURL(baseURL, screenName, nil)
  292. case err != nil:
  293. s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
  294. "screenName", screenName.String(), "err", err.Error())
  295. return ""
  296. }
  297. return iconURL(baseURL, screenName, id.Hash)
  298. }
  299. // URLForHash formats a buddyIcon URL for a hash already known to the caller,
  300. // skipping the metadata lookup that URL/PublishedURL do. The event pump uses this
  301. // on presence broadcasts, whose SNAC already carries the buddy's icon hash (TLV
  302. // wire.OServiceUserInfoBARTInfo).
  303. //
  304. // A non-empty hash yields the content-addressed URL; a nil/empty hash yields the
  305. // hash-less placeholder URL (which serves the blank icon), so a buddy who cleared
  306. // or never set an icon still gets a non-empty URL the client's shallow merge can
  307. // act on. An empty baseURL (no origin to build against) yields "".
  308. func (s BuddyIconSource) URLForHash(baseURL string, screenName state.IdentScreenName, hash []byte) string {
  309. if baseURL == "" {
  310. return ""
  311. }
  312. return iconURL(baseURL, screenName, hash)
  313. }
  314. // iconURL formats the expressions endpoint URL for screenName's icon. A non-empty
  315. // hash is content-addressed and cacheable; an empty hash yields the placeholder
  316. // form that serves the blank icon.
  317. func iconURL(baseURL string, screenName state.IdentScreenName, hash []byte) string {
  318. if len(hash) == 0 {
  319. return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon",
  320. baseURL, url.QueryEscape(screenName.String()))
  321. }
  322. return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon&bartId=%x",
  323. baseURL, url.QueryEscape(screenName.String()), hash)
  324. }
  325. // Image returns the image bytes of screenName's current buddy icon. It returns
  326. // ErrNoBuddyIcon if the user has no icon set.
  327. func (s BuddyIconSource) Image(ctx context.Context, screenName state.IdentScreenName) ([]byte, error) {
  328. id, err := s.iconID(ctx, screenName)
  329. if err != nil {
  330. return nil, err
  331. }
  332. return s.ImageForHash(ctx, screenName, id.Hash)
  333. }
  334. // ImageForHash returns the bytes of the BART asset identified by hash for
  335. // screenName, independent of the user's current icon reference. This lets a
  336. // content-addressed URL resolve to the exact image its hash names, so a URL that
  337. // was cached as immutable never resolves to a different image later. It returns
  338. // ErrNoBuddyIcon if no asset with that hash exists. Passing the clear-icon hash
  339. // yields the blank placeholder image.
  340. func (s BuddyIconSource) ImageForHash(ctx context.Context, screenName state.IdentScreenName, hash []byte) ([]byte, error) {
  341. msg, err := s.BARTService.RetrieveItem(ctx, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
  342. ScreenName: screenName.String(),
  343. BARTID: wire.BARTID{
  344. Type: wire.BARTTypesBuddyIcon,
  345. BARTInfo: wire.BARTInfo{Hash: hash},
  346. },
  347. })
  348. if err != nil {
  349. return nil, fmt.Errorf("RetrieveItem: %w", err)
  350. }
  351. reply, ok := msg.Body.(wire.SNAC_0x10_0x05_BARTDownloadReply)
  352. if !ok {
  353. return nil, fmt.Errorf("unexpected BART reply body type %T", msg.Body)
  354. }
  355. if len(reply.Data) == 0 {
  356. return nil, ErrNoBuddyIcon
  357. }
  358. return reply.Data, nil
  359. }
  360. // iconID looks up a user's buddy icon reference, translating "no icon" and
  361. // "icon cleared" into ErrNoBuddyIcon.
  362. func (s BuddyIconSource) iconID(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) {
  363. id, err := s.IconRetriever.BuddyIconMetadata(ctx, screenName)
  364. if err != nil {
  365. return nil, fmt.Errorf("BuddyIconMetadata: %w", err)
  366. }
  367. if id == nil || id.HasClearIconHash() || len(id.Hash) == 0 {
  368. return nil, ErrNoBuddyIcon
  369. }
  370. return id, nil
  371. }