webapi_session.go 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021
  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. "strings"
  12. "sync"
  13. "time"
  14. "github.com/google/uuid"
  15. "github.com/mk6i/open-oscar-server/server/webapi/types"
  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. // WebAPISession represents an active Web AIM API session.
  53. type WebAPISession struct {
  54. AimSID string // Unique session ID for web client
  55. ScreenName DisplayScreenName // User identity
  56. OSCARSession *SessionInstance // Bridge to existing OSCAR session
  57. OSCARCookie []byte // OSCAR auth cookie for the startOSCARSession handoff
  58. BOSHost string // BOS host advertised to the web client
  59. BOSPort int // BOS port advertised to the web client
  60. UseSSL bool // Whether the handoff advertised an SSL BOS connection
  61. BaseURL string // Web API base URL advertised to the web client, used to build absolute asset URLs
  62. Events []string // Subscribed event types
  63. EventQueue *types.EventQueue // Per-session event queue
  64. DevID string // Developer ID that created this session
  65. ClientName string // Client application name
  66. ClientVersion string // Client application version
  67. CreatedAt time.Time // SessionInstance creation time
  68. LastAccessed time.Time // Last activity time
  69. ExpiresAt time.Time // SessionInstance expiration time
  70. FetchTimeout int // Long-polling timeout in milliseconds
  71. TimeToNextFetch int // Suggested delay before next fetch
  72. RemoteAddr string // Client IP address
  73. TempBuddies map[string]bool // Temporary buddies for this session only
  74. BuddyListRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push buddylist event
  75. PermitDenyRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push permitDeny event
  76. MyInfoRefresher func(ctx context.Context) (interface{}, error) // Called on self user-info updates (e.g. icon change) to push myInfo event
  77. BuddyAliasLoader func(ctx context.Context) (map[string]string, error)
  78. // BuddyIconURL formats the absolute buddyIcon URL for a buddy from the icon
  79. // hash carried in a presence SNAC. Returns "" when no URL can be published.
  80. BuddyIconURL func(screenName IdentScreenName, hash []byte) string
  81. aliases map[string]string // cached BuddyAliasLoader result, nil when unloaded or invalidated
  82. aliasMu sync.Mutex
  83. imLog map[string][]WebAPIStoredIM
  84. imLogMu sync.Mutex
  85. logger *slog.Logger // Logger for debugging
  86. listeners sync.WaitGroup
  87. // chatRooms maps a joined room id (the ChatRoom cookie the web client uses as
  88. // its im/sendIM target) to that room's dedicated chat SessionInstance. Each
  89. // join registers a separate OSCAR chat session here, analogous to TOC's
  90. // ChatRegistry. chatRoomsClosed is set once Close has drained the map, so a
  91. // join racing teardown cannot re-add (and thereby leak) an instance Close will
  92. // never see. Both guarded by chatRoomsMu.
  93. chatRooms map[string]*SessionInstance
  94. chatRoomsClosed bool
  95. chatRoomsMu sync.Mutex
  96. ctx context.Context
  97. cancel context.CancelFunc
  98. closeMu sync.Mutex
  99. closed bool
  100. }
  101. // IsExpired checks if the session has expired.
  102. func (s *WebAPISession) IsExpired() bool {
  103. return time.Now().After(s.ExpiresAt)
  104. }
  105. // Aliases returns this session owner's private buddy aliases, keyed by normalized
  106. // screen name. Aliases live in the owner's feedbag, so the map is loaded once and
  107. // cached until a feedbag change invalidates it: a signon that brings a large buddy
  108. // list online costs one feedbag query instead of one per buddy.
  109. //
  110. // The map is owned by the session and must not be mutated by callers.
  111. //
  112. // aliasMu is deliberately held across the load rather than released while the
  113. // feedbag is queried. Another instance of the owner can rename a buddy mid-query,
  114. // and its FeedbagUpdateItem SNAC invalidates this cache; if the load ran outside
  115. // the lock, that query's pre-rename result could be stored *after* the
  116. // invalidation and serve the old alias until the next feedbag change. Holding the
  117. // lock makes the invalidation wait for the load and then win.
  118. func (s *WebAPISession) Aliases(ctx context.Context) map[string]string {
  119. s.aliasMu.Lock()
  120. defer s.aliasMu.Unlock()
  121. // The loader is wired after the session is created, so an event arriving in
  122. // that window has no way to resolve aliases.
  123. if s.BuddyAliasLoader == nil {
  124. return nil
  125. }
  126. if s.aliases == nil {
  127. aliases, err := s.BuddyAliasLoader(ctx)
  128. if err != nil {
  129. s.logger.Error("failed to load buddy aliases", "err", err.Error())
  130. return nil
  131. }
  132. s.aliases = aliases
  133. }
  134. return s.aliases
  135. }
  136. // InvalidateAliases drops the cached alias map so the next Aliases call reloads it.
  137. // Callers that change the owner's feedbag must call this: the feedbag service
  138. // relays FeedbagUpdateItem only to the owner's *other* instances, so a session
  139. // never sees a SNAC for its own writes.
  140. func (s *WebAPISession) InvalidateAliases() {
  141. s.aliasMu.Lock()
  142. defer s.aliasMu.Unlock()
  143. s.aliases = nil
  144. }
  145. // aliasFor returns this session owner's private alias for buddy, or "" when none is
  146. // set. The web client deletes the alias it holds whenever it merges a user map, so
  147. // every event naming a buddy has to repeat it.
  148. func (s *WebAPISession) aliasFor(buddy IdentScreenName) string {
  149. // Runs on the SNAC listener goroutine, which has no request context.
  150. return s.Aliases(s.ctx)[buddy.String()]
  151. }
  152. // Touch updates the last accessed time and extends expiration if needed.
  153. func (s *WebAPISession) Touch() {
  154. s.LastAccessed = time.Now()
  155. newExpiry := s.LastAccessed.Add(webAPISessionTTL)
  156. if newExpiry.After(s.ExpiresAt) {
  157. s.ExpiresAt = newExpiry
  158. }
  159. }
  160. // IsSubscribedTo checks if the session is subscribed to a specific event type.
  161. func (s *WebAPISession) IsSubscribedTo(eventType string) bool {
  162. for _, event := range s.Events {
  163. if event == eventType {
  164. return true
  165. }
  166. }
  167. return false
  168. }
  169. // StartListeningToOSCARSession starts a goroutine that listens to the OSCAR session's
  170. // message channel and converts SNAC messages into WebAPI events.
  171. func (s *WebAPISession) StartListeningToOSCARSession() {
  172. if s.OSCARSession == nil {
  173. return
  174. }
  175. s.closeMu.Lock()
  176. defer s.closeMu.Unlock()
  177. if s.closed {
  178. return
  179. }
  180. s.listeners.Add(1)
  181. go func() {
  182. defer s.listeners.Done()
  183. msgCh := s.OSCARSession.ReceiveMessage()
  184. for {
  185. select {
  186. case msg, ok := <-msgCh:
  187. if !ok {
  188. // Channel closed, OSCAR session ended
  189. return
  190. }
  191. s.handleSNACMessage(msg)
  192. case <-s.OSCARSession.Closed():
  193. // OSCAR session closed
  194. return
  195. }
  196. }
  197. }()
  198. }
  199. // Close tears down the session: it releases any parked event fetchers, closes
  200. // the OSCAR instance, and waits for the listener goroutine to unwind. Safe to
  201. // call more than once.
  202. func (s *WebAPISession) Close() {
  203. s.closeMu.Lock()
  204. if s.closed {
  205. s.closeMu.Unlock()
  206. return
  207. }
  208. s.closed = true
  209. s.closeMu.Unlock()
  210. s.EventQueue.Close()
  211. s.OSCARSession.CloseInstance()
  212. // Close every joined chat instance so each room announces the user's
  213. // departure and SignoutChat runs. Their listeners also observe s.ctx below,
  214. // but closing the instances is what tears down the room membership.
  215. // chatRoomsClosed is set under the same lock so a concurrent AddChatRoom that
  216. // arrives after this drain is turned away instead of re-adding an instance
  217. // this drain would never close.
  218. s.chatRoomsMu.Lock()
  219. for _, inst := range s.chatRooms {
  220. inst.CloseInstance()
  221. }
  222. s.chatRooms = nil
  223. s.chatRoomsClosed = true
  224. s.chatRoomsMu.Unlock()
  225. s.cancel()
  226. s.listeners.Wait()
  227. }
  228. // AddChatRoom registers a joined room's chat SessionInstance under its room id
  229. // (the ChatRoom cookie). Returns false if the session is already closing, in
  230. // which case the caller must close inst itself.
  231. func (s *WebAPISession) AddChatRoom(roomID string, inst *SessionInstance) bool {
  232. // The closed-check and the insert must be atomic with Close's drain, so both
  233. // run under chatRoomsMu. A nil map alone can't distinguish "closing" from
  234. // "never used yet", which is why Close sets chatRoomsClosed.
  235. s.chatRoomsMu.Lock()
  236. defer s.chatRoomsMu.Unlock()
  237. if s.chatRoomsClosed {
  238. return false
  239. }
  240. if s.chatRooms == nil {
  241. s.chatRooms = make(map[string]*SessionInstance)
  242. }
  243. s.chatRooms[roomID] = inst
  244. return true
  245. }
  246. // ChatRoom returns the chat SessionInstance for a joined room id, or false if
  247. // the session is not in that room. The web client addresses a room exactly like
  248. // a buddy, so this is how the im/sendIM path tells a room target from a buddy.
  249. func (s *WebAPISession) ChatRoom(roomID string) (*SessionInstance, bool) {
  250. s.chatRoomsMu.Lock()
  251. defer s.chatRoomsMu.Unlock()
  252. inst, ok := s.chatRooms[roomID]
  253. return inst, ok
  254. }
  255. // RemoveChatRoom unregisters a joined room and returns its chat SessionInstance
  256. // so the caller can close it. Returns false if the room was not joined.
  257. func (s *WebAPISession) RemoveChatRoom(roomID string) (*SessionInstance, bool) {
  258. s.chatRoomsMu.Lock()
  259. defer s.chatRoomsMu.Unlock()
  260. inst, ok := s.chatRooms[roomID]
  261. if ok {
  262. delete(s.chatRooms, roomID)
  263. }
  264. return inst, ok
  265. }
  266. // StartListeningToChatSession converts SNACs relayed to a joined room's chat
  267. // SessionInstance into web events on this session's queue. roomID is the room's
  268. // ChatRoom cookie, which the web client uses to key the conversation. The
  269. // goroutine exits when the chat instance closes (room left) or the web session is
  270. // torn down. closeMu is held across Add so a concurrent Close either observes the
  271. // listener or is observed by it — mirrors StartListeningToOSCARSession.
  272. func (s *WebAPISession) StartListeningToChatSession(roomID string, inst *SessionInstance) {
  273. s.closeMu.Lock()
  274. defer s.closeMu.Unlock()
  275. if s.closed {
  276. return
  277. }
  278. s.listeners.Add(1)
  279. go func() {
  280. defer s.listeners.Done()
  281. msgCh := inst.ReceiveMessage()
  282. for {
  283. select {
  284. case <-s.ctx.Done():
  285. return
  286. case <-inst.Closed():
  287. return
  288. case msg, ok := <-msgCh:
  289. if !ok {
  290. return
  291. }
  292. s.handleChatSNAC(roomID, msg)
  293. }
  294. }
  295. }()
  296. }
  297. // handleChatSNAC converts a SNAC relayed to a room's chat instance into a web
  298. // event: chat messages become `im` events, and participant join/leave SNACs
  299. // become `imserv` activity events that live-update the client's roster.
  300. func (s *WebAPISession) handleChatSNAC(roomID string, msg wire.SNACMessage) {
  301. if s.EventQueue == nil {
  302. return
  303. }
  304. switch body := msg.Body.(type) {
  305. case wire.SNAC_0x0E_0x06_ChatChannelMsgToClient:
  306. s.handleRoomMessage(roomID, body)
  307. case wire.SNAC_0x0E_0x03_ChatUsersJoined:
  308. s.handleMembershipChange(roomID, "memberJoin", body.Users)
  309. case wire.SNAC_0x0E_0x04_ChatUsersLeft:
  310. s.handleMembershipChange(roomID, "memberLeft", body.Users)
  311. }
  312. }
  313. // handleMembershipChange pushes an `imserv` activity event so an existing
  314. // participant's roster live-updates when someone joins or leaves. Each affected
  315. // user becomes one activity keyed to the room; the client reads member1 as the
  316. // affected member for both join and leave. Not gated on a subscription: the
  317. // event only fires for a room the user has joined, and the client's imserv
  318. // listener is active whenever its chat controller is. See types.ImservEvent.
  319. func (s *WebAPISession) handleMembershipChange(roomID, action string, users []wire.TLVUserInfo) {
  320. if len(users) == 0 {
  321. return
  322. }
  323. now := float64(time.Now().Unix())
  324. activities := make([]types.ImservActivity, 0, len(users))
  325. for _, user := range users {
  326. // The client keys the member by normalized aimId (matching getMembers) and
  327. // renders the display form from the friendly name.
  328. aimID := NewIdentScreenName(user.ScreenName).String()
  329. activities = append(activities, types.ImservActivity{
  330. Action: action,
  331. Member1: aimID,
  332. Member2: aimID,
  333. Member1FriendlyName: user.ScreenName,
  334. Timestamp: now,
  335. })
  336. }
  337. s.EventQueue.Push(types.EventTypeImserv, types.ImservEvent{
  338. RecentActivities: []types.ImservRoomActivity{{
  339. Imserv: roomID,
  340. Activities: activities,
  341. }},
  342. })
  343. }
  344. // handleRoomMessage pushes a relayed room line as an `im` event. The client keys
  345. // the conversation by the room id but reads the speaker and text from
  346. // specialData.imFromImserv, so both are populated here. See RoomIMEvent.
  347. func (s *WebAPISession) handleRoomMessage(roomID string, body wire.SNAC_0x0E_0x06_ChatChannelMsgToClient) {
  348. if !s.IsSubscribedTo("im") {
  349. return
  350. }
  351. senderInfo, ok := body.Bytes(wire.ChatTLVSenderInformation)
  352. if !ok {
  353. return
  354. }
  355. var userInfo wire.TLVUserInfo
  356. if err := wire.UnmarshalBE(&userInfo, bytes.NewReader(senderInfo)); err != nil {
  357. s.logger.Error("failed to unmarshal chat sender info", "err", err)
  358. return
  359. }
  360. msgInfo, ok := body.Bytes(wire.ChatTLVMessageInfo)
  361. if !ok {
  362. return
  363. }
  364. text, err := wire.UnmarshalChatMessageText(msgInfo)
  365. if err != nil {
  366. s.logger.Error("failed to unmarshal chat message text", "err", err)
  367. return
  368. }
  369. // The sender screen name is carried as its display form; the client keys the
  370. // speaker by the normalized aimId and renders the display form separately.
  371. speakerAimID := NewIdentScreenName(userInfo.ScreenName).String()
  372. s.PushRoomLine(roomID, speakerAimID, text)
  373. }
  374. // PushRoomLine queues a group-chat room line as an `im` event. roomID keys the
  375. // conversation; speakerAimID attributes the line to the participant who spoke.
  376. // Both the relay listener (incoming lines) and the send path (the sender's own
  377. // reflected line) funnel through here so every room line has the same shape the
  378. // client expects — see types.RoomIMEvent.
  379. func (s *WebAPISession) PushRoomLine(roomID, speakerAimID, text string) {
  380. if s.EventQueue == nil || !s.IsSubscribedTo("im") {
  381. return
  382. }
  383. s.EventQueue.Push(types.EventTypeIM, types.RoomIMEvent{
  384. Source: types.UserInfo{
  385. AimID: roomID,
  386. UserType: "aim",
  387. State: "online",
  388. },
  389. Imserv: roomID,
  390. SpecialIM: "imservMsg",
  391. SpecialData: types.RoomSpecialData{
  392. ImFromImserv: types.RoomImFrom{
  393. OrigSender: speakerAimID,
  394. Sender: speakerAimID,
  395. Text: text,
  396. },
  397. },
  398. Message: text,
  399. MsgID: strconv.FormatUint(mrand.Uint64(), 16),
  400. Timestamp: float64(time.Now().Unix()),
  401. })
  402. }
  403. // handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
  404. func (s *WebAPISession) handleSNACMessage(msg wire.SNACMessage) {
  405. if s.EventQueue == nil {
  406. return
  407. }
  408. // Convert SNAC message to WebAPI events based on food group and subgroup
  409. switch msg.Frame.FoodGroup {
  410. case wire.ICBM:
  411. s.handleICBMMessage(msg)
  412. case wire.Buddy:
  413. s.handleBuddyMessage(msg)
  414. case wire.Feedbag:
  415. s.handleFeedbagMessage(msg)
  416. case wire.OService:
  417. s.handleOServiceMessage(msg)
  418. }
  419. }
  420. // handleOServiceMessage handles OService SNAC messages relayed to the session's
  421. // own OSCAR instance. The only one we surface is OServiceUserInfoUpdate, which the
  422. // server relays to a user when their own user info changes (notably a buddy icon
  423. // upload or clear). The client re-renders its identity badge from myInfo events
  424. // only, so we translate this into a fresh myInfo.
  425. func (s *WebAPISession) handleOServiceMessage(msg wire.SNACMessage) {
  426. if msg.Frame.SubGroup != wire.OServiceUserInfoUpdate {
  427. return
  428. }
  429. if !s.IsSubscribedTo("myInfo") && !s.IsSubscribedTo("presence") {
  430. return
  431. }
  432. if s.MyInfoRefresher == nil {
  433. return
  434. }
  435. data, err := s.MyInfoRefresher(s.ctx)
  436. if err != nil {
  437. s.logger.Error("failed to refresh myInfo after user-info update", "err", err)
  438. return
  439. }
  440. s.EventQueue.Push(types.EventType("myInfo"), data)
  441. }
  442. // handleICBMMessage handles ICBM (instant messaging) SNAC messages.
  443. func (s *WebAPISession) handleICBMMessage(msg wire.SNACMessage) {
  444. switch msg.Frame.SubGroup {
  445. case wire.ICBMChannelMsgToClient:
  446. // A chat-room invitation arrives as a channel-2 rendezvous, not a plain
  447. // IM. Route it to the invite handler; everything else is a 1:1 message.
  448. if body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient); ok &&
  449. body.ChannelID == wire.ICBMChannelRendezvous {
  450. s.handleChatInvite(body)
  451. return
  452. }
  453. s.handleIncomingIM(msg)
  454. case wire.ICBMClientEvent:
  455. s.handleTypingNotification(msg)
  456. }
  457. }
  458. // handleChatInvite converts an incoming chat-room invitation (a channel-2
  459. // CapChat rendezvous) into an `im` event carrying specialData.invitation, which
  460. // is how the web client recognizes and can accept an invite (via imserv/join
  461. // with the room id). Non-chat rendezvous (file transfer, etc.) are ignored.
  462. func (s *WebAPISession) handleChatInvite(body wire.SNAC_0x04_0x07_ICBMChannelMsgToClient) {
  463. if !s.IsSubscribedTo("im") {
  464. return
  465. }
  466. rdinfo, ok := body.Bytes(wire.ICBMTLVData)
  467. if !ok {
  468. return
  469. }
  470. var frag wire.ICBMCh2Fragment
  471. if err := wire.UnmarshalBE(&frag, bytes.NewReader(rdinfo)); err != nil {
  472. return
  473. }
  474. if frag.Type != wire.ICBMRdvMessagePropose || uuid.UUID(frag.Capability) != wire.CapChat {
  475. return
  476. }
  477. svcData, ok := frag.Bytes(wire.ICBMRdvTLVTagsSvcData)
  478. if !ok {
  479. return
  480. }
  481. var roomInfo wire.ICBMRoomInfo
  482. if err := wire.UnmarshalBE(&roomInfo, bytes.NewReader(svcData)); err != nil {
  483. return
  484. }
  485. prompt, _ := frag.String(wire.ICBMRdvTLVTagsInvitation)
  486. inviterAimID := NewIdentScreenName(body.ScreenName).String()
  487. s.EventQueue.Push(types.EventTypeIM, types.RoomInviteEvent{
  488. Source: types.UserInfo{
  489. AimID: inviterAimID,
  490. UserType: "aim",
  491. State: "online",
  492. },
  493. Imserv: roomInfo.Cookie,
  494. Message: prompt,
  495. MsgID: strconv.FormatUint(mrand.Uint64(), 16),
  496. Timestamp: float64(time.Now().Unix()),
  497. SpecialData: types.RoomInviteSpecial{
  498. Invitation: types.RoomInvitation{
  499. From: body.ScreenName,
  500. GroupName: roomNameFromCookie(roomInfo.Cookie),
  501. },
  502. },
  503. })
  504. }
  505. // roomNameFromCookie returns the human room name embedded in a chat room cookie
  506. // of the form "<exchange>-<instance>-<name>". The name may itself contain "-",
  507. // so only the first two segments are split off. Falls back to the whole cookie.
  508. func roomNameFromCookie(cookie string) string {
  509. parts := strings.SplitN(cookie, "-", 3)
  510. if len(parts) < 3 {
  511. return cookie
  512. }
  513. return parts[2]
  514. }
  515. // handleIncomingIM handles incoming instant messages.
  516. func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
  517. if !s.IsSubscribedTo("im") {
  518. return
  519. }
  520. body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
  521. if !ok {
  522. return
  523. }
  524. // Extract message text from TLV data
  525. var messageText string
  526. if msgData, hasMsg := body.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
  527. if text, err := wire.UnmarshalICBMMessageText(msgData); err == nil {
  528. messageText = text
  529. }
  530. }
  531. if messageText == "" {
  532. return
  533. }
  534. // Check if it's an auto-response (channel 2)
  535. autoResponse := body.ChannelID == 0x0002
  536. // msgId must be unique per delivered event. The OSCAR cookie is not a
  537. // reliable unique id (some clients reuse it across messages), and the web
  538. // client dedupes its conversation list by msgId, silently dropping any
  539. // collisions. Mint a fresh random id instead of reusing body.Cookie.
  540. msgID := strconv.FormatUint(mrand.Uint64(), 16)
  541. // SNAC user info carries the sender's display screen name. The web client
  542. // keys conversations and users by the normalized aimId and only renders
  543. // displayId, so the two forms must not be interchanged.
  544. partnerDisplay := body.ScreenName
  545. partnerAimID := NewIdentScreenName(partnerDisplay).String()
  546. nowSec := time.Now().Unix()
  547. s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, nowSec)
  548. // Create IM event
  549. imEvent := types.IMEvent{
  550. Source: types.UserInfo{
  551. AimID: partnerAimID,
  552. DisplayID: partnerDisplay,
  553. Friendly: s.aliasFor(NewIdentScreenName(partnerAimID)),
  554. UserType: "aim",
  555. State: "online",
  556. },
  557. Message: messageText,
  558. MsgID: msgID,
  559. Timestamp: float64(time.Now().Unix()),
  560. AutoResp: autoResponse,
  561. }
  562. s.EventQueue.Push(types.EventTypeIM, imEvent)
  563. s.logger.Debug("delivered instant message",
  564. "from", partnerDisplay,
  565. "to", s.ScreenName)
  566. if s.IsSubscribedTo("conversation") {
  567. // unread is 0 here, not 1, because the "im" event pushed above already
  568. // causes the client to increment its own persisted per-buddy unread
  569. // tally. The "Recent chats" badge is the sum of that persisted tally and
  570. // this conversation's unreadCount, so sending 1 here would double-count
  571. // the message (badge shows 2 for the first IM). Mirrors the sent-IM path,
  572. // which also passes 0.
  573. s.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
  574. types.ConversationEntry(
  575. partnerAimID,
  576. partnerDisplay,
  577. messageText,
  578. msgID,
  579. partnerAimID,
  580. false,
  581. 0,
  582. ),
  583. }))
  584. }
  585. }
  586. // handleTypingNotification handles typing notifications.
  587. func (s *WebAPISession) handleTypingNotification(msg wire.SNACMessage) {
  588. if !s.IsSubscribedTo("typing") {
  589. return
  590. }
  591. body, ok := msg.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)
  592. if !ok {
  593. return
  594. }
  595. // Event types: 0x0000=none, 0x0001=typed (paused), 0x0002=typing
  596. var typingStatus string
  597. switch body.Event {
  598. case 0x0002:
  599. typingStatus = "typing"
  600. case 0x0001:
  601. typingStatus = "typed"
  602. default:
  603. typingStatus = "none"
  604. }
  605. typingEvent := types.TypingEvent{
  606. AimID: NewIdentScreenName(body.ScreenName).String(),
  607. TypingStatus: typingStatus,
  608. }
  609. s.EventQueue.Push(types.EventTypeTyping, typingEvent)
  610. }
  611. // handleBuddyMessage handles buddy/presence SNAC messages.
  612. func (s *WebAPISession) handleBuddyMessage(msg wire.SNACMessage) {
  613. switch msg.Frame.SubGroup {
  614. case wire.BuddyArrived:
  615. s.handleBuddyArrived(msg)
  616. case wire.BuddyDeparted:
  617. s.handleBuddyDeparted(msg)
  618. }
  619. }
  620. // handleBuddyArrived handles when a buddy comes online.
  621. func (s *WebAPISession) handleBuddyArrived(msg wire.SNACMessage) {
  622. if !s.IsSubscribedTo("presence") {
  623. return
  624. }
  625. body, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
  626. if !ok {
  627. return
  628. }
  629. stateStr := "online"
  630. // For BuddyArrived updates, infer presence state from the TLVUserInfo.
  631. // Away and invisible transitions are typically broadcast using BuddyArrived
  632. // with updated user flags/status bits, not BuddyDeparted.
  633. if body.IsInvisible() {
  634. stateStr = "offline"
  635. } else if body.IsAway() {
  636. stateStr = "away"
  637. } else if mask, ok := body.Uint32BE(wire.OServiceUserInfoStatus); ok {
  638. if mask&wire.OServiceUserStatusDND == wire.OServiceUserStatusDND {
  639. stateStr = "dnd"
  640. } else if mask&wire.OServiceUserStatusAway == wire.OServiceUserStatusAway {
  641. stateStr = "away"
  642. }
  643. }
  644. buddy := NewIdentScreenName(body.ScreenName)
  645. presenceEvent := types.PresenceEvent{
  646. AimID: buddy.String(),
  647. Friendly: s.aliasFor(buddy),
  648. State: stateStr,
  649. UserType: "aim",
  650. }
  651. // A BuddyArrived carries the buddy's current icon in TLV 0x1D whenever they
  652. // have one, so an icon change (or clear, which arrives as the sentinel hash)
  653. // rides along on the presence broadcast. Publish the matching URL: with an
  654. // icon it is content-addressed; without one it is the placeholder URL, which
  655. // differs from any prior icon URL and so clears a removed icon under the
  656. // client's shallow merge. An empty result (no origin known) is omitted, which
  657. // preserves whatever icon the client already holds.
  658. if s.BuddyIconURL != nil {
  659. var hash []byte
  660. if b, ok := body.Bytes(wire.OServiceUserInfoBARTInfo); ok {
  661. var id wire.BARTID
  662. if err := wire.UnmarshalBE(&id, bytes.NewBuffer(b)); err == nil {
  663. hash = id.Hash
  664. }
  665. }
  666. presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, hash)
  667. }
  668. s.EventQueue.Push(types.EventTypePresence, presenceEvent)
  669. }
  670. // handleBuddyDeparted handles when a buddy goes offline.
  671. func (s *WebAPISession) handleBuddyDeparted(msg wire.SNACMessage) {
  672. if !s.IsSubscribedTo("presence") {
  673. return
  674. }
  675. body, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
  676. if !ok {
  677. return
  678. }
  679. buddy := NewIdentScreenName(body.ScreenName)
  680. // BuddyIcon is deliberately omitted: an offline buddy keeps their icon, and
  681. // omitting it lets the client's merge preserve the icon it already holds.
  682. presenceEvent := types.PresenceEvent{
  683. AimID: buddy.String(),
  684. Friendly: s.aliasFor(buddy),
  685. State: "offline",
  686. UserType: "aim",
  687. }
  688. s.EventQueue.Push(types.EventTypePresence, presenceEvent)
  689. }
  690. func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
  691. switch msg.Frame.SubGroup {
  692. case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
  693. // A buddy item carries its alias, so any feedbag write can change the map.
  694. s.InvalidateAliases()
  695. if s.BuddyListRefresher != nil {
  696. groups, err := s.BuddyListRefresher(s.ctx)
  697. if err != nil {
  698. s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
  699. } else {
  700. s.EventQueue.Push(types.EventTypeBuddyList, map[string]interface{}{"groups": groups})
  701. }
  702. }
  703. if msg.Frame.SubGroup == wire.FeedbagUpdateItem && s.PermitDenyRefresher != nil {
  704. body, ok := msg.Body.(wire.SNAC_0x13_0x09_FeedbagUpdateItem)
  705. if ok {
  706. for _, item := range body.Items {
  707. if item.ClassID == wire.FeedbagClassIDPermit ||
  708. item.ClassID == wire.FeedbagClassIDDeny ||
  709. item.ClassID == wire.FeedbagClassIdPdinfo {
  710. pdd, err := s.PermitDenyRefresher(s.ctx)
  711. if err != nil {
  712. s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
  713. } else {
  714. s.EventQueue.Push(types.EventTypePermitDeny, pdd)
  715. }
  716. break
  717. }
  718. }
  719. }
  720. }
  721. }
  722. }
  723. // WebAPISessionManager manages Web API sessions with thread-safe operations.
  724. // Construct it with NewWebAPISessionManager and drive its reaper with Run.
  725. type WebAPISessionManager struct {
  726. sessions map[string]*WebAPISession // Keyed by aimsid
  727. mu sync.RWMutex
  728. closed bool // set by Shutdown; rejects new sessions and makes drain idempotent
  729. stopCh chan struct{} // closed by Shutdown to stop the reaper
  730. reaperWG sync.WaitGroup // tracks a running reaper so Shutdown can join it
  731. }
  732. // NewWebAPISessionManager creates a new WebAPI session manager. It does not start
  733. // any goroutines; call Run to start reaping expired sessions.
  734. func NewWebAPISessionManager() *WebAPISessionManager {
  735. return &WebAPISessionManager{
  736. sessions: make(map[string]*WebAPISession),
  737. stopCh: make(chan struct{}),
  738. }
  739. }
  740. // CreateSession creates a new WebAPI session.
  741. //
  742. // The session does not begin listening to its OSCAR instance yet: the caller
  743. // must wire the session's refresher callbacks (BuddyListRefresher, BuddyIconURL,
  744. // MyInfoRefresher, ...) and then call StartListeningToOSCARSession. Wiring them
  745. // after the listener starts would race the goroutine, which reads them as it
  746. // converts SNACs into events.
  747. func (m *WebAPISessionManager) CreateSession(screenName DisplayScreenName, devID string, events []string, oscarSession *SessionInstance, baseURL string, logger *slog.Logger) (*WebAPISession, error) {
  748. m.mu.Lock()
  749. defer m.mu.Unlock()
  750. // Refuse to create sessions once shut down: the reaper is stopped, so a
  751. // session added now would never be closed or reaped.
  752. if m.closed {
  753. return nil, ErrWebAPISessionManagerClosed
  754. }
  755. // Generate unique session ID
  756. aimsid, err := generateSessionID()
  757. if err != nil {
  758. return nil, err
  759. }
  760. now := time.Now()
  761. sessCtx, sessCancel := context.WithCancel(context.Background())
  762. session := &WebAPISession{
  763. ctx: sessCtx,
  764. cancel: sessCancel,
  765. AimSID: aimsid,
  766. ScreenName: screenName,
  767. OSCARSession: oscarSession,
  768. BaseURL: baseURL,
  769. Events: events,
  770. EventQueue: types.NewEventQueue(1000), // Max 1000 events per session
  771. DevID: devID,
  772. CreatedAt: now,
  773. LastAccessed: now,
  774. ExpiresAt: now.Add(webAPISessionTTL),
  775. FetchTimeout: 60000, // 60 seconds default for better stability
  776. TimeToNextFetch: 500, // 500ms suggested delay
  777. logger: logger,
  778. }
  779. m.sessions[aimsid] = session
  780. // The caller starts the OSCAR listener (StartListeningToOSCARSession) once it
  781. // has wired the session's refresher callbacks; starting it here would race
  782. // those assignments.
  783. return session, nil
  784. }
  785. // GetSession retrieves a session by aimsid.
  786. func (m *WebAPISessionManager) GetSession(ctx context.Context, aimsid string) (*WebAPISession, error) {
  787. m.mu.RLock()
  788. defer m.mu.RUnlock()
  789. session, exists := m.sessions[aimsid]
  790. if !exists {
  791. return nil, ErrNoWebAPISession
  792. }
  793. if session.IsExpired() {
  794. return nil, ErrWebAPISessionExpired
  795. }
  796. return session, nil
  797. }
  798. // RemoveSession removes a session by aimsid.
  799. func (m *WebAPISessionManager) RemoveSession(ctx context.Context, aimsid string) error {
  800. m.mu.Lock()
  801. session, exists := m.sessions[aimsid]
  802. if !exists {
  803. m.mu.Unlock()
  804. return ErrNoWebAPISession
  805. }
  806. delete(m.sessions, aimsid)
  807. m.mu.Unlock()
  808. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  809. // broadcasts and signout, which we don't want to run under m.mu.
  810. session.Close()
  811. return nil
  812. }
  813. // TouchSession updates the last accessed time for a session.
  814. func (m *WebAPISessionManager) TouchSession(ctx context.Context, aimsid string) error {
  815. m.mu.Lock()
  816. defer m.mu.Unlock()
  817. session, exists := m.sessions[aimsid]
  818. if !exists {
  819. return ErrNoWebAPISession
  820. }
  821. session.Touch()
  822. return nil
  823. }
  824. // Run reaps expired sessions on a fixed interval until ctx is cancelled or
  825. // Shutdown is called. The caller owns the goroutine's lifecycle; typically
  826. // launch it under the server's errgroup:
  827. //
  828. // g.Go(func() error { mgr.Run(ctx); return nil })
  829. //
  830. // Run is a no-op once the manager is closed, so a reaper that loses the race
  831. // with Shutdown never starts reaping a drained manager.
  832. func (m *WebAPISessionManager) Run(ctx context.Context) {
  833. m.mu.Lock()
  834. if m.closed {
  835. m.mu.Unlock()
  836. return
  837. }
  838. // Registering under m.mu is what makes Shutdown's join sound: Shutdown flips
  839. // closed under the same lock, so a reaper either registers before Shutdown
  840. // waits or is turned away here.
  841. m.reaperWG.Add(1)
  842. m.mu.Unlock()
  843. defer m.reaperWG.Done()
  844. ticker := time.NewTicker(webAPISessionReapInterval)
  845. defer ticker.Stop()
  846. for {
  847. select {
  848. case <-ticker.C:
  849. m.reapExpired()
  850. case <-m.stopCh:
  851. return
  852. case <-ctx.Done():
  853. return
  854. }
  855. }
  856. }
  857. // reapExpired removes every expired session and tears it down.
  858. func (m *WebAPISessionManager) reapExpired() {
  859. m.mu.Lock()
  860. now := time.Now()
  861. var expired []*WebAPISession
  862. for aimsid, session := range m.sessions {
  863. if now.After(session.ExpiresAt) {
  864. delete(m.sessions, aimsid)
  865. expired = append(expired, session)
  866. }
  867. }
  868. m.mu.Unlock()
  869. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  870. // broadcasts and signout, which we don't want to run under m.mu.
  871. for _, session := range expired {
  872. session.Close()
  873. }
  874. }
  875. // Shutdown drains and closes all sessions, stops the reaper started by Run, and
  876. // blocks further CreateSession calls. It does not depend on the caller
  877. // cancelling Run's context. Safe to call more than once, though only the first
  878. // call waits for the drain. The drain is bounded by ctx: Shutdown returns
  879. // ctx.Err() rather than block forever on a listener that ignores cancellation.
  880. func (m *WebAPISessionManager) Shutdown(ctx context.Context) error {
  881. m.mu.Lock()
  882. if m.closed {
  883. m.mu.Unlock()
  884. return nil
  885. }
  886. m.closed = true
  887. close(m.stopCh)
  888. sessions := make([]*WebAPISession, 0, len(m.sessions))
  889. for _, session := range m.sessions {
  890. sessions = append(sessions, session)
  891. }
  892. // Clear all sessions
  893. m.sessions = make(map[string]*WebAPISession)
  894. m.mu.Unlock()
  895. drained := make(chan struct{})
  896. go func() {
  897. defer close(drained)
  898. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  899. // broadcasts and signout, which we don't want to run under m.mu.
  900. for _, session := range sessions {
  901. session.Close()
  902. }
  903. m.reaperWG.Wait()
  904. }()
  905. select {
  906. case <-drained:
  907. return nil
  908. case <-ctx.Done():
  909. return ctx.Err()
  910. }
  911. }
  912. // generateSessionID creates a cryptographically secure session ID.
  913. func generateSessionID() (string, error) {
  914. bytes := make([]byte, 32) // 256 bits
  915. if _, err := rand.Read(bytes); err != nil {
  916. return "", err
  917. }
  918. return hex.EncodeToString(bytes), nil
  919. }