presence_handler.go 24 KB

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