4
0

expressions_handler.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  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. ctx := r.Context()
  144. var bartType uint16
  145. switch t := r.URL.Query().Get("type"); t {
  146. case "buddyIcon", "bigBuddyIcon":
  147. bartType = wire.BARTTypesBuddyIcon
  148. case "":
  149. SendErrorDetail(w, r, http.StatusBadRequest, statusMissingParameter, 0,
  150. "required parameter 'type' is missing")
  151. return
  152. default:
  153. SendErrorDetail(w, r, http.StatusBadRequest, statusParameterError, 0,
  154. "unsupported expression type")
  155. return
  156. }
  157. // One byte past the cap distinguishes "exactly at the limit" from "over it".
  158. image, err := io.ReadAll(io.LimitReader(r.Body, bartUploadMaxBytes+1))
  159. if err != nil {
  160. h.Logger.ErrorContext(ctx, "failed to read upload body", "err", err.Error())
  161. SendError(w, r, http.StatusBadRequest, "failed to read request body")
  162. return
  163. }
  164. switch {
  165. case len(image) == 0:
  166. SendErrorDetail(w, r, http.StatusBadRequest, statusParameterError, 0, "empty image")
  167. return
  168. case len(image) > bartUploadMaxBytes:
  169. SendErrorDetail(w, r, http.StatusBadRequest, statusParameterError, 0, "image too large")
  170. return
  171. }
  172. uploadReply, err := h.BARTService.UpsertItem(ctx, session.OSCARSession,
  173. wire.SNACFrame{FoodGroup: wire.BART, SubGroup: wire.BARTUploadQuery},
  174. wire.SNAC_0x10_0x02_BARTUploadQuery{Type: bartType, Data: image})
  175. if err != nil {
  176. h.Logger.ErrorContext(ctx, "failed to store BART item", "err", err.Error())
  177. SendError(w, r, http.StatusInternalServerError, "failed to store expression")
  178. return
  179. }
  180. body, ok := uploadReply.Body.(wire.SNAC_0x10_0x03_BARTUploadReply)
  181. if !ok {
  182. h.Logger.ErrorContext(ctx, "unexpected BART upload reply",
  183. "type", fmt.Sprintf("%T", uploadReply.Body))
  184. SendError(w, r, http.StatusInternalServerError, "failed to store expression")
  185. return
  186. }
  187. if body.Code != wire.BARTReplyCodesSuccess {
  188. h.Logger.ErrorContext(ctx, "BART store rejected upload", "code", body.Code)
  189. SendError(w, r, http.StatusInternalServerError, "failed to store expression")
  190. return
  191. }
  192. if err := h.publishIcon(ctx, session.OSCARSession, bartType, body.ID.Hash); err != nil {
  193. h.Logger.ErrorContext(ctx, "failed to publish buddy icon",
  194. "screenName", session.OSCARSession.IdentScreenName().String(), "err", err.Error())
  195. SendError(w, r, http.StatusInternalServerError, "failed to publish expression")
  196. return
  197. }
  198. h.Logger.InfoContext(ctx, "stored buddy icon",
  199. "screenName", session.OSCARSession.IdentScreenName().String(),
  200. "bytes", len(image),
  201. "hash", fmt.Sprintf("%x", body.ID.Hash))
  202. SendOK(w, r, &UploadData{ID: fmt.Sprintf("%04x%x", bartType, body.ID.Hash)}, h.Logger)
  203. }
  204. // publishIcon points the user's feedbag BART reference at hash, which is what
  205. // makes the icon visible to buddies and to expressions/get.
  206. //
  207. // Upserting the item runs the same path an OSCAR client takes when it sets an
  208. // icon: the feedbag service sees the asset already in the BART store, stamps it
  209. // on the session and broadcasts the change to buddies.
  210. func (h *ExpressionsHandler) publishIcon(
  211. ctx context.Context,
  212. instance *state.SessionInstance,
  213. bartType uint16,
  214. hash []byte,
  215. ) error {
  216. msg, err := h.FeedbagService.Query(ctx, instance,
  217. wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery})
  218. if err != nil {
  219. return err
  220. }
  221. reply, ok := msg.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  222. if !ok {
  223. return fmt.Errorf("unexpected feedbag reply %T", msg.Body)
  224. }
  225. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  226. item, inserted := fl.SetIcon(bartType, hash)
  227. subGroup := wire.FeedbagUpdateItem
  228. if inserted {
  229. subGroup = wire.FeedbagInsertItem
  230. }
  231. _, err = h.FeedbagService.UpsertItem(ctx, instance,
  232. wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: subGroup},
  233. []wire.FeedbagItem{item})
  234. return err
  235. }
  236. // ErrNoBuddyIcon indicates that a user has not set a buddy icon.
  237. var ErrNoBuddyIcon = errors.New("no buddy icon")
  238. // BuddyIconSource resolves buddy icons, both as URLs to publish to the web
  239. // client and as the image bytes those URLs serve.
  240. //
  241. // The client never derives an icon URL: it renders whatever string the server
  242. // puts in a user's buddyIcon field, falling back to a blank-person placeholder
  243. // when the field is absent.
  244. type BuddyIconSource struct {
  245. IconRetriever BuddyIconRetriever
  246. BARTService BARTService
  247. Logger *slog.Logger
  248. }
  249. // URL returns the absolute, content-addressed URL that screenName's buddy icon
  250. // is served from, or an empty string if the user has no icon.
  251. //
  252. // The icon hash is part of the URL so that the URL changes whenever the user
  253. // changes their icon. Browsers cache icons by URL, and the client refetches a
  254. // user's large icon only when it observes buddyIconUrl change.
  255. func (s BuddyIconSource) URL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
  256. // The client loads icons from a different origin than the page it runs on,
  257. // so a URL is only publishable if it can be made absolute. Callers that have
  258. // no origin to build against pass an empty baseURL to opt out.
  259. if baseURL == "" {
  260. return ""
  261. }
  262. id, err := s.iconID(ctx, screenName)
  263. if err != nil {
  264. if !errors.Is(err, ErrNoBuddyIcon) {
  265. s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
  266. "screenName", screenName.String(), "err", err.Error())
  267. }
  268. return ""
  269. }
  270. return iconURL(baseURL, screenName, id.Hash)
  271. }
  272. // PublishedURL returns a buddyIcon URL that is always non-empty when baseURL is
  273. // set: the content-addressed URL when the user has an icon, otherwise a hash-less
  274. // URL that resolves to the blank placeholder.
  275. //
  276. // Callers that publish icons into buddy-list, presence, or myInfo payloads use
  277. // this so the client always receives a URL. The web client's shallow user-object
  278. // merge never drops a stale buddyIconUrl on its own, so a user who clears their
  279. // icon only stops rendering it once a *different* URL arrives; the hash-less
  280. // placeholder URL is that different URL.
  281. func (s BuddyIconSource) PublishedURL(ctx context.Context, baseURL string, screenName state.IdentScreenName) string {
  282. if baseURL == "" {
  283. return ""
  284. }
  285. id, err := s.iconID(ctx, screenName)
  286. switch {
  287. case errors.Is(err, ErrNoBuddyIcon):
  288. // No icon set: publish the hash-less placeholder rather than nothing, so
  289. // a cleared icon propagates to the client.
  290. return iconURL(baseURL, screenName, nil)
  291. case err != nil:
  292. s.Logger.WarnContext(ctx, "failed to resolve buddy icon",
  293. "screenName", screenName.String(), "err", err.Error())
  294. return ""
  295. }
  296. return iconURL(baseURL, screenName, id.Hash)
  297. }
  298. // URLForHash formats a buddyIcon URL for a hash already known to the caller,
  299. // skipping the metadata lookup that URL/PublishedURL do. The event pump uses this
  300. // on presence broadcasts, whose SNAC already carries the buddy's icon hash (TLV
  301. // wire.OServiceUserInfoBARTInfo).
  302. //
  303. // A non-empty hash yields the content-addressed URL; a nil/empty hash yields the
  304. // hash-less placeholder URL (which serves the blank icon), so a buddy who cleared
  305. // or never set an icon still gets a non-empty URL the client's shallow merge can
  306. // act on. An empty baseURL (no origin to build against) yields "".
  307. func (s BuddyIconSource) URLForHash(baseURL string, screenName state.IdentScreenName, hash []byte) string {
  308. if baseURL == "" {
  309. return ""
  310. }
  311. return iconURL(baseURL, screenName, hash)
  312. }
  313. // iconURL formats the expressions endpoint URL for screenName's icon. A non-empty
  314. // hash is content-addressed and cacheable; an empty hash yields the placeholder
  315. // form that serves the blank icon.
  316. func iconURL(baseURL string, screenName state.IdentScreenName, hash []byte) string {
  317. if len(hash) == 0 {
  318. return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon",
  319. baseURL, url.QueryEscape(screenName.String()))
  320. }
  321. return fmt.Sprintf("%s/expressions/get?t=%s&type=buddyIcon&bartId=%x",
  322. baseURL, url.QueryEscape(screenName.String()), hash)
  323. }
  324. // Image returns the image bytes of screenName's current buddy icon. It returns
  325. // ErrNoBuddyIcon if the user has no icon set.
  326. func (s BuddyIconSource) Image(ctx context.Context, screenName state.IdentScreenName) ([]byte, error) {
  327. id, err := s.iconID(ctx, screenName)
  328. if err != nil {
  329. return nil, err
  330. }
  331. return s.ImageForHash(ctx, screenName, id.Hash)
  332. }
  333. // ImageForHash returns the bytes of the BART asset identified by hash for
  334. // screenName, independent of the user's current icon reference. This lets a
  335. // content-addressed URL resolve to the exact image its hash names, so a URL that
  336. // was cached as immutable never resolves to a different image later. It returns
  337. // ErrNoBuddyIcon if no asset with that hash exists. Passing the clear-icon hash
  338. // yields the blank placeholder image.
  339. func (s BuddyIconSource) ImageForHash(ctx context.Context, screenName state.IdentScreenName, hash []byte) ([]byte, error) {
  340. msg, err := s.BARTService.RetrieveItem(ctx, wire.SNACFrame{}, wire.SNAC_0x10_0x04_BARTDownloadQuery{
  341. ScreenName: screenName.String(),
  342. BARTID: wire.BARTID{
  343. Type: wire.BARTTypesBuddyIcon,
  344. BARTInfo: wire.BARTInfo{Hash: hash},
  345. },
  346. })
  347. if err != nil {
  348. return nil, fmt.Errorf("RetrieveItem: %w", err)
  349. }
  350. reply, ok := msg.Body.(wire.SNAC_0x10_0x05_BARTDownloadReply)
  351. if !ok {
  352. return nil, fmt.Errorf("unexpected BART reply body type %T", msg.Body)
  353. }
  354. if len(reply.Data) == 0 {
  355. return nil, ErrNoBuddyIcon
  356. }
  357. return reply.Data, nil
  358. }
  359. // iconID looks up a user's buddy icon reference, translating "no icon" and
  360. // "icon cleared" into ErrNoBuddyIcon.
  361. func (s BuddyIconSource) iconID(ctx context.Context, screenName state.IdentScreenName) (*wire.BARTID, error) {
  362. id, err := s.IconRetriever.BuddyIconMetadata(ctx, screenName)
  363. if err != nil {
  364. return nil, fmt.Errorf("BuddyIconMetadata: %w", err)
  365. }
  366. if id == nil || id.HasClearIconHash() || len(id.Hash) == 0 {
  367. return nil, ErrNoBuddyIcon
  368. }
  369. return id, nil
  370. }