session.go 44 KB

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