4
0

session.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. package handlers
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "encoding/xml"
  6. "fmt"
  7. "log/slog"
  8. "net/http"
  9. "slices"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "github.com/google/uuid"
  14. "github.com/mk6i/open-oscar-server/server/webapi/middleware"
  15. "github.com/mk6i/open-oscar-server/server/webapi/types"
  16. "github.com/mk6i/open-oscar-server/state"
  17. "github.com/mk6i/open-oscar-server/wire"
  18. )
  19. // SessionHandler handles Web AIM API session management endpoints.
  20. type SessionHandler struct {
  21. SessionManager *state.WebAPISessionManager
  22. OSCARAuthService AuthService
  23. FeedbagService FeedbagService
  24. BuddyListManager *BuddyListManager
  25. IconSource BuddyIconSource
  26. Logger *slog.Logger
  27. OServiceService OServiceService
  28. FnSessCfg func(sess *state.Session)
  29. FnSessInit func(instance *state.SessionInstance) func() error
  30. FnInstanceClose func(instance *state.SessionInstance) func()
  31. }
  32. // AuthService defines methods needed for authentication.
  33. type AuthService interface {
  34. BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
  35. BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, advertisedHost string) (wire.SNACMessage, error)
  36. CrackCookie(authCookie []byte) (state.ServerCookie, error)
  37. RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error)
  38. FLAPLogin(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
  39. Signout(ctx context.Context, session *state.Session)
  40. SignoutChat(ctx context.Context, sess *state.Session)
  41. }
  42. // SessionManager defines methods for OSCAR session management.
  43. type SessionManager interface {
  44. AddSession(ctx context.Context, screenName state.DisplayScreenName, doMultiSess bool, cfg ...func(sess *state.Session)) (*state.SessionInstance, error)
  45. RemoveSession(session *state.Session)
  46. RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)
  47. }
  48. // BuddyListRegistry defines methods for buddy list management.
  49. type BuddyListRegistry interface {
  50. RegisterBuddyList(ctx context.Context, screenName state.IdentScreenName) error
  51. UnregisterBuddyList(ctx context.Context, screenName state.IdentScreenName) error
  52. }
  53. type ChatSessionManager interface {
  54. RemoveUserFromAllChats(user state.IdentScreenName)
  55. }
  56. // BuddyGroup represents a group of buddies.
  57. type BuddyGroup struct {
  58. Name string `json:"name"`
  59. Buddies []Buddy `json:"buddies"`
  60. }
  61. // Buddy represents a buddy in the buddy list.
  62. type Buddy struct {
  63. AimID string `json:"aimId"`
  64. State string `json:"state"`
  65. StatusMsg string `json:"statusMsg,omitempty"`
  66. AwayMsg string `json:"awayMsg,omitempty"`
  67. UserType string `json:"userType"`
  68. }
  69. // StartSessionResponse represents the response for startSession endpoint.
  70. type StartSessionResponse struct {
  71. Response struct {
  72. StatusCode int `json:"statusCode"`
  73. StatusText string `json:"statusText"`
  74. Data struct {
  75. AimSID string `json:"aimsid"`
  76. Ts int64 `json:"ts"`
  77. FetchTimeout int `json:"fetchTimeout"`
  78. TimeToNextFetch int `json:"timeToNextFetch"`
  79. FetchBaseURL string `json:"fetchBaseURL"` // Gromit expects this directly in data!
  80. MyInfo map[string]interface{} `json:"myInfo,omitempty"`
  81. Events map[string]interface{} `json:"events,omitempty"`
  82. WellKnownUrls map[string]string `json:"wellKnownUrls,omitempty"`
  83. } `json:"data"`
  84. } `json:"response"`
  85. }
  86. // StartSessionXMLResponse represents the XML response for startSession endpoint.
  87. type StartSessionXMLResponse struct {
  88. XMLName xml.Name `xml:"response"`
  89. StatusCode int `xml:"statusCode"`
  90. StatusText string `xml:"statusText"`
  91. Data struct {
  92. AimSID string `xml:"aimsid"`
  93. FetchTimeout int `xml:"fetchTimeout"`
  94. TimeToNextFetch int `xml:"timeToNextFetch"`
  95. FetchBaseURL string `xml:"fetchBaseURL"` // Gromit expects this directly!
  96. WellKnownUrls *struct {
  97. WebApiBase string `xml:"webApiBase"`
  98. FetchBaseURL string `xml:"fetchBaseURL"`
  99. LifestreamApiBase string `xml:"lifestreamApiBase"`
  100. } `xml:"wellKnownUrls,omitempty"`
  101. MyInfo *struct {
  102. AimID string `xml:"aimId"`
  103. DisplayID string `xml:"displayId"`
  104. Buddylist struct {
  105. Groups *[]BuddyGroup `xml:"group,omitempty"`
  106. } `xml:"buddylist,omitempty"`
  107. } `xml:"myInfo,omitempty"`
  108. Events *struct {
  109. BuddyList struct {
  110. Groups *[]BuddyGroup `xml:"group,omitempty"`
  111. } `xml:"buddylist"`
  112. } `xml:"events,omitempty"`
  113. } `xml:"data"`
  114. }
  115. // EndSessionResponse represents the response for endSession endpoint.
  116. type EndSessionResponse struct {
  117. Response struct {
  118. StatusCode int `json:"statusCode"`
  119. StatusText string `json:"statusText"`
  120. } `json:"response"`
  121. }
  122. // StartSession handles GET /aim/startSession requests.
  123. func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
  124. ctx := r.Context()
  125. // Get API key info from context (set by auth middleware)
  126. apiKey, ok := ctx.Value(middleware.ContextKeyAPIKey).(*state.WebAPIKey)
  127. if !ok {
  128. h.sendError(w, r, http.StatusInternalServerError, "internal server error")
  129. return
  130. }
  131. // Parse parameters
  132. params := r.URL.Query()
  133. // Get authentication token if provided
  134. authToken := params.Get("a")
  135. // Get client info
  136. clientName := params.Get("clientName")
  137. if clientName == "" {
  138. clientName = "WebAIM"
  139. }
  140. clientVersion := params.Get("clientVersion")
  141. if clientVersion == "" {
  142. clientVersion = "1.0"
  143. }
  144. // Get events to subscribe to
  145. eventsParam := params.Get("events")
  146. var events []string
  147. if eventsParam != "" {
  148. events = strings.Split(eventsParam, ",")
  149. h.Logger.DebugContext(ctx, "parsing events from request",
  150. "eventsParam", eventsParam,
  151. "parsedEvents", events,
  152. )
  153. } else {
  154. // Default events if none specified
  155. events = []string{"buddylist", "presence", "im", "sentIM"}
  156. h.Logger.DebugContext(ctx, "using default events",
  157. "events", events,
  158. )
  159. }
  160. // Get timeout settings
  161. timeout := 60000 // Default 60 seconds for better stability with Gromit
  162. if t := params.Get("timeout"); t != "" {
  163. if val, err := strconv.Atoi(t); err == nil && val > 0 {
  164. timeout = val * 1000 // Convert to milliseconds
  165. }
  166. }
  167. // A Web API session must be bridged to an authenticated OSCAR session;
  168. // anonymous guests are not supported.
  169. if authToken == "" {
  170. h.sendError(w, r, http.StatusUnauthorized, "authentication token required")
  171. return
  172. }
  173. rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(authToken))
  174. if err != nil {
  175. h.Logger.Warn("invalid authentication token (base64)", "error", err)
  176. h.sendError(w, r, http.StatusUnauthorized, "invalid or expired token")
  177. return
  178. }
  179. cookie, err := h.OSCARAuthService.CrackCookie(rawCookie)
  180. if err != nil {
  181. h.Logger.Warn("invalid authentication token", "error", err)
  182. h.sendError(w, r, http.StatusUnauthorized, "invalid or expired token")
  183. return
  184. }
  185. screenName := cookie.ScreenName
  186. tokenPreview := authToken
  187. if len(tokenPreview) > 8 {
  188. tokenPreview = tokenPreview[:8] + "..."
  189. }
  190. h.Logger.Info("authenticated session requested",
  191. "token", tokenPreview,
  192. "screenName", screenName)
  193. var instance *state.SessionInstance
  194. // Create OSCAR session
  195. instance, err = h.OSCARAuthService.RegisterBOSSession(ctx, cookie, h.FnSessCfg)
  196. if err != nil {
  197. h.Logger.ErrorContext(ctx, "failed to create OSCAR session", "err", err.Error())
  198. h.sendError(w, r, http.StatusServiceUnavailable, "unable to establish session")
  199. return
  200. }
  201. if err = instance.Session().RunOnce(h.FnSessInit(instance)); err != nil {
  202. h.Logger.ErrorContext(context.Background(), "failed to init session", "err", err.Error())
  203. // Nothing owns the instance yet, so close it here. Left open, it stays
  204. // registered in the session manager, counting against the user's
  205. // concurrent-instance budget until the server restarts.
  206. instance.CloseInstance()
  207. h.sendError(w, r, http.StatusInternalServerError, "internal server error")
  208. return
  209. }
  210. instance.OnClose(h.FnInstanceClose(instance))
  211. if err := h.FeedbagService.Use(ctx, instance); err != nil {
  212. h.Logger.ErrorContext(ctx, "failed to use feedbag", "err", err.Error())
  213. }
  214. // A web client signals that it wants typing events through its event
  215. // subscription, not through a stored feedbag buddy pref. Reflect that
  216. // on the OSCAR session so ICBMService attaches the WantEvents TLV to
  217. // outgoing IMs, prompting recipients to send typing notifications
  218. // back. This must run after FeedbagService.Use, which otherwise
  219. // overwrites the flag from stored prefs the web user may not have set.
  220. instance.Session().SetTypingEventsEnabled(slices.Contains(events, "typing"))
  221. instance.SetSignonComplete()
  222. if err := h.OServiceService.ClientOnline(ctx, wire.BOS, wire.SNAC_0x01_0x02_OServiceClientOnline{}, instance); err != nil {
  223. h.Logger.ErrorContext(ctx, "failed to set client online", "err", err.Error())
  224. instance.CloseInstance()
  225. h.sendError(w, r, http.StatusInternalServerError, "internal server error")
  226. return
  227. }
  228. // Create WebAPI session
  229. // Record the origin the client reached us on. Asset URLs published to the
  230. // client (buddy icons) must be absolute, since the client page is served from
  231. // a different origin than this API, and they are built in places that have no
  232. // request in hand. Pinning the origin here also keeps those URLs on the same
  233. // host as the wellKnownUrls advertised below. It is passed into CreateSession
  234. // so it is set before the session is published and its listener goroutine can
  235. // read it.
  236. baseURL := baseURLFromRequest(r)
  237. session, err := h.SessionManager.CreateSession(screenName, apiKey.DevID, events, instance, baseURL, h.Logger)
  238. if err != nil {
  239. h.Logger.ErrorContext(ctx, "failed to create session", "err", err.Error())
  240. // CreateSession refuses once the manager is shut down, so this is the
  241. // path a startSession racing shutdown takes. The WebAPISession that
  242. // would have owned the instance was never created.
  243. instance.CloseInstance()
  244. h.sendError(w, r, http.StatusInternalServerError, "failed to create session")
  245. return
  246. }
  247. h.Logger.DebugContext(ctx, "session created with event subscriptions",
  248. "aimsid", session.AimSID,
  249. "events", events,
  250. )
  251. // Wire buddy list refresher so feedbag SNACs from the OSCAR bridge trigger a buddylist event.
  252. session.BuddyListRefresher = func(ctx context.Context) (interface{}, error) {
  253. groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
  254. if err != nil {
  255. return nil, err
  256. }
  257. // CANNED: keep the fake group chat present across feedbag-driven refreshes.
  258. // TODO(groupchat): list the user's joined rooms.
  259. return append(groups, CannedGroupChatGroup()), nil
  260. }
  261. // Wire the alias loader so OSCAR-driven im/presence events can repeat the
  262. // buddy's friendly name. The client discards the alias it holds each time it
  263. // merges a user map, so an event that omits it renames the buddy. The session
  264. // caches what this returns until a feedbag change invalidates it.
  265. session.BuddyAliasLoader = func(ctx context.Context) (map[string]string, error) {
  266. return LookupBuddyAliases(ctx, h.FeedbagService, session.OSCARSession)
  267. }
  268. // Wire the buddy-icon URL formatter so presence broadcasts (BuddyArrived) can
  269. // publish a buddy's current icon from the hash carried in the SNAC, no lookup.
  270. session.BuddyIconURL = func(sn state.IdentScreenName, hash []byte) string {
  271. return h.IconSource.URLForHash(session.BaseURL, sn, hash)
  272. }
  273. // Wire the myInfo refresher so a self user-info update (icon upload/clear)
  274. // re-renders the identity badge. currentWebState reflects the user's live
  275. // presence; PublishedURL reflects the feedbag icon, already updated by the time
  276. // the OServiceUserInfoUpdate is relayed.
  277. session.MyInfoRefresher = func(ctx context.Context) (interface{}, error) {
  278. icon := h.IconSource.PublishedURL(ctx, session.BaseURL, screenName.IdentScreenName())
  279. return buildMyInfo(screenName, currentWebState(session.OSCARSession), icon), nil
  280. }
  281. // Wire permit/deny refresher so FeedbagUpdateItem SNACs trigger a permitDeny event.
  282. session.PermitDenyRefresher = func(ctx context.Context) (interface{}, error) {
  283. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  284. fb, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
  285. if err != nil {
  286. return nil, err
  287. }
  288. reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  289. if !ok {
  290. return nil, fmt.Errorf("unexpected feedbag reply type")
  291. }
  292. return permitDenyData(reply.Items), nil
  293. }
  294. // Now that every refresher callback is wired, start the OSCAR listener. Doing
  295. // this inside CreateSession would race these assignments, since the goroutine
  296. // reads the callbacks as it converts SNACs into events.
  297. session.StartListeningToOSCARSession()
  298. // Store client info
  299. session.ClientName = clientName
  300. session.ClientVersion = clientVersion
  301. session.FetchTimeout = timeout
  302. session.RemoteAddr = r.RemoteAddr
  303. // The identity badge renders the user's own icon from myInfo, which is the
  304. // only event it binds its self-presence render to. PublishedURL always yields
  305. // a URL (the blank placeholder when the user has no icon) so that clearing the
  306. // icon propagates to the badge.
  307. myIconURL := h.IconSource.PublishedURL(ctx, baseURL, screenName.IdentScreenName())
  308. // Queue myInfo event for authenticated users
  309. if authToken != "" {
  310. for _, event := range events {
  311. if event == "myInfo" || event == "presence" {
  312. myInfoData := buildMyInfo(screenName, "online", myIconURL)
  313. myInfoData["onlineTime"] = time.Now().Unix()
  314. myInfoData["memberSince"] = time.Now().Unix() - 86400*30 // 30 days ago
  315. session.EventQueue.Push(types.EventType("myInfo"), myInfoData)
  316. break
  317. }
  318. }
  319. for _, event := range events {
  320. if event == "conversation" {
  321. // CANNED: seed a fake group chat so we can confirm the conversation
  322. // "list" event drives the client's "Group Chats" section. Real
  323. // per-user room membership persistence would replace this list.
  324. // TODO(groupchat): load the user's joined rooms and list them here.
  325. session.EventQueue.Push(types.EventTypeConversation,
  326. types.ConversationEventData("list", []map[string]interface{}{
  327. types.ConversationGroupEntry("4-0-Canned Group Chat", "Canned Group Chat", 1),
  328. }))
  329. break
  330. }
  331. }
  332. }
  333. now := time.Now().Unix()
  334. // Prepare response
  335. resp := StartSessionResponse{}
  336. resp.Response.StatusCode = 200
  337. resp.Response.StatusText = "OK"
  338. resp.Response.Data.AimSID = session.AimSID
  339. resp.Response.Data.Ts = now
  340. resp.Response.Data.FetchTimeout = session.FetchTimeout
  341. resp.Response.Data.TimeToNextFetch = session.TimeToNextFetch
  342. // Gromit expects fetchBaseURL directly in data, not in wellKnownUrls
  343. resp.Response.Data.FetchBaseURL = fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=0", baseURL, session.AimSID)
  344. // Add wellKnownUrls for other clients that might use it.
  345. resp.Response.Data.WellKnownUrls = map[string]string{
  346. "webApiBase": baseURL + "/",
  347. "fetchBaseURL": baseURL + "/aim/fetchEvents",
  348. "lifestreamApiBase": baseURL + "/",
  349. }
  350. if authToken != "" {
  351. myInfoPayload := buildMyInfo(screenName, "online", myIconURL)
  352. myInfoPayload["onlineTime"] = time.Now().Unix()
  353. myInfoPayload["memberSince"] = time.Now().Unix() - 86400*30 // 30 days ago
  354. myInfoPayload["self"] = map[string]interface{}{
  355. "instNum": 1,
  356. "loginTime": time.Now().Unix(),
  357. "sessionTimeout": 30,
  358. "events": events,
  359. "assertCaps": []string{},
  360. "rightsInfo": map[string]interface{}{
  361. "maxDenies": 500,
  362. "maxPermits": 500,
  363. "maxWatchers": 3000,
  364. "maxBuddies": 500,
  365. "maxTempBuddies": 160,
  366. "maxIMSize": 3987,
  367. "minInterIcbmInterval": 1000,
  368. "maxSourceEvil": 900,
  369. "maxDstEvil": 999,
  370. "maxSigLen": 4096,
  371. },
  372. }
  373. resp.Response.Data.MyInfo = myInfoPayload
  374. if resp.Response.Data.Events == nil {
  375. resp.Response.Data.Events = make(map[string]interface{})
  376. }
  377. resp.Response.Data.Events["myInfo"] = myInfoPayload
  378. }
  379. for _, event := range events {
  380. switch types.EventType(event) {
  381. case types.EventTypeBuddyList:
  382. buddyGroups := []WebAPIBuddyGroup{}
  383. if authToken != "" && h.BuddyListManager != nil {
  384. var err error
  385. buddyGroups, err = h.BuddyListManager.GetBuddyListForUser(ctx, session)
  386. if err != nil {
  387. h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
  388. buddyGroups = []WebAPIBuddyGroup{}
  389. }
  390. }
  391. if buddyGroups == nil {
  392. buddyGroups = []WebAPIBuddyGroup{}
  393. }
  394. // CANNED: surface a fake group chat under the client's "Group chats"
  395. // buddy-list section. TODO(groupchat): list the user's joined rooms.
  396. buddyGroups = append(buddyGroups, CannedGroupChatGroup())
  397. blPayload := map[string]interface{}{"groups": buddyGroups}
  398. if resp.Response.Data.Events == nil {
  399. resp.Response.Data.Events = make(map[string]interface{})
  400. }
  401. resp.Response.Data.Events["buddylist"] = blPayload
  402. if authToken != "" {
  403. session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
  404. }
  405. case types.EventTypePreference:
  406. // Seed the client with effective preference values: the user's stored
  407. // prefs where set, and the server-side spec defaults otherwise. The
  408. // client reads its buddy-list display prefs (e.g. showGroups) only from
  409. // this event and has no default of its own for them, so an omitted pref
  410. // would silently fall back to the client's hidden default and, for
  411. // showGroups, hide group headers.
  412. prefPayload := map[string]interface{}{}
  413. if authToken != "" && session.OSCARSession != nil {
  414. if item, err := buddyPrefsItem(ctx, h.FeedbagService, session.OSCARSession); err != nil {
  415. h.Logger.ErrorContext(ctx, "failed to get preferences", "err", err.Error())
  416. } else {
  417. prefPayload = effectiveBuddyPrefs(item.TLVList)
  418. }
  419. }
  420. if resp.Response.Data.Events == nil {
  421. resp.Response.Data.Events = make(map[string]interface{})
  422. }
  423. resp.Response.Data.Events["preference"] = prefPayload
  424. if authToken != "" {
  425. session.EventQueue.Push(types.EventTypePreference, prefPayload)
  426. }
  427. }
  428. }
  429. // Check response format
  430. format := r.URL.Query().Get("f")
  431. if format == "" {
  432. format = "json" // default to JSON
  433. }
  434. // Send response in requested format
  435. if format == "xml" {
  436. // Build XML response
  437. xmlResp := StartSessionXMLResponse{}
  438. xmlResp.StatusCode = 200
  439. xmlResp.StatusText = "OK"
  440. xmlResp.Data.AimSID = session.AimSID
  441. xmlResp.Data.FetchTimeout = timeout
  442. xmlResp.Data.TimeToNextFetch = 500
  443. // Gromit expects fetchBaseURL directly in data
  444. xmlResp.Data.FetchBaseURL = fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=0", baseURL, session.AimSID)
  445. // Add wellKnownUrls for other clients
  446. xmlBase := baseURL + "/"
  447. xmlResp.Data.WellKnownUrls = &struct {
  448. WebApiBase string `xml:"webApiBase"`
  449. FetchBaseURL string `xml:"fetchBaseURL"`
  450. LifestreamApiBase string `xml:"lifestreamApiBase"`
  451. }{
  452. WebApiBase: xmlBase,
  453. FetchBaseURL: baseURL + "/aim/fetchEvents",
  454. LifestreamApiBase: xmlBase,
  455. }
  456. // Add myInfo with user data
  457. xmlResp.Data.MyInfo = &struct {
  458. AimID string `xml:"aimId"`
  459. DisplayID string `xml:"displayId"`
  460. Buddylist struct {
  461. Groups *[]BuddyGroup `xml:"group,omitempty"`
  462. } `xml:"buddylist,omitempty"`
  463. }{
  464. AimID: session.ScreenName.IdentScreenName().String(),
  465. DisplayID: session.ScreenName.String(),
  466. }
  467. // Add buddy list if requested in myInfo or events
  468. for _, event := range events {
  469. if event == "buddylist" || event == "myInfo" {
  470. var buddyGroups []BuddyGroup
  471. if authToken != "" && h.BuddyListManager != nil {
  472. // Fetch actual buddy list from service
  473. webAPIGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
  474. if err != nil {
  475. h.Logger.ErrorContext(ctx, "failed to get buddy list for XML response", "err", err.Error())
  476. buddyGroups = []BuddyGroup{}
  477. } else {
  478. // Convert WebAPIBuddyGroup to handler.BuddyGroup
  479. for _, webGroup := range webAPIGroups {
  480. group := BuddyGroup{
  481. Name: webGroup.Name,
  482. Buddies: []Buddy{},
  483. }
  484. for _, webBuddy := range webGroup.Buddies {
  485. buddy := Buddy{
  486. AimID: webBuddy.AimID,
  487. State: webBuddy.State,
  488. StatusMsg: webBuddy.StatusMsg,
  489. AwayMsg: webBuddy.AwayMsg,
  490. UserType: webBuddy.UserType,
  491. }
  492. group.Buddies = append(group.Buddies, buddy)
  493. }
  494. buddyGroups = append(buddyGroups, group)
  495. }
  496. }
  497. } else {
  498. buddyGroups = []BuddyGroup{}
  499. }
  500. // Add to myInfo buddylist
  501. xmlResp.Data.MyInfo.Buddylist.Groups = &buddyGroups
  502. // Also add to events if specifically requested
  503. if event == "buddylist" {
  504. if xmlResp.Data.Events == nil {
  505. xmlResp.Data.Events = &struct {
  506. BuddyList struct {
  507. Groups *[]BuddyGroup `xml:"group,omitempty"`
  508. } `xml:"buddylist"`
  509. }{}
  510. }
  511. xmlResp.Data.Events.BuddyList.Groups = &buddyGroups
  512. }
  513. break
  514. }
  515. }
  516. // Send XML response
  517. w.Header().Set("Content-Type", "text/xml; charset=utf-8")
  518. // Build complete XML string first
  519. xmlData, err := xml.Marshal(xmlResp)
  520. if err != nil {
  521. h.Logger.Error("failed to marshal XML response", "error", err)
  522. h.sendError(w, r, http.StatusInternalServerError, "internal server error")
  523. return
  524. }
  525. // Write XML declaration and data as one response
  526. xmlOutput := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>%s`, xmlData)
  527. w.Header().Set("Content-Length", strconv.Itoa(len(xmlOutput)))
  528. _, _ = fmt.Fprint(w, xmlOutput)
  529. } else {
  530. // Send response in requested format (JSON, JSONP, or AMF)
  531. SendResponse(w, r, resp, h.Logger)
  532. }
  533. h.Logger.DebugContext(ctx, "session started",
  534. "aimsid", session.AimSID,
  535. "screen_name", screenName,
  536. "dev_id", apiKey.DevID,
  537. "events", events,
  538. "format", format,
  539. )
  540. }
  541. // EndSession handles GET /aim/endSession requests.
  542. func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
  543. ctx := r.Context()
  544. // RemoveSession evicts the session from the manager and tears it down
  545. // (closes the event queue and the OSCAR instance). Without this the aimsid
  546. // stays resolvable until the reaper sweeps it, and RequireSession would keep
  547. // handing handlers a session whose OSCAR instance is already closed.
  548. if err := h.SessionManager.RemoveSession(ctx, session.AimSID); err != nil {
  549. h.Logger.ErrorContext(ctx, "failed to remove session", "err", err.Error())
  550. }
  551. // Send response
  552. resp := EndSessionResponse{}
  553. resp.Response.StatusCode = 200
  554. resp.Response.StatusText = "OK"
  555. // Send response in requested format (JSON, JSONP, or AMF)
  556. SendResponse(w, r, resp, h.Logger)
  557. h.Logger.DebugContext(ctx, "session ended",
  558. "aimsid", session.AimSID,
  559. "screen_name", session.ScreenName,
  560. )
  561. }
  562. // sendError sends a Web AIM API error envelope, honoring JSONP when requested.
  563. func (h *SessionHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
  564. resp := BaseResponse{}
  565. resp.Response.StatusCode = statusCode
  566. resp.Response.StatusText = message
  567. SendResponse(w, r, resp, h.Logger)
  568. }
  569. // buildMyInfo assembles the shared base of a myInfo payload — the user's own
  570. // identity blob that the AIM client renders in its identity badge.
  571. //
  572. // It carries only fields that are safe to repeat on every myInfo: the client's
  573. // user-object merge deletes friendly and capabilities before merging, so both
  574. // must be present on each push or the badge loses them. Time-sensitive fields
  575. // (onlineTime, memberSince) are intentionally excluded — a mid-session refresh
  576. // omits them so the client keeps the signon time it already has; the startSession
  577. // builders add them explicitly. buddyIcon is included only when non-empty; an
  578. // empty value would be dropped by the client merge anyway, and the placeholder
  579. // URL (not "") is what clears an icon.
  580. func buildMyInfo(screenName state.DisplayScreenName, webState, buddyIcon string) map[string]interface{} {
  581. // The web client compares userType/service case-sensitively; a UIN account must
  582. // report ICQ so it renders as an ICQ contact rather than AIM.
  583. userType, service := "aim", "AIM"
  584. if screenName.IsUIN() {
  585. userType, service = "icq", "ICQ"
  586. }
  587. myInfo := map[string]interface{}{
  588. "aimId": screenName.IdentScreenName().String(),
  589. "displayId": screenName.String(),
  590. "friendly": screenName.String(),
  591. "state": webState,
  592. "userType": userType,
  593. "capabilities": []string{},
  594. "bot": false,
  595. "service": service,
  596. }
  597. if buddyIcon != "" {
  598. myInfo["buddyIcon"] = buddyIcon
  599. }
  600. return myInfo
  601. }
  602. func requestScheme(r *http.Request) string {
  603. if r.TLS != nil {
  604. return "https"
  605. }
  606. if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
  607. return proto
  608. }
  609. return "http"
  610. }
  611. // baseURLFromRequest returns the absolute base URL the client reached this
  612. // server on, used to build asset URLs that the client loads directly.
  613. func baseURLFromRequest(r *http.Request) string {
  614. return fmt.Sprintf("%s://%s", requestScheme(r), r.Host)
  615. }