session.go 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435
  1. package webapi
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/md5"
  6. "crypto/rand"
  7. "encoding/hex"
  8. "errors"
  9. "log/slog"
  10. "slices"
  11. "sort"
  12. "strings"
  13. "sync"
  14. "time"
  15. "github.com/google/uuid"
  16. "github.com/mk6i/open-oscar-server/state"
  17. "github.com/mk6i/open-oscar-server/wire"
  18. )
  19. var (
  20. // ErrNoWebAPISession is returned when a WebAPI session is not found.
  21. ErrNoWebAPISession = errors.New("WebAPI session not found")
  22. // ErrWebAPISessionExpired is returned when a WebAPI session has expired.
  23. ErrWebAPISessionExpired = errors.New("WebAPI session expired")
  24. // ErrWebAPISessionManagerClosed is returned when a session is requested from
  25. // a manager that has been shut down.
  26. ErrWebAPISessionManagerClosed = errors.New("WebAPI session manager is shut down")
  27. )
  28. // Web API session lifecycle timeline.
  29. //
  30. // A web client keeps its session alive by long-polling GET /aim/fetchEvents.
  31. // Every authenticated request touches the session (middleware.RequireSession
  32. // calls TouchSession at request arrival), sliding expiry to now + the TTL. A
  33. // single poll blocks for up to 60s (the fetchEvents long-poll cap) and the
  34. // client waits ~500ms (TimeToNextFetch) before re-polling, so in steady state a
  35. // healthy client touches the session at worst every ~60-65s once jitter is
  36. // included. That worst-case touch interval is the floor the TTL must clear.
  37. //
  38. // If a client hangs up without calling endSession, its last touch was at its
  39. // last poll: the session then expires webAPISessionTTL later and the reaper
  40. // sweeps it within one webAPISessionReapInterval tick. So a silent client is
  41. // removed (and its OSCAR session closed) within TTL + tick of going quiet.
  42. const (
  43. // webAPISessionTTL bounds how long a session survives without a poll. It is
  44. // sized to absorb one missed poll cycle: ~60s for the normal cycle, ~60s for
  45. // the absorbed miss, plus ~20s of jitter margin. Two consecutive misses mean
  46. // the client is genuinely gone and the session is reaped.
  47. webAPISessionTTL = 150 * time.Second
  48. // webAPISessionReapInterval is how often the cleanup goroutine sweeps for
  49. // expired sessions (~TTL/5). A dead session lingers at most
  50. // webAPISessionTTL + webAPISessionReapInterval before removal.
  51. webAPISessionReapInterval = 30 * time.Second
  52. )
  53. // webAPICaps are the capabilities the Web API advertises on behalf of its
  54. // clients. It seeds every session's capability list and is sent as-is at
  55. // sign-on, so the two never drift.
  56. var webAPICaps = [][16]byte{
  57. wire.CapICQCh2Extended,
  58. wire.CapUTF8Messages,
  59. }
  60. // Session represents an active Web AIM API session.
  61. type Session struct {
  62. AimSID string // Unique session ID for web client
  63. ScreenName state.DisplayScreenName // User identity
  64. OSCARSession *state.SessionInstance // Bridge to existing OSCAR session
  65. BaseURL string // Web API base URL advertised to the web client, used to build absolute asset URLs
  66. Events []string // Subscribed event types
  67. EventQueue *EventQueue // Per-session event queue
  68. ClientName string // Client application name
  69. ClientVersion string // Client application version
  70. CreatedAt time.Time // SessionInstance creation time
  71. LastAccessed time.Time // Last activity time
  72. ExpiresAt time.Time // SessionInstance expiration time
  73. FetchTimeout int // Long-polling timeout in milliseconds
  74. TimeToNextFetch int // Suggested delay before next fetch
  75. RemoteAddr string // Client IP address
  76. BuddyListRefresher func(ctx context.Context) (any, error) // Called on feedbag changes to push buddylist event
  77. PermitDenyRefresher func(ctx context.Context) (any, error) // Called on feedbag changes to push permitDeny event
  78. FeedbagLoader func(ctx context.Context) ([]wire.FeedbagItem, error) // Reads the owner's feedbag; every read-only view of the roster derives from it
  79. // BuddyIconURL formats the absolute buddyIcon URL for a buddy from the icon
  80. // hash carried in a presence SNAC. Returns "" when no URL can be published.
  81. BuddyIconURL func(screenName state.IdentScreenName, hash []byte) string
  82. feedbag []wire.FeedbagItem // cached FeedbagLoader result, nil when unloaded or invalidated
  83. aliases map[string]string // aliases derived from feedbag, nil when unloaded or invalidated
  84. feedbagMu sync.Mutex
  85. // presence is the session's view of its buddies' last-known presence, keyed by
  86. // normalized aimId and fed by the BuddyArrived/BuddyDeparted SNACs the listener
  87. // receives.
  88. presence map[string]BuddyPresence
  89. presenceMu sync.RWMutex
  90. imLog map[string][]WebAPIStoredIM
  91. imLogMu sync.Mutex
  92. sentIMs map[uint64]string // OSCAR message cookie -> the msgId given to the client
  93. sentIMOrder []uint64 // insertion order of sentIMs, oldest first
  94. sentIMMu sync.Mutex
  95. // typingPeers lists all peers whose last live IM asked for typing events.
  96. // Only these peers are sent this session's typing events.
  97. typingPeers map[string]bool
  98. typingPeersMu sync.Mutex
  99. // IMRateClassID is the rate class that sending an IM spends. The web client
  100. // renders any rate limit event as the IM banner, so only this class's updates
  101. // may reach it. Zero disables the alert.
  102. IMRateClassID wire.RateLimitClassID
  103. logger *slog.Logger // Logger for debugging
  104. listeners sync.WaitGroup
  105. ctx context.Context
  106. cancel context.CancelFunc
  107. closeMu sync.Mutex
  108. closed bool
  109. capabilities [][16]byte
  110. capsMu sync.RWMutex
  111. }
  112. // IsExpired checks if the session has expired.
  113. func (s *Session) IsExpired() bool {
  114. return time.Now().After(s.ExpiresAt)
  115. }
  116. // Aliases returns this session owner's private buddy aliases, keyed by normalized
  117. // screen name, derived from the cached feedbag and memoized alongside it. Buddies
  118. // without an alias are absent. The map is owned by the session and must not be
  119. // mutated by callers.
  120. func (s *Session) Aliases(ctx context.Context) map[string]string {
  121. s.feedbagMu.Lock()
  122. defer s.feedbagMu.Unlock()
  123. if s.aliases == nil {
  124. items, err := s.feedbagLocked(ctx)
  125. if err != nil {
  126. return nil
  127. }
  128. aliases := make(map[string]string)
  129. for _, item := range items {
  130. if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
  131. continue
  132. }
  133. alias, ok := item.String(wire.FeedbagAttributesAlias)
  134. if !ok || alias == "" {
  135. continue
  136. }
  137. aliases[state.NewIdentScreenName(item.Name).String()] = alias
  138. }
  139. s.aliases = aliases
  140. }
  141. return s.aliases
  142. }
  143. // Feedbag returns the owner's feedbag rows, reading them through FeedbagLoader on
  144. // the first call after a change and serving the cached copy afterwards. The slice
  145. // is owned by the session and must not be mutated by callers.
  146. //
  147. // Code that rewrites the feedbag must re-read it rather than use this: it computes
  148. // item ids and a pending diff from what it reads, and a stale snapshot would
  149. // overwrite another instance's change.
  150. func (s *Session) Feedbag(ctx context.Context) ([]wire.FeedbagItem, error) {
  151. s.feedbagMu.Lock()
  152. defer s.feedbagMu.Unlock()
  153. return s.feedbagLocked(ctx)
  154. }
  155. // feedbagLocked loads and caches the feedbag. Callers hold feedbagMu.
  156. //
  157. // The lock is deliberately held across the load. Another instance can change the
  158. // list mid-read, and its feedbag SNAC invalidates this cache; a load outside the
  159. // lock could store its pre-change result after that invalidation.
  160. func (s *Session) feedbagLocked(ctx context.Context) ([]wire.FeedbagItem, error) {
  161. if s.feedbag == nil {
  162. items, err := s.FeedbagLoader(ctx)
  163. if err != nil {
  164. s.logger.Error("failed to load feedbag", "err", err.Error())
  165. return nil, err
  166. }
  167. if items == nil {
  168. // An empty feedbag must still count as loaded.
  169. items = []wire.FeedbagItem{}
  170. }
  171. s.feedbag = items
  172. }
  173. return s.feedbag, nil
  174. }
  175. // InvalidateFeedbag drops the cached feedbag and the aliases derived from it.
  176. // Callers that change the owner's feedbag must call this: the feedbag service
  177. // relays its item SNACs only to the owner's *other* instances.
  178. func (s *Session) InvalidateFeedbag() {
  179. s.feedbagMu.Lock()
  180. defer s.feedbagMu.Unlock()
  181. s.feedbag = nil
  182. s.aliases = nil
  183. }
  184. // aliasFor returns this session owner's private alias for buddy, or "" when none is
  185. // set. The web client deletes the alias it holds whenever it merges a user map, so
  186. // every event naming a buddy has to repeat it.
  187. func (s *Session) aliasFor(buddy state.IdentScreenName) string {
  188. // Runs on the SNAC listener goroutine, which has no request context.
  189. return s.Aliases(s.ctx)[buddy.String()]
  190. }
  191. // BuddyPresence is a buddy's last-known presence, assembled from the
  192. // BuddyArrived and BuddyDeparted SNACs a session receives. It carries only what
  193. // those SNACs carry: an away message lives in the locate reply's LocateInfo and
  194. // is not part of a presence broadcast.
  195. type BuddyPresence struct {
  196. DisplayID string // screen name as the buddy formats it
  197. State string // "online", "away", "idle", "occupied", "dnd", "offline"
  198. StatusMsg string
  199. OnlineTime int64
  200. IdleTime int // minutes
  201. Caps [][16]byte
  202. IconHash []byte
  203. }
  204. // Online reports whether the buddy is visible to the viewer.
  205. func (p BuddyPresence) Online() bool {
  206. return p.State != "offline"
  207. }
  208. // BuddyPresence returns a buddy's last-known presence and whether the session
  209. // has one. Callers rendering the roster treat a miss as offline.
  210. func (s *Session) BuddyPresence(buddy state.IdentScreenName) (BuddyPresence, bool) {
  211. s.presenceMu.RLock()
  212. defer s.presenceMu.RUnlock()
  213. p, ok := s.presence[buddy.String()]
  214. return p, ok
  215. }
  216. // setBuddyPresence records a buddy's presence, replacing whatever was there.
  217. func (s *Session) setBuddyPresence(buddy state.IdentScreenName, p BuddyPresence) {
  218. s.presenceMu.Lock()
  219. defer s.presenceMu.Unlock()
  220. if s.presence == nil {
  221. s.presence = make(map[string]BuddyPresence)
  222. }
  223. s.presence[buddy.String()] = p
  224. }
  225. // setBuddyOffline marks a buddy offline, keeping the display name last seen for
  226. // them. A BuddyDeparted carries no TLV block, so nothing else survives: an
  227. // offline buddy publishes no icon, status message or mood.
  228. func (s *Session) setBuddyOffline(buddy state.IdentScreenName) {
  229. s.presenceMu.Lock()
  230. defer s.presenceMu.Unlock()
  231. if s.presence == nil {
  232. s.presence = make(map[string]BuddyPresence)
  233. }
  234. s.presence[buddy.String()] = BuddyPresence{
  235. DisplayID: s.presence[buddy.String()].DisplayID,
  236. State: "offline",
  237. }
  238. }
  239. // forgetBuddyPresence drops a buddy's cached presence. Callers do this when the
  240. // viewer stops watching them: OSCAR notifies only users who currently watch each
  241. // other, so no arrival or departure for that buddy is relayed here again.
  242. func (s *Session) forgetBuddyPresence(buddy state.IdentScreenName) {
  243. s.presenceMu.Lock()
  244. defer s.presenceMu.Unlock()
  245. delete(s.presence, buddy.String())
  246. }
  247. // forgetUnlistedBuddies drops the cached presence of the named buddies that are no
  248. // longer on the roster. One still listed in another group is still watched.
  249. func (s *Session) forgetUnlistedBuddies(names []string) {
  250. if len(names) == 0 {
  251. return
  252. }
  253. items, err := s.Feedbag(s.ctx)
  254. if err != nil {
  255. // Without the roster a partial removal cannot be told from a full one, and
  256. // a wrongly dropped entry reads offline until the buddy changes presence.
  257. return
  258. }
  259. for _, name := range names {
  260. if stillListsBuddy(items, name) {
  261. continue
  262. }
  263. s.forgetBuddyPresence(state.NewIdentScreenName(name))
  264. }
  265. }
  266. // isAIMViewer reports whether the session owner is an AIM account.
  267. func (s *Session) isAIMViewer() bool {
  268. return s.ScreenName.IdentScreenName().UIN() == 0
  269. }
  270. // Touch updates the last accessed time and extends expiration if needed.
  271. func (s *Session) Touch() {
  272. s.LastAccessed = time.Now()
  273. newExpiry := s.LastAccessed.Add(webAPISessionTTL)
  274. if newExpiry.After(s.ExpiresAt) {
  275. s.ExpiresAt = newExpiry
  276. }
  277. }
  278. // IsSubscribedTo checks if the session is subscribed to a specific event type.
  279. func (s *Session) IsSubscribedTo(eventType EventType) bool {
  280. return slices.Contains(s.Events, string(eventType))
  281. }
  282. // PushEvent queues an event for delivery to the web client.
  283. func (s *Session) PushEvent(eventType EventType, data any) {
  284. s.EventQueue.Push(eventType, data)
  285. }
  286. // StartListeningToOSCARSession starts a goroutine that listens to the OSCAR session's
  287. // message channel and converts SNAC messages into WebAPI events.
  288. func (s *Session) StartListeningToOSCARSession() {
  289. s.closeMu.Lock()
  290. defer s.closeMu.Unlock()
  291. if s.closed {
  292. return
  293. }
  294. s.listeners.Go(func() {
  295. msgCh := s.OSCARSession.ReceiveMessage()
  296. for {
  297. select {
  298. case msg, ok := <-msgCh:
  299. if !ok {
  300. // Channel closed, OSCAR session ended
  301. return
  302. }
  303. s.handleSNACMessage(msg)
  304. case <-s.OSCARSession.Closed():
  305. // The OSCAR instance went away without this session asking — a
  306. // boot, a rate-limit disconnect. Tell the client rather than
  307. // leaving its parked fetcher to hang: a sessionEnded event
  308. // releases the poll at once and the client signs off on the
  309. // spot, instead of waiting out the reaper's next sweep.
  310. //
  311. // A teardown this session started needs no event, and gets
  312. // none: Close closes the queue before it closes the instance,
  313. // so this Push is a no-op on that path.
  314. s.PushEvent(EventTypeSessionEnded, struct{}{})
  315. return
  316. }
  317. }
  318. })
  319. }
  320. // Close tears down the session: it releases any parked event fetchers, closes
  321. // the OSCAR instance, and waits for the listener goroutine to unwind. Safe to
  322. // call more than once.
  323. func (s *Session) Close() {
  324. s.closeMu.Lock()
  325. if s.closed {
  326. s.closeMu.Unlock()
  327. return
  328. }
  329. s.closed = true
  330. s.closeMu.Unlock()
  331. s.EventQueue.Close()
  332. s.OSCARSession.CloseInstance()
  333. s.cancel()
  334. s.listeners.Wait()
  335. }
  336. // handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
  337. func (s *Session) handleSNACMessage(msg wire.SNACMessage) {
  338. // Convert SNAC message to WebAPI events based on food group and subgroup
  339. switch msg.Frame.FoodGroup {
  340. case wire.ICBM:
  341. s.handleICBMMessage(msg)
  342. case wire.Buddy:
  343. s.handleBuddyMessage(msg)
  344. case wire.Feedbag:
  345. s.handleFeedbagMessage(msg)
  346. case wire.OService:
  347. s.handleOServiceMessage(msg)
  348. }
  349. }
  350. // handleOServiceMessage handles OService SNAC messages relayed to the session's
  351. // own OSCAR instance.
  352. func (s *Session) handleOServiceMessage(msg wire.SNACMessage) {
  353. switch msg.Frame.SubGroup {
  354. case wire.OServiceUserInfoUpdate:
  355. s.handleUserInfoUpdate(msg)
  356. case wire.OServiceRateParamChange:
  357. s.handleRateLimitUpdate(msg)
  358. }
  359. }
  360. // handleUserInfoUpdate surfaces OServiceUserInfoUpdate, which the server relays to
  361. // a user when their own user info changes (notably a buddy icon upload or clear).
  362. // The client re-renders its identity badge from myInfo events only, so we
  363. // translate this into a fresh myInfo. The away message is the one field read off
  364. // the session, since no user info block carries the text.
  365. func (s *Session) handleUserInfoUpdate(msg wire.SNACMessage) {
  366. if !s.IsSubscribedTo(EventTypeMyInfo) && !s.IsSubscribedTo(EventTypePresence) {
  367. return
  368. }
  369. body, ok := msg.Body.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
  370. if !ok || len(body.UserInfo) == 0 {
  371. return
  372. }
  373. info := body.UserInfo[0]
  374. screenName := state.DisplayScreenName(info.ScreenName)
  375. webState := selfWebState(info, screenName.IdentScreenName().UIN() == 0)
  376. // A missing icon TLV yields a nil hash, which publishes the placeholder URL
  377. // and so clears an icon the client still holds.
  378. hash := buddyIconHash(info)
  379. myInfo := buildMyInfo(
  380. screenName,
  381. webState,
  382. s.BuddyIconURL(screenName.IdentScreenName(), hash),
  383. moodIconURL(s.BaseURL, webState, userInfoCaps(info)),
  384. )
  385. myInfo.AwayMsg = s.OSCARSession.Session().AwayMessage()
  386. myInfo.StatusMsg = userStatusMsg(info)
  387. s.PushEvent(EventTypeMyInfo, myInfo)
  388. }
  389. // handleRateLimitUpdate translates a rate limit status change — broadcast by the
  390. // account's rate limit monitor — into a rateLimit event. Only the IM class is
  391. // surfaced, since the client feeds any rateLimit event into the
  392. // conversation-window alert. Code 1 is a class-params change, not a status
  393. // transition, and is ignored.
  394. func (s *Session) handleRateLimitUpdate(msg wire.SNACMessage) {
  395. if s.IMRateClassID == 0 {
  396. return
  397. }
  398. body, ok := msg.Body.(wire.SNAC_0x01_0x0A_OServiceRateParamsChange)
  399. if !ok {
  400. return
  401. }
  402. if wire.RateLimitClassID(body.Rate.ID) != s.IMRateClassID {
  403. return
  404. }
  405. var status string
  406. switch body.Code {
  407. case 2:
  408. status = "warn"
  409. case 3:
  410. status = "limit"
  411. case 4:
  412. status = "clear"
  413. default:
  414. return
  415. }
  416. s.PushEvent(EventTypeRateLimit, RateLimitEvent{
  417. Classes: []RateLimitClass{
  418. {ID: int(body.Rate.ID), Status: status},
  419. },
  420. })
  421. }
  422. // handleICBMMessage handles ICBM (instant messaging) SNAC messages.
  423. func (s *Session) handleICBMMessage(msg wire.SNACMessage) {
  424. switch msg.Frame.SubGroup {
  425. case wire.ICBMChannelMsgToClient:
  426. s.handleIncomingIM(msg)
  427. case wire.ICBMClientEvent:
  428. s.handleTypingNotification(msg)
  429. case wire.ICBMClientErr:
  430. s.handleClientError(msg)
  431. }
  432. }
  433. // handleIncomingIM handles incoming instant messages.
  434. func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
  435. body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
  436. if !ok {
  437. return
  438. }
  439. // A send time is only stamped on a message replayed out of the offline store,
  440. // so its presence marks this as a delivery of something sent while the user was
  441. // signed off, and carries the moment the sender actually sent it.
  442. sentTime, isOffline := body.Uint32BE(wire.ICBMTLVSendTime)
  443. if !isOffline && (body.ChannelID == wire.ICBMChannelIM || body.ChannelID == wire.ICBMChannelMIME) {
  444. s.setPeerWantsTyping(state.NewIdentScreenName(body.ScreenName), body.HasTag(wire.ICBMTLVWantEvents))
  445. }
  446. // Retrieval answers only the instance that asked, and StartSession asks only
  447. // when the client subscribed to offlineIM, so a stamped message here is one
  448. // this session requested. A live IM still needs the im subscription.
  449. if !isOffline && !s.IsSubscribedTo(EventTypeIM) {
  450. return
  451. }
  452. // Extract message text from TLV data
  453. var messageText string
  454. if msgData, hasMsg := body.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
  455. if text, err := wire.UnmarshalICBMMessageText(msgData); err == nil {
  456. messageText = text
  457. }
  458. }
  459. if messageText == "" {
  460. return
  461. }
  462. // Check if it's an auto-response (channel 2)
  463. autoResponse := body.ChannelID == 0x0002
  464. // msgId must be unique per delivered event so that the client can properly
  465. // dedupe messages. the SNAC contains enough unique data—request ID, cookie,
  466. // message text—that hashing the whole struct creates a sufficiently unique
  467. // identifier.
  468. h := md5.New()
  469. if err := wire.MarshalBE(msg, h); err != nil {
  470. s.logger.Error("unable to marshal ICBM msg for hash", "err", err.Error())
  471. }
  472. msgID := hex.EncodeToString(h.Sum(nil))
  473. // SNAC user info carries the sender's display screen name. The web client
  474. // keys conversations and users by the normalized aimId and only renders
  475. // displayId, so the two forms must not be interchanged.
  476. partnerDisplay := body.ScreenName
  477. partner := state.NewIdentScreenName(partnerDisplay)
  478. partnerAimID := partner.String()
  479. // An offline message is logged under the time it was sent, so the stored-IM
  480. // history it lands in stays in the order the conversation happened.
  481. timestamp := time.Now().Unix()
  482. if isOffline {
  483. timestamp = int64(sentTime)
  484. }
  485. s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, timestamp)
  486. if isOffline {
  487. // The client resolves an offline sender from aimId and friendly alone,
  488. // so friendly falls back to the sender's own formatting when the viewer
  489. // has no alias for them.
  490. friendly := s.aliasFor(partner)
  491. if friendly == "" {
  492. friendly = partnerDisplay
  493. }
  494. s.PushEvent(EventTypeOfflineIM, OfflineIMEvent{
  495. AimID: partnerAimID,
  496. Friendly: friendly,
  497. Message: messageText,
  498. MsgID: msgID,
  499. Timestamp: timestamp,
  500. Imf: imfPlainText,
  501. AutoResp: autoResponse,
  502. })
  503. s.logger.Debug("delivered offline instant message",
  504. "from", partnerDisplay,
  505. "to", s.ScreenName,
  506. "sent", timestamp)
  507. } else {
  508. source := UserInfo{
  509. AimID: partnerAimID,
  510. DisplayID: partnerDisplay,
  511. Friendly: s.aliasFor(partner),
  512. UserType: userTypeFor(partner),
  513. }
  514. if presence, ok := s.BuddyPresence(partner); ok {
  515. source.State = presence.State
  516. source.OnlineTime = presence.OnlineTime
  517. }
  518. s.PushEvent(EventTypeIM, IMEvent{
  519. Source: source,
  520. Message: messageText,
  521. MsgID: msgID,
  522. Timestamp: timestamp,
  523. Imf: imfPlainText,
  524. AutoResp: autoResponse,
  525. })
  526. s.logger.Debug("delivered instant message",
  527. "from", partnerDisplay,
  528. "to", s.ScreenName)
  529. }
  530. if s.IsSubscribedTo(EventTypeConversation) {
  531. // unread is 0 here, not 1, because the "im"/"offlineIM" event pushed above
  532. // already causes the client to increment its own persisted per-buddy unread
  533. // tally. The "Recent chats" badge is the sum of that persisted tally and
  534. // this conversation's unreadCount, so sending 1 here would double-count
  535. // the message (badge shows 2 for the first IM). Mirrors the sent-IM path,
  536. // which also passes 0.
  537. s.PushEvent(EventTypeConversation, ConversationEventData("update", []ConversationEntryData{
  538. ConversationEntry(
  539. partnerAimID,
  540. partnerDisplay,
  541. messageText,
  542. msgID,
  543. partnerAimID,
  544. false,
  545. 0,
  546. ),
  547. }))
  548. }
  549. }
  550. // handleClientError translates ICBMClientErr — the recipient reporting that it
  551. // could not handle a message already delivered to it — into a clientError event
  552. // for the sender. Only OSCAR clients raise this SNAC.
  553. func (s *Session) handleClientError(msg wire.SNACMessage) {
  554. if !s.IsSubscribedTo(EventTypeIM) {
  555. return
  556. }
  557. body, ok := msg.Body.(wire.SNAC_0x04_0x0B_ICBMClientErr)
  558. if !ok {
  559. return
  560. }
  561. // The SNAC names the erroring party by their own formatting, so both the
  562. // normalized aimId and displayId are sent, along with the viewer's alias.
  563. sender := state.NewIdentScreenName(body.ScreenName)
  564. channel := "im"
  565. if body.ChannelID == wire.ICBMChannelRendezvous {
  566. channel = "data"
  567. }
  568. s.PushEvent(EventTypeClientError, ClientErrorEvent{
  569. Source: UserInfo{
  570. AimID: sender.String(),
  571. DisplayID: body.ScreenName,
  572. Friendly: s.aliasFor(sender),
  573. UserType: userTypeFor(sender),
  574. },
  575. Cookie: s.msgIDForCookie(body.Cookie),
  576. Channel: channel,
  577. })
  578. }
  579. // handleTypingNotification handles typing notifications.
  580. func (s *Session) handleTypingNotification(msg wire.SNACMessage) {
  581. if !s.IsSubscribedTo(EventTypeTyping) {
  582. return
  583. }
  584. body, ok := msg.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)
  585. if !ok {
  586. return
  587. }
  588. // Event types: 0x0000=none, 0x0001=typed (paused), 0x0002=typing
  589. var typingStatus string
  590. switch body.Event {
  591. case 0x0002:
  592. typingStatus = "typing"
  593. case 0x0001:
  594. typingStatus = "typed"
  595. default:
  596. typingStatus = "none"
  597. }
  598. typingEvent := TypingEvent{
  599. AimID: state.NewIdentScreenName(body.ScreenName).String(),
  600. TypingStatus: typingStatus,
  601. }
  602. s.PushEvent(EventTypeTyping, typingEvent)
  603. }
  604. // handleBuddyMessage handles buddy/presence SNAC messages.
  605. func (s *Session) handleBuddyMessage(msg wire.SNACMessage) {
  606. switch msg.Frame.SubGroup {
  607. case wire.BuddyArrived:
  608. s.handleBuddyArrived(msg)
  609. case wire.BuddyDeparted:
  610. s.handleBuddyDeparted(msg)
  611. }
  612. }
  613. // handleBuddyArrived handles when a buddy comes online.
  614. //
  615. // For BuddyArrived updates, presence state is inferred from the TLVUserInfo.
  616. // Away and invisible transitions are typically broadcast using BuddyArrived
  617. // with updated user flags/status bits, not BuddyDeparted.
  618. func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
  619. body, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
  620. if !ok {
  621. return
  622. }
  623. buddy := state.NewIdentScreenName(body.ScreenName)
  624. presence := buddyPresenceFrom(body.TLVUserInfo, s.isAIMViewer())
  625. s.setBuddyPresence(buddy, presence)
  626. if !s.IsSubscribedTo(EventTypePresence) {
  627. return
  628. }
  629. presenceEvent := PresenceEvent{
  630. AimID: buddy.String(),
  631. Friendly: s.aliasFor(buddy),
  632. State: presence.State,
  633. UserType: userTypeFor(buddy),
  634. StatusMsg: presence.StatusMsg,
  635. IdleTime: presence.IdleTime,
  636. OnlineTime: presence.OnlineTime,
  637. MoodIcon: moodIconURL(s.BaseURL, presence.State, presence.Caps),
  638. }
  639. // A BuddyArrived carries the buddy's current icon in TLV 0x1D whenever they
  640. // have one, so an icon change (or clear, which arrives as the sentinel hash)
  641. // rides along on the presence broadcast. Publish the matching URL: with an
  642. // icon it is content-addressed; without one it is the placeholder URL, which
  643. // differs from any prior icon URL and so clears a removed icon under the
  644. // client's shallow merge. An empty result (no origin known) is omitted, which
  645. // preserves whatever icon the client already holds.
  646. if s.BuddyIconURL != nil {
  647. presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, presence.IconHash)
  648. }
  649. s.PushEvent(EventTypePresence, presenceEvent)
  650. }
  651. // bartIDs returns the BART items a user info block carries. They travel as a list
  652. // in one TLV: a user's buddy icon and status message ride in it together.
  653. func bartIDs(info wire.TLVUserInfo) []wire.BARTID {
  654. b, ok := info.Bytes(wire.OServiceUserInfoBARTInfo)
  655. if !ok {
  656. return nil
  657. }
  658. var ids []wire.BARTID
  659. if err := wire.UnmarshalBE(&ids, bytes.NewReader(b)); err != nil {
  660. return nil
  661. }
  662. return ids
  663. }
  664. // buddyIconHash returns the buddy icon hash carried in a user info block, or nil
  665. // when it carries none.
  666. func buddyIconHash(info wire.TLVUserInfo) []byte {
  667. for _, id := range bartIDs(info) {
  668. if id.Type == wire.BARTTypesBuddyIcon || id.Type == wire.BARTTypesBuddyIconSmall {
  669. return id.Hash
  670. }
  671. }
  672. return nil
  673. }
  674. // userStatusMsg returns the status message carried in a user info block, or ""
  675. // when it carries none or one that cannot be decoded.
  676. func userStatusMsg(info wire.TLVUserInfo) string {
  677. for _, id := range bartIDs(info) {
  678. msg, err := id.StatusText()
  679. if err != nil {
  680. continue
  681. }
  682. if msg != "" {
  683. return msg
  684. }
  685. }
  686. return ""
  687. }
  688. // sessionStatusMsg returns the status message a session currently advertises.
  689. func sessionStatusMsg(instance *state.SessionInstance) string {
  690. status, _ := instance.Session().Status()
  691. msg, err := status.StatusText()
  692. if err != nil {
  693. return ""
  694. }
  695. return msg
  696. }
  697. // buddyWebState reports the web state a buddy's user info describes, along with
  698. // the minutes they have been idle. Invisibility reads as offline. Idle is always
  699. // reported, but only upgrades an otherwise online user to "idle".
  700. func buddyWebState(info wire.TLVUserInfo, isAIMViewer bool) (string, int) {
  701. idle := 0
  702. if mins, ok := info.Uint16BE(wire.OServiceUserInfoIdleTime); ok {
  703. idle = int(mins)
  704. }
  705. if info.IsInvisible() {
  706. return "offline", idle
  707. }
  708. // statusBitState must be consulted before IsAway: Busy and DND also raise
  709. // the unavailable flag, so an away-first test reports every busy user as
  710. // away.
  711. if st := statusBitState(info, isAIMViewer); st != "" {
  712. return st, idle
  713. }
  714. if info.IsAway() {
  715. return "away", idle
  716. }
  717. if mask, ok := info.Uint32BE(wire.OServiceUserInfoStatus); ok && mask&wire.OServiceUserStatusAway != 0 {
  718. return "away", idle
  719. }
  720. if idle > 0 {
  721. return "idle", idle
  722. }
  723. return "online", idle
  724. }
  725. // buddyPresenceFrom builds a presence record from the user info block a
  726. // BuddyArrived carries. isAIMViewer decides whether Busy and DND collapse to
  727. // "away".
  728. func buddyPresenceFrom(info wire.TLVUserInfo, isAIMViewer bool) BuddyPresence {
  729. st, idle := buddyWebState(info, isAIMViewer)
  730. p := BuddyPresence{
  731. DisplayID: info.ScreenName,
  732. State: st,
  733. IdleTime: idle,
  734. StatusMsg: userStatusMsg(info),
  735. Caps: userInfoCaps(info),
  736. IconHash: buddyIconHash(info),
  737. }
  738. if tod, ok := info.Uint32BE(wire.OServiceUserInfoSignonTOD); ok {
  739. p.OnlineTime = int64(tod)
  740. }
  741. return p
  742. }
  743. // hasAwayMsg reports whether a state is one whose user may be advertising an
  744. // away message.
  745. func hasAwayMsg(webState string) bool {
  746. switch webState {
  747. case "away", "occupied", "dnd":
  748. return true
  749. }
  750. return false
  751. }
  752. // awayMessage reads a buddy's away message, which lives in the locate reply's
  753. // LocateInfo and so is absent from presence broadcasts. Callers ask only for a
  754. // buddy the view already reports as unavailable.
  755. func awayMessage(
  756. ctx context.Context,
  757. locateService LocateService,
  758. instance *state.SessionInstance,
  759. buddy state.IdentScreenName,
  760. logger *slog.Logger,
  761. ) string {
  762. reply, err := locateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
  763. wire.SNAC_0x02_0x05_LocateUserInfoQuery{
  764. Type: uint16(wire.LocateTypeUnavailable),
  765. ScreenName: buddy.String(),
  766. })
  767. if err != nil {
  768. logger.WarnContext(ctx, "failed to query away message",
  769. "screenName", buddy.String(), "error", err)
  770. return ""
  771. }
  772. info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply)
  773. if !ok {
  774. // Locate error => the buddy went offline or blocked the caller between
  775. // their arrival and this query.
  776. return ""
  777. }
  778. msg, _ := info.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData)
  779. return msg
  780. }
  781. // handleBuddyDeparted handles when a buddy goes offline.
  782. func (s *Session) handleBuddyDeparted(msg wire.SNACMessage) {
  783. body, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
  784. if !ok {
  785. return
  786. }
  787. buddy := state.NewIdentScreenName(body.ScreenName)
  788. // Recorded before the subscription check, for the same reason as arrivals.
  789. s.setBuddyOffline(buddy)
  790. if !s.IsSubscribedTo(EventTypePresence) {
  791. return
  792. }
  793. // BuddyIcon is deliberately omitted: an offline buddy keeps their icon, and
  794. // omitting it lets the client's merge preserve the icon it already holds.
  795. presenceEvent := PresenceEvent{
  796. AimID: buddy.String(),
  797. Friendly: s.aliasFor(buddy),
  798. State: "offline",
  799. UserType: userTypeFor(buddy),
  800. }
  801. s.PushEvent(EventTypePresence, presenceEvent)
  802. }
  803. // feedbagResultAuthRequired is the per-item feedbag result meaning the target's ICQ
  804. // settings require authorization, so the item was not stored.
  805. const feedbagResultAuthRequired = uint16(0x000E)
  806. // refreshBuddyList re-reads the roster and pushes it to the client. Runs on the SNAC
  807. // listener goroutine, so it uses the session context rather than a request context.
  808. func (s *Session) refreshBuddyList() {
  809. if s.BuddyListRefresher == nil {
  810. return
  811. }
  812. payload, err := s.BuddyListRefresher(s.ctx)
  813. if err != nil {
  814. s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
  815. return
  816. }
  817. s.PushEvent(EventTypeBuddyList, payload)
  818. }
  819. func (s *Session) handleFeedbagMessage(msg wire.SNACMessage) {
  820. s.InvalidateFeedbag()
  821. switch msg.Frame.SubGroup {
  822. case wire.FeedbagStatus:
  823. // Insert/update/delete below reach only a user's *other* instances, so this
  824. // is the one notification a session gets for its own feedbag write.
  825. if !s.IsSubscribedTo(EventTypeBuddyList) {
  826. return
  827. }
  828. if body, ok := msg.Body.(wire.SNAC_0x13_0x0E_FeedbagStatus); ok {
  829. // A buddy declined for authorization is not stored, and is simply
  830. // absent from the refreshed roster.
  831. if slices.Contains(body.Results, feedbagResultAuthRequired) {
  832. s.logger.Info("feedbag item declined pending authorization")
  833. }
  834. }
  835. s.refreshBuddyList()
  836. case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
  837. // An insert and an update both relay an UpdateItem body; only a delete
  838. // carries a DeleteItem body.
  839. var items []wire.FeedbagItem
  840. isDelete := false
  841. switch body := msg.Body.(type) {
  842. case wire.SNAC_0x13_0x09_FeedbagUpdateItem:
  843. items = body.Items
  844. case wire.SNAC_0x13_0x0A_FeedbagDeleteItem:
  845. items = body.Items
  846. isDelete = true
  847. }
  848. if isDelete {
  849. var removed []string
  850. for _, item := range items {
  851. if item.ClassID == wire.FeedbagClassIdBuddy && item.Name != "" {
  852. removed = append(removed, item.Name)
  853. }
  854. }
  855. s.forgetUnlistedBuddies(removed)
  856. }
  857. s.refreshBuddyList()
  858. if s.PermitDenyRefresher != nil {
  859. for _, item := range items {
  860. if item.ClassID == wire.FeedbagClassIDPermit ||
  861. item.ClassID == wire.FeedbagClassIDDeny ||
  862. item.ClassID == wire.FeedbagClassIdPdinfo {
  863. pdd, err := s.PermitDenyRefresher(s.ctx)
  864. if err != nil {
  865. s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
  866. } else {
  867. s.PushEvent(EventTypePermitDeny, pdd)
  868. }
  869. break
  870. }
  871. }
  872. }
  873. }
  874. }
  875. // SessionManager manages Web API sessions with thread-safe operations.
  876. // Construct it with NewSessionManager and drive its reaper with Run.
  877. type SessionManager struct {
  878. sessions map[string]*Session // Keyed by aimsid
  879. mu sync.RWMutex
  880. closed bool // set by Shutdown; rejects new sessions and makes drain idempotent
  881. stopCh chan struct{} // closed by Shutdown to stop the reaper
  882. reaperWG sync.WaitGroup // tracks a running reaper so Shutdown can join it
  883. }
  884. // NewSessionManager creates a new WebAPI session manager. It does not start
  885. // any goroutines; call Run to start reaping expired sessions.
  886. func NewSessionManager() *SessionManager {
  887. return &SessionManager{
  888. sessions: make(map[string]*Session),
  889. stopCh: make(chan struct{}),
  890. }
  891. }
  892. // CreateSession creates a new WebAPI session.
  893. //
  894. // The session does not begin listening to its OSCAR instance yet: the caller
  895. // must wire the session's refresher callbacks (BuddyListRefresher, BuddyIconURL,
  896. // ...) and then call StartListeningToOSCARSession. Wiring them after the
  897. // listener starts would race the goroutine, which reads them as it converts
  898. // SNACs into events.
  899. func (m *SessionManager) CreateSession(screenName state.DisplayScreenName, events []string, oscarSession *state.SessionInstance, baseURL string, logger *slog.Logger) (*Session, error) {
  900. m.mu.Lock()
  901. defer m.mu.Unlock()
  902. // Refuse to create sessions once shut down: the reaper is stopped, so a
  903. // session added now would never be closed or reaped.
  904. if m.closed {
  905. return nil, ErrWebAPISessionManagerClosed
  906. }
  907. // Generate unique session ID
  908. aimsid, err := generateSessionID()
  909. if err != nil {
  910. return nil, err
  911. }
  912. now := time.Now()
  913. sessCtx, sessCancel := context.WithCancel(context.Background())
  914. session := &Session{
  915. ctx: sessCtx,
  916. cancel: sessCancel,
  917. AimSID: aimsid,
  918. ScreenName: screenName,
  919. OSCARSession: oscarSession,
  920. BaseURL: baseURL,
  921. Events: events,
  922. EventQueue: NewEventQueue(1000), // Max 1000 events per session
  923. CreatedAt: now,
  924. LastAccessed: now,
  925. ExpiresAt: now.Add(webAPISessionTTL),
  926. FetchTimeout: 60000, // 60 seconds default for better stability
  927. TimeToNextFetch: 500, // 500ms suggested delay
  928. logger: logger,
  929. capabilities: slices.Clone(webAPICaps),
  930. }
  931. m.sessions[aimsid] = session
  932. // The caller starts the OSCAR listener (StartListeningToOSCARSession) once it
  933. // has wired the session's refresher callbacks; starting it here would race
  934. // those assignments.
  935. return session, nil
  936. }
  937. // GetSession retrieves a session by aimsid.
  938. func (m *SessionManager) GetSession(ctx context.Context, aimsid string) (*Session, error) {
  939. m.mu.RLock()
  940. defer m.mu.RUnlock()
  941. session, exists := m.sessions[aimsid]
  942. if !exists {
  943. return nil, ErrNoWebAPISession
  944. }
  945. if session.IsExpired() {
  946. return nil, ErrWebAPISessionExpired
  947. }
  948. // A rate-limit disconnect (EvaluateRateLimit -> Session.CloseSession) closes
  949. // every instance for the account while this web session is still unexpired.
  950. // The aimsid must stop resolving at that point, otherwise a client told to
  951. // disconnect could keep issuing charged requests against a dead session (the
  952. // reaper only removes it on time expiry, up to a TTL later).
  953. if session.OSCARSession.IsClosed() {
  954. return nil, ErrWebAPISessionExpired
  955. }
  956. return session, nil
  957. }
  958. // RemoveSession removes a session by aimsid.
  959. func (m *SessionManager) RemoveSession(ctx context.Context, aimsid string) error {
  960. m.mu.Lock()
  961. session, exists := m.sessions[aimsid]
  962. if !exists {
  963. m.mu.Unlock()
  964. return ErrNoWebAPISession
  965. }
  966. delete(m.sessions, aimsid)
  967. m.mu.Unlock()
  968. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  969. // broadcasts and signout, which we don't want to run under m.mu.
  970. session.Close()
  971. return nil
  972. }
  973. // TouchSession updates the last accessed time for a session.
  974. func (m *SessionManager) TouchSession(ctx context.Context, aimsid string) error {
  975. m.mu.Lock()
  976. defer m.mu.Unlock()
  977. session, exists := m.sessions[aimsid]
  978. if !exists {
  979. return ErrNoWebAPISession
  980. }
  981. session.Touch()
  982. return nil
  983. }
  984. // Run reaps expired sessions on a fixed interval until ctx is cancelled or
  985. // Shutdown is called. The caller owns the goroutine's lifecycle; typically
  986. // launch it under the server's errgroup:
  987. //
  988. // g.Go(func() error { mgr.Run(ctx); return nil })
  989. //
  990. // Run is a no-op once the manager is closed, so a reaper that loses the race
  991. // with Shutdown never starts reaping a drained manager.
  992. func (m *SessionManager) Run(ctx context.Context) {
  993. m.mu.Lock()
  994. if m.closed {
  995. m.mu.Unlock()
  996. return
  997. }
  998. // Registering under m.mu is what makes Shutdown's join sound: Shutdown flips
  999. // closed under the same lock, so a reaper either registers before Shutdown
  1000. // waits or is turned away here.
  1001. m.reaperWG.Add(1)
  1002. m.mu.Unlock()
  1003. defer m.reaperWG.Done()
  1004. ticker := time.NewTicker(webAPISessionReapInterval)
  1005. defer ticker.Stop()
  1006. for {
  1007. select {
  1008. case <-ticker.C:
  1009. m.reapExpired()
  1010. case <-m.stopCh:
  1011. return
  1012. case <-ctx.Done():
  1013. return
  1014. }
  1015. }
  1016. }
  1017. // reapExpired removes every dead session and tears it down. A session is dead
  1018. // once it has passed its expiry, or once its underlying OSCAR session has been
  1019. // closed out from under it (e.g. by a rate-limit disconnect) — the latter is
  1020. // already rejected by GetSession, and reaping it here frees the entry promptly
  1021. // rather than leaving it until time expiry.
  1022. func (m *SessionManager) reapExpired() {
  1023. m.mu.Lock()
  1024. now := time.Now()
  1025. var expired []*Session
  1026. for aimsid, session := range m.sessions {
  1027. if now.After(session.ExpiresAt) || session.OSCARSession.IsClosed() {
  1028. delete(m.sessions, aimsid)
  1029. expired = append(expired, session)
  1030. }
  1031. }
  1032. m.mu.Unlock()
  1033. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  1034. // broadcasts and signout, which we don't want to run under m.mu.
  1035. for _, session := range expired {
  1036. session.Close()
  1037. }
  1038. }
  1039. // Shutdown drains and closes all sessions, stops the reaper started by Run, and
  1040. // blocks further CreateSession calls. It does not depend on the caller
  1041. // cancelling Run's context. Safe to call more than once, though only the first
  1042. // call waits for the drain. The drain is bounded by ctx: Shutdown returns
  1043. // ctx.Err() rather than block forever on a listener that ignores cancellation.
  1044. func (m *SessionManager) Shutdown(ctx context.Context) error {
  1045. m.mu.Lock()
  1046. if m.closed {
  1047. m.mu.Unlock()
  1048. return nil
  1049. }
  1050. m.closed = true
  1051. close(m.stopCh)
  1052. sessions := make([]*Session, 0, len(m.sessions))
  1053. for _, session := range m.sessions {
  1054. sessions = append(sessions, session)
  1055. }
  1056. // Clear all sessions
  1057. m.sessions = make(map[string]*Session)
  1058. m.mu.Unlock()
  1059. drained := make(chan struct{})
  1060. go func() {
  1061. defer close(drained)
  1062. // Tear down outside the lock: CloseInstance fans out to buddy-departed
  1063. // broadcasts and signout, which we don't want to run under m.mu.
  1064. for _, session := range sessions {
  1065. session.Close()
  1066. }
  1067. m.reaperWG.Wait()
  1068. }()
  1069. select {
  1070. case <-drained:
  1071. return nil
  1072. case <-ctx.Done():
  1073. return ctx.Err()
  1074. }
  1075. }
  1076. // generateSessionID creates a cryptographically secure session ID.
  1077. func generateSessionID() (string, error) {
  1078. bytes := make([]byte, 32) // 256 bits
  1079. if _, err := rand.Read(bytes); err != nil {
  1080. return "", err
  1081. }
  1082. return hex.EncodeToString(bytes), nil
  1083. }
  1084. // sentIMCookieLimit bounds the cookie->msgId map. An error arrives within seconds
  1085. // of the send, so a small window suffices; the oldest entry is evicted once full.
  1086. const sentIMCookieLimit = 256
  1087. // RecordSentIM remembers the msgId handed out for an outgoing message, keyed by the
  1088. // OSCAR cookie that message carries on the wire. The two are unrelated by
  1089. // construction, so a clientError — which names its message by cookie — could not
  1090. // otherwise say which message it refers to.
  1091. func (s *Session) RecordSentIM(cookie uint64, msgID string) {
  1092. if s == nil || msgID == "" {
  1093. return
  1094. }
  1095. s.sentIMMu.Lock()
  1096. defer s.sentIMMu.Unlock()
  1097. if s.sentIMs == nil {
  1098. s.sentIMs = make(map[uint64]string)
  1099. }
  1100. if _, seen := s.sentIMs[cookie]; !seen {
  1101. s.sentIMOrder = append(s.sentIMOrder, cookie)
  1102. }
  1103. s.sentIMs[cookie] = msgID
  1104. if len(s.sentIMOrder) > sentIMCookieLimit {
  1105. delete(s.sentIMs, s.sentIMOrder[0])
  1106. s.sentIMOrder = s.sentIMOrder[1:]
  1107. }
  1108. }
  1109. // msgIDForCookie resolves an OSCAR message cookie back to the msgId handed out for
  1110. // it, or "" when the message is not one this session sent.
  1111. func (s *Session) msgIDForCookie(cookie uint64) string {
  1112. if s == nil {
  1113. return ""
  1114. }
  1115. s.sentIMMu.Lock()
  1116. defer s.sentIMMu.Unlock()
  1117. return s.sentIMs[cookie]
  1118. }
  1119. // setPeerWantsTyping records whether a peer's latest IM asked for typing events.
  1120. func (s *Session) setPeerWantsTyping(peer state.IdentScreenName, wants bool) {
  1121. s.typingPeersMu.Lock()
  1122. defer s.typingPeersMu.Unlock()
  1123. if !wants {
  1124. delete(s.typingPeers, peer.String())
  1125. return
  1126. }
  1127. if s.typingPeers == nil {
  1128. s.typingPeers = make(map[string]bool)
  1129. }
  1130. s.typingPeers[peer.String()] = true
  1131. }
  1132. // PeerWantsTyping reports whether a peer's latest IM asked for typing events. A
  1133. // peer that has not sent this session an IM has not asked.
  1134. func (s *Session) PeerWantsTyping(peer state.IdentScreenName) bool {
  1135. s.typingPeersMu.Lock()
  1136. defer s.typingPeersMu.Unlock()
  1137. return s.typingPeers[peer.String()]
  1138. }
  1139. // WebAPIStoredIM is one message in a Web AIM session's in-memory IM log.
  1140. // The Web AIM client expects fetchStoredIMs entries with sender, message, msgId, and date.
  1141. type WebAPIStoredIM struct {
  1142. Sender string
  1143. Message string
  1144. MsgID string
  1145. Date int64 // Unix seconds
  1146. }
  1147. // AddStoredIM appends a message to the per-partner log for this session.
  1148. func (s *Session) AddStoredIM(partnerAimID, sender, message, msgID string, date int64) {
  1149. if s == nil || partnerAimID == "" || message == "" {
  1150. return
  1151. }
  1152. s.imLogMu.Lock()
  1153. defer s.imLogMu.Unlock()
  1154. if s.imLog == nil {
  1155. s.imLog = make(map[string][]WebAPIStoredIM)
  1156. }
  1157. s.imLog[normalizeWebAPIAimID(partnerAimID)] = append(s.imLog[normalizeWebAPIAimID(partnerAimID)], WebAPIStoredIM{
  1158. Sender: sender,
  1159. Message: message,
  1160. MsgID: msgID,
  1161. Date: date,
  1162. })
  1163. }
  1164. // StoredIM is one entry in a fetchStoredIMs reply.
  1165. type StoredIM struct {
  1166. Sender string `json:"sender" xml:"sender"`
  1167. Message string `json:"message" xml:"message"`
  1168. MsgID string `json:"msgId" xml:"msgId"`
  1169. Date int64 `json:"date" xml:"date"`
  1170. }
  1171. // StoredIMQuery describes filters for fetchStoredIMs.
  1172. type StoredIMQuery struct {
  1173. PartnerAimID string
  1174. StartTime int64
  1175. EndTime int64
  1176. NToGet int
  1177. SortOrder string
  1178. SkipMsgID string
  1179. StopMsgID string
  1180. }
  1181. // GetStoredIMs returns stored messages for a conversation partner, filtered and sorted
  1182. // per the Web AIM client's fetchStoredIMs parameters.
  1183. func (s *Session) GetStoredIMs(q StoredIMQuery) []StoredIM {
  1184. if s == nil || q.PartnerAimID == "" {
  1185. return nil
  1186. }
  1187. s.imLogMu.Lock()
  1188. msgs := append([]WebAPIStoredIM(nil), s.imLog[normalizeWebAPIAimID(q.PartnerAimID)]...)
  1189. s.imLogMu.Unlock()
  1190. if len(msgs) == 0 {
  1191. return []StoredIM{}
  1192. }
  1193. filtered := make([]WebAPIStoredIM, 0, len(msgs))
  1194. for _, msg := range msgs {
  1195. if q.StartTime > 0 && msg.Date < q.StartTime {
  1196. continue
  1197. }
  1198. if q.EndTime > 0 && msg.Date > q.EndTime {
  1199. continue
  1200. }
  1201. filtered = append(filtered, msg)
  1202. }
  1203. descending := strings.EqualFold(q.SortOrder, "descendingDate")
  1204. sort.Slice(filtered, func(i, j int) bool {
  1205. if descending {
  1206. return filtered[i].Date > filtered[j].Date
  1207. }
  1208. return filtered[i].Date < filtered[j].Date
  1209. })
  1210. if q.SkipMsgID != "" {
  1211. for i, msg := range filtered {
  1212. if msg.MsgID == q.SkipMsgID {
  1213. filtered = filtered[i+1:]
  1214. break
  1215. }
  1216. }
  1217. }
  1218. if q.StopMsgID != "" {
  1219. for i, msg := range filtered {
  1220. if msg.MsgID == q.StopMsgID {
  1221. filtered = filtered[:i]
  1222. break
  1223. }
  1224. }
  1225. }
  1226. n := q.NToGet
  1227. if n <= 0 {
  1228. n = 100
  1229. }
  1230. if len(filtered) > n {
  1231. filtered = filtered[:n]
  1232. }
  1233. out := make([]StoredIM, len(filtered))
  1234. for i, msg := range filtered {
  1235. out[i] = StoredIM(msg)
  1236. }
  1237. return out
  1238. }
  1239. func (s *Session) Caps() [][16]byte {
  1240. s.capsMu.RLock()
  1241. defer s.capsMu.RUnlock()
  1242. return slices.Clone(s.capabilities)
  1243. }
  1244. // ClearMood removes the mood capability the session advertises, if any. The
  1245. // user then presents whatever presence state they are in.
  1246. func (s *Session) ClearMood() {
  1247. s.capsMu.Lock()
  1248. defer s.capsMu.Unlock()
  1249. s.clearMood()
  1250. }
  1251. // clearMood drops every mood capability the session advertises. The caller must
  1252. // hold s.capsMu.
  1253. func (s *Session) clearMood() {
  1254. s.capabilities = slices.DeleteFunc(s.capabilities, func(cap [16]byte) bool {
  1255. return wire.IsMoodCap(cap)
  1256. })
  1257. }
  1258. // SetMood replaces the mood capability the session advertises. A client shows
  1259. // one mood at a time, so whichever mood was set before is dropped.
  1260. //
  1261. // It panics when mood is not a mood capability: the caller resolves it from the
  1262. // mood table, so anything else is a programming error rather than bad input.
  1263. func (s *Session) SetMood(mood uuid.UUID) {
  1264. s.capsMu.Lock()
  1265. defer s.capsMu.Unlock()
  1266. if !wire.IsMoodCap(mood) {
  1267. panic("uuid is not a mood capability")
  1268. }
  1269. s.clearMood()
  1270. s.capabilities = append(s.capabilities, mood)
  1271. }
  1272. // normalizeWebAPIAimID keys the IM log by the same normalization the web client
  1273. // applies to aimIds, so a partner stored from a display screen name is still
  1274. // found when the client queries by aimId.
  1275. func normalizeWebAPIAimID(aimID string) string {
  1276. return state.NewIdentScreenName(aimID).String()
  1277. }