session.go 31 KB

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