webapi_session.go 28 KB

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