webapi_session.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  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. OSCARCookie []byte // OSCAR auth cookie for the startOSCARSession handoff
  56. BOSHost string // BOS host advertised to the web client
  57. BOSPort int // BOS port advertised to the web client
  58. UseSSL bool // Whether the handoff advertised an SSL BOS connection
  59. BaseURL string // Web API base URL advertised to the web client, used to build absolute asset URLs
  60. Events []string // Subscribed event types
  61. EventQueue *types.EventQueue // Per-session event queue
  62. DevID string // Developer ID that created this session
  63. ClientName string // Client application name
  64. ClientVersion string // Client application version
  65. CreatedAt time.Time // SessionInstance creation time
  66. LastAccessed time.Time // Last activity time
  67. ExpiresAt time.Time // SessionInstance expiration time
  68. FetchTimeout int // Long-polling timeout in milliseconds
  69. TimeToNextFetch int // Suggested delay before next fetch
  70. RemoteAddr string // Client IP address
  71. TempBuddies map[string]bool // Temporary buddies for this session only
  72. BuddyListRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push buddylist event
  73. PermitDenyRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push permitDeny event
  74. MyInfoRefresher func(ctx context.Context) (interface{}, error) // Called on self user-info updates (e.g. icon change) to push myInfo event
  75. BuddyAliasLoader func(ctx context.Context) (map[string]string, error)
  76. // BuddyIconURL formats the absolute buddyIcon URL for a buddy from the icon
  77. // hash carried in a presence SNAC. Returns "" when no URL can be published.
  78. BuddyIconURL func(screenName IdentScreenName, hash []byte) string
  79. aliases map[string]string // cached BuddyAliasLoader result, nil when unloaded or invalidated
  80. aliasMu sync.Mutex
  81. imLog map[string][]WebAPIStoredIM
  82. imLogMu sync.Mutex
  83. logger *slog.Logger // Logger for debugging
  84. }
  85. // IsExpired checks if the session has expired.
  86. func (s *WebAPISession) IsExpired() bool {
  87. return time.Now().After(s.ExpiresAt)
  88. }
  89. // Aliases returns this session owner's private buddy aliases, keyed by normalized
  90. // screen name. Aliases live in the owner's feedbag, so the map is loaded once and
  91. // cached until a feedbag change invalidates it: a signon that brings a large buddy
  92. // list online costs one feedbag query instead of one per buddy.
  93. //
  94. // The map is owned by the session and must not be mutated by callers.
  95. //
  96. // aliasMu is deliberately held across the load rather than released while the
  97. // feedbag is queried. Another instance of the owner can rename a buddy mid-query,
  98. // and its FeedbagUpdateItem SNAC invalidates this cache; if the load ran outside
  99. // the lock, that query's pre-rename result could be stored *after* the
  100. // invalidation and serve the old alias until the next feedbag change. Holding the
  101. // lock makes the invalidation wait for the load and then win.
  102. func (s *WebAPISession) Aliases(ctx context.Context) map[string]string {
  103. s.aliasMu.Lock()
  104. defer s.aliasMu.Unlock()
  105. // The loader is wired after the session is created, so an event arriving in
  106. // that window has no way to resolve aliases.
  107. if s.BuddyAliasLoader == nil {
  108. return nil
  109. }
  110. if s.aliases == nil {
  111. aliases, err := s.BuddyAliasLoader(ctx)
  112. if err != nil {
  113. s.logger.Error("failed to load buddy aliases", "err", err.Error())
  114. return nil
  115. }
  116. s.aliases = aliases
  117. }
  118. return s.aliases
  119. }
  120. // InvalidateAliases drops the cached alias map so the next Aliases call reloads it.
  121. // Callers that change the owner's feedbag must call this: the feedbag service
  122. // relays FeedbagUpdateItem only to the owner's *other* instances, so a session
  123. // never sees a SNAC for its own writes.
  124. func (s *WebAPISession) InvalidateAliases() {
  125. s.aliasMu.Lock()
  126. defer s.aliasMu.Unlock()
  127. s.aliases = nil
  128. }
  129. // aliasFor returns this session owner's private alias for buddy, or "" when none is
  130. // set. The web client deletes the alias it holds whenever it merges a user map, so
  131. // every event naming a buddy has to repeat it.
  132. func (s *WebAPISession) aliasFor(buddy IdentScreenName) string {
  133. // Runs on the SNAC listener goroutine, which has no request context.
  134. return s.Aliases(context.Background())[buddy.String()]
  135. }
  136. // Touch updates the last accessed time and extends expiration if needed.
  137. func (s *WebAPISession) Touch() {
  138. s.LastAccessed = time.Now()
  139. newExpiry := s.LastAccessed.Add(webAPISessionTTL)
  140. if newExpiry.After(s.ExpiresAt) {
  141. s.ExpiresAt = newExpiry
  142. }
  143. }
  144. // IsSubscribedTo checks if the session is subscribed to a specific event type.
  145. func (s *WebAPISession) IsSubscribedTo(eventType string) bool {
  146. for _, event := range s.Events {
  147. if event == eventType {
  148. return true
  149. }
  150. }
  151. return false
  152. }
  153. // StartListeningToOSCARSession starts a goroutine that listens to the OSCAR session's
  154. // message channel and converts SNAC messages into WebAPI events.
  155. func (s *WebAPISession) StartListeningToOSCARSession() {
  156. if s.OSCARSession == nil {
  157. return
  158. }
  159. // Start goroutine to listen for OSCAR messages
  160. go func() {
  161. msgCh := s.OSCARSession.ReceiveMessage()
  162. for {
  163. select {
  164. case msg, ok := <-msgCh:
  165. if !ok {
  166. // Channel closed, OSCAR session ended
  167. return
  168. }
  169. s.handleSNACMessage(msg)
  170. case <-s.OSCARSession.Closed():
  171. // OSCAR session closed
  172. return
  173. }
  174. }
  175. }()
  176. }
  177. // handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
  178. func (s *WebAPISession) handleSNACMessage(msg wire.SNACMessage) {
  179. if s.EventQueue == nil {
  180. return
  181. }
  182. // Convert SNAC message to WebAPI events based on food group and subgroup
  183. switch msg.Frame.FoodGroup {
  184. case wire.ICBM:
  185. s.handleICBMMessage(msg)
  186. case wire.Buddy:
  187. s.handleBuddyMessage(msg)
  188. case wire.Feedbag:
  189. s.handleFeedbagMessage(msg)
  190. case wire.OService:
  191. s.handleOServiceMessage(msg)
  192. }
  193. }
  194. // handleOServiceMessage handles OService SNAC messages relayed to the session's
  195. // own OSCAR instance. The only one we surface is OServiceUserInfoUpdate, which the
  196. // server relays to a user when their own user info changes (notably a buddy icon
  197. // upload or clear). The client re-renders its identity badge from myInfo events
  198. // only, so we translate this into a fresh myInfo.
  199. func (s *WebAPISession) handleOServiceMessage(msg wire.SNACMessage) {
  200. if msg.Frame.SubGroup != wire.OServiceUserInfoUpdate {
  201. return
  202. }
  203. if !s.IsSubscribedTo("myInfo") && !s.IsSubscribedTo("presence") {
  204. return
  205. }
  206. if s.MyInfoRefresher == nil {
  207. return
  208. }
  209. data, err := s.MyInfoRefresher(context.Background())
  210. if err != nil {
  211. s.logger.Error("failed to refresh myInfo after user-info update", "err", err)
  212. return
  213. }
  214. s.EventQueue.Push(types.EventType("myInfo"), data)
  215. }
  216. // handleICBMMessage handles ICBM (instant messaging) SNAC messages.
  217. func (s *WebAPISession) handleICBMMessage(msg wire.SNACMessage) {
  218. switch msg.Frame.SubGroup {
  219. case wire.ICBMChannelMsgToClient:
  220. s.handleIncomingIM(msg)
  221. case wire.ICBMClientEvent:
  222. s.handleTypingNotification(msg)
  223. }
  224. }
  225. // handleIncomingIM handles incoming instant messages.
  226. func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
  227. if !s.IsSubscribedTo("im") {
  228. return
  229. }
  230. body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
  231. if !ok {
  232. return
  233. }
  234. // Extract message text from TLV data
  235. var messageText string
  236. if msgData, hasMsg := body.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
  237. if text, err := wire.UnmarshalICBMMessageText(msgData); err == nil {
  238. messageText = text
  239. }
  240. }
  241. if messageText == "" {
  242. return
  243. }
  244. // Check if it's an auto-response (channel 2)
  245. autoResponse := body.ChannelID == 0x0002
  246. // msgId must be unique per delivered event. The OSCAR cookie is not a
  247. // reliable unique id (some clients reuse it across messages), and the web
  248. // client dedupes its conversation list by msgId, silently dropping any
  249. // collisions. Mint a fresh random id instead of reusing body.Cookie.
  250. msgID := strconv.FormatUint(mrand.Uint64(), 16)
  251. // SNAC user info carries the sender's display screen name. The web client
  252. // keys conversations and users by the normalized aimId and only renders
  253. // displayId, so the two forms must not be interchanged.
  254. partnerDisplay := body.ScreenName
  255. partnerAimID := NewIdentScreenName(partnerDisplay).String()
  256. nowSec := time.Now().Unix()
  257. s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, nowSec)
  258. // Create IM event
  259. imEvent := types.IMEvent{
  260. Source: types.UserInfo{
  261. AimID: partnerAimID,
  262. DisplayID: partnerDisplay,
  263. Friendly: s.aliasFor(NewIdentScreenName(partnerAimID)),
  264. UserType: "aim",
  265. State: "online",
  266. },
  267. Message: messageText,
  268. MsgID: msgID,
  269. Timestamp: float64(time.Now().Unix()),
  270. AutoResp: autoResponse,
  271. }
  272. s.EventQueue.Push(types.EventTypeIM, imEvent)
  273. s.logger.Debug("delivered instant message",
  274. "from", partnerDisplay,
  275. "to", s.ScreenName)
  276. if s.IsSubscribedTo("conversation") {
  277. // unread is 0 here, not 1, because the "im" event pushed above already
  278. // causes the client to increment its own persisted per-buddy unread
  279. // tally. The "Recent chats" badge is the sum of that persisted tally and
  280. // this conversation's unreadCount, so sending 1 here would double-count
  281. // the message (badge shows 2 for the first IM). Mirrors the sent-IM path,
  282. // which also passes 0.
  283. s.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
  284. types.ConversationEntry(
  285. partnerAimID,
  286. partnerDisplay,
  287. messageText,
  288. msgID,
  289. partnerAimID,
  290. false,
  291. 0,
  292. ),
  293. }))
  294. }
  295. }
  296. // handleTypingNotification handles typing notifications.
  297. func (s *WebAPISession) handleTypingNotification(msg wire.SNACMessage) {
  298. if !s.IsSubscribedTo("typing") {
  299. return
  300. }
  301. body, ok := msg.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)
  302. if !ok {
  303. return
  304. }
  305. // Event types: 0x0000=none, 0x0001=typed (paused), 0x0002=typing
  306. var typingStatus string
  307. switch body.Event {
  308. case 0x0002:
  309. typingStatus = "typing"
  310. case 0x0001:
  311. typingStatus = "typed"
  312. default:
  313. typingStatus = "none"
  314. }
  315. typingEvent := types.TypingEvent{
  316. AimID: NewIdentScreenName(body.ScreenName).String(),
  317. TypingStatus: typingStatus,
  318. }
  319. s.EventQueue.Push(types.EventTypeTyping, typingEvent)
  320. }
  321. // handleBuddyMessage handles buddy/presence SNAC messages.
  322. func (s *WebAPISession) handleBuddyMessage(msg wire.SNACMessage) {
  323. switch msg.Frame.SubGroup {
  324. case wire.BuddyArrived:
  325. s.handleBuddyArrived(msg)
  326. case wire.BuddyDeparted:
  327. s.handleBuddyDeparted(msg)
  328. }
  329. }
  330. // handleBuddyArrived handles when a buddy comes online.
  331. func (s *WebAPISession) handleBuddyArrived(msg wire.SNACMessage) {
  332. if !s.IsSubscribedTo("presence") {
  333. return
  334. }
  335. body, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
  336. if !ok {
  337. return
  338. }
  339. stateStr := "online"
  340. // For BuddyArrived updates, infer presence state from the TLVUserInfo.
  341. // Away and invisible transitions are typically broadcast using BuddyArrived
  342. // with updated user flags/status bits, not BuddyDeparted.
  343. if body.IsInvisible() {
  344. stateStr = "offline"
  345. } else if body.IsAway() {
  346. stateStr = "away"
  347. } else if mask, ok := body.Uint32BE(wire.OServiceUserInfoStatus); ok {
  348. if mask&wire.OServiceUserStatusDND == wire.OServiceUserStatusDND {
  349. stateStr = "dnd"
  350. } else if mask&wire.OServiceUserStatusAway == wire.OServiceUserStatusAway {
  351. stateStr = "away"
  352. }
  353. }
  354. buddy := NewIdentScreenName(body.ScreenName)
  355. presenceEvent := types.PresenceEvent{
  356. AimID: buddy.String(),
  357. Friendly: s.aliasFor(buddy),
  358. State: stateStr,
  359. UserType: "aim",
  360. }
  361. // A BuddyArrived carries the buddy's current icon in TLV 0x1D whenever they
  362. // have one, so an icon change (or clear, which arrives as the sentinel hash)
  363. // rides along on the presence broadcast. Publish the matching URL: with an
  364. // icon it is content-addressed; without one it is the placeholder URL, which
  365. // differs from any prior icon URL and so clears a removed icon under the
  366. // client's shallow merge. An empty result (no origin known) is omitted, which
  367. // preserves whatever icon the client already holds.
  368. if s.BuddyIconURL != nil {
  369. var hash []byte
  370. if b, ok := body.Bytes(wire.OServiceUserInfoBARTInfo); ok {
  371. var id wire.BARTID
  372. if err := wire.UnmarshalBE(&id, bytes.NewBuffer(b)); err == nil {
  373. hash = id.Hash
  374. }
  375. }
  376. presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, hash)
  377. }
  378. s.EventQueue.Push(types.EventTypePresence, presenceEvent)
  379. }
  380. // handleBuddyDeparted handles when a buddy goes offline.
  381. func (s *WebAPISession) handleBuddyDeparted(msg wire.SNACMessage) {
  382. if !s.IsSubscribedTo("presence") {
  383. return
  384. }
  385. body, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
  386. if !ok {
  387. return
  388. }
  389. buddy := NewIdentScreenName(body.ScreenName)
  390. // BuddyIcon is deliberately omitted: an offline buddy keeps their icon, and
  391. // omitting it lets the client's merge preserve the icon it already holds.
  392. presenceEvent := types.PresenceEvent{
  393. AimID: buddy.String(),
  394. Friendly: s.aliasFor(buddy),
  395. State: "offline",
  396. UserType: "aim",
  397. }
  398. s.EventQueue.Push(types.EventTypePresence, presenceEvent)
  399. }
  400. func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
  401. switch msg.Frame.SubGroup {
  402. case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
  403. // A buddy item carries its alias, so any feedbag write can change the map.
  404. s.InvalidateAliases()
  405. if s.BuddyListRefresher != nil {
  406. groups, err := s.BuddyListRefresher(context.Background())
  407. if err != nil {
  408. s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
  409. } else {
  410. s.EventQueue.Push(types.EventTypeBuddyList, map[string]interface{}{"groups": groups})
  411. }
  412. }
  413. if msg.Frame.SubGroup == wire.FeedbagUpdateItem && s.PermitDenyRefresher != nil {
  414. body, ok := msg.Body.(wire.SNAC_0x13_0x09_FeedbagUpdateItem)
  415. if ok {
  416. for _, item := range body.Items {
  417. if item.ClassID == wire.FeedbagClassIDPermit ||
  418. item.ClassID == wire.FeedbagClassIDDeny ||
  419. item.ClassID == wire.FeedbagClassIdPdinfo {
  420. pdd, err := s.PermitDenyRefresher(context.Background())
  421. if err != nil {
  422. s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
  423. } else {
  424. s.EventQueue.Push(types.EventTypePermitDeny, pdd)
  425. }
  426. break
  427. }
  428. }
  429. }
  430. }
  431. }
  432. }
  433. // WebAPISessionManager manages Web API sessions with thread-safe operations.
  434. // Construct it with NewWebAPISessionManager and drive its reaper with Run.
  435. type WebAPISessionManager struct {
  436. sessions map[string]*WebAPISession // Keyed by aimsid
  437. mu sync.RWMutex
  438. closed bool // set by Shutdown; rejects new sessions and makes drain idempotent
  439. }
  440. // NewWebAPISessionManager creates a new WebAPI session manager. It does not start
  441. // any goroutines; call Run to start reaping expired sessions.
  442. func NewWebAPISessionManager() *WebAPISessionManager {
  443. return &WebAPISessionManager{
  444. sessions: make(map[string]*WebAPISession),
  445. }
  446. }
  447. // CreateSession creates a new WebAPI session.
  448. //
  449. // The session does not begin listening to its OSCAR instance yet: the caller
  450. // must wire the session's refresher callbacks (BuddyListRefresher, BuddyIconURL,
  451. // MyInfoRefresher, ...) and then call StartListeningToOSCARSession. Wiring them
  452. // after the listener starts would race the goroutine, which reads them as it
  453. // converts SNACs into events.
  454. func (m *WebAPISessionManager) CreateSession(ctx context.Context, screenName DisplayScreenName, devID string, events []string, oscarSession *SessionInstance, baseURL string, logger *slog.Logger) (*WebAPISession, error) {
  455. m.mu.Lock()
  456. defer m.mu.Unlock()
  457. // Refuse to create sessions once shut down: the reaper is stopped, so a
  458. // session added now would never be closed or reaped.
  459. if m.closed {
  460. return nil, ErrWebAPISessionManagerClosed
  461. }
  462. // Generate unique session ID
  463. aimsid, err := generateSessionID()
  464. if err != nil {
  465. return nil, err
  466. }
  467. now := time.Now()
  468. session := &WebAPISession{
  469. AimSID: aimsid,
  470. ScreenName: screenName,
  471. OSCARSession: oscarSession,
  472. BaseURL: baseURL,
  473. Events: events,
  474. EventQueue: types.NewEventQueue(1000), // Max 1000 events per session
  475. DevID: devID,
  476. CreatedAt: now,
  477. LastAccessed: now,
  478. ExpiresAt: now.Add(webAPISessionTTL),
  479. FetchTimeout: 60000, // 60 seconds default for better stability
  480. TimeToNextFetch: 500, // 500ms suggested delay
  481. logger: logger,
  482. }
  483. m.sessions[aimsid] = session
  484. // The caller starts the OSCAR listener (StartListeningToOSCARSession) once it
  485. // has wired the session's refresher callbacks; starting it here would race
  486. // those assignments.
  487. return session, nil
  488. }
  489. // GetSession retrieves a session by aimsid.
  490. func (m *WebAPISessionManager) GetSession(ctx context.Context, aimsid string) (*WebAPISession, error) {
  491. m.mu.RLock()
  492. defer m.mu.RUnlock()
  493. session, exists := m.sessions[aimsid]
  494. if !exists {
  495. return nil, ErrNoWebAPISession
  496. }
  497. if session.IsExpired() {
  498. return nil, ErrWebAPISessionExpired
  499. }
  500. return session, nil
  501. }
  502. // RemoveSession removes a session by aimsid.
  503. func (m *WebAPISessionManager) RemoveSession(ctx context.Context, aimsid string) error {
  504. m.mu.Lock()
  505. session, exists := m.sessions[aimsid]
  506. if !exists {
  507. m.mu.Unlock()
  508. return ErrNoWebAPISession
  509. }
  510. delete(m.sessions, aimsid)
  511. m.mu.Unlock()
  512. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  513. // broadcasts and signout, which we don't want to run under m.mu.
  514. session.EventQueue.Close()
  515. session.OSCARSession.CloseInstance()
  516. return nil
  517. }
  518. // TouchSession updates the last accessed time for a session.
  519. func (m *WebAPISessionManager) TouchSession(ctx context.Context, aimsid string) error {
  520. m.mu.Lock()
  521. defer m.mu.Unlock()
  522. session, exists := m.sessions[aimsid]
  523. if !exists {
  524. return ErrNoWebAPISession
  525. }
  526. session.Touch()
  527. return nil
  528. }
  529. // Run reaps expired sessions on a fixed interval until ctx is cancelled. The
  530. // caller owns the goroutine's lifecycle; typically launch it under the server's
  531. // errgroup:
  532. //
  533. // g.Go(func() error { mgr.Run(ctx); return nil })
  534. func (m *WebAPISessionManager) Run(ctx context.Context) {
  535. ticker := time.NewTicker(webAPISessionReapInterval)
  536. defer ticker.Stop()
  537. for {
  538. select {
  539. case <-ticker.C:
  540. m.reapExpired()
  541. case <-ctx.Done():
  542. return
  543. }
  544. }
  545. }
  546. // reapExpired removes every expired session and tears it down.
  547. func (m *WebAPISessionManager) reapExpired() {
  548. m.mu.Lock()
  549. now := time.Now()
  550. var expired []*WebAPISession
  551. for aimsid, session := range m.sessions {
  552. if now.After(session.ExpiresAt) {
  553. delete(m.sessions, aimsid)
  554. expired = append(expired, session)
  555. }
  556. }
  557. m.mu.Unlock()
  558. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  559. // broadcasts and signout, which we don't want to run under m.mu.
  560. for _, session := range expired {
  561. session.EventQueue.Close()
  562. session.OSCARSession.CloseInstance()
  563. }
  564. }
  565. // Shutdown drains and closes all sessions and blocks further CreateSession
  566. // calls. The reaper goroutine is stopped separately by cancelling the context
  567. // passed to Run. Safe to call more than once.
  568. func (m *WebAPISessionManager) Shutdown() {
  569. m.mu.Lock()
  570. if m.closed {
  571. m.mu.Unlock()
  572. return
  573. }
  574. m.closed = true
  575. sessions := make([]*WebAPISession, 0, len(m.sessions))
  576. for _, session := range m.sessions {
  577. sessions = append(sessions, session)
  578. }
  579. // Clear all sessions
  580. m.sessions = make(map[string]*WebAPISession)
  581. m.mu.Unlock()
  582. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  583. // broadcasts and signout, which we don't want to run under m.mu.
  584. for _, session := range sessions {
  585. session.EventQueue.Close()
  586. session.OSCARSession.CloseInstance()
  587. }
  588. }
  589. // generateSessionID creates a cryptographically secure session ID.
  590. func generateSessionID() (string, error) {
  591. bytes := make([]byte, 32) // 256 bits
  592. if _, err := rand.Read(bytes); err != nil {
  593. return "", err
  594. }
  595. return hex.EncodeToString(bytes), nil
  596. }