presence_handler.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  1. package webapi
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "slices"
  9. "strings"
  10. "time"
  11. "github.com/mk6i/open-oscar-server/state"
  12. "github.com/mk6i/open-oscar-server/wire"
  13. )
  14. // PresenceHandler handles Web AIM API presence-related endpoints.
  15. type PresenceHandler struct {
  16. SessionManager *SessionManager
  17. FeedbagService FeedbagService
  18. LocateService LocateService
  19. OServiceService OServiceService
  20. IconSource BuddyIconSource
  21. Logger *slog.Logger
  22. }
  23. const maxPresenceTargets = 32
  24. // ProfileData is the getProfile payload.
  25. type ProfileData struct {
  26. ScreenName string `json:"screenName" xml:"screenName"`
  27. Profile string `json:"profile" xml:"profile"`
  28. LastUpdated int64 `json:"lastUpdated" xml:"lastUpdated"`
  29. }
  30. // SetStateData echoes the identity fields a setState changed.
  31. type SetStateData struct {
  32. AimID string `json:"aimId" xml:"aimId"`
  33. DisplayID string `json:"displayId" xml:"displayId"`
  34. State string `json:"state" xml:"state"`
  35. AwayMsg string `json:"awayMsg" xml:"awayMsg"`
  36. StatusMsg string `json:"statusMsg" xml:"statusMsg"`
  37. UserType string `json:"userType" xml:"userType"` // "aim", "icq"
  38. OnlineTime int64 `json:"onlineTime" xml:"onlineTime"`
  39. }
  40. // PresenceData contains presence information. Each query fills in one field and
  41. // leaves the other nil.
  42. //
  43. // omitzero, not omitempty: a query matching nothing must still render its key as an
  44. // empty array, since clients read data.groups and data.users strictly.
  45. type PresenceData struct {
  46. Groups []BuddyGroupInfo `json:"groups,omitzero" xml:"groups>group,omitempty"`
  47. Users []BuddyPresenceInfo `json:"users,omitzero" xml:"users>user,omitempty"`
  48. }
  49. // BuddyGroupInfo represents a buddy group with its members.
  50. type BuddyGroupInfo struct {
  51. Name string `json:"name" xml:"name"`
  52. Buddies []BuddyPresenceInfo `json:"buddies" xml:"buddies>buddy"`
  53. }
  54. // BuddyPresenceInfo represents presence information for a buddy.
  55. //
  56. // AimID is the normalized screen name the web client keys users by; DisplayID
  57. // preserves the casing and spacing the user signed on with. The client renders
  58. // DisplayID and falls back to AimID when it is absent.
  59. type BuddyPresenceInfo struct {
  60. AimID string `json:"aimId" xml:"aimId"`
  61. DisplayID string `json:"displayId,omitempty" xml:"displayId,omitempty"`
  62. Friendly string `json:"friendly,omitempty" xml:"friendly,omitempty"` // Viewer's private alias, rendered in preference to DisplayID
  63. State string `json:"state" xml:"state"` // "online", "offline", "away", "idle"
  64. StatusMsg string `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
  65. AwayMsg string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
  66. ProfileMsg string `json:"profileMsg,omitempty" xml:"profileMsg,omitempty"`
  67. IdleTime int `json:"idleTime,omitempty" xml:"idleTime,omitempty"`
  68. OnlineTime int64 `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
  69. UserType string `json:"userType" xml:"userType"` // "aim", "icq"
  70. Service string `json:"service,omitempty" xml:"service,omitempty"` // Non-native network; omitted for AIM
  71. BuddyIcon string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
  72. MoodIcon string `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
  73. // Profile carries member-directory fields, present only under mdir=1. It must be
  74. // non-nil even when empty: clients treat a missing profile as "not a user".
  75. Profile *BuddyProfileInfo `json:"profile,omitempty" xml:"profile,omitempty"`
  76. }
  77. // BuddyProfileInfo is the nested "profile" object carried under mdir=1. Gender and
  78. // birth date are absent because the directory record has nowhere to store them.
  79. type BuddyProfileInfo struct {
  80. FriendlyName string `json:"friendlyName,omitempty" xml:"friendlyName,omitempty"`
  81. FirstName string `json:"firstName,omitempty" xml:"firstName,omitempty"`
  82. LastName string `json:"lastName,omitempty" xml:"lastName,omitempty"`
  83. HomeAddress []BuddyAddressInfo `json:"homeAddress,omitempty" xml:"homeAddress,omitempty"`
  84. }
  85. // BuddyAddressInfo is one entry of a profile's homeAddress array.
  86. type BuddyAddressInfo struct {
  87. City string `json:"city,omitempty" xml:"city,omitempty"`
  88. State string `json:"state,omitempty" xml:"state,omitempty"`
  89. Country string `json:"country,omitempty" xml:"country,omitempty"`
  90. }
  91. // GetPresence handles GET /presence/get requests.
  92. func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, session *Session) {
  93. ctx := r.Context()
  94. aimsid := session.AimSID
  95. getBuddyList := r.URL.Query().Get("bl") == "1"
  96. wantProfileMsg := r.URL.Query().Get("profileMsg") == "1"
  97. // mdir asks for member-directory fields alongside presence.
  98. wantDirInfo := isTrueParam(r.URL.Query().Get("mdir"))
  99. targetUsers := targetNames(r)
  100. // Create PresenceData struct to hold the response data
  101. presenceData := PresenceData{}
  102. if getBuddyList {
  103. // Retrieve buddy list from feedbag
  104. groups, err := h.getBuddyListGroups(ctx, session, wantProfileMsg)
  105. if err != nil {
  106. h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
  107. // Return empty buddy list on error instead of failing
  108. groups = []BuddyGroupInfo{}
  109. }
  110. presenceData.Groups = groups
  111. } else if len(targetUsers) > 0 {
  112. // Get presence for specific users
  113. if len(targetUsers) > maxPresenceTargets {
  114. // truncate rather than reject
  115. h.Logger.WarnContext(ctx, "presence get: truncating oversized target list",
  116. "aimsid", session.AimSID,
  117. "requested", len(targetUsers),
  118. "cap", maxPresenceTargets,
  119. )
  120. targetUsers = targetUsers[:maxPresenceTargets]
  121. }
  122. presenceList := make([]BuddyPresenceInfo, 0, len(targetUsers))
  123. // The client's user-object merge deletes any alias it holds, so every
  124. // presence payload has to carry friendly for aliased buddies.
  125. aliases := session.Aliases(ctx)
  126. for _, user := range targetUsers {
  127. info := h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(user), wantProfileMsg)
  128. info.Friendly = aliases[info.AimID]
  129. if wantDirInfo {
  130. info.Profile = h.directoryProfile(ctx, user)
  131. }
  132. presenceList = append(presenceList, info)
  133. }
  134. presenceData.Users = presenceList
  135. } else {
  136. presenceData.Groups = []BuddyGroupInfo{}
  137. presenceData.Users = []BuddyPresenceInfo{}
  138. }
  139. SendOK(w, r, presenceData, h.Logger)
  140. h.Logger.DebugContext(ctx, "presence retrieved",
  141. "aimsid", aimsid,
  142. "buddy_list", getBuddyList,
  143. "targets", targetUsers,
  144. )
  145. }
  146. // directoryProfile reads a user's member-directory record for the mdir=1 profile
  147. // object. It never returns nil: a user with no directory record must still appear
  148. // as an empty profile rather than be dropped.
  149. func (h *PresenceHandler) directoryProfile(ctx context.Context, screenName string) *BuddyProfileInfo {
  150. profile := &BuddyProfileInfo{}
  151. reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: screenName})
  152. if err != nil {
  153. h.Logger.ErrorContext(ctx, "presence: directory lookup failed",
  154. "screenName", screenName, "err", err.Error())
  155. return profile
  156. }
  157. body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
  158. if !ok {
  159. return profile
  160. }
  161. profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
  162. profile.LastName, _ = body.String(wire.ODirTLVLastName)
  163. profile.FriendlyName, _ = body.String(wire.ODirTLVNickName)
  164. city, _ := body.String(wire.ODirTLVCity)
  165. stateName, _ := body.String(wire.ODirTLVState)
  166. country, _ := body.String(wire.ODirTLVCountry)
  167. if city != "" || stateName != "" || country != "" {
  168. profile.HomeAddress = []BuddyAddressInfo{{City: city, State: stateName, Country: country}}
  169. }
  170. return profile
  171. }
  172. // getBuddyListGroups retrieves the buddy list organized by groups.
  173. func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *Session, wantProfileMsg bool) ([]BuddyGroupInfo, error) {
  174. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  175. reply, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
  176. if err != nil {
  177. return nil, err
  178. }
  179. body, ok := reply.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  180. if !ok {
  181. return nil, fmt.Errorf("unexpected feedbag reply body type %T", reply.Body)
  182. }
  183. items := body.Items
  184. // Organize items into groups, keyed by GroupID. Group rows store their
  185. // identity in GroupID (ItemID is 0 for every group), so a GroupID-keyed map
  186. // is the only way to associate buddies — which reference their group via
  187. // GroupID — with the right group.
  188. groupMap := make(map[uint16]*BuddyGroupInfo)
  189. // First pass: identify groups. Skip the root group (GroupID 0), which holds
  190. // the master group order rather than buddies.
  191. for _, item := range items {
  192. if item.ClassID != wire.FeedbagClassIdGroup || item.GroupID == 0 {
  193. continue
  194. }
  195. name := item.Name
  196. if name == "" {
  197. name = "Buddies" // Default group name
  198. }
  199. groupMap[item.GroupID] = &BuddyGroupInfo{
  200. Name: name,
  201. Buddies: []BuddyPresenceInfo{},
  202. }
  203. }
  204. // Second pass: add buddies to their group with presence info.
  205. for _, item := range items {
  206. if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
  207. continue
  208. }
  209. group, exists := groupMap[item.GroupID]
  210. if !exists {
  211. // Orphan buddy whose group row is missing: synthesize a default
  212. // group for its GroupID so the buddy is not dropped.
  213. group = &BuddyGroupInfo{Name: "Buddies", Buddies: []BuddyPresenceInfo{}}
  214. groupMap[item.GroupID] = group
  215. }
  216. // UserInfoQuery performs the blocking check and online lookup; blocked or
  217. // offline buddies come back as "offline", preserving the list structure.
  218. presence := h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(item.Name), wantProfileMsg)
  219. group.Buddies = append(group.Buddies, presence)
  220. }
  221. // If no groups exist at all, return a single default group.
  222. if len(groupMap) == 0 {
  223. groupMap[0] = &BuddyGroupInfo{
  224. Name: "Buddies",
  225. Buddies: []BuddyPresenceInfo{},
  226. }
  227. }
  228. // Convert map to slice
  229. groups := make([]BuddyGroupInfo, 0, len(groupMap))
  230. for _, group := range groupMap {
  231. groups = append(groups, *group)
  232. }
  233. return groups, nil
  234. }
  235. // getUserPresence resolves a user's presence by issuing a locate UserInfoQuery
  236. // on behalf of the requesting OSCAR session (instance). UserInfoQuery performs
  237. // the OSCAR blocking check and online lookup internally: blocked and offline
  238. // users both come back as a locate error, which we surface as "offline".
  239. func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.SessionInstance, baseURL string, target state.DisplayScreenName, wantProfileMsg bool) BuddyPresenceInfo {
  240. ident := target.IdentScreenName()
  241. // Default offline presence
  242. presence := BuddyPresenceInfo{
  243. AimID: ident.String(),
  244. DisplayID: target.String(),
  245. State: "offline",
  246. UserType: userTypeFor(ident),
  247. Service: serviceFor(ident),
  248. }
  249. // The unauthenticated icon endpoint resolves presence without a session, so
  250. // there may be no OSCAR instance to query on behalf of.
  251. if instance == nil {
  252. return presence
  253. }
  254. reqType := wire.LocateTypeUnavailable // away message
  255. if wantProfileMsg {
  256. reqType |= wire.LocateTypeSig // profile text
  257. }
  258. reply, err := h.LocateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
  259. wire.SNAC_0x02_0x05_LocateUserInfoQuery{Type: uint16(reqType), ScreenName: ident.String()})
  260. if err != nil {
  261. h.Logger.WarnContext(ctx, "failed to query user info", "screenName", ident.String(), "error", err)
  262. return presence
  263. }
  264. info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply)
  265. if !ok {
  266. // Locate error => user is blocked or offline.
  267. return presence
  268. }
  269. presence.State = "online"
  270. // Publish the icon only now that locate has confirmed the user is online and
  271. // has not blocked the caller. Offline and blocking users return above without
  272. // an icon, so neither their icon nor its activity-revealing hash leaks to a
  273. // caller they are otherwise invisible to.
  274. presence.BuddyIcon = h.IconSource.PublishedURL(ctx, baseURL, ident)
  275. // The locate reply carries the screen name as the user formatted it, which
  276. // beats whatever casing the caller happened to pass in.
  277. if info.ScreenName != "" {
  278. presence.DisplayID = info.ScreenName
  279. }
  280. if tod, ok := info.Uint32BE(wire.OServiceUserInfoSignonTOD); ok {
  281. presence.OnlineTime = int64(tod)
  282. }
  283. if st := statusBitState(info.TLVUserInfo, instance.IdentScreenName().UIN() == 0); st != "" {
  284. presence.State = st
  285. } else if info.IsAway() {
  286. presence.State = "away"
  287. }
  288. if idle, ok := info.Uint16BE(wire.OServiceUserInfoIdleTime); ok && idle > 0 {
  289. presence.State = "idle"
  290. presence.IdleTime = int(idle)
  291. }
  292. if msg, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData); ok {
  293. presence.AwayMsg = msg
  294. }
  295. if wantProfileMsg {
  296. if prof, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoSigData); ok {
  297. presence.ProfileMsg = prof
  298. }
  299. }
  300. presence.MoodIcon = moodIconURL(baseURL, presence.State, userInfoCaps(info.TLVUserInfo))
  301. presence.StatusMsg = userStatusMsg(info.TLVUserInfo)
  302. return presence
  303. }
  304. // SetState handles GET /presence/setState requests to update user's presence state.
  305. func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, session *Session) {
  306. ctx := r.Context()
  307. stateParam := r.URL.Query().Get("state")
  308. if stateParam == "" {
  309. stateParam = r.URL.Query().Get("view")
  310. }
  311. awayMsg := r.URL.Query().Get("awayMsg")
  312. if awayMsg == "" {
  313. awayMsg = r.URL.Query().Get("away")
  314. }
  315. oscarSession := session.OSCARSession
  316. // Map web state to OSCAR status bits. setAwayMsg reports whether the away
  317. // message needs rewriting: only going online clears it, and only an away
  318. // state with text replaces it, so the other states keep what is there.
  319. var statusBitmask uint32
  320. var setAwayMsg bool
  321. switch stateParam {
  322. case "online":
  323. statusBitmask = 0x0000 // Clear all status bits
  324. awayMsg = ""
  325. setAwayMsg = true
  326. case "away":
  327. statusBitmask = wire.OServiceUserStatusAway
  328. setAwayMsg = awayMsg != ""
  329. case "invisible":
  330. statusBitmask = wire.OServiceUserStatusInvisible
  331. case "dnd":
  332. statusBitmask = wire.OServiceUserStatusDND
  333. case "occupied":
  334. // ICQ's Busy, a distinct status bit from DND.
  335. statusBitmask = wire.OServiceUserStatusBusy
  336. default:
  337. SendError(w, r, http.StatusBadRequest, "invalid state parameter")
  338. return
  339. }
  340. // Set the away message first, so that the user info update the status change
  341. // relays back carries the new message.
  342. if setAwayMsg {
  343. setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
  344. TLVRestBlock: wire.TLVRestBlock{
  345. TLVList: wire.TLVList{
  346. wire.NewTLVBE(wire.LocateTLVTagsInfoUnavailableData, awayMsg),
  347. },
  348. },
  349. }
  350. if err := h.LocateService.SetInfo(ctx, oscarSession, setInfo); err != nil {
  351. h.Logger.ErrorContext(ctx, "failed to set away message", "err", err.Error())
  352. SendError(w, r, http.StatusInternalServerError, "failed to set state")
  353. return
  354. }
  355. }
  356. setFields := wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
  357. TLVRestBlock: wire.TLVRestBlock{
  358. TLVList: wire.TLVList{
  359. wire.NewTLVBE(wire.OServiceUserInfoStatus, statusBitmask),
  360. },
  361. },
  362. }
  363. if err := h.OServiceService.SetUserInfoFields(ctx, oscarSession, wire.SNACFrame{}, setFields); err != nil {
  364. h.Logger.ErrorContext(ctx, "failed to set user info fields", "err", err.Error())
  365. SendError(w, r, http.StatusInternalServerError, "failed to set state")
  366. return
  367. }
  368. reportedState := stateParam
  369. if st := statusMaskState(statusBitmask, oscarSession.IdentScreenName().UIN() == 0); st != "" {
  370. reportedState = st
  371. }
  372. h.Logger.InfoContext(ctx, "presence state updated",
  373. "screenName", session.ScreenName.String(),
  374. "state", stateParam,
  375. "hasAwayMsg", awayMsg != "",
  376. )
  377. SendOK(w, r, &SetStateData{
  378. AimID: session.ScreenName.IdentScreenName().String(),
  379. DisplayID: session.ScreenName.String(),
  380. State: reportedState,
  381. AwayMsg: awayMsg,
  382. StatusMsg: sessionStatusMsg(oscarSession),
  383. UserType: userTypeFor(session.ScreenName.IdentScreenName()),
  384. OnlineTime: time.Now().Unix(),
  385. }, h.Logger)
  386. }
  387. // SetStatus handles GET /presence/setStatus requests to update user's status message.
  388. func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, session *Session) {
  389. ctx := r.Context()
  390. statusMsg := r.URL.Query().Get("statusMsg")
  391. statusCode := r.URL.Query().Get("statusCode")
  392. if r.URL.Query().Has("statusMsg") {
  393. var bid wire.BARTID
  394. if err := bid.SetStatusText(statusMsg); err != nil {
  395. if errors.Is(err, wire.ErrStatusTextSizeExceeded) {
  396. SendError(w, r, http.StatusBadRequest, err.Error())
  397. } else {
  398. h.Logger.ErrorContext(ctx, "failed to marshal status message", "err", err.Error())
  399. SendError(w, r, http.StatusInternalServerError, "failed to set status")
  400. }
  401. return
  402. }
  403. setFields := wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields{
  404. TLVRestBlock: wire.TLVRestBlock{
  405. TLVList: wire.TLVList{
  406. wire.NewTLVBE(wire.OServiceUserInfoBARTInfo, bid),
  407. },
  408. },
  409. }
  410. if err := h.OServiceService.SetUserInfoFields(ctx, session.OSCARSession, wire.SNACFrame{}, setFields); err != nil {
  411. h.Logger.ErrorContext(ctx, "failed to set user info fields", "err", err.Error())
  412. SendError(w, r, http.StatusInternalServerError, "failed to set status")
  413. return
  414. }
  415. }
  416. if r.URL.Query().Has("mood") {
  417. moodID := r.URL.Query().Get("mood")
  418. if moodID == "" {
  419. session.ClearMood()
  420. } else {
  421. m, hasMood := wire.MoodByID(moodID)
  422. if !hasMood {
  423. SendError(w, r, http.StatusBadRequest, "invalid mood ID")
  424. return
  425. }
  426. session.SetMood(m.Cap)
  427. }
  428. setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
  429. TLVRestBlock: wire.TLVRestBlock{
  430. TLVList: wire.TLVList{
  431. wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, session.Caps()),
  432. },
  433. },
  434. }
  435. if err := h.LocateService.SetInfo(ctx, session.OSCARSession, setInfo); err != nil {
  436. h.Logger.ErrorContext(ctx, "failed to set mood capability", "err", err.Error())
  437. SendError(w, r, http.StatusInternalServerError, "failed to save status")
  438. return
  439. }
  440. }
  441. h.Logger.InfoContext(ctx, "status message updated",
  442. "screenName", session.ScreenName.String(),
  443. "statusMsg", statusMsg,
  444. "statusCode", statusCode,
  445. "mood", r.URL.Query().Get("mood"),
  446. )
  447. SendOK(w, r, nil, h.Logger)
  448. }
  449. // SetProfile handles GET /presence/setProfile requests to update user's profile.
  450. func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, session *Session) {
  451. ctx := r.Context()
  452. profileText := r.URL.Query().Get("profile")
  453. if len(profileText) > 4096 {
  454. SendError(w, r, http.StatusBadRequest, "profile too large (max 4KB)")
  455. return
  456. }
  457. instance := session.OSCARSession
  458. // Save profile via OSCAR LocateService.
  459. setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
  460. TLVRestBlock: wire.TLVRestBlock{
  461. TLVList: wire.TLVList{
  462. wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, profileText),
  463. },
  464. },
  465. }
  466. if err := h.LocateService.SetInfo(ctx, instance, setInfo); err != nil {
  467. h.Logger.ErrorContext(ctx, "failed to set profile", "err", err.Error())
  468. SendError(w, r, http.StatusInternalServerError, "failed to save profile")
  469. return
  470. }
  471. h.Logger.InfoContext(ctx, "profile updated",
  472. "screenName", session.ScreenName.String(),
  473. "profileSize", len(profileText),
  474. )
  475. SendOK(w, r, nil, h.Logger)
  476. }
  477. // GetProfile handles GET /presence/getProfile requests to retrieve user's profile.
  478. func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, session *Session) {
  479. ctx := r.Context()
  480. targetSN := r.URL.Query().Get("sn")
  481. if targetSN == "" {
  482. targetSN = session.ScreenName.String()
  483. }
  484. var profileText string
  485. instance := session.OSCARSession
  486. reply, err := h.LocateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
  487. wire.SNAC_0x02_0x05_LocateUserInfoQuery{Type: uint16(wire.LocateTypeSig), ScreenName: targetSN})
  488. if err != nil {
  489. h.Logger.WarnContext(ctx, "failed to get profile", "err", err.Error())
  490. } else if info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply); ok {
  491. if prof, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoSigData); ok {
  492. profileText = prof
  493. }
  494. }
  495. responseData := &ProfileData{ScreenName: targetSN, Profile: profileText}
  496. SendOK(w, r, responseData, h.Logger)
  497. }
  498. // Icon handles GET /presence/icon requests for presence icons.
  499. func (h *PresenceHandler) Icon(w http.ResponseWriter, r *http.Request) {
  500. name := r.URL.Query().Get("name")
  501. size := r.URL.Query().Get("size")
  502. iconType := r.URL.Query().Get("type")
  503. if name == "" {
  504. SendError(w, r, http.StatusBadRequest, "missing name parameter")
  505. return
  506. }
  507. // Default values
  508. if size == "" {
  509. size = "32"
  510. }
  511. if iconType == "" {
  512. iconType = "aim"
  513. }
  514. // For now, redirect to a placeholder icon
  515. // In production, this would redirect to actual icon storage/CDN
  516. var iconURL string
  517. // If it's an email lookup, extract username
  518. if strings.Contains(name, "@") {
  519. parts := strings.Split(name, "@")
  520. if len(parts) > 0 {
  521. name = parts[0]
  522. }
  523. }
  524. // Resolve the target's presence via OSCAR LocateService, querying on behalf
  525. // of the caller's session. This endpoint is unauthenticated, so fall back to
  526. // the offline icon when no valid session is supplied.
  527. var instance *state.SessionInstance
  528. if aimsid := r.URL.Query().Get("aimsid"); aimsid != "" {
  529. if session, err := h.SessionManager.GetSession(r.Context(), aimsid); err == nil {
  530. instance = session.OSCARSession
  531. }
  532. }
  533. // This endpoint serves a presence state badge, not the user's buddy icon, so
  534. // it has no use for a buddy icon URL.
  535. switch h.getUserPresence(r.Context(), instance, "", state.DisplayScreenName(name), false).State {
  536. // occupied and dnd have no badge of their own and both mean unavailable.
  537. case "away", "occupied", "dnd":
  538. iconURL = "/static/icons/away_" + iconType + "_" + size + ".png"
  539. case "idle":
  540. iconURL = "/static/icons/idle_" + iconType + "_" + size + ".png"
  541. case "offline":
  542. iconURL = "/static/icons/offline_" + iconType + "_" + size + ".png"
  543. default:
  544. iconURL = "/static/icons/online_" + iconType + "_" + size + ".png"
  545. }
  546. // Redirect to icon URL
  547. http.Redirect(w, r, iconURL, http.StatusFound)
  548. }
  549. // statusBitState reports the web state named by a user's ICQ status bits, or ""
  550. // when neither Busy nor DND is set. Callers must consult it before IsAway(): Busy
  551. // and DND also raise the unavailable flag, so an away-first test reports every busy
  552. // user as away.
  553. func statusBitState(info wire.TLVUserInfo, isAIMCaller bool) string {
  554. status, ok := info.Uint32BE(wire.OServiceUserInfoStatus)
  555. if !ok {
  556. return ""
  557. }
  558. return statusMaskState(status, isAIMCaller)
  559. }
  560. // statusMaskState names the web state a status bitmask describes, or "" when
  561. // neither Busy nor DND is set.
  562. func statusMaskState(status uint32, isAIMCaller bool) string {
  563. var st string
  564. switch {
  565. case status&wire.OServiceUserStatusBusy != 0:
  566. st = "occupied"
  567. case status&wire.OServiceUserStatusDND != 0:
  568. st = "dnd"
  569. default:
  570. return ""
  571. }
  572. if isAIMCaller {
  573. return "away"
  574. }
  575. return st
  576. }
  577. // selfWebState maps a user's own user info block to the web state string the
  578. // clients expect ("online", "away", "idle", "invisible", "occupied", "dnd").
  579. // Invisibility yields "invisible", not the "offline" a buddy sees.
  580. func selfWebState(info wire.TLVUserInfo, isAIMCaller bool) string {
  581. if info.IsInvisible() {
  582. return "invisible"
  583. }
  584. if st := statusBitState(info, isAIMCaller); st != "" {
  585. return st
  586. }
  587. if info.IsAway() {
  588. return "away"
  589. }
  590. if mask, ok := info.Uint32BE(wire.OServiceUserInfoStatus); ok && mask&wire.OServiceUserStatusAway != 0 {
  591. return "away"
  592. }
  593. if idle, ok := info.Uint16BE(wire.OServiceUserInfoIdleTime); ok && idle > 0 {
  594. return "idle"
  595. }
  596. return "online"
  597. }
  598. // userInfoCaps returns the capability UUIDs a user info block advertises.
  599. func userInfoCaps(info wire.TLVUserInfo) [][16]byte {
  600. b, ok := info.Bytes(wire.OServiceUserInfoOscarCaps)
  601. if !ok {
  602. return nil
  603. }
  604. caps := make([][16]byte, 0, len(b)/16)
  605. for chunk := range slices.Chunk(b, 16) {
  606. if len(chunk) < 16 {
  607. break
  608. }
  609. caps = append(caps, [16]byte(chunk))
  610. }
  611. return caps
  612. }
  613. // moodIconURL returns the mood icon URL for the mood advertised in caps, or ""
  614. // when there is none. A mood supersedes webState on the client, so a user who is
  615. // not visibly online never gets one.
  616. func moodIconURL(baseURL, webState string, caps [][16]byte) string {
  617. if webState == "offline" || webState == "invisible" {
  618. return ""
  619. }
  620. for _, c := range caps {
  621. if m, ok := wire.MoodByCap(c); ok {
  622. return baseURL + "/mood?id=" + wire.MoodIconID(m.ID)
  623. }
  624. }
  625. return ""
  626. }