buddylist_handler.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. package webapi
  2. import (
  3. "context"
  4. "log/slog"
  5. "math/rand"
  6. "net/http"
  7. "strings"
  8. "github.com/mk6i/open-oscar-server/state"
  9. "github.com/mk6i/open-oscar-server/wire"
  10. )
  11. // BuddyListHandler handles Web AIM API buddy list management endpoints.
  12. type BuddyListHandler struct {
  13. BuddyListManager *BuddyListManager
  14. Logger *slog.Logger
  15. FeedbagService FeedbagService
  16. }
  17. // AddBuddy handles GET /buddylist/addBuddy requests.
  18. func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
  19. ctx := r.Context()
  20. aimsid := r.URL.Query().Get("aimsid")
  21. buddyName := strings.TrimSpace(param(r, "buddy"))
  22. groupName := strings.TrimSpace(param(r, "group"))
  23. // preAuthorized says the caller permits the contact they are adding to add
  24. // them back without an authorization prompt. authorizationMsg is the note
  25. // shown to that contact alongside the grant.
  26. preAuthorized := isTrueParam(param(r, "preAuthorized"))
  27. authorizationMsg := strings.TrimSpace(param(r, "authorizationMsg"))
  28. if buddyName == "" {
  29. SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
  30. return
  31. }
  32. if groupName == "" {
  33. groupName = "Buddies" // Default group
  34. }
  35. // Add buddy to feedbag
  36. resultCode := h.addBuddyToFeedbag(ctx, session, buddyName, groupName, preAuthorized, authorizationMsg)
  37. // Prepare response
  38. sendMutationResult(w, r, resultCode, h.Logger)
  39. h.Logger.InfoContext(ctx, "buddy added",
  40. "aimsid", aimsid,
  41. "buddy", buddyName,
  42. "group", groupName,
  43. "preAuthorized", preAuthorized,
  44. "result", resultCode,
  45. )
  46. }
  47. // AddGroup handles GET /buddylist/addGroup requests.
  48. func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, session *Session) {
  49. ctx := r.Context()
  50. aimsid := r.URL.Query().Get("aimsid")
  51. groupName := strings.TrimSpace(r.URL.Query().Get("group"))
  52. if groupName == "" {
  53. SendError(w, r, http.StatusBadRequest, "missing group parameter")
  54. return
  55. }
  56. resultCode := h.addGroupToFeedbag(ctx, session, groupName)
  57. sendMutationResult(w, r, resultCode, h.Logger)
  58. h.Logger.InfoContext(ctx, "buddy list group added",
  59. "aimsid", aimsid,
  60. "group", groupName,
  61. "result", resultCode,
  62. )
  63. }
  64. func (h *BuddyListHandler) addGroupToFeedbag(ctx context.Context, sess *Session, groupName string) string {
  65. // A session sees no SNAC for its own feedbag writes, so it drops the alias
  66. // cache itself. See WebAPISession.InvalidateAliases.
  67. defer sess.InvalidateAliases()
  68. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  69. snac, err := h.FeedbagService.Query(ctx, sess.OSCARSession, frame)
  70. if err != nil {
  71. h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
  72. return "error"
  73. }
  74. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  75. if !ok {
  76. return "error"
  77. }
  78. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  79. fl.AddGroup(groupName)
  80. pending := fl.PendingUpdates()
  81. if len(pending) == 0 {
  82. return "alreadyExists"
  83. }
  84. insertFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  85. if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, insertFrame, pending); err != nil {
  86. h.Logger.ErrorContext(ctx, "failed to add group", "err", err.Error())
  87. return "error"
  88. }
  89. return resultSuccess
  90. }
  91. // RemoveBuddy handles GET /buddylist/removeBuddy requests.
  92. func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
  93. ctx := r.Context()
  94. aimsid := r.URL.Query().Get("aimsid")
  95. buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
  96. groupName := strings.TrimSpace(r.URL.Query().Get("group"))
  97. allGroupsParam := r.URL.Query().Get("allGroups")
  98. allGroups := allGroupsParam == "true" || allGroupsParam == "1"
  99. if buddyName == "" {
  100. SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
  101. return
  102. }
  103. resultCode, rmErr := h.BuddyListManager.RemoveBuddyFromFeedbag(ctx, session, buddyName, groupName, allGroups)
  104. if rmErr != nil {
  105. h.Logger.ErrorContext(ctx, "remove buddy failed", "err", rmErr.Error())
  106. }
  107. sendMutationResult(w, r, resultCode, h.Logger)
  108. h.Logger.InfoContext(ctx, "buddy removed",
  109. "aimsid", aimsid,
  110. "buddy", buddyName,
  111. "group", groupName,
  112. "result", resultCode,
  113. )
  114. }
  115. // todo don't remove empty group?
  116. // RemoveGroup handles GET /buddylist/removeGroup requests.
  117. func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, session *Session) {
  118. ctx := r.Context()
  119. aimsid := r.URL.Query().Get("aimsid")
  120. groupName := strings.TrimSpace(r.URL.Query().Get("group"))
  121. if groupName == "" {
  122. SendError(w, r, http.StatusBadRequest, "missing group parameter")
  123. return
  124. }
  125. resultCode, rmErr := h.BuddyListManager.RemoveGroupFromFeedbag(ctx, session, groupName)
  126. if rmErr != nil {
  127. h.Logger.ErrorContext(ctx, "remove group failed", "err", rmErr.Error())
  128. }
  129. sendMutationResult(w, r, resultCode, h.Logger)
  130. h.Logger.InfoContext(ctx, "buddy list group removed",
  131. "aimsid", aimsid,
  132. "group", groupName,
  133. "result", resultCode,
  134. )
  135. }
  136. // addBuddyToFeedbag adds a buddy to the user's feedbag.
  137. func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session, buddyName, groupName string, preAuthorized bool, authorizationMsg string) string {
  138. defer sess.InvalidateAliases()
  139. // Retrieve current feedbag
  140. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  141. snac, err := h.FeedbagService.Query(ctx, sess.OSCARSession, frame)
  142. if err != nil {
  143. h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
  144. return "error"
  145. }
  146. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  147. if !ok {
  148. // todo what
  149. return "error"
  150. }
  151. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  152. fl.AddGroup(groupName)
  153. if pending := fl.PendingUpdates(); len(pending) > 0 {
  154. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  155. if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, pending); err != nil {
  156. h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
  157. return "error"
  158. }
  159. }
  160. added, err := fl.AddBuddy(groupName, buddyName, "", "")
  161. if err != nil {
  162. h.Logger.ErrorContext(ctx, "failed to add buddy to feedbag", "err", err.Error())
  163. return "error"
  164. }
  165. if !added {
  166. return "alreadyExists"
  167. }
  168. if pending := fl.PendingUpdates(); len(pending) > 0 {
  169. buddyItems := make(map[uint16][]wire.FeedbagItem)
  170. for _, item := range pending {
  171. if item.ClassID == wire.FeedbagClassIdBuddy {
  172. if _, ok := buddyItems[item.GroupID]; !ok {
  173. buddyItems[item.GroupID] = nil
  174. }
  175. buddyItems[item.GroupID] = append(buddyItems[item.GroupID], item)
  176. }
  177. }
  178. for _, buddies := range buddyItems {
  179. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  180. if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, buddies); err != nil {
  181. h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
  182. return "error"
  183. }
  184. }
  185. for _, item := range pending { // todo why not filter buddies out of pending?
  186. if item.ClassID == wire.FeedbagClassIdGroup {
  187. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  188. if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, []wire.FeedbagItem{item}); err != nil {
  189. h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
  190. return "error"
  191. }
  192. }
  193. }
  194. }
  195. if preAuthorized {
  196. // preAuthorized is a grant, not a retry: the caller is allowing the buddy
  197. // they just added to add them back without an authorization prompt. This
  198. // is the same request an ICQ client makes with SNAC(0x13,0x14), so it
  199. // records the grant and tells the buddy about it.
  200. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagPreAuthorizeBuddy}
  201. body := wire.SNAC_0x13_0x14_FeedbagPreAuthorizeBuddy{
  202. ScreenName: buddyName,
  203. Message: authorizationMsg,
  204. }
  205. // The buddy is on the list either way, so a failure here is logged rather
  206. // than reported: answering "error" would only make the client retry an add
  207. // that already succeeded.
  208. if _, err := h.FeedbagService.PreAuthorizeBuddy(ctx, sess.OSCARSession, frame, body); err != nil {
  209. h.Logger.ErrorContext(ctx, "failed to pre-authorize buddy",
  210. "buddy", buddyName, "err", err.Error())
  211. }
  212. }
  213. return resultSuccess
  214. }
  215. // RenameGroup handles GET /buddylist/renameGroup requests.
  216. //
  217. // The Web AIM client calls this with oldGroup (current group name) and newGroup
  218. // (the requested new name). This is a stub; it does not yet rename the group in
  219. // the feedbag.
  220. func (h *BuddyListHandler) RenameGroup(w http.ResponseWriter, r *http.Request, session *Session) {
  221. ctx := r.Context()
  222. aimsid := r.URL.Query().Get("aimsid")
  223. oldGroup := strings.TrimSpace(r.URL.Query().Get("oldGroup"))
  224. newGroup := strings.TrimSpace(r.URL.Query().Get("newGroup"))
  225. if oldGroup == "" || newGroup == "" {
  226. SendError(w, r, http.StatusBadRequest, "missing oldGroup or newGroup parameter")
  227. return
  228. }
  229. resultCode, rnErr := h.BuddyListManager.RenameGroupInFeedbag(ctx, session, oldGroup, newGroup)
  230. if rnErr != nil {
  231. h.Logger.ErrorContext(ctx, "rename group failed", "err", rnErr.Error())
  232. }
  233. sendMutationResult(w, r, resultCode, h.Logger)
  234. h.Logger.InfoContext(ctx, "buddy list group renamed",
  235. "aimsid", aimsid,
  236. "oldGroup", oldGroup,
  237. "newGroup", newGroup,
  238. "result", resultCode,
  239. )
  240. }
  241. // MoveBuddy handles GET /buddylist/moveBuddy requests.
  242. //
  243. // The Web AIM client calls this with buddy (the buddy to move), group (the
  244. // current group), and optionally newGroup (destination group) and beforeBuddy
  245. // (buddy to position it before). This is a stub; it does not yet move the buddy
  246. // in the feedbag.
  247. func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
  248. ctx := r.Context()
  249. aimsid := r.URL.Query().Get("aimsid")
  250. buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
  251. groupName := strings.TrimSpace(r.URL.Query().Get("group"))
  252. newGroup := strings.TrimSpace(r.URL.Query().Get("newGroup"))
  253. beforeBuddy := strings.TrimSpace(r.URL.Query().Get("beforeBuddy"))
  254. if buddyName == "" {
  255. SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
  256. return
  257. }
  258. if groupName == "" {
  259. SendError(w, r, http.StatusBadRequest, "missing group parameter")
  260. return
  261. }
  262. resultCode, mvErr := h.BuddyListManager.MoveBuddyInFeedbag(ctx, session, buddyName, groupName, newGroup, beforeBuddy)
  263. if mvErr != nil {
  264. h.Logger.ErrorContext(ctx, "move buddy failed", "err", mvErr.Error())
  265. }
  266. sendMutationResult(w, r, resultCode, h.Logger)
  267. h.Logger.InfoContext(ctx, "buddy moved",
  268. "aimsid", aimsid,
  269. "buddy", buddyName,
  270. "group", groupName,
  271. "newGroup", newGroup,
  272. "beforeBuddy", beforeBuddy,
  273. "result", resultCode,
  274. )
  275. }
  276. // SetBuddyAttribute handles GET /buddylist/setBuddyAttribute requests.
  277. //
  278. // The Web AIM client calls this with t (the buddy) and friendly (the display
  279. // name / alias). This is a stub; it does not yet persist the attribute to the
  280. // feedbag.
  281. func (h *BuddyListHandler) SetBuddyAttribute(w http.ResponseWriter, r *http.Request, session *Session) {
  282. ctx := r.Context()
  283. aimsid := r.URL.Query().Get("aimsid")
  284. buddyName := strings.TrimSpace(r.URL.Query().Get("t"))
  285. friendly := strings.TrimSpace(r.URL.Query().Get("friendly"))
  286. if buddyName == "" {
  287. SendError(w, r, http.StatusBadRequest, "missing t parameter")
  288. return
  289. }
  290. resultCode, saErr := h.BuddyListManager.SetBuddyAttributeInFeedbag(ctx, session, buddyName, friendly)
  291. if saErr != nil {
  292. h.Logger.ErrorContext(ctx, "set buddy attribute failed", "err", saErr.Error())
  293. }
  294. sendMutationResult(w, r, resultCode, h.Logger)
  295. h.Logger.InfoContext(ctx, "buddy attribute set",
  296. "aimsid", aimsid,
  297. "buddy", buddyName,
  298. "friendly", friendly,
  299. "result", resultCode,
  300. )
  301. }
  302. // SetGroupAttribute handles GET /buddylist/setGroupAttribute requests.
  303. //
  304. // The Web AIM client calls this with collapsed (the group's collapsed state)
  305. // and, for named groups, group. The unnamed default group omits group. This is
  306. // a stub; it does not yet persist the attribute to the feedbag.
  307. func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Request, session *Session) {
  308. ctx := r.Context()
  309. aimsid := r.URL.Query().Get("aimsid")
  310. query := r.URL.Query()
  311. groupName := strings.TrimSpace(query.Get("group"))
  312. collapsedParam := strings.TrimSpace(query.Get("collapsed"))
  313. if collapsedParam == "" {
  314. SendError(w, r, http.StatusBadRequest, "missing collapsed parameter")
  315. return
  316. }
  317. collapsed := collapsedParam == "true" || collapsedParam == "1"
  318. resultCode, saErr := h.BuddyListManager.SetGroupAttributeInFeedbag(ctx, session, groupName, collapsed)
  319. if saErr != nil {
  320. h.Logger.ErrorContext(ctx, "set group attribute failed", "err", saErr.Error())
  321. }
  322. sendMutationResult(w, r, resultCode, h.Logger)
  323. h.Logger.InfoContext(ctx, "buddy list group attribute set",
  324. "aimsid", aimsid,
  325. "group", groupName,
  326. "collapsed", collapsed,
  327. "result", resultCode,
  328. )
  329. }
  330. // resultSuccess is the result code the buddy list methods no longer report; see
  331. // sendMutationResult.
  332. const resultSuccess = "success"
  333. // sendMutationResult answers a buddy-list mutation. It reports only an error or a
  334. // request that did not apply ("alreadyExists", "notFound"). Success is not reported:
  335. // the authoritative roster arrives asynchronously as a buddylist event, and an item
  336. // accepted here can still be declined by the feedbag service.
  337. func sendMutationResult(w http.ResponseWriter, r *http.Request, resultCode string, logger *slog.Logger) {
  338. if resultCode == resultSuccess {
  339. // Nil data renders as an empty "data":{} — no resultCode, no buddyInfo.
  340. SendOK(w, r, nil, logger)
  341. return
  342. }
  343. SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, logger)
  344. }
  345. // ResultCodeData is the payload the buddy list editing methods answer with.
  346. //
  347. // The spec shows these methods returning an empty data; the Web AIM client
  348. // reads resultCode from it, so the server sends one.
  349. type ResultCodeData struct {
  350. ResultCode string `json:"resultCode" xml:"resultCode"`
  351. // BuddyNames accompanies the temp-buddy methods only.
  352. BuddyNames []string `json:"buddyNames,omitempty" xml:"buddyNames>buddyName,omitempty"`
  353. }