session.go 34 KB

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