4
0

session.go 45 KB

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