session.go 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161
  1. package webapi
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/rand"
  6. "encoding/hex"
  7. "errors"
  8. "log/slog"
  9. mrand "math/rand/v2"
  10. "slices"
  11. "sort"
  12. "strconv"
  13. "strings"
  14. "sync"
  15. "time"
  16. "github.com/google/uuid"
  17. "github.com/mk6i/open-oscar-server/state"
  18. "github.com/mk6i/open-oscar-server/wire"
  19. )
  20. var (
  21. // ErrNoWebAPISession is returned when a WebAPI session is not found.
  22. ErrNoWebAPISession = errors.New("WebAPI session not found")
  23. // ErrWebAPISessionExpired is returned when a WebAPI session has expired.
  24. ErrWebAPISessionExpired = errors.New("WebAPI session expired")
  25. // ErrWebAPISessionManagerClosed is returned when a session is requested from
  26. // a manager that has been shut down.
  27. ErrWebAPISessionManagerClosed = errors.New("WebAPI session manager is shut down")
  28. )
  29. // Web API session lifecycle timeline.
  30. //
  31. // A web client keeps its session alive by long-polling GET /aim/fetchEvents.
  32. // Every authenticated request touches the session (middleware.RequireSession
  33. // calls TouchSession at request arrival), sliding expiry to now + the TTL. A
  34. // single poll blocks for up to 60s (the fetchEvents long-poll cap) and the
  35. // client waits ~500ms (TimeToNextFetch) before re-polling, so in steady state a
  36. // healthy client touches the session at worst every ~60-65s once jitter is
  37. // included. That worst-case touch interval is the floor the TTL must clear.
  38. //
  39. // If a client hangs up without calling endSession, its last touch was at its
  40. // last poll: the session then expires webAPISessionTTL later and the reaper
  41. // sweeps it within one webAPISessionReapInterval tick. So a silent client is
  42. // removed (and its OSCAR session closed) within TTL + tick of going quiet.
  43. const (
  44. // webAPISessionTTL bounds how long a session survives without a poll. It is
  45. // sized to absorb one missed poll cycle: ~60s for the normal cycle, ~60s for
  46. // the absorbed miss, plus ~20s of jitter margin. Two consecutive misses mean
  47. // the client is genuinely gone and the session is reaped.
  48. webAPISessionTTL = 150 * time.Second
  49. // webAPISessionReapInterval is how often the cleanup goroutine sweeps for
  50. // expired sessions (~TTL/5). A dead session lingers at most
  51. // webAPISessionTTL + webAPISessionReapInterval before removal.
  52. webAPISessionReapInterval = 30 * time.Second
  53. )
  54. // webAPICaps are the capabilities the Web API advertises on behalf of its
  55. // clients. It seeds every session's capability list and is sent as-is at
  56. // sign-on, so the two never drift.
  57. var webAPICaps = [][16]byte{wire.CapICQCh2Extended}
  58. // Session represents an active Web AIM API session.
  59. type Session struct {
  60. AimSID string // Unique session ID for web client
  61. ScreenName state.DisplayScreenName // User identity
  62. OSCARSession *state.SessionInstance // Bridge to existing OSCAR session
  63. BaseURL string // Web API base URL advertised to the web client, used to build absolute asset URLs
  64. Events []string // Subscribed event types
  65. EventQueue *EventQueue // Per-session event queue
  66. ClientName string // Client application name
  67. ClientVersion string // Client application version
  68. CreatedAt time.Time // SessionInstance creation time
  69. LastAccessed time.Time // Last activity time
  70. ExpiresAt time.Time // SessionInstance expiration time
  71. FetchTimeout int // Long-polling timeout in milliseconds
  72. TimeToNextFetch int // Suggested delay before next fetch
  73. RemoteAddr string // Client IP address
  74. BuddyListRefresher func(ctx context.Context) (any, error) // Called on feedbag changes to push buddylist event
  75. PermitDenyRefresher func(ctx context.Context) (any, error) // Called on feedbag changes to push permitDeny event
  76. BuddyAliasLoader func(ctx context.Context) (map[string]string, error)
  77. // BuddyIconURL formats the absolute buddyIcon URL for a buddy from the icon
  78. // hash carried in a presence SNAC. Returns "" when no URL can be published.
  79. BuddyIconURL func(screenName state.IdentScreenName, hash []byte) string
  80. aliases map[string]string // cached BuddyAliasLoader result, nil when unloaded or invalidated
  81. aliasMu sync.Mutex
  82. imLog map[string][]WebAPIStoredIM
  83. imLogMu sync.Mutex
  84. sentIMs map[uint64]string // OSCAR message cookie -> the msgId given to the client
  85. sentIMOrder []uint64 // insertion order of sentIMs, oldest first
  86. sentIMMu sync.Mutex
  87. // IMRateClassID is the rate class that sending an IM spends. The web client
  88. // renders any rate limit event as the IM banner, so only this class's updates
  89. // may reach it. Zero disables the alert.
  90. IMRateClassID wire.RateLimitClassID
  91. logger *slog.Logger // Logger for debugging
  92. listeners sync.WaitGroup
  93. ctx context.Context
  94. cancel context.CancelFunc
  95. closeMu sync.Mutex
  96. closed bool
  97. capabilities [][16]byte
  98. capsMu sync.RWMutex
  99. }
  100. // IsExpired checks if the session has expired.
  101. func (s *Session) IsExpired() bool {
  102. return time.Now().After(s.ExpiresAt)
  103. }
  104. // Aliases returns this session owner's private buddy aliases, keyed by normalized
  105. // screen name. Aliases live in the owner's feedbag, so the map is loaded once and
  106. // cached until a feedbag change invalidates it: a signon that brings a large buddy
  107. // list online costs one feedbag query instead of one per buddy.
  108. //
  109. // The map is owned by the session and must not be mutated by callers.
  110. //
  111. // aliasMu is deliberately held across the load rather than released while the
  112. // feedbag is queried. Another instance of the owner can rename a buddy mid-query,
  113. // and its FeedbagUpdateItem SNAC invalidates this cache; if the load ran outside
  114. // the lock, that query's pre-rename result could be stored *after* the
  115. // invalidation and serve the old alias until the next feedbag change. Holding the
  116. // lock makes the invalidation wait for the load and then win.
  117. func (s *Session) Aliases(ctx context.Context) map[string]string {
  118. s.aliasMu.Lock()
  119. defer s.aliasMu.Unlock()
  120. // The loader is wired after the session is created, so an event arriving in
  121. // that window has no way to resolve aliases.
  122. if s.BuddyAliasLoader == nil {
  123. return nil
  124. }
  125. if s.aliases == nil {
  126. aliases, err := s.BuddyAliasLoader(ctx)
  127. if err != nil {
  128. s.logger.Error("failed to load buddy aliases", "err", err.Error())
  129. return nil
  130. }
  131. s.aliases = aliases
  132. }
  133. return s.aliases
  134. }
  135. // InvalidateAliases drops the cached alias map so the next Aliases call reloads it.
  136. // Callers that change the owner's feedbag must call this: the feedbag service
  137. // relays FeedbagUpdateItem only to the owner's *other* instances, so a session
  138. // never sees a SNAC for its own writes.
  139. func (s *Session) InvalidateAliases() {
  140. s.aliasMu.Lock()
  141. defer s.aliasMu.Unlock()
  142. s.aliases = nil
  143. }
  144. // aliasFor returns this session owner's private alias for buddy, or "" when none is
  145. // set. The web client deletes the alias it holds whenever it merges a user map, so
  146. // every event naming a buddy has to repeat it.
  147. func (s *Session) aliasFor(buddy state.IdentScreenName) string {
  148. // Runs on the SNAC listener goroutine, which has no request context.
  149. return s.Aliases(s.ctx)[buddy.String()]
  150. }
  151. // Touch updates the last accessed time and extends expiration if needed.
  152. func (s *Session) Touch() {
  153. s.LastAccessed = time.Now()
  154. newExpiry := s.LastAccessed.Add(webAPISessionTTL)
  155. if newExpiry.After(s.ExpiresAt) {
  156. s.ExpiresAt = newExpiry
  157. }
  158. }
  159. // IsSubscribedTo checks if the session is subscribed to a specific event type.
  160. func (s *Session) IsSubscribedTo(eventType string) bool {
  161. return slices.Contains(s.Events, eventType)
  162. }
  163. // StartListeningToOSCARSession starts a goroutine that listens to the OSCAR session's
  164. // message channel and converts SNAC messages into WebAPI events.
  165. func (s *Session) StartListeningToOSCARSession() {
  166. s.closeMu.Lock()
  167. defer s.closeMu.Unlock()
  168. if s.closed {
  169. return
  170. }
  171. s.listeners.Go(func() {
  172. msgCh := s.OSCARSession.ReceiveMessage()
  173. for {
  174. select {
  175. case msg, ok := <-msgCh:
  176. if !ok {
  177. // Channel closed, OSCAR session ended
  178. return
  179. }
  180. s.handleSNACMessage(msg)
  181. case <-s.OSCARSession.Closed():
  182. // The OSCAR instance went away without this session asking — a
  183. // boot, a rate-limit disconnect. Tell the client rather than
  184. // leaving its parked fetcher to hang: a sessionEnded event
  185. // releases the poll at once and the client signs off on the
  186. // spot, instead of waiting out the reaper's next sweep.
  187. //
  188. // A teardown this session started needs no event, and gets
  189. // none: Close closes the queue before it closes the instance,
  190. // so this Push is a no-op on that path.
  191. s.EventQueue.Push(EventTypeSessionEnded, struct{}{})
  192. return
  193. }
  194. }
  195. })
  196. }
  197. // Close tears down the session: it releases any parked event fetchers, closes
  198. // the OSCAR instance, and waits for the listener goroutine to unwind. Safe to
  199. // call more than once.
  200. func (s *Session) Close() {
  201. s.closeMu.Lock()
  202. if s.closed {
  203. s.closeMu.Unlock()
  204. return
  205. }
  206. s.closed = true
  207. s.closeMu.Unlock()
  208. s.EventQueue.Close()
  209. s.OSCARSession.CloseInstance()
  210. s.cancel()
  211. s.listeners.Wait()
  212. }
  213. // handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
  214. func (s *Session) handleSNACMessage(msg wire.SNACMessage) {
  215. // Convert SNAC message to WebAPI events based on food group and subgroup
  216. switch msg.Frame.FoodGroup {
  217. case wire.ICBM:
  218. s.handleICBMMessage(msg)
  219. case wire.Buddy:
  220. s.handleBuddyMessage(msg)
  221. case wire.Feedbag:
  222. s.handleFeedbagMessage(msg)
  223. case wire.OService:
  224. s.handleOServiceMessage(msg)
  225. }
  226. }
  227. // handleOServiceMessage handles OService SNAC messages relayed to the session's
  228. // own OSCAR instance.
  229. func (s *Session) handleOServiceMessage(msg wire.SNACMessage) {
  230. switch msg.Frame.SubGroup {
  231. case wire.OServiceUserInfoUpdate:
  232. s.handleUserInfoUpdate(msg)
  233. case wire.OServiceRateParamChange:
  234. s.handleRateLimitUpdate(msg)
  235. }
  236. }
  237. // handleUserInfoUpdate surfaces OServiceUserInfoUpdate, which the server relays to
  238. // a user when their own user info changes (notably a buddy icon upload or clear).
  239. // The client re-renders its identity badge from myInfo events only, so we
  240. // translate this into a fresh myInfo. The away message is the one field read off
  241. // the session, since no user info block carries the text.
  242. func (s *Session) handleUserInfoUpdate(msg wire.SNACMessage) {
  243. if !s.IsSubscribedTo("myInfo") && !s.IsSubscribedTo("presence") {
  244. return
  245. }
  246. body, ok := msg.Body.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
  247. if !ok || len(body.UserInfo) == 0 {
  248. return
  249. }
  250. info := body.UserInfo[0]
  251. screenName := state.DisplayScreenName(info.ScreenName)
  252. webState := selfWebState(info, screenName.IdentScreenName().UIN() == 0)
  253. // A missing icon TLV yields a nil hash, which publishes the placeholder URL
  254. // and so clears an icon the client still holds.
  255. hash := buddyIconHash(info)
  256. myInfo := buildMyInfo(
  257. screenName,
  258. webState,
  259. s.BuddyIconURL(screenName.IdentScreenName(), hash),
  260. moodIconURL(s.BaseURL, webState, userInfoCaps(info)),
  261. )
  262. myInfo.AwayMsg = s.OSCARSession.Session().AwayMessage()
  263. myInfo.StatusMsg = userStatusMsg(info)
  264. s.EventQueue.Push(EventTypeMyInfo, myInfo)
  265. }
  266. // handleRateLimitUpdate translates a rate limit status change — broadcast by the
  267. // account's rate limit monitor — into a rateLimit event. Only the IM class is
  268. // surfaced, since the client feeds any rateLimit event into the
  269. // conversation-window alert. Code 1 is a class-params change, not a status
  270. // transition, and is ignored.
  271. func (s *Session) handleRateLimitUpdate(msg wire.SNACMessage) {
  272. if s.IMRateClassID == 0 {
  273. return
  274. }
  275. body, ok := msg.Body.(wire.SNAC_0x01_0x0A_OServiceRateParamsChange)
  276. if !ok {
  277. return
  278. }
  279. if wire.RateLimitClassID(body.Rate.ID) != s.IMRateClassID {
  280. return
  281. }
  282. var status string
  283. switch body.Code {
  284. case 2:
  285. status = "warn"
  286. case 3:
  287. status = "limit"
  288. case 4:
  289. status = "clear"
  290. default:
  291. return
  292. }
  293. s.EventQueue.Push(EventTypeRateLimit, RateLimitEvent{
  294. Classes: []RateLimitClass{
  295. {ID: int(body.Rate.ID), Status: status},
  296. },
  297. })
  298. }
  299. // handleICBMMessage handles ICBM (instant messaging) SNAC messages.
  300. func (s *Session) handleICBMMessage(msg wire.SNACMessage) {
  301. switch msg.Frame.SubGroup {
  302. case wire.ICBMChannelMsgToClient:
  303. s.handleIncomingIM(msg)
  304. case wire.ICBMClientEvent:
  305. s.handleTypingNotification(msg)
  306. case wire.ICBMClientErr:
  307. s.handleClientError(msg)
  308. }
  309. }
  310. // handleIncomingIM handles incoming instant messages.
  311. func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
  312. body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
  313. if !ok {
  314. return
  315. }
  316. // A send time is only stamped on a message replayed out of the offline store,
  317. // so its presence marks this as a delivery of something sent while the user was
  318. // signed off, and carries the moment the sender actually sent it.
  319. sentTime, isOffline := body.Uint32BE(wire.ICBMTLVSendTime)
  320. // Retrieval answers only the instance that asked, and StartSession asks only
  321. // when the client subscribed to offlineIM, so a stamped message here is one
  322. // this session requested. A live IM still needs the im subscription.
  323. if !isOffline && !s.IsSubscribedTo("im") {
  324. return
  325. }
  326. // Extract message text from TLV data
  327. var messageText string
  328. if msgData, hasMsg := body.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
  329. if text, err := wire.UnmarshalICBMMessageText(msgData); err == nil {
  330. messageText = text
  331. }
  332. }
  333. if messageText == "" {
  334. return
  335. }
  336. // Check if it's an auto-response (channel 2)
  337. autoResponse := body.ChannelID == 0x0002
  338. // msgId must be unique per delivered event. The OSCAR cookie is not a
  339. // reliable unique id (some clients reuse it across messages), and the web
  340. // client dedupes its conversation list by msgId, silently dropping any
  341. // collisions. Mint a fresh random id instead of reusing body.Cookie.
  342. msgID := strconv.FormatUint(mrand.Uint64(), 16)
  343. // SNAC user info carries the sender's display screen name. The web client
  344. // keys conversations and users by the normalized aimId and only renders
  345. // displayId, so the two forms must not be interchanged.
  346. partnerDisplay := body.ScreenName
  347. partner := state.NewIdentScreenName(partnerDisplay)
  348. partnerAimID := partner.String()
  349. // An offline message is logged under the time it was sent, so the stored-IM
  350. // history it lands in stays in the order the conversation happened.
  351. timestamp := time.Now().Unix()
  352. if isOffline {
  353. timestamp = int64(sentTime)
  354. }
  355. s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, timestamp)
  356. if isOffline {
  357. // The client resolves an offline sender from aimId and friendly alone,
  358. // so friendly falls back to the sender's own formatting when the viewer
  359. // has no alias for them.
  360. friendly := s.aliasFor(partner)
  361. if friendly == "" {
  362. friendly = partnerDisplay
  363. }
  364. s.EventQueue.Push(EventTypeOfflineIM, OfflineIMEvent{
  365. AimID: partnerAimID,
  366. Friendly: friendly,
  367. Message: messageText,
  368. MsgID: msgID,
  369. Timestamp: timestamp,
  370. Imf: imfPlainText,
  371. AutoResp: autoResponse,
  372. })
  373. s.logger.Debug("delivered offline instant message",
  374. "from", partnerDisplay,
  375. "to", s.ScreenName,
  376. "sent", timestamp)
  377. } else {
  378. s.EventQueue.Push(EventTypeIM, IMEvent{
  379. Source: UserInfo{
  380. AimID: partnerAimID,
  381. DisplayID: partnerDisplay,
  382. Friendly: s.aliasFor(partner),
  383. UserType: userTypeFor(partner),
  384. State: "online",
  385. },
  386. Message: messageText,
  387. MsgID: msgID,
  388. Timestamp: timestamp,
  389. Imf: imfPlainText,
  390. AutoResp: autoResponse,
  391. })
  392. s.logger.Debug("delivered instant message",
  393. "from", partnerDisplay,
  394. "to", s.ScreenName)
  395. }
  396. if s.IsSubscribedTo("conversation") {
  397. // unread is 0 here, not 1, because the "im"/"offlineIM" event pushed above
  398. // already causes the client to increment its own persisted per-buddy unread
  399. // tally. The "Recent chats" badge is the sum of that persisted tally and
  400. // this conversation's unreadCount, so sending 1 here would double-count
  401. // the message (badge shows 2 for the first IM). Mirrors the sent-IM path,
  402. // which also passes 0.
  403. s.EventQueue.Push(EventTypeConversation, ConversationEventData("update", []ConversationEntryData{
  404. ConversationEntry(
  405. partnerAimID,
  406. partnerDisplay,
  407. messageText,
  408. msgID,
  409. partnerAimID,
  410. false,
  411. 0,
  412. ),
  413. }))
  414. }
  415. }
  416. // handleClientError translates ICBMClientErr — the recipient reporting that it
  417. // could not handle a message already delivered to it — into a clientError event
  418. // for the sender. Only OSCAR clients raise this SNAC.
  419. func (s *Session) handleClientError(msg wire.SNACMessage) {
  420. if !s.IsSubscribedTo("im") {
  421. return
  422. }
  423. body, ok := msg.Body.(wire.SNAC_0x04_0x0B_ICBMClientErr)
  424. if !ok {
  425. return
  426. }
  427. // The SNAC names the erroring party by their own formatting, so both the
  428. // normalized aimId and displayId are sent, along with the viewer's alias.
  429. sender := state.NewIdentScreenName(body.ScreenName)
  430. channel := "im"
  431. if body.ChannelID == wire.ICBMChannelRendezvous {
  432. channel = "data"
  433. }
  434. s.EventQueue.Push(EventTypeClientError, ClientErrorEvent{
  435. Source: UserInfo{
  436. AimID: sender.String(),
  437. DisplayID: body.ScreenName,
  438. Friendly: s.aliasFor(sender),
  439. UserType: userTypeFor(sender),
  440. },
  441. Cookie: s.msgIDForCookie(body.Cookie),
  442. Channel: channel,
  443. })
  444. }
  445. // handleTypingNotification handles typing notifications.
  446. func (s *Session) handleTypingNotification(msg wire.SNACMessage) {
  447. if !s.IsSubscribedTo("typing") {
  448. return
  449. }
  450. body, ok := msg.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)
  451. if !ok {
  452. return
  453. }
  454. // Event types: 0x0000=none, 0x0001=typed (paused), 0x0002=typing
  455. var typingStatus string
  456. switch body.Event {
  457. case 0x0002:
  458. typingStatus = "typing"
  459. case 0x0001:
  460. typingStatus = "typed"
  461. default:
  462. typingStatus = "none"
  463. }
  464. typingEvent := TypingEvent{
  465. AimID: state.NewIdentScreenName(body.ScreenName).String(),
  466. TypingStatus: typingStatus,
  467. }
  468. s.EventQueue.Push(EventTypeTyping, typingEvent)
  469. }
  470. // handleBuddyMessage handles buddy/presence SNAC messages.
  471. func (s *Session) handleBuddyMessage(msg wire.SNACMessage) {
  472. switch msg.Frame.SubGroup {
  473. case wire.BuddyArrived:
  474. s.handleBuddyArrived(msg)
  475. case wire.BuddyDeparted:
  476. s.handleBuddyDeparted(msg)
  477. }
  478. }
  479. // handleBuddyArrived handles when a buddy comes online.
  480. func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
  481. if !s.IsSubscribedTo("presence") {
  482. return
  483. }
  484. body, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
  485. if !ok {
  486. return
  487. }
  488. stateStr := "online"
  489. // For BuddyArrived updates, infer presence state from the TLVUserInfo.
  490. // Away and invisible transitions are typically broadcast using BuddyArrived
  491. // with updated user flags/status bits, not BuddyDeparted.
  492. if body.IsInvisible() {
  493. stateStr = "offline"
  494. } else if st := statusBitState(body.TLVUserInfo, s.ScreenName.IdentScreenName().UIN() == 0); st != "" {
  495. stateStr = st
  496. } else if body.IsAway() {
  497. stateStr = "away"
  498. } else if mask, ok := body.Uint32BE(wire.OServiceUserInfoStatus); ok && mask&wire.OServiceUserStatusAway != 0 {
  499. stateStr = "away"
  500. }
  501. buddy := state.NewIdentScreenName(body.ScreenName)
  502. presenceEvent := PresenceEvent{
  503. AimID: buddy.String(),
  504. Friendly: s.aliasFor(buddy),
  505. State: stateStr,
  506. UserType: userTypeFor(buddy),
  507. }
  508. presenceEvent.MoodIcon = moodIconURL(s.BaseURL, stateStr, userInfoCaps(body.TLVUserInfo))
  509. presenceEvent.StatusMsg = userStatusMsg(body.TLVUserInfo)
  510. // A BuddyArrived carries the buddy's current icon in TLV 0x1D whenever they
  511. // have one, so an icon change (or clear, which arrives as the sentinel hash)
  512. // rides along on the presence broadcast. Publish the matching URL: with an
  513. // icon it is content-addressed; without one it is the placeholder URL, which
  514. // differs from any prior icon URL and so clears a removed icon under the
  515. // client's shallow merge. An empty result (no origin known) is omitted, which
  516. // preserves whatever icon the client already holds.
  517. if s.BuddyIconURL != nil {
  518. presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, buddyIconHash(body.TLVUserInfo))
  519. }
  520. s.EventQueue.Push(EventTypePresence, presenceEvent)
  521. }
  522. // bartIDs returns the BART items a user info block carries. They travel as a list
  523. // in one TLV: a user's buddy icon and status message ride in it together.
  524. func bartIDs(info wire.TLVUserInfo) []wire.BARTID {
  525. b, ok := info.Bytes(wire.OServiceUserInfoBARTInfo)
  526. if !ok {
  527. return nil
  528. }
  529. var ids []wire.BARTID
  530. if err := wire.UnmarshalBE(&ids, bytes.NewReader(b)); err != nil {
  531. return nil
  532. }
  533. return ids
  534. }
  535. // buddyIconHash returns the buddy icon hash carried in a user info block, or nil
  536. // when it carries none.
  537. func buddyIconHash(info wire.TLVUserInfo) []byte {
  538. for _, id := range bartIDs(info) {
  539. if id.Type == wire.BARTTypesBuddyIcon || id.Type == wire.BARTTypesBuddyIconSmall {
  540. return id.Hash
  541. }
  542. }
  543. return nil
  544. }
  545. // userStatusMsg returns the status message carried in a user info block, or ""
  546. // when it carries none or one that cannot be decoded.
  547. func userStatusMsg(info wire.TLVUserInfo) string {
  548. for _, id := range bartIDs(info) {
  549. msg, err := id.StatusText()
  550. if err != nil {
  551. continue
  552. }
  553. if msg != "" {
  554. return msg
  555. }
  556. }
  557. return ""
  558. }
  559. // sessionStatusMsg returns the status message a session currently advertises.
  560. func sessionStatusMsg(instance *state.SessionInstance) string {
  561. status, _ := instance.Session().Status()
  562. msg, err := status.StatusText()
  563. if err != nil {
  564. return ""
  565. }
  566. return msg
  567. }
  568. // handleBuddyDeparted handles when a buddy goes offline.
  569. func (s *Session) handleBuddyDeparted(msg wire.SNACMessage) {
  570. if !s.IsSubscribedTo("presence") {
  571. return
  572. }
  573. body, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
  574. if !ok {
  575. return
  576. }
  577. buddy := state.NewIdentScreenName(body.ScreenName)
  578. // BuddyIcon is deliberately omitted: an offline buddy keeps their icon, and
  579. // omitting it lets the client's merge preserve the icon it already holds.
  580. presenceEvent := PresenceEvent{
  581. AimID: buddy.String(),
  582. Friendly: s.aliasFor(buddy),
  583. State: "offline",
  584. UserType: userTypeFor(buddy),
  585. }
  586. s.EventQueue.Push(EventTypePresence, presenceEvent)
  587. }
  588. // feedbagResultAuthRequired is the per-item feedbag result meaning the target's ICQ
  589. // settings require authorization, so the item was not stored.
  590. const feedbagResultAuthRequired = uint16(0x000E)
  591. // refreshBuddyList re-reads the roster and pushes it to the client. Runs on the SNAC
  592. // listener goroutine, so it uses the session context rather than a request context.
  593. func (s *Session) refreshBuddyList() {
  594. // A buddy item carries its alias, so any feedbag write can change the map.
  595. s.InvalidateAliases()
  596. if s.BuddyListRefresher == nil {
  597. return
  598. }
  599. payload, err := s.BuddyListRefresher(s.ctx)
  600. if err != nil {
  601. s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
  602. return
  603. }
  604. s.EventQueue.Push(EventTypeBuddyList, payload)
  605. }
  606. func (s *Session) handleFeedbagMessage(msg wire.SNACMessage) {
  607. switch msg.Frame.SubGroup {
  608. case wire.FeedbagStatus:
  609. // Insert/update/delete below reach only a user's *other* instances, so this
  610. // is the one notification a session gets for its own feedbag write.
  611. if !s.IsSubscribedTo(string(EventTypeBuddyList)) {
  612. return
  613. }
  614. if body, ok := msg.Body.(wire.SNAC_0x13_0x0E_FeedbagStatus); ok {
  615. // A buddy declined for authorization is not stored, and is simply
  616. // absent from the refreshed roster.
  617. if slices.Contains(body.Results, feedbagResultAuthRequired) {
  618. s.logger.Info("feedbag item declined pending authorization")
  619. }
  620. }
  621. s.refreshBuddyList()
  622. case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
  623. s.refreshBuddyList()
  624. if s.PermitDenyRefresher != nil {
  625. // An insert and an update both relay an UpdateItem body; only a
  626. // delete carries a DeleteItem body.
  627. var items []wire.FeedbagItem
  628. switch body := msg.Body.(type) {
  629. case wire.SNAC_0x13_0x09_FeedbagUpdateItem:
  630. items = body.Items
  631. case wire.SNAC_0x13_0x0A_FeedbagDeleteItem:
  632. items = body.Items
  633. }
  634. for _, item := range items {
  635. if item.ClassID == wire.FeedbagClassIDPermit ||
  636. item.ClassID == wire.FeedbagClassIDDeny ||
  637. item.ClassID == wire.FeedbagClassIdPdinfo {
  638. pdd, err := s.PermitDenyRefresher(s.ctx)
  639. if err != nil {
  640. s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
  641. } else {
  642. s.EventQueue.Push(EventTypePermitDeny, pdd)
  643. }
  644. break
  645. }
  646. }
  647. }
  648. }
  649. }
  650. // SessionManager manages Web API sessions with thread-safe operations.
  651. // Construct it with NewSessionManager and drive its reaper with Run.
  652. type SessionManager struct {
  653. sessions map[string]*Session // Keyed by aimsid
  654. mu sync.RWMutex
  655. closed bool // set by Shutdown; rejects new sessions and makes drain idempotent
  656. stopCh chan struct{} // closed by Shutdown to stop the reaper
  657. reaperWG sync.WaitGroup // tracks a running reaper so Shutdown can join it
  658. }
  659. // NewSessionManager creates a new WebAPI session manager. It does not start
  660. // any goroutines; call Run to start reaping expired sessions.
  661. func NewSessionManager() *SessionManager {
  662. return &SessionManager{
  663. sessions: make(map[string]*Session),
  664. stopCh: make(chan struct{}),
  665. }
  666. }
  667. // CreateSession creates a new WebAPI session.
  668. //
  669. // The session does not begin listening to its OSCAR instance yet: the caller
  670. // must wire the session's refresher callbacks (BuddyListRefresher, BuddyIconURL,
  671. // ...) and then call StartListeningToOSCARSession. Wiring them after the
  672. // listener starts would race the goroutine, which reads them as it converts
  673. // SNACs into events.
  674. func (m *SessionManager) CreateSession(screenName state.DisplayScreenName, events []string, oscarSession *state.SessionInstance, baseURL string, logger *slog.Logger) (*Session, error) {
  675. m.mu.Lock()
  676. defer m.mu.Unlock()
  677. // Refuse to create sessions once shut down: the reaper is stopped, so a
  678. // session added now would never be closed or reaped.
  679. if m.closed {
  680. return nil, ErrWebAPISessionManagerClosed
  681. }
  682. // Generate unique session ID
  683. aimsid, err := generateSessionID()
  684. if err != nil {
  685. return nil, err
  686. }
  687. now := time.Now()
  688. sessCtx, sessCancel := context.WithCancel(context.Background())
  689. session := &Session{
  690. ctx: sessCtx,
  691. cancel: sessCancel,
  692. AimSID: aimsid,
  693. ScreenName: screenName,
  694. OSCARSession: oscarSession,
  695. BaseURL: baseURL,
  696. Events: events,
  697. EventQueue: NewEventQueue(1000), // Max 1000 events per session
  698. CreatedAt: now,
  699. LastAccessed: now,
  700. ExpiresAt: now.Add(webAPISessionTTL),
  701. FetchTimeout: 60000, // 60 seconds default for better stability
  702. TimeToNextFetch: 500, // 500ms suggested delay
  703. logger: logger,
  704. capabilities: slices.Clone(webAPICaps),
  705. }
  706. m.sessions[aimsid] = session
  707. // The caller starts the OSCAR listener (StartListeningToOSCARSession) once it
  708. // has wired the session's refresher callbacks; starting it here would race
  709. // those assignments.
  710. return session, nil
  711. }
  712. // GetSession retrieves a session by aimsid.
  713. func (m *SessionManager) GetSession(ctx context.Context, aimsid string) (*Session, error) {
  714. m.mu.RLock()
  715. defer m.mu.RUnlock()
  716. session, exists := m.sessions[aimsid]
  717. if !exists {
  718. return nil, ErrNoWebAPISession
  719. }
  720. if session.IsExpired() {
  721. return nil, ErrWebAPISessionExpired
  722. }
  723. // A rate-limit disconnect (EvaluateRateLimit -> Session.CloseSession) closes
  724. // every instance for the account while this web session is still unexpired.
  725. // The aimsid must stop resolving at that point, otherwise a client told to
  726. // disconnect could keep issuing charged requests against a dead session (the
  727. // reaper only removes it on time expiry, up to a TTL later).
  728. if session.OSCARSession.IsClosed() {
  729. return nil, ErrWebAPISessionExpired
  730. }
  731. return session, nil
  732. }
  733. // RemoveSession removes a session by aimsid.
  734. func (m *SessionManager) RemoveSession(ctx context.Context, aimsid string) error {
  735. m.mu.Lock()
  736. session, exists := m.sessions[aimsid]
  737. if !exists {
  738. m.mu.Unlock()
  739. return ErrNoWebAPISession
  740. }
  741. delete(m.sessions, aimsid)
  742. m.mu.Unlock()
  743. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  744. // broadcasts and signout, which we don't want to run under m.mu.
  745. session.Close()
  746. return nil
  747. }
  748. // TouchSession updates the last accessed time for a session.
  749. func (m *SessionManager) TouchSession(ctx context.Context, aimsid string) error {
  750. m.mu.Lock()
  751. defer m.mu.Unlock()
  752. session, exists := m.sessions[aimsid]
  753. if !exists {
  754. return ErrNoWebAPISession
  755. }
  756. session.Touch()
  757. return nil
  758. }
  759. // Run reaps expired sessions on a fixed interval until ctx is cancelled or
  760. // Shutdown is called. The caller owns the goroutine's lifecycle; typically
  761. // launch it under the server's errgroup:
  762. //
  763. // g.Go(func() error { mgr.Run(ctx); return nil })
  764. //
  765. // Run is a no-op once the manager is closed, so a reaper that loses the race
  766. // with Shutdown never starts reaping a drained manager.
  767. func (m *SessionManager) Run(ctx context.Context) {
  768. m.mu.Lock()
  769. if m.closed {
  770. m.mu.Unlock()
  771. return
  772. }
  773. // Registering under m.mu is what makes Shutdown's join sound: Shutdown flips
  774. // closed under the same lock, so a reaper either registers before Shutdown
  775. // waits or is turned away here.
  776. m.reaperWG.Add(1)
  777. m.mu.Unlock()
  778. defer m.reaperWG.Done()
  779. ticker := time.NewTicker(webAPISessionReapInterval)
  780. defer ticker.Stop()
  781. for {
  782. select {
  783. case <-ticker.C:
  784. m.reapExpired()
  785. case <-m.stopCh:
  786. return
  787. case <-ctx.Done():
  788. return
  789. }
  790. }
  791. }
  792. // reapExpired removes every dead session and tears it down. A session is dead
  793. // once it has passed its expiry, or once its underlying OSCAR session has been
  794. // closed out from under it (e.g. by a rate-limit disconnect) — the latter is
  795. // already rejected by GetSession, and reaping it here frees the entry promptly
  796. // rather than leaving it until time expiry.
  797. func (m *SessionManager) reapExpired() {
  798. m.mu.Lock()
  799. now := time.Now()
  800. var expired []*Session
  801. for aimsid, session := range m.sessions {
  802. if now.After(session.ExpiresAt) || session.OSCARSession.IsClosed() {
  803. delete(m.sessions, aimsid)
  804. expired = append(expired, session)
  805. }
  806. }
  807. m.mu.Unlock()
  808. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  809. // broadcasts and signout, which we don't want to run under m.mu.
  810. for _, session := range expired {
  811. session.Close()
  812. }
  813. }
  814. // Shutdown drains and closes all sessions, stops the reaper started by Run, and
  815. // blocks further CreateSession calls. It does not depend on the caller
  816. // cancelling Run's context. Safe to call more than once, though only the first
  817. // call waits for the drain. The drain is bounded by ctx: Shutdown returns
  818. // ctx.Err() rather than block forever on a listener that ignores cancellation.
  819. func (m *SessionManager) Shutdown(ctx context.Context) error {
  820. m.mu.Lock()
  821. if m.closed {
  822. m.mu.Unlock()
  823. return nil
  824. }
  825. m.closed = true
  826. close(m.stopCh)
  827. sessions := make([]*Session, 0, len(m.sessions))
  828. for _, session := range m.sessions {
  829. sessions = append(sessions, session)
  830. }
  831. // Clear all sessions
  832. m.sessions = make(map[string]*Session)
  833. m.mu.Unlock()
  834. drained := make(chan struct{})
  835. go func() {
  836. defer close(drained)
  837. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  838. // broadcasts and signout, which we don't want to run under m.mu.
  839. for _, session := range sessions {
  840. session.Close()
  841. }
  842. m.reaperWG.Wait()
  843. }()
  844. select {
  845. case <-drained:
  846. return nil
  847. case <-ctx.Done():
  848. return ctx.Err()
  849. }
  850. }
  851. // generateSessionID creates a cryptographically secure session ID.
  852. func generateSessionID() (string, error) {
  853. bytes := make([]byte, 32) // 256 bits
  854. if _, err := rand.Read(bytes); err != nil {
  855. return "", err
  856. }
  857. return hex.EncodeToString(bytes), nil
  858. }
  859. // sentIMCookieLimit bounds the cookie->msgId map. An error arrives within seconds
  860. // of the send, so a small window suffices; the oldest entry is evicted once full.
  861. const sentIMCookieLimit = 256
  862. // RecordSentIM remembers the msgId handed out for an outgoing message, keyed by the
  863. // OSCAR cookie that message carries on the wire. The two are unrelated by
  864. // construction, so a clientError — which names its message by cookie — could not
  865. // otherwise say which message it refers to.
  866. func (s *Session) RecordSentIM(cookie uint64, msgID string) {
  867. if s == nil || msgID == "" {
  868. return
  869. }
  870. s.sentIMMu.Lock()
  871. defer s.sentIMMu.Unlock()
  872. if s.sentIMs == nil {
  873. s.sentIMs = make(map[uint64]string)
  874. }
  875. if _, seen := s.sentIMs[cookie]; !seen {
  876. s.sentIMOrder = append(s.sentIMOrder, cookie)
  877. }
  878. s.sentIMs[cookie] = msgID
  879. if len(s.sentIMOrder) > sentIMCookieLimit {
  880. delete(s.sentIMs, s.sentIMOrder[0])
  881. s.sentIMOrder = s.sentIMOrder[1:]
  882. }
  883. }
  884. // msgIDForCookie resolves an OSCAR message cookie back to the msgId handed out for
  885. // it, or "" when the message is not one this session sent.
  886. func (s *Session) msgIDForCookie(cookie uint64) string {
  887. if s == nil {
  888. return ""
  889. }
  890. s.sentIMMu.Lock()
  891. defer s.sentIMMu.Unlock()
  892. return s.sentIMs[cookie]
  893. }
  894. // WebAPIStoredIM is one message in a Web AIM session's in-memory IM log.
  895. // The Web AIM client expects fetchStoredIMs entries with sender, message, msgId, and date.
  896. type WebAPIStoredIM struct {
  897. Sender string
  898. Message string
  899. MsgID string
  900. Date int64 // Unix seconds
  901. }
  902. // AddStoredIM appends a message to the per-partner log for this session.
  903. func (s *Session) AddStoredIM(partnerAimID, sender, message, msgID string, date int64) {
  904. if s == nil || partnerAimID == "" || message == "" {
  905. return
  906. }
  907. s.imLogMu.Lock()
  908. defer s.imLogMu.Unlock()
  909. if s.imLog == nil {
  910. s.imLog = make(map[string][]WebAPIStoredIM)
  911. }
  912. s.imLog[normalizeWebAPIAimID(partnerAimID)] = append(s.imLog[normalizeWebAPIAimID(partnerAimID)], WebAPIStoredIM{
  913. Sender: sender,
  914. Message: message,
  915. MsgID: msgID,
  916. Date: date,
  917. })
  918. }
  919. // StoredIM is one entry in a fetchStoredIMs reply.
  920. type StoredIM struct {
  921. Sender string `json:"sender" xml:"sender"`
  922. Message string `json:"message" xml:"message"`
  923. MsgID string `json:"msgId" xml:"msgId"`
  924. Date int64 `json:"date" xml:"date"`
  925. }
  926. // StoredIMQuery describes filters for fetchStoredIMs.
  927. type StoredIMQuery struct {
  928. PartnerAimID string
  929. StartTime int64
  930. EndTime int64
  931. NToGet int
  932. SortOrder string
  933. SkipMsgID string
  934. StopMsgID string
  935. }
  936. // GetStoredIMs returns stored messages for a conversation partner, filtered and sorted
  937. // per the Web AIM client's fetchStoredIMs parameters.
  938. func (s *Session) GetStoredIMs(q StoredIMQuery) []StoredIM {
  939. if s == nil || q.PartnerAimID == "" {
  940. return nil
  941. }
  942. s.imLogMu.Lock()
  943. msgs := append([]WebAPIStoredIM(nil), s.imLog[normalizeWebAPIAimID(q.PartnerAimID)]...)
  944. s.imLogMu.Unlock()
  945. if len(msgs) == 0 {
  946. return []StoredIM{}
  947. }
  948. filtered := make([]WebAPIStoredIM, 0, len(msgs))
  949. for _, msg := range msgs {
  950. if q.StartTime > 0 && msg.Date < q.StartTime {
  951. continue
  952. }
  953. if q.EndTime > 0 && msg.Date > q.EndTime {
  954. continue
  955. }
  956. filtered = append(filtered, msg)
  957. }
  958. descending := strings.EqualFold(q.SortOrder, "descendingDate")
  959. sort.Slice(filtered, func(i, j int) bool {
  960. if descending {
  961. return filtered[i].Date > filtered[j].Date
  962. }
  963. return filtered[i].Date < filtered[j].Date
  964. })
  965. if q.SkipMsgID != "" {
  966. for i, msg := range filtered {
  967. if msg.MsgID == q.SkipMsgID {
  968. filtered = filtered[i+1:]
  969. break
  970. }
  971. }
  972. }
  973. if q.StopMsgID != "" {
  974. for i, msg := range filtered {
  975. if msg.MsgID == q.StopMsgID {
  976. filtered = filtered[:i]
  977. break
  978. }
  979. }
  980. }
  981. n := q.NToGet
  982. if n <= 0 {
  983. n = 100
  984. }
  985. if len(filtered) > n {
  986. filtered = filtered[:n]
  987. }
  988. out := make([]StoredIM, len(filtered))
  989. for i, msg := range filtered {
  990. out[i] = StoredIM(msg)
  991. }
  992. return out
  993. }
  994. func (s *Session) Caps() [][16]byte {
  995. s.capsMu.RLock()
  996. defer s.capsMu.RUnlock()
  997. return slices.Clone(s.capabilities)
  998. }
  999. // ClearMood removes the mood capability the session advertises, if any. The
  1000. // user then presents whatever presence state they are in.
  1001. func (s *Session) ClearMood() {
  1002. s.capsMu.Lock()
  1003. defer s.capsMu.Unlock()
  1004. s.clearMood()
  1005. }
  1006. // clearMood drops every mood capability the session advertises. The caller must
  1007. // hold s.capsMu.
  1008. func (s *Session) clearMood() {
  1009. s.capabilities = slices.DeleteFunc(s.capabilities, func(cap [16]byte) bool {
  1010. return wire.IsMoodCap(cap)
  1011. })
  1012. }
  1013. // SetMood replaces the mood capability the session advertises. A client shows
  1014. // one mood at a time, so whichever mood was set before is dropped.
  1015. //
  1016. // It panics when mood is not a mood capability: the caller resolves it from the
  1017. // mood table, so anything else is a programming error rather than bad input.
  1018. func (s *Session) SetMood(mood uuid.UUID) {
  1019. s.capsMu.Lock()
  1020. defer s.capsMu.Unlock()
  1021. if !wire.IsMoodCap(mood) {
  1022. panic("uuid is not a mood capability")
  1023. }
  1024. s.clearMood()
  1025. s.capabilities = append(s.capabilities, mood)
  1026. }
  1027. // normalizeWebAPIAimID keys the IM log by the same normalization the web client
  1028. // applies to aimIds, so a partner stored from a display screen name is still
  1029. // found when the client queries by aimId.
  1030. func normalizeWebAPIAimID(aimID string) string {
  1031. return state.NewIdentScreenName(aimID).String()
  1032. }