session.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  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. return h.BuddyListManager.GetBuddyListForUser(ctx, session)
  254. }
  255. // Wire the alias loader so OSCAR-driven im/presence events can repeat the
  256. // buddy's friendly name. The client discards the alias it holds each time it
  257. // merges a user map, so an event that omits it renames the buddy. The session
  258. // caches what this returns until a feedbag change invalidates it.
  259. session.BuddyAliasLoader = func(ctx context.Context) (map[string]string, error) {
  260. return LookupBuddyAliases(ctx, h.FeedbagService, session.OSCARSession)
  261. }
  262. // Wire the buddy-icon URL formatter so presence broadcasts (BuddyArrived) can
  263. // publish a buddy's current icon from the hash carried in the SNAC, no lookup.
  264. session.BuddyIconURL = func(sn state.IdentScreenName, hash []byte) string {
  265. return h.IconSource.URLForHash(session.BaseURL, sn, hash)
  266. }
  267. // Wire the myInfo refresher so a self user-info update (icon upload/clear)
  268. // re-renders the identity badge. currentWebState reflects the user's live
  269. // presence; PublishedURL reflects the feedbag icon, already updated by the time
  270. // the OServiceUserInfoUpdate is relayed.
  271. session.MyInfoRefresher = func(ctx context.Context) (interface{}, error) {
  272. icon := h.IconSource.PublishedURL(ctx, session.BaseURL, screenName.IdentScreenName())
  273. return buildMyInfo(screenName, currentWebState(session.OSCARSession), icon), nil
  274. }
  275. // Wire permit/deny refresher so FeedbagUpdateItem SNACs trigger a permitDeny event.
  276. session.PermitDenyRefresher = func(ctx context.Context) (interface{}, error) {
  277. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  278. fb, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
  279. if err != nil {
  280. return nil, err
  281. }
  282. reply, ok := fb.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  283. if !ok {
  284. return nil, fmt.Errorf("unexpected feedbag reply type")
  285. }
  286. return permitDenyData(reply.Items), nil
  287. }
  288. // Now that every refresher callback is wired, start the OSCAR listener. Doing
  289. // this inside CreateSession would race these assignments, since the goroutine
  290. // reads the callbacks as it converts SNACs into events.
  291. session.StartListeningToOSCARSession()
  292. // Store client info
  293. session.ClientName = clientName
  294. session.ClientVersion = clientVersion
  295. session.FetchTimeout = timeout
  296. session.RemoteAddr = r.RemoteAddr
  297. // The identity badge renders the user's own icon from myInfo, which is the
  298. // only event it binds its self-presence render to. PublishedURL always yields
  299. // a URL (the blank placeholder when the user has no icon) so that clearing the
  300. // icon propagates to the badge.
  301. myIconURL := h.IconSource.PublishedURL(ctx, baseURL, screenName.IdentScreenName())
  302. // Queue myInfo event for authenticated users
  303. if authToken != "" {
  304. for _, event := range events {
  305. if event == "myInfo" || event == "presence" {
  306. myInfoData := buildMyInfo(screenName, "online", myIconURL)
  307. myInfoData["onlineTime"] = time.Now().Unix()
  308. myInfoData["memberSince"] = time.Now().Unix() - 86400*30 // 30 days ago
  309. session.EventQueue.Push(types.EventType("myInfo"), myInfoData)
  310. break
  311. }
  312. }
  313. for _, event := range events {
  314. if event == "conversation" {
  315. session.EventQueue.Push(types.EventTypeConversation,
  316. types.ConversationEventData("list", nil))
  317. break
  318. }
  319. }
  320. }
  321. now := time.Now().Unix()
  322. // Prepare response
  323. resp := StartSessionResponse{}
  324. resp.Response.StatusCode = 200
  325. resp.Response.StatusText = "OK"
  326. resp.Response.Data.AimSID = session.AimSID
  327. resp.Response.Data.Ts = now
  328. resp.Response.Data.FetchTimeout = session.FetchTimeout
  329. resp.Response.Data.TimeToNextFetch = session.TimeToNextFetch
  330. // Gromit expects fetchBaseURL directly in data, not in wellKnownUrls
  331. resp.Response.Data.FetchBaseURL = fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=0", baseURL, session.AimSID)
  332. // Add wellKnownUrls for other clients that might use it.
  333. resp.Response.Data.WellKnownUrls = map[string]string{
  334. "webApiBase": baseURL + "/",
  335. "fetchBaseURL": baseURL + "/aim/fetchEvents",
  336. "lifestreamApiBase": baseURL + "/",
  337. }
  338. if authToken != "" {
  339. myInfoPayload := buildMyInfo(screenName, "online", myIconURL)
  340. myInfoPayload["onlineTime"] = time.Now().Unix()
  341. myInfoPayload["memberSince"] = time.Now().Unix() - 86400*30 // 30 days ago
  342. myInfoPayload["self"] = map[string]interface{}{
  343. "instNum": 1,
  344. "loginTime": time.Now().Unix(),
  345. "sessionTimeout": 30,
  346. "events": events,
  347. "assertCaps": []string{},
  348. "rightsInfo": map[string]interface{}{
  349. "maxDenies": 500,
  350. "maxPermits": 500,
  351. "maxWatchers": 3000,
  352. "maxBuddies": 500,
  353. "maxTempBuddies": 160,
  354. "maxIMSize": 3987,
  355. "minInterIcbmInterval": 1000,
  356. "maxSourceEvil": 900,
  357. "maxDstEvil": 999,
  358. "maxSigLen": 4096,
  359. },
  360. }
  361. resp.Response.Data.MyInfo = myInfoPayload
  362. if resp.Response.Data.Events == nil {
  363. resp.Response.Data.Events = make(map[string]interface{})
  364. }
  365. resp.Response.Data.Events["myInfo"] = myInfoPayload
  366. }
  367. for _, event := range events {
  368. switch types.EventType(event) {
  369. case types.EventTypeBuddyList:
  370. buddyGroups := []WebAPIBuddyGroup{}
  371. if authToken != "" && h.BuddyListManager != nil {
  372. var err error
  373. buddyGroups, err = h.BuddyListManager.GetBuddyListForUser(ctx, session)
  374. if err != nil {
  375. h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
  376. buddyGroups = []WebAPIBuddyGroup{}
  377. }
  378. }
  379. if buddyGroups == nil {
  380. buddyGroups = []WebAPIBuddyGroup{}
  381. }
  382. blPayload := map[string]interface{}{"groups": buddyGroups}
  383. if resp.Response.Data.Events == nil {
  384. resp.Response.Data.Events = make(map[string]interface{})
  385. }
  386. resp.Response.Data.Events["buddylist"] = blPayload
  387. if authToken != "" {
  388. session.EventQueue.Push(types.EventTypeBuddyList, blPayload)
  389. }
  390. case types.EventTypePreference:
  391. // Seed the client with effective preference values: the user's stored
  392. // prefs where set, and the server-side spec defaults otherwise. The
  393. // client reads its buddy-list display prefs (e.g. showGroups) only from
  394. // this event and has no default of its own for them, so an omitted pref
  395. // would silently fall back to the client's hidden default and, for
  396. // showGroups, hide group headers.
  397. prefPayload := map[string]interface{}{}
  398. if authToken != "" && session.OSCARSession != nil {
  399. if item, err := buddyPrefsItem(ctx, h.FeedbagService, session.OSCARSession); err != nil {
  400. h.Logger.ErrorContext(ctx, "failed to get preferences", "err", err.Error())
  401. } else {
  402. prefPayload = effectiveBuddyPrefs(item.TLVList)
  403. }
  404. }
  405. if resp.Response.Data.Events == nil {
  406. resp.Response.Data.Events = make(map[string]interface{})
  407. }
  408. resp.Response.Data.Events["preference"] = prefPayload
  409. if authToken != "" {
  410. session.EventQueue.Push(types.EventTypePreference, prefPayload)
  411. }
  412. }
  413. }
  414. // Check response format
  415. format := r.URL.Query().Get("f")
  416. if format == "" {
  417. format = "json" // default to JSON
  418. }
  419. // Send response in requested format
  420. if format == "xml" {
  421. // Build XML response
  422. xmlResp := StartSessionXMLResponse{}
  423. xmlResp.StatusCode = 200
  424. xmlResp.StatusText = "OK"
  425. xmlResp.Data.AimSID = session.AimSID
  426. xmlResp.Data.FetchTimeout = timeout
  427. xmlResp.Data.TimeToNextFetch = 500
  428. // Gromit expects fetchBaseURL directly in data
  429. xmlResp.Data.FetchBaseURL = fmt.Sprintf("%s/aim/fetchEvents?aimsid=%s&seqNum=0", baseURL, session.AimSID)
  430. // Add wellKnownUrls for other clients
  431. xmlBase := baseURL + "/"
  432. xmlResp.Data.WellKnownUrls = &struct {
  433. WebApiBase string `xml:"webApiBase"`
  434. FetchBaseURL string `xml:"fetchBaseURL"`
  435. LifestreamApiBase string `xml:"lifestreamApiBase"`
  436. }{
  437. WebApiBase: xmlBase,
  438. FetchBaseURL: baseURL + "/aim/fetchEvents",
  439. LifestreamApiBase: xmlBase,
  440. }
  441. // Add myInfo with user data
  442. xmlResp.Data.MyInfo = &struct {
  443. AimID string `xml:"aimId"`
  444. DisplayID string `xml:"displayId"`
  445. Buddylist struct {
  446. Groups *[]BuddyGroup `xml:"group,omitempty"`
  447. } `xml:"buddylist,omitempty"`
  448. }{
  449. AimID: session.ScreenName.IdentScreenName().String(),
  450. DisplayID: session.ScreenName.String(),
  451. }
  452. // Add buddy list if requested in myInfo or events
  453. for _, event := range events {
  454. if event == "buddylist" || event == "myInfo" {
  455. var buddyGroups []BuddyGroup
  456. if authToken != "" && h.BuddyListManager != nil {
  457. // Fetch actual buddy list from service
  458. webAPIGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
  459. if err != nil {
  460. h.Logger.ErrorContext(ctx, "failed to get buddy list for XML response", "err", err.Error())
  461. buddyGroups = []BuddyGroup{}
  462. } else {
  463. // Convert WebAPIBuddyGroup to handler.BuddyGroup
  464. for _, webGroup := range webAPIGroups {
  465. group := BuddyGroup{
  466. Name: webGroup.Name,
  467. Buddies: []Buddy{},
  468. }
  469. for _, webBuddy := range webGroup.Buddies {
  470. buddy := Buddy{
  471. AimID: webBuddy.AimID,
  472. State: webBuddy.State,
  473. StatusMsg: webBuddy.StatusMsg,
  474. AwayMsg: webBuddy.AwayMsg,
  475. UserType: webBuddy.UserType,
  476. }
  477. group.Buddies = append(group.Buddies, buddy)
  478. }
  479. buddyGroups = append(buddyGroups, group)
  480. }
  481. }
  482. } else {
  483. buddyGroups = []BuddyGroup{}
  484. }
  485. // Add to myInfo buddylist
  486. xmlResp.Data.MyInfo.Buddylist.Groups = &buddyGroups
  487. // Also add to events if specifically requested
  488. if event == "buddylist" {
  489. if xmlResp.Data.Events == nil {
  490. xmlResp.Data.Events = &struct {
  491. BuddyList struct {
  492. Groups *[]BuddyGroup `xml:"group,omitempty"`
  493. } `xml:"buddylist"`
  494. }{}
  495. }
  496. xmlResp.Data.Events.BuddyList.Groups = &buddyGroups
  497. }
  498. break
  499. }
  500. }
  501. // Send XML response
  502. w.Header().Set("Content-Type", "text/xml; charset=utf-8")
  503. // Build complete XML string first
  504. xmlData, err := xml.Marshal(xmlResp)
  505. if err != nil {
  506. h.Logger.Error("failed to marshal XML response", "error", err)
  507. h.sendError(w, r, http.StatusInternalServerError, "internal server error")
  508. return
  509. }
  510. // Write XML declaration and data as one response
  511. xmlOutput := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>%s`, xmlData)
  512. w.Header().Set("Content-Length", strconv.Itoa(len(xmlOutput)))
  513. _, _ = fmt.Fprint(w, xmlOutput)
  514. } else {
  515. // Send response in requested format (JSON, JSONP, or AMF)
  516. SendResponse(w, r, resp, h.Logger)
  517. }
  518. h.Logger.DebugContext(ctx, "session started",
  519. "aimsid", session.AimSID,
  520. "screen_name", screenName,
  521. "dev_id", apiKey.DevID,
  522. "events", events,
  523. "format", format,
  524. )
  525. }
  526. // EndSession handles GET /aim/endSession requests.
  527. func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request, session *state.WebAPISession) {
  528. ctx := r.Context()
  529. // RemoveSession evicts the session from the manager and tears it down
  530. // (closes the event queue and the OSCAR instance). Without this the aimsid
  531. // stays resolvable until the reaper sweeps it, and RequireSession would keep
  532. // handing handlers a session whose OSCAR instance is already closed.
  533. if err := h.SessionManager.RemoveSession(ctx, session.AimSID); err != nil {
  534. h.Logger.ErrorContext(ctx, "failed to remove session", "err", err.Error())
  535. }
  536. // Send response
  537. resp := EndSessionResponse{}
  538. resp.Response.StatusCode = 200
  539. resp.Response.StatusText = "OK"
  540. // Send response in requested format (JSON, JSONP, or AMF)
  541. SendResponse(w, r, resp, h.Logger)
  542. h.Logger.DebugContext(ctx, "session ended",
  543. "aimsid", session.AimSID,
  544. "screen_name", session.ScreenName,
  545. )
  546. }
  547. // sendError sends a Web AIM API error envelope, honoring JSONP when requested.
  548. func (h *SessionHandler) sendError(w http.ResponseWriter, r *http.Request, statusCode int, message string) {
  549. resp := BaseResponse{}
  550. resp.Response.StatusCode = statusCode
  551. resp.Response.StatusText = message
  552. SendResponse(w, r, resp, h.Logger)
  553. }
  554. // buildMyInfo assembles the shared base of a myInfo payload — the user's own
  555. // identity blob that the AIM client renders in its identity badge.
  556. //
  557. // It carries only fields that are safe to repeat on every myInfo: the client's
  558. // user-object merge deletes friendly and capabilities before merging, so both
  559. // must be present on each push or the badge loses them. Time-sensitive fields
  560. // (onlineTime, memberSince) are intentionally excluded — a mid-session refresh
  561. // omits them so the client keeps the signon time it already has; the startSession
  562. // builders add them explicitly. buddyIcon is included only when non-empty; an
  563. // empty value would be dropped by the client merge anyway, and the placeholder
  564. // URL (not "") is what clears an icon.
  565. func buildMyInfo(screenName state.DisplayScreenName, webState, buddyIcon string) map[string]interface{} {
  566. // The web client compares userType/service case-sensitively; a UIN account must
  567. // report ICQ so it renders as an ICQ contact rather than AIM.
  568. userType, service := "aim", "AIM"
  569. if screenName.IsUIN() {
  570. userType, service = "icq", "ICQ"
  571. }
  572. myInfo := map[string]interface{}{
  573. "aimId": screenName.IdentScreenName().String(),
  574. "displayId": screenName.String(),
  575. "friendly": screenName.String(),
  576. "state": webState,
  577. "userType": userType,
  578. "capabilities": []string{},
  579. "bot": false,
  580. "service": service,
  581. }
  582. if buddyIcon != "" {
  583. myInfo["buddyIcon"] = buddyIcon
  584. }
  585. return myInfo
  586. }
  587. func requestScheme(r *http.Request) string {
  588. if r.TLS != nil {
  589. return "https"
  590. }
  591. if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
  592. return proto
  593. }
  594. return "http"
  595. }
  596. // baseURLFromRequest returns the absolute base URL the client reached this
  597. // server on, used to build asset URLs that the client loads directly.
  598. func baseURLFromRequest(r *http.Request) string {
  599. return fmt.Sprintf("%s://%s", requestScheme(r), r.Host)
  600. }