4
0

preference.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. package handlers
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "math/rand"
  7. "net/http"
  8. "reflect"
  9. "strings"
  10. "github.com/mk6i/open-oscar-server/server/webapi/types"
  11. "github.com/mk6i/open-oscar-server/state"
  12. "github.com/mk6i/open-oscar-server/wire"
  13. )
  14. // PreferenceHandler handles Web AIM API preference-related endpoints.
  15. type PreferenceHandler struct {
  16. FeedbagService FeedbagService
  17. SessionManager *state.WebAPISessionManager
  18. Logger *slog.Logger
  19. }
  20. // webBuddyPrefs maps Web AIM API preference names to OSCAR buddy-pref bit
  21. // numbers, which are stored as a bitmask in the user's feedbag (see
  22. // wire.BuddyPref). Default values for absent prefs are owned by wire.BuddyPref,
  23. // not here.
  24. var webBuddyPrefs = map[string]uint16{
  25. "displayLogin": wire.FeedbagBuddyPrefsDisplayLogin,
  26. "displayEBuddy": wire.FeedbagBuddyPrefsDisplayEBuddy,
  27. "playEnter": wire.FeedbagBuddyPrefsPlayEnter,
  28. "playExit": wire.FeedbagBuddyPrefsPlayExit,
  29. "viewIMTimestamps": wire.FeedbagBuddyPrefsViewIMStamp,
  30. "viewSmilies": wire.FeedbagBuddyPrefsViewSmileys,
  31. "acceptIcons": wire.FeedbagBuddyPrefsAcceptIcons,
  32. "knockNonAOLIMs": wire.FeedbagBuddyPrefsKnockNonAOLIMs,
  33. "knockNonListIMs": wire.FeedbagBuddyPrefsKnockNonListIMs,
  34. "discloseIdle": wire.FeedbagBuddyPrefsDiscloseIdle,
  35. "acceptCustomBart": wire.FeedbagBuddyPrefsAcceptCustomBart,
  36. "acceptNonListBart": wire.FeedbagBuddyPrefsAcceptNonListBart,
  37. "acceptBgs": wire.FeedbagBuddyPrefsAcceptBgs,
  38. "acceptChromes": wire.FeedbagBuddyPrefsAcceptChromes,
  39. "acceptBLSounds": wire.FeedbagBuddyPrefsAcceptBLSounds,
  40. "acceptIMsounds": wire.FeedbagBuddyPrefsAcceptIMSounds,
  41. "noSeeRecentBuddies": wire.FeedbagBuddyPrefsNoSeeRecentBuddies,
  42. "acceptSMSLegal": wire.FeedbagBuddyPrefsAcceptSMSLegal,
  43. "enterDoesCRLF": wire.FeedbagBuddyPrefsEnterDoesCRLF,
  44. "playIMSound": wire.FeedbagBuddyPrefsPlayIMSound,
  45. "discloseTyping": wire.FeedbagBuddyPrefsDiscloseTyping,
  46. "acceptSuperIcons": wire.FeedbagBuddyPrefsAcceptSuperIcons,
  47. "acceptBLRichText": wire.FeedbagBuddyPrefsAcceptBLRichText,
  48. "reduceIMSound": wire.FeedbagBuddyPrefsReduceIMSound,
  49. "confirmDirectIM": wire.FeedbagBuddyPrefsConfirmDirectIM,
  50. "oneTabbedIMWindow": wire.FeedbagBuddyPrefsOneTabbedIMWindow,
  51. "buddyInfoOnMouseover": wire.FeedbagBuddyPrefsBuddyInfoOnMouseover,
  52. "discloseBuddyMatches": wire.FeedbagBuddyPrefsDiscloseBuddyMatches,
  53. "catchIMs": wire.FeedbagBuddyPrefsCatchIMs,
  54. "showFriendlyName": wire.FeedbagBuddyPrefsShowFriendlyName,
  55. "discloseRadio": wire.FeedbagBuddyPrefsDiscloseRadio,
  56. "showCapabilities": wire.FeedbagBuddyPrefsShowCapabilities,
  57. "showBuddyListFilter": wire.FeedbagBuddyPrefsShowBuddyListFilter,
  58. "showAwayIdle": wire.FeedbagBuddyPrefsShowAwayIdle,
  59. "showMobile": wire.FeedbagBuddyPrefsShowMobile,
  60. "sortBuddyList": wire.FeedbagBuddyPrefsSortBuddyList,
  61. "catchIMsForClient": wire.FeedbagBuddyPrefsCatchIMsForClient,
  62. "newMessageSmallNotification": wire.FeedbagBuddyPrefsNewMessageSmallNotify,
  63. "noFrequentBuddies": wire.FeedbagBuddyPrefsNoFrequentBuddies,
  64. "blogAwayMessages": wire.FeedbagBuddyPrefsBlogAwayMessages,
  65. "blogAIMSigMessages": wire.FeedbagBuddyPrefsBlogAIMSigMessages,
  66. "blogNoComments": wire.FeedbagBuddyPrefsBlogNoComments,
  67. "friendOfFriend": wire.FeedbagBuddyPrefsFriendOfFriend,
  68. "friendGetContactList": wire.FeedbagBuddyPrefsFriendGetContactList,
  69. "compadInit": wire.FeedbagBuddyPrefsCompadInit,
  70. "sendBuddyFeed": wire.FeedbagBuddyPrefsSendBuddyFeed,
  71. "blkSendIMWhileAway": wire.FeedbagBuddyPrefsBlkSendIMWhileAway,
  72. "showBuddyFeed": wire.FeedbagBuddyPrefsShowBuddyFeed,
  73. "noSaveVanityInfo": wire.FeedbagBuddyPrefsNoSaveVanityInfo,
  74. "acceptOffLineIM": wire.FeedbagBuddyPrefsAcceptOfflineIM,
  75. "showGroups": wire.FeedbagBuddyPrefsShowGroups,
  76. "sortGroup": wire.FeedbagBuddyPrefsSortGroup,
  77. "showOffLineBuddies": wire.FeedbagBuddyPrefsShowOfflineBuddies,
  78. "expandBuddies": wire.FeedbagBuddyPrefsExpandBuddies,
  79. "thirdPartyFeeds": wire.FeedbagBuddyPrefsThirdPartyFeeds,
  80. "notifyReceivedInvite": wire.FeedbagBuddyPrefsNotifyReceivedInvite,
  81. "apfAutoAccept": wire.FeedbagBuddyPrefsApfAutoAccept,
  82. "apfAutoAcceptBuddy": wire.FeedbagBuddyPrefsApfAutoAcceptBuddy,
  83. "blockAwayMsgFeed": wire.FeedbagBuddyPrefsBlockAwayMsgFeed,
  84. "blockAIMProfileFeed": wire.FeedbagBuddyPrefsBlockAIMProfileFeed,
  85. "blockAIMPagesFeed": wire.FeedbagBuddyPrefsBlockAIMPagesFeed,
  86. "blockJournalsFeed": wire.FeedbagBuddyPrefsBlockJournalsFeed,
  87. "blockLocationFeed": wire.FeedbagBuddyPrefsBlockLocationFeed,
  88. "blockStickiesFeed": wire.FeedbagBuddyPrefsBlockStickiesFeed,
  89. "blockUncutFeed": wire.FeedbagBuddyPrefsBlockUncutFeed,
  90. "blockLinksFeed": wire.FeedbagBuddyPrefsBlockLinksFeed,
  91. "blockAIMBulletinFeed": wire.FeedbagBuddyPrefsBlockAIMBulletinFeed,
  92. "saveStatusMsg": wire.FeedbagBuddyPrefsSaveStatusMsg,
  93. // Not in the spec Preferences enum, but sent by the web client.
  94. "apfNotifyReceivedInviteByEmail": wire.FeedbagBuddyPrefsApfNotifyReceivedByEmail,
  95. "showOfflineGrp": wire.FeedbagBuddyPrefsShowOfflineGrp,
  96. "offlineGrpCollapsed": wire.FeedbagBuddyPrefsOfflineGrpCollapsed,
  97. "firstImSoundOnly": wire.FeedbagBuddyPrefsFirstIMSoundOnly,
  98. "imblastInviteNotify": wire.FeedbagBuddyPrefsImblastInviteNotify,
  99. // Web-client-only preferences with no OSCAR buddy-pref equivalent. OSCAR
  100. // defines prefs through 0x4B, so we persist these in the same feedbag
  101. // buddy-prefs bitmask at positions above that range; no real OSCAR client
  102. // reads or writes these bits.
  103. "viewIMsInBubbles": wire.FeedbagBuddyPrefsViewIMsInBubbles,
  104. "viewIMTimestampsRelative": wire.FeedbagBuddyPrefsViewIMTimestampsRelative,
  105. "globalOTR": wire.FeedbagBuddyPrefsGlobalOTR,
  106. "imblastInviteFromBuddyOnly": wire.FeedbagBuddyPrefsImblastInviteFromBuddyOnly,
  107. }
  108. // PreferenceData carries Web API buddy preferences.
  109. //
  110. // Values are numbers, not "1"/"0" strings, because the client evaluates them
  111. // with JavaScript truthiness and numeric comparison, where the string "0" is
  112. // truthy. They are pointers because a preference set to 0 and a preference not
  113. // carried at all mean different things and both occur: getPreference and
  114. // setPreference answer with just the preferences the request named, while the
  115. // startSession seed carries every one. A plain int could not tell the two
  116. // apart, and the client reads its buddy-list display preferences only from
  117. // here — an omitted showGroups falls back to a hidden client default that hides
  118. // group headers.
  119. //
  120. // The fields mirror webBuddyPrefs one for one; TestPreferenceDataMatchesPrefTable
  121. // fails if the two drift apart.
  122. type PreferenceData struct {
  123. DisplayLogin *int `json:"displayLogin,omitempty" xml:"displayLogin,omitempty"`
  124. DisplayEBuddy *int `json:"displayEBuddy,omitempty" xml:"displayEBuddy,omitempty"`
  125. PlayEnter *int `json:"playEnter,omitempty" xml:"playEnter,omitempty"`
  126. PlayExit *int `json:"playExit,omitempty" xml:"playExit,omitempty"`
  127. ViewIMTimestamps *int `json:"viewIMTimestamps,omitempty" xml:"viewIMTimestamps,omitempty"`
  128. ViewSmilies *int `json:"viewSmilies,omitempty" xml:"viewSmilies,omitempty"`
  129. AcceptIcons *int `json:"acceptIcons,omitempty" xml:"acceptIcons,omitempty"`
  130. KnockNonAOLIMs *int `json:"knockNonAOLIMs,omitempty" xml:"knockNonAOLIMs,omitempty"`
  131. KnockNonListIMs *int `json:"knockNonListIMs,omitempty" xml:"knockNonListIMs,omitempty"`
  132. DiscloseIdle *int `json:"discloseIdle,omitempty" xml:"discloseIdle,omitempty"`
  133. AcceptCustomBart *int `json:"acceptCustomBart,omitempty" xml:"acceptCustomBart,omitempty"`
  134. AcceptNonListBart *int `json:"acceptNonListBart,omitempty" xml:"acceptNonListBart,omitempty"`
  135. AcceptBgs *int `json:"acceptBgs,omitempty" xml:"acceptBgs,omitempty"`
  136. AcceptChromes *int `json:"acceptChromes,omitempty" xml:"acceptChromes,omitempty"`
  137. AcceptBLSounds *int `json:"acceptBLSounds,omitempty" xml:"acceptBLSounds,omitempty"`
  138. AcceptIMsounds *int `json:"acceptIMsounds,omitempty" xml:"acceptIMsounds,omitempty"`
  139. NoSeeRecentBuddies *int `json:"noSeeRecentBuddies,omitempty" xml:"noSeeRecentBuddies,omitempty"`
  140. AcceptSMSLegal *int `json:"acceptSMSLegal,omitempty" xml:"acceptSMSLegal,omitempty"`
  141. EnterDoesCRLF *int `json:"enterDoesCRLF,omitempty" xml:"enterDoesCRLF,omitempty"`
  142. PlayIMSound *int `json:"playIMSound,omitempty" xml:"playIMSound,omitempty"`
  143. DiscloseTyping *int `json:"discloseTyping,omitempty" xml:"discloseTyping,omitempty"`
  144. AcceptSuperIcons *int `json:"acceptSuperIcons,omitempty" xml:"acceptSuperIcons,omitempty"`
  145. AcceptBLRichText *int `json:"acceptBLRichText,omitempty" xml:"acceptBLRichText,omitempty"`
  146. ReduceIMSound *int `json:"reduceIMSound,omitempty" xml:"reduceIMSound,omitempty"`
  147. ConfirmDirectIM *int `json:"confirmDirectIM,omitempty" xml:"confirmDirectIM,omitempty"`
  148. OneTabbedIMWindow *int `json:"oneTabbedIMWindow,omitempty" xml:"oneTabbedIMWindow,omitempty"`
  149. BuddyInfoOnMouseover *int `json:"buddyInfoOnMouseover,omitempty" xml:"buddyInfoOnMouseover,omitempty"`
  150. DiscloseBuddyMatches *int `json:"discloseBuddyMatches,omitempty" xml:"discloseBuddyMatches,omitempty"`
  151. CatchIMs *int `json:"catchIMs,omitempty" xml:"catchIMs,omitempty"`
  152. ShowFriendlyName *int `json:"showFriendlyName,omitempty" xml:"showFriendlyName,omitempty"`
  153. DiscloseRadio *int `json:"discloseRadio,omitempty" xml:"discloseRadio,omitempty"`
  154. ShowCapabilities *int `json:"showCapabilities,omitempty" xml:"showCapabilities,omitempty"`
  155. ShowBuddyListFilter *int `json:"showBuddyListFilter,omitempty" xml:"showBuddyListFilter,omitempty"`
  156. ShowAwayIdle *int `json:"showAwayIdle,omitempty" xml:"showAwayIdle,omitempty"`
  157. ShowMobile *int `json:"showMobile,omitempty" xml:"showMobile,omitempty"`
  158. SortBuddyList *int `json:"sortBuddyList,omitempty" xml:"sortBuddyList,omitempty"`
  159. CatchIMsForClient *int `json:"catchIMsForClient,omitempty" xml:"catchIMsForClient,omitempty"`
  160. NewMessageSmallNotification *int `json:"newMessageSmallNotification,omitempty" xml:"newMessageSmallNotification,omitempty"`
  161. NoFrequentBuddies *int `json:"noFrequentBuddies,omitempty" xml:"noFrequentBuddies,omitempty"`
  162. BlogAwayMessages *int `json:"blogAwayMessages,omitempty" xml:"blogAwayMessages,omitempty"`
  163. BlogAIMSigMessages *int `json:"blogAIMSigMessages,omitempty" xml:"blogAIMSigMessages,omitempty"`
  164. BlogNoComments *int `json:"blogNoComments,omitempty" xml:"blogNoComments,omitempty"`
  165. FriendOfFriend *int `json:"friendOfFriend,omitempty" xml:"friendOfFriend,omitempty"`
  166. FriendGetContactList *int `json:"friendGetContactList,omitempty" xml:"friendGetContactList,omitempty"`
  167. CompadInit *int `json:"compadInit,omitempty" xml:"compadInit,omitempty"`
  168. SendBuddyFeed *int `json:"sendBuddyFeed,omitempty" xml:"sendBuddyFeed,omitempty"`
  169. BlkSendIMWhileAway *int `json:"blkSendIMWhileAway,omitempty" xml:"blkSendIMWhileAway,omitempty"`
  170. ShowBuddyFeed *int `json:"showBuddyFeed,omitempty" xml:"showBuddyFeed,omitempty"`
  171. NoSaveVanityInfo *int `json:"noSaveVanityInfo,omitempty" xml:"noSaveVanityInfo,omitempty"`
  172. AcceptOffLineIM *int `json:"acceptOffLineIM,omitempty" xml:"acceptOffLineIM,omitempty"`
  173. ShowGroups *int `json:"showGroups,omitempty" xml:"showGroups,omitempty"`
  174. SortGroup *int `json:"sortGroup,omitempty" xml:"sortGroup,omitempty"`
  175. ShowOffLineBuddies *int `json:"showOffLineBuddies,omitempty" xml:"showOffLineBuddies,omitempty"`
  176. ExpandBuddies *int `json:"expandBuddies,omitempty" xml:"expandBuddies,omitempty"`
  177. ThirdPartyFeeds *int `json:"thirdPartyFeeds,omitempty" xml:"thirdPartyFeeds,omitempty"`
  178. NotifyReceivedInvite *int `json:"notifyReceivedInvite,omitempty" xml:"notifyReceivedInvite,omitempty"`
  179. ApfAutoAccept *int `json:"apfAutoAccept,omitempty" xml:"apfAutoAccept,omitempty"`
  180. ApfAutoAcceptBuddy *int `json:"apfAutoAcceptBuddy,omitempty" xml:"apfAutoAcceptBuddy,omitempty"`
  181. BlockAwayMsgFeed *int `json:"blockAwayMsgFeed,omitempty" xml:"blockAwayMsgFeed,omitempty"`
  182. BlockAIMProfileFeed *int `json:"blockAIMProfileFeed,omitempty" xml:"blockAIMProfileFeed,omitempty"`
  183. BlockAIMPagesFeed *int `json:"blockAIMPagesFeed,omitempty" xml:"blockAIMPagesFeed,omitempty"`
  184. BlockJournalsFeed *int `json:"blockJournalsFeed,omitempty" xml:"blockJournalsFeed,omitempty"`
  185. BlockLocationFeed *int `json:"blockLocationFeed,omitempty" xml:"blockLocationFeed,omitempty"`
  186. BlockStickiesFeed *int `json:"blockStickiesFeed,omitempty" xml:"blockStickiesFeed,omitempty"`
  187. BlockUncutFeed *int `json:"blockUncutFeed,omitempty" xml:"blockUncutFeed,omitempty"`
  188. BlockLinksFeed *int `json:"blockLinksFeed,omitempty" xml:"blockLinksFeed,omitempty"`
  189. BlockAIMBulletinFeed *int `json:"blockAIMBulletinFeed,omitempty" xml:"blockAIMBulletinFeed,omitempty"`
  190. SaveStatusMsg *int `json:"saveStatusMsg,omitempty" xml:"saveStatusMsg,omitempty"`
  191. ApfNotifyReceivedInviteByEmail *int `json:"apfNotifyReceivedInviteByEmail,omitempty" xml:"apfNotifyReceivedInviteByEmail,omitempty"`
  192. ShowOfflineGrp *int `json:"showOfflineGrp,omitempty" xml:"showOfflineGrp,omitempty"`
  193. OfflineGrpCollapsed *int `json:"offlineGrpCollapsed,omitempty" xml:"offlineGrpCollapsed,omitempty"`
  194. FirstImSoundOnly *int `json:"firstImSoundOnly,omitempty" xml:"firstImSoundOnly,omitempty"`
  195. ImblastInviteNotify *int `json:"imblastInviteNotify,omitempty" xml:"imblastInviteNotify,omitempty"`
  196. ViewIMsInBubbles *int `json:"viewIMsInBubbles,omitempty" xml:"viewIMsInBubbles,omitempty"`
  197. ViewIMTimestampsRelative *int `json:"viewIMTimestampsRelative,omitempty" xml:"viewIMTimestampsRelative,omitempty"`
  198. GlobalOTR *int `json:"globalOTR,omitempty" xml:"globalOTR,omitempty"`
  199. ImblastInviteFromBuddyOnly *int `json:"imblastInviteFromBuddyOnly,omitempty" xml:"imblastInviteFromBuddyOnly,omitempty"`
  200. }
  201. // PermitDenyData contains permit/deny list information.
  202. //
  203. // The XML item names follow the spec's getPermitDeny, which nests <allow> under
  204. // <allows> and <block> under <blocks>.
  205. type PermitDenyData struct {
  206. PDMode string `json:"pdMode" xml:"pdMode"`
  207. PermitList []string `json:"allows,omitempty" xml:"allows>allow,omitempty"`
  208. DenyList []string `json:"blocks,omitempty" xml:"blocks>block,omitempty"`
  209. }
  210. // SetPreferences handles GET /preference/set requests to update user preferences.
  211. func (h *PreferenceHandler) SetPreferences(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
  212. ctx := r.Context()
  213. // Preferences are stored as OSCAR buddy prefs in the feedbag, which requires
  214. // an OSCAR session to act on behalf of.
  215. instance := session.OSCARSession
  216. // Read-modify-write the buddy-prefs item so bits the web client doesn't
  217. // manage (e.g. the typing-events bit consumed by the OSCAR session) survive.
  218. item, err := buddyPrefsItem(ctx, h.FeedbagService, instance)
  219. if err != nil {
  220. h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
  221. h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
  222. return
  223. }
  224. applied := &PreferenceData{}
  225. for name, pref := range webBuddyPrefs {
  226. val := r.URL.Query().Get(name)
  227. if val == "" {
  228. continue
  229. }
  230. on := parseBoolPref(val)
  231. item.TLVList = wire.SetBuddyPref(item.TLVList, pref, on)
  232. applied.Set(name, boolToPrefInt(on))
  233. }
  234. if applied.Len() > 0 {
  235. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  236. if _, err := h.FeedbagService.UpsertItem(ctx, instance, frame, []wire.FeedbagItem{item}); err != nil {
  237. h.Logger.ErrorContext(ctx, "failed to set preferences", "err", err.Error())
  238. h.sendError(w, r, http.StatusInternalServerError, "failed to save preferences")
  239. return
  240. }
  241. // Notify the client's open windows via the event stream so display
  242. // changes (e.g. bubbles/classic) take effect immediately without a
  243. // browser refresh.
  244. session.EventQueue.Push(types.EventTypePreference, applied)
  245. }
  246. h.Logger.DebugContext(ctx, "preferences updated",
  247. "screenName", session.ScreenName.String(),
  248. "prefCount", applied.Len(),
  249. )
  250. // Send success response
  251. response := BaseResponse{}
  252. response.Response.StatusCode = 200
  253. response.Response.StatusText = "OK"
  254. response.Response.Data = applied
  255. SendResponse(w, r, response, h.Logger)
  256. }
  257. // GetPreferences handles GET /preference/get requests to retrieve user preferences.
  258. func (h *PreferenceHandler) GetPreferences(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
  259. ctx := r.Context()
  260. // Load the buddy-prefs bitmask from the feedbag. Absent prefs fall back to
  261. // the spec default.
  262. var prefsList wire.TLVList
  263. if item, err := buddyPrefsItem(ctx, h.FeedbagService, session.OSCARSession); err != nil {
  264. h.Logger.WarnContext(ctx, "failed to get preferences", "err", err.Error())
  265. } else {
  266. prefsList = item.TLVList
  267. }
  268. // When specific preferences are named in the query (e.g. playIMSound=1), the
  269. // client is selecting those; otherwise return all preferences.
  270. requestedPrefs := &PreferenceData{}
  271. for name, pref := range webBuddyPrefs {
  272. if r.URL.Query().Has(name) {
  273. requestedPrefs.Set(name, effectivePrefValue(prefsList, pref))
  274. }
  275. }
  276. prefs := requestedPrefs
  277. if prefs.Len() == 0 {
  278. prefs = effectiveBuddyPrefs(prefsList)
  279. }
  280. h.Logger.DebugContext(ctx, "preferences retrieved",
  281. "screenName", session.ScreenName.String(),
  282. "prefCount", prefs.Len(),
  283. "requested", requestedPrefs.Len() > 0,
  284. )
  285. var payload any = prefs
  286. // AMF clients (e.g. Gromit) expect the payload shaped a specific way. Pref
  287. // values are already numeric 0/1, which is what these clients expect.
  288. format := strings.ToLower(r.URL.Query().Get("f"))
  289. if format == "amf" || format == "amf3" {
  290. amfPrefs := prefs.Map()
  291. // Ensure prefs is never empty for Gromit.
  292. if len(amfPrefs) == 0 {
  293. amfPrefs = map[string]any{"playIMSound": 1}
  294. }
  295. // A single preference is returned directly; multiple are wrapped in
  296. // jsonData for Gromit compatibility.
  297. if len(amfPrefs) != 1 {
  298. amfPrefs = map[string]any{"jsonData": amfPrefs}
  299. }
  300. payload = amfPrefs
  301. h.Logger.DebugContext(ctx, "AMF preference response",
  302. "prefCount", prefs.Len(),
  303. "format", format,
  304. )
  305. }
  306. // Send response in requested format
  307. response := BaseResponse{}
  308. response.Response.StatusCode = 200
  309. response.Response.StatusText = "OK"
  310. response.Response.Data = payload
  311. SendResponse(w, r, response, h.Logger)
  312. }
  313. // buddyPrefsItem returns the user's buddy-prefs feedbag item, creating a fresh
  314. // (empty) one if the feedbag does not have it yet.
  315. func buddyPrefsItem(ctx context.Context, fs FeedbagService, instance *state.SessionInstance) (wire.FeedbagItem, error) {
  316. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  317. fb, err := fs.Query(ctx, instance, frame)
  318. if err != nil {
  319. return wire.FeedbagItem{}, err
  320. }
  321. reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  322. if !ok {
  323. return wire.FeedbagItem{}, fmt.Errorf("unexpected feedbag reply type")
  324. }
  325. for _, item := range reply.Items {
  326. if item.ClassID == wire.FeedbagClassIdBuddyPrefs {
  327. return item, nil
  328. }
  329. }
  330. // No buddy-prefs item yet; create one. Only a single item of this class is
  331. // allowed, with an empty name and no group.
  332. return wire.FeedbagItem{
  333. ClassID: wire.FeedbagClassIdBuddyPrefs,
  334. ItemID: uint16(rand.Intn(0xFFFF)),
  335. }, nil
  336. }
  337. // effectiveBuddyPrefs returns the 0/1 value of every web buddy pref, using the
  338. // feedbag value when the pref's valid bit is set and the spec default otherwise.
  339. // This mirrors GetPreferences so the pushed preference event and the
  340. // preference/get endpoint agree: server-side defaults (e.g. showGroups) reach
  341. // the client even for prefs the user has never explicitly set. The web client
  342. // reads these from the startup preference event and has no other default for
  343. // them, so an omitted pref would silently fall back to the client's own hidden
  344. // default (which, for showGroups, hides group headers).
  345. func effectiveBuddyPrefs(list wire.TLVList) *PreferenceData {
  346. prefs := &PreferenceData{}
  347. for name, pref := range webBuddyPrefs {
  348. prefs.Set(name, effectivePrefValue(list, pref))
  349. }
  350. return prefs
  351. }
  352. // prefFieldIndex maps a preference name to its PreferenceData field.
  353. var prefFieldIndex = func() map[string]int {
  354. t := reflect.TypeOf(PreferenceData{})
  355. index := make(map[string]int, t.NumField())
  356. for i := 0; i < t.NumField(); i++ {
  357. name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
  358. index[name] = i
  359. }
  360. return index
  361. }()
  362. // Set records one preference by its Web API name, leaving every preference not
  363. // set this way absent from the payload. It reports whether the name is one this
  364. // server carries.
  365. func (p *PreferenceData) Set(name string, value int) bool {
  366. i, ok := prefFieldIndex[name]
  367. if !ok {
  368. return false
  369. }
  370. reflect.ValueOf(p).Elem().Field(i).Set(reflect.ValueOf(&value))
  371. return true
  372. }
  373. // Get returns a preference by its Web API name and whether it is carried.
  374. func (p *PreferenceData) Get(name string) (int, bool) {
  375. i, ok := prefFieldIndex[name]
  376. if !ok {
  377. return 0, false
  378. }
  379. field := reflect.ValueOf(p).Elem().Field(i)
  380. if field.IsNil() {
  381. return 0, false
  382. }
  383. return int(field.Elem().Int()), true
  384. }
  385. // Map returns the carried preferences keyed by Web API name, for the AMF path
  386. // that reshapes the payload rather than sending it as-is.
  387. func (p *PreferenceData) Map() map[string]any {
  388. out := make(map[string]any, len(prefFieldIndex))
  389. for name := range prefFieldIndex {
  390. if v, ok := p.Get(name); ok {
  391. out[name] = v
  392. }
  393. }
  394. return out
  395. }
  396. // Len reports how many preferences the payload carries.
  397. func (p *PreferenceData) Len() int {
  398. fields := reflect.ValueOf(p).Elem()
  399. n := 0
  400. for i := 0; i < fields.NumField(); i++ {
  401. if !fields.Field(i).IsNil() {
  402. n++
  403. }
  404. }
  405. return n
  406. }
  407. // effectivePrefValue returns the 0/1 value for the buddy pref prefNum, deferring
  408. // to wire.BuddyPref for both the stored value and its default. Values are emitted
  409. // as numbers (not "1"/"0" strings) because the web client evaluates them with
  410. // JavaScript truthiness/numeric comparisons, where the string "0" is truthy.
  411. func effectivePrefValue(list wire.TLVList, prefNum uint16) int {
  412. return boolToPrefInt(wire.BuddyPref(list, prefNum))
  413. }
  414. // parseBoolPref interprets a Web API preference query value as a boolean.
  415. func parseBoolPref(v string) bool {
  416. switch strings.ToLower(v) {
  417. case "1", "true", "yes", "on":
  418. return true
  419. default:
  420. return false
  421. }
  422. }
  423. func boolToPrefInt(b bool) int {
  424. if b {
  425. return 1
  426. }
  427. return 0
  428. }
  429. // SetPermitDeny handles GET /preference/setPermitDeny requests to update permit/deny settings.
  430. func (h *PreferenceHandler) SetPermitDeny(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
  431. ctx := r.Context()
  432. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  433. fb, err := h.FeedbagService.Query(r.Context(), session.OSCARSession, frame)
  434. if err != nil {
  435. h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
  436. return
  437. }
  438. reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  439. if !ok {
  440. h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
  441. return
  442. }
  443. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  444. // Get pdMode parameter
  445. pdModeStr := r.URL.Query().Get("pdMode")
  446. if pdModeStr != "" {
  447. switch pdModeStr { // todo: are the string ints possible inputs?
  448. case "permitAll", "1":
  449. fl.SetMode(uint8(wire.FeedbagPDModePermitAll))
  450. case "denyAll", "2":
  451. fl.SetMode(uint8(wire.FeedbagPDModeDenyAll))
  452. case "permitSome", "3":
  453. fl.SetMode(uint8(wire.FeedbagPDModePermitSome))
  454. case "denySome", "4":
  455. fl.SetMode(uint8(wire.FeedbagPDModeDenySome))
  456. case "permitOnList", "5":
  457. fl.SetMode(uint8(wire.FeedbagPDModePermitOnList))
  458. default:
  459. h.sendError(w, r, http.StatusBadRequest, "invalid pdMode value")
  460. return
  461. }
  462. }
  463. // Handle permit list updates
  464. if pdAllow := r.URL.Query().Get("pdAllow"); pdAllow != "" {
  465. users := strings.Split(pdAllow, ",")
  466. for _, user := range users {
  467. user = strings.TrimSpace(user)
  468. if user != "" {
  469. fl.PermitUser(user)
  470. }
  471. }
  472. }
  473. if pdAllowRemove := r.URL.Query().Get("pdAllowRemove"); pdAllowRemove != "" {
  474. users := strings.Split(pdAllowRemove, ",")
  475. for _, user := range users {
  476. user = strings.TrimSpace(user)
  477. if user != "" {
  478. fl.DeletePermit(user)
  479. }
  480. }
  481. }
  482. // Handle deny list updates
  483. if pdBlock := r.URL.Query().Get("pdBlock"); pdBlock != "" {
  484. users := strings.Split(pdBlock, ",")
  485. for _, user := range users {
  486. user = strings.TrimSpace(user)
  487. if user != "" {
  488. fl.DenyUser(user)
  489. }
  490. }
  491. }
  492. if pdBlockRemove := r.URL.Query().Get("pdBlockRemove"); pdBlockRemove != "" {
  493. users := strings.Split(pdBlockRemove, ",")
  494. for _, user := range users {
  495. user = strings.TrimSpace(user)
  496. if user != "" {
  497. fl.DeleteDeny(user)
  498. }
  499. }
  500. }
  501. if pending := fl.PendingUpdates(); len(pending) > 0 {
  502. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  503. if _, err := h.FeedbagService.UpsertItem(ctx, session.OSCARSession, frame, pending); err != nil {
  504. h.Logger.ErrorContext(ctx, "failed to set PD mode", "err", err.Error())
  505. h.sendError(w, r, http.StatusInternalServerError, "failed to update PD mode")
  506. return
  507. }
  508. }
  509. if pending := fl.PendingDeletes(); len(pending) > 0 {
  510. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
  511. body := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
  512. if _, err := h.FeedbagService.DeleteItem(ctx, session.OSCARSession, frame, body); err != nil {
  513. h.Logger.ErrorContext(ctx, "failed to set PD mode", "err", err.Error())
  514. h.sendError(w, r, http.StatusInternalServerError, "failed to update PD mode")
  515. return
  516. }
  517. }
  518. pdd := permitDenyData(fl.Items())
  519. // The client reads the privacy state it renders (blocked buddies, the
  520. // block/unblock menu label) only from the permitDeny event, and it sees no
  521. // SNAC for the write it just made. Without this the block takes effect
  522. // server-side but the UI keeps showing the buddy as unblocked.
  523. session.EventQueue.Push(types.EventTypePermitDeny, pdd)
  524. h.Logger.DebugContext(ctx, "permit/deny settings updated",
  525. "screenName", session.ScreenName.String(),
  526. "pdMode", pdd.PDMode,
  527. "permitCount", len(pdd.PermitList),
  528. "denyCount", len(pdd.DenyList),
  529. )
  530. response := BaseResponse{}
  531. response.Response.StatusCode = 200
  532. response.Response.StatusText = "OK"
  533. response.Response.Data = pdd
  534. SendResponse(w, r, response, h.Logger)
  535. }
  536. func permitDenyData(fl []wire.FeedbagItem) PermitDenyData {
  537. // A feedbag with no PD info item means no restrictions, the same default the
  538. // feedbag store applies. The client drives its block flow off pdMode and
  539. // sends no permit/deny change at all when the mode is absent.
  540. pdd := PermitDenyData{PDMode: "permitAll"}
  541. for _, item := range fl {
  542. switch item.ClassID {
  543. case wire.FeedbagClassIDDeny:
  544. pdd.DenyList = append(pdd.DenyList, item.Name)
  545. case wire.FeedbagClassIDPermit:
  546. pdd.PermitList = append(pdd.PermitList, item.Name)
  547. case wire.FeedbagClassIdPdinfo:
  548. mode, _ := item.Uint8(wire.FeedbagAttributesPdMode)
  549. switch wire.FeedbagPDMode(mode) {
  550. case wire.FeedbagPDModePermitAll:
  551. pdd.PDMode = "permitAll"
  552. case wire.FeedbagPDModeDenyAll:
  553. pdd.PDMode = "denyAll"
  554. case wire.FeedbagPDModePermitSome:
  555. pdd.PDMode = "permitSome"
  556. case wire.FeedbagPDModeDenySome:
  557. pdd.PDMode = "denySome"
  558. case wire.FeedbagPDModePermitOnList:
  559. pdd.PDMode = "permitOnList"
  560. }
  561. }
  562. }
  563. return pdd
  564. }
  565. // GetPermitDeny handles GET /preference/getPermitDeny requests to retrieve permit/deny settings.
  566. func (h *PreferenceHandler) GetPermitDeny(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
  567. ctx := r.Context()
  568. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  569. fb, err := h.FeedbagService.Query(r.Context(), session.OSCARSession, frame)
  570. if err != nil {
  571. h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
  572. return
  573. }
  574. reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  575. if !ok {
  576. h.sendError(w, r, http.StatusInternalServerError, "failed to retrieve feedbag")
  577. return
  578. }
  579. pdd := permitDenyData(reply.Items)
  580. h.Logger.DebugContext(ctx, "permit/deny settings retrieved",
  581. "screenName", session.ScreenName.String(),
  582. "pdMode", pdd.PDMode,
  583. "permitCount", len(pdd.PermitList),
  584. "denyCount", len(pdd.DenyList),
  585. )
  586. response := BaseResponse{}
  587. response.Response.StatusCode = 200
  588. response.Response.StatusText = "OK"
  589. response.Response.Data = pdd
  590. SendResponse(w, r, response, h.Logger)
  591. }
  592. func (h *PreferenceHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
  593. // todo log
  594. SendError(w, r, statusCode, message)
  595. }