session.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. package handlers
  2. import (
  3. "context"
  4. "encoding/xml"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "github.com/google/uuid"
  12. "github.com/mk6i/open-oscar-server/server/webapi/middleware"
  13. "github.com/mk6i/open-oscar-server/server/webapi/types"
  14. "github.com/mk6i/open-oscar-server/state"
  15. "github.com/mk6i/open-oscar-server/wire"
  16. )
  17. // SessionHandler handles Web AIM API session management endpoints.
  18. type SessionHandler struct {
  19. SessionManager *state.WebAPISessionManager
  20. OSCARSessionManager SessionManager
  21. OSCARAuthService AuthService
  22. BuddyListService BuddyListService
  23. BuddyListRegistry BuddyListRegistry
  24. BuddyBroadcaster BuddyBroadcaster
  25. BuddyListManager *BuddyListManager
  26. TokenStore TokenStore
  27. Logger *slog.Logger
  28. }
  29. // AuthService defines methods needed for authentication.
  30. type AuthService interface {
  31. BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error)
  32. BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0x02_BUCPLoginRequest, newUserFn func(screenName state.DisplayScreenName) (state.User, error), advertisedHost string) (wire.SNACMessage, error)
  33. RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie) (*state.SessionInstance, error)
  34. }
  35. // SessionManager defines methods for OSCAR session management.
  36. type SessionManager interface {
  37. AddSession(ctx context.Context, screenName state.DisplayScreenName) (*state.SessionInstance, error)
  38. RemoveSession(instance *state.SessionInstance)
  39. RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)
  40. }
  41. // BuddyListRegistry defines methods for buddy list management.
  42. type BuddyListRegistry interface {
  43. RegisterBuddyList(ctx context.Context, screenName state.IdentScreenName) error
  44. UnregisterBuddyList(ctx context.Context, screenName state.IdentScreenName) error
  45. }
  46. // BuddyListService defines methods for buddy list operations.
  47. type BuddyListService interface {
  48. GetBuddyList(ctx context.Context, screenName state.IdentScreenName) ([]BuddyGroup, error)
  49. }
  50. // BuddyGroup represents a group of buddies.
  51. type BuddyGroup struct {
  52. Name string `json:"name"`
  53. Buddies []Buddy `json:"buddies"`
  54. }
  55. // Buddy represents a buddy in the buddy list.
  56. type Buddy struct {
  57. AimID string `json:"aimId"`
  58. State string `json:"state"`
  59. StatusMsg string `json:"statusMsg,omitempty"`
  60. AwayMsg string `json:"awayMsg,omitempty"`
  61. UserType string `json:"userType"`
  62. }
  63. // StartSessionResponse represents the response for startSession endpoint.
  64. type StartSessionResponse struct {
  65. Response struct {
  66. StatusCode int `json:"statusCode"`
  67. StatusText string `json:"statusText"`
  68. Data struct {
  69. AimSID string `json:"aimsid"`
  70. FetchTimeout int `json:"fetchTimeout"`
  71. TimeToNextFetch int `json:"timeToNextFetch"`
  72. FetchBaseURL string `json:"fetchBaseURL"` // Gromit expects this directly in data!
  73. Events map[string]interface{} `json:"events,omitempty"`
  74. WellKnownUrls map[string]string `json:"wellKnownUrls,omitempty"`
  75. } `json:"data"`
  76. } `json:"response"`
  77. }
  78. // StartSessionXMLResponse represents the XML response for startSession endpoint.
  79. type StartSessionXMLResponse struct {
  80. XMLName xml.Name `xml:"response"`
  81. StatusCode int `xml:"statusCode"`
  82. StatusText string `xml:"statusText"`
  83. Data struct {
  84. AimSID string `xml:"aimsid"`
  85. FetchTimeout int `xml:"fetchTimeout"`
  86. TimeToNextFetch int `xml:"timeToNextFetch"`
  87. FetchBaseURL string `xml:"fetchBaseURL"` // Gromit expects this directly!
  88. WellKnownUrls *struct {
  89. WebApiBase string `xml:"webApiBase"`
  90. FetchBaseURL string `xml:"fetchBaseURL"`
  91. } `xml:"wellKnownUrls,omitempty"`
  92. MyInfo *struct {
  93. AimID string `xml:"aimId"`
  94. DisplayID string `xml:"displayId"`
  95. Buddylist struct {
  96. Groups *[]BuddyGroup `xml:"group,omitempty"`
  97. } `xml:"buddylist,omitempty"`
  98. } `xml:"myInfo,omitempty"`
  99. Events *struct {
  100. BuddyList struct {
  101. Groups *[]BuddyGroup `xml:"group,omitempty"`
  102. } `xml:"buddylist"`
  103. } `xml:"events,omitempty"`
  104. } `xml:"data"`
  105. }
  106. // EndSessionResponse represents the response for endSession endpoint.
  107. type EndSessionResponse struct {
  108. Response struct {
  109. StatusCode int `json:"statusCode"`
  110. StatusText string `json:"statusText"`
  111. } `json:"response"`
  112. }
  113. // StartSession handles GET /aim/startSession requests.
  114. func (h *SessionHandler) StartSession(w http.ResponseWriter, r *http.Request) {
  115. ctx := r.Context()
  116. // Get API key info from context (set by auth middleware)
  117. apiKey, ok := ctx.Value(middleware.ContextKeyAPIKey).(*state.WebAPIKey)
  118. if !ok {
  119. h.sendError(w, http.StatusInternalServerError, "internal server error")
  120. return
  121. }
  122. // Parse parameters
  123. params := r.URL.Query()
  124. // Get authentication token if provided
  125. authToken := params.Get("a")
  126. // Get client info
  127. clientName := params.Get("clientName")
  128. if clientName == "" {
  129. clientName = "WebAIM"
  130. }
  131. clientVersion := params.Get("clientVersion")
  132. if clientVersion == "" {
  133. clientVersion = "1.0"
  134. }
  135. // Get events to subscribe to
  136. eventsParam := params.Get("events")
  137. var events []string
  138. if eventsParam != "" {
  139. events = strings.Split(eventsParam, ",")
  140. h.Logger.DebugContext(ctx, "parsing events from request",
  141. "eventsParam", eventsParam,
  142. "parsedEvents", events,
  143. )
  144. } else {
  145. // Default events if none specified
  146. events = []string{"buddylist", "presence", "im", "sentIM"}
  147. h.Logger.DebugContext(ctx, "using default events",
  148. "events", events,
  149. )
  150. }
  151. // Get timeout settings
  152. timeout := 60000 // Default 60 seconds for better stability with Gromit
  153. if t := params.Get("timeout"); t != "" {
  154. if val, err := strconv.Atoi(t); err == nil && val > 0 {
  155. timeout = val * 1000 // Convert to milliseconds
  156. }
  157. }
  158. // Determine screen name from auth token or anonymous
  159. var screenName state.DisplayScreenName
  160. if authToken != "" {
  161. // Validate auth token and get screen name
  162. if h.TokenStore == nil {
  163. h.Logger.Error("TokenStore not configured")
  164. h.sendError(w, http.StatusInternalServerError, "authentication not configured")
  165. return
  166. }
  167. identScreenName, err := h.TokenStore.ValidateToken(r.Context(), authToken)
  168. if err != nil {
  169. h.Logger.Warn("invalid authentication token",
  170. "error", err)
  171. h.sendError(w, http.StatusUnauthorized, "invalid or expired token")
  172. return
  173. }
  174. // For WebAPI sessions, we can use the IdentScreenName directly as DisplayScreenName
  175. // since WRAITH handles the display formatting
  176. screenName = state.DisplayScreenName(identScreenName.String())
  177. tokenPreview := authToken
  178. if len(tokenPreview) > 8 {
  179. tokenPreview = tokenPreview[:8] + "..."
  180. }
  181. h.Logger.Info("authenticated session requested",
  182. "token", tokenPreview,
  183. "screenName", screenName)
  184. } else {
  185. // Anonymous session - generate guest name
  186. screenName = state.DisplayScreenName("Guest_" + strconv.FormatInt(time.Now().Unix(), 36))
  187. h.Logger.Info("anonymous session requested",
  188. "screenName", screenName)
  189. }
  190. // Create OSCAR session for authenticated users
  191. var oscarInstance *state.SessionInstance
  192. var err error
  193. if authToken != "" && h.OSCARSessionManager != nil {
  194. // Create OSCAR session
  195. oscarInstance, err = h.OSCARSessionManager.AddSession(ctx, screenName)
  196. if err != nil {
  197. h.Logger.ErrorContext(ctx, "failed to create OSCAR session", "err", err.Error())
  198. // Continue without OSCAR session - WebAPI can work standalone
  199. oscarInstance = nil
  200. } else {
  201. oscarInstance.SetSignonComplete()
  202. // Register buddy list
  203. if h.BuddyListRegistry != nil {
  204. if err := h.BuddyListRegistry.RegisterBuddyList(ctx, screenName.IdentScreenName()); err != nil {
  205. h.Logger.ErrorContext(ctx, "failed to register buddy list", "err", err.Error())
  206. }
  207. }
  208. // Broadcast buddy arrival to OSCAR clients
  209. if h.BuddyBroadcaster != nil {
  210. if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarInstance.IdentScreenName(), oscarInstance.Session().TLVUserInfo()); err != nil {
  211. h.Logger.ErrorContext(ctx, "failed to broadcast buddy arrival", "err", err.Error())
  212. }
  213. }
  214. }
  215. }
  216. // Create WebAPI session
  217. session, err := h.SessionManager.CreateSession(r.Context(), screenName, apiKey.DevID, events, oscarInstance, h.Logger)
  218. if err != nil {
  219. h.Logger.ErrorContext(ctx, "failed to create session", "err", err.Error())
  220. h.sendError(w, http.StatusInternalServerError, "failed to create session")
  221. return
  222. }
  223. h.Logger.DebugContext(ctx, "session created with event subscriptions",
  224. "aimsid", session.AimSID,
  225. "events", events,
  226. )
  227. // Store client info
  228. session.ClientName = clientName
  229. session.ClientVersion = clientVersion
  230. session.FetchTimeout = timeout
  231. session.RemoteAddr = r.RemoteAddr
  232. // Queue myInfo event for authenticated users
  233. if authToken != "" {
  234. for _, event := range events {
  235. if event == "myInfo" || event == "presence" {
  236. myInfoData := map[string]interface{}{
  237. "aimId": screenName.String(),
  238. "displayId": screenName.String(),
  239. "state": "online",
  240. "onlineTime": time.Now().Unix(),
  241. "memberSince": time.Now().Unix() - 86400*30, // 30 days ago
  242. "capabilities": []string{},
  243. "bot": false,
  244. "service": "aim",
  245. }
  246. session.EventQueue.Push(types.EventType("myInfo"), myInfoData)
  247. break
  248. }
  249. }
  250. }
  251. // Prepare response
  252. resp := StartSessionResponse{}
  253. resp.Response.StatusCode = 200
  254. resp.Response.StatusText = "OK"
  255. resp.Response.Data.AimSID = session.AimSID
  256. resp.Response.Data.FetchTimeout = session.FetchTimeout
  257. resp.Response.Data.TimeToNextFetch = session.TimeToNextFetch
  258. // Gromit expects fetchBaseURL directly in data, not in wellKnownUrls
  259. resp.Response.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=0", r.Host, session.AimSID)
  260. // Add wellKnownUrls for other clients that might use it
  261. resp.Response.Data.WellKnownUrls = map[string]string{
  262. "webApiBase": fmt.Sprintf("http://%s/", r.Host),
  263. "fetchBaseURL": fmt.Sprintf("http://%s/aim/fetchEvents", r.Host),
  264. }
  265. // Add myInfo data if authenticated
  266. if authToken != "" {
  267. if resp.Response.Data.Events == nil {
  268. resp.Response.Data.Events = make(map[string]interface{})
  269. }
  270. resp.Response.Data.Events["myInfo"] = map[string]interface{}{
  271. "aimId": screenName.String(),
  272. "displayId": screenName.String(),
  273. "state": "online",
  274. "onlineTime": time.Now().Unix(),
  275. "memberSince": time.Now().Unix() - 86400*30, // 30 days ago
  276. "capabilities": []string{},
  277. "bot": false,
  278. "service": "aim",
  279. "self": map[string]interface{}{
  280. "instNum": 1,
  281. "loginTime": time.Now().Unix(),
  282. "sessionTimeout": 30,
  283. "events": events,
  284. "assertCaps": []string{},
  285. "rightsInfo": map[string]interface{}{
  286. "maxDenies": 500,
  287. "maxPermits": 500,
  288. "maxWatchers": 3000,
  289. "maxBuddies": 500,
  290. "maxTempBuddies": 160,
  291. "maxIMSize": 3987,
  292. "minInterIcbmInterval": 1000,
  293. "maxSourceEvil": 900,
  294. "maxDstEvil": 999,
  295. "maxSigLen": 4096,
  296. },
  297. },
  298. }
  299. }
  300. // If buddy list event is subscribed, include initial buddy list
  301. for _, event := range events {
  302. if event == "buddylist" {
  303. if authToken != "" && h.BuddyListManager != nil {
  304. // Fetch actual buddy list from service
  305. buddyGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session.ScreenName.IdentScreenName())
  306. if err != nil {
  307. h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
  308. // Continue with empty buddy list
  309. buddyGroups = []WebAPIBuddyGroup{}
  310. }
  311. // Convert to handler format and include in response
  312. if resp.Response.Data.Events == nil {
  313. resp.Response.Data.Events = make(map[string]interface{})
  314. }
  315. resp.Response.Data.Events["buddylist"] = map[string]interface{}{
  316. "groups": buddyGroups,
  317. }
  318. } else {
  319. // No auth token, return empty buddy list
  320. if resp.Response.Data.Events == nil {
  321. resp.Response.Data.Events = make(map[string]interface{})
  322. }
  323. resp.Response.Data.Events["buddylist"] = map[string]interface{}{
  324. "groups": []WebAPIBuddyGroup{},
  325. }
  326. }
  327. break
  328. }
  329. }
  330. // Check response format
  331. format := r.URL.Query().Get("f")
  332. if format == "" {
  333. format = "json" // default to JSON
  334. }
  335. // Send response in requested format
  336. if format == "xml" {
  337. // Build XML response
  338. xmlResp := StartSessionXMLResponse{}
  339. xmlResp.StatusCode = 200
  340. xmlResp.StatusText = "OK"
  341. xmlResp.Data.AimSID = session.AimSID
  342. xmlResp.Data.FetchTimeout = timeout
  343. xmlResp.Data.TimeToNextFetch = 500
  344. // Gromit expects fetchBaseURL directly in data
  345. xmlResp.Data.FetchBaseURL = fmt.Sprintf("http://%s/aim/fetchEvents?aimsid=%s&seqNum=0", r.Host, session.AimSID)
  346. // Add wellKnownUrls for other clients
  347. xmlResp.Data.WellKnownUrls = &struct {
  348. WebApiBase string `xml:"webApiBase"`
  349. FetchBaseURL string `xml:"fetchBaseURL"`
  350. }{
  351. WebApiBase: fmt.Sprintf("http://%s/", r.Host),
  352. FetchBaseURL: fmt.Sprintf("http://%s/aim/fetchEvents", r.Host),
  353. }
  354. // Add myInfo with user data
  355. xmlResp.Data.MyInfo = &struct {
  356. AimID string `xml:"aimId"`
  357. DisplayID string `xml:"displayId"`
  358. Buddylist struct {
  359. Groups *[]BuddyGroup `xml:"group,omitempty"`
  360. } `xml:"buddylist,omitempty"`
  361. }{
  362. AimID: session.ScreenName.String(),
  363. DisplayID: session.ScreenName.String(),
  364. }
  365. // Add buddy list if requested in myInfo or events
  366. for _, event := range events {
  367. if event == "buddylist" || event == "myInfo" {
  368. var buddyGroups []BuddyGroup
  369. if authToken != "" && h.BuddyListManager != nil {
  370. // Fetch actual buddy list from service
  371. webAPIGroups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session.ScreenName.IdentScreenName())
  372. if err != nil {
  373. h.Logger.ErrorContext(ctx, "failed to get buddy list for XML response", "err", err.Error())
  374. buddyGroups = []BuddyGroup{}
  375. } else {
  376. // Convert WebAPIBuddyGroup to handler.BuddyGroup
  377. for _, webGroup := range webAPIGroups {
  378. group := BuddyGroup{
  379. Name: webGroup.Name,
  380. Buddies: []Buddy{},
  381. }
  382. for _, webBuddy := range webGroup.Buddies {
  383. buddy := Buddy{
  384. AimID: webBuddy.AimID,
  385. State: webBuddy.State,
  386. StatusMsg: webBuddy.StatusMsg,
  387. AwayMsg: webBuddy.AwayMsg,
  388. UserType: webBuddy.UserType,
  389. }
  390. group.Buddies = append(group.Buddies, buddy)
  391. }
  392. buddyGroups = append(buddyGroups, group)
  393. }
  394. }
  395. } else {
  396. buddyGroups = []BuddyGroup{}
  397. }
  398. // Add to myInfo buddylist
  399. xmlResp.Data.MyInfo.Buddylist.Groups = &buddyGroups
  400. // Also add to events if specifically requested
  401. if event == "buddylist" {
  402. if xmlResp.Data.Events == nil {
  403. xmlResp.Data.Events = &struct {
  404. BuddyList struct {
  405. Groups *[]BuddyGroup `xml:"group,omitempty"`
  406. } `xml:"buddylist"`
  407. }{}
  408. }
  409. xmlResp.Data.Events.BuddyList.Groups = &buddyGroups
  410. }
  411. break
  412. }
  413. }
  414. // Send XML response
  415. w.Header().Set("Content-Type", "text/xml; charset=utf-8")
  416. // Build complete XML string first
  417. xmlData, err := xml.Marshal(xmlResp)
  418. if err != nil {
  419. h.Logger.Error("failed to marshal XML response", "error", err)
  420. h.sendError(w, http.StatusInternalServerError, "internal server error")
  421. return
  422. }
  423. // Write XML declaration and data as one response
  424. xmlOutput := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>%s`, xmlData)
  425. w.Header().Set("Content-Length", strconv.Itoa(len(xmlOutput)))
  426. fmt.Fprint(w, xmlOutput)
  427. } else {
  428. // Send response in requested format (JSON, JSONP, or AMF)
  429. SendResponse(w, r, resp, h.Logger)
  430. }
  431. h.Logger.DebugContext(ctx, "session started",
  432. "aimsid", session.AimSID,
  433. "screen_name", screenName,
  434. "dev_id", apiKey.DevID,
  435. "events", events,
  436. "format", format,
  437. )
  438. }
  439. // EndSession handles GET /aim/endSession requests.
  440. func (h *SessionHandler) EndSession(w http.ResponseWriter, r *http.Request) {
  441. ctx := r.Context()
  442. // Get session ID from parameters
  443. aimsid := r.URL.Query().Get("aimsid")
  444. if aimsid == "" {
  445. h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
  446. return
  447. }
  448. // Get session
  449. session, err := h.SessionManager.GetSession(r.Context(), aimsid)
  450. if err != nil {
  451. if err == state.ErrNoWebAPISession {
  452. h.sendError(w, http.StatusNotFound, "session not found")
  453. } else if err == state.ErrWebAPISessionExpired {
  454. h.sendError(w, http.StatusGone, "session expired")
  455. } else {
  456. h.sendError(w, http.StatusInternalServerError, "internal server error")
  457. }
  458. return
  459. }
  460. // Clean up OSCAR session if present
  461. if session.OSCARSession != nil && h.OSCARSessionManager != nil {
  462. // Broadcast departure to OSCAR clients
  463. if h.BuddyBroadcaster != nil {
  464. if err := h.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, session.OSCARSession); err != nil {
  465. h.Logger.ErrorContext(ctx, "failed to broadcast buddy departure", "err", err.Error())
  466. }
  467. }
  468. // Unregister buddy list
  469. if h.BuddyListRegistry != nil {
  470. if err := h.BuddyListRegistry.UnregisterBuddyList(ctx, session.ScreenName.IdentScreenName()); err != nil {
  471. h.Logger.ErrorContext(ctx, "failed to unregister buddy list", "err", err.Error())
  472. }
  473. }
  474. // Remove OSCAR session
  475. h.OSCARSessionManager.RemoveSession(session.OSCARSession)
  476. session.OSCARSession = nil
  477. }
  478. // Remove session
  479. if err := h.SessionManager.RemoveSession(r.Context(), aimsid); err != nil {
  480. h.Logger.ErrorContext(ctx, "failed to remove session", "err", err.Error())
  481. h.sendError(w, http.StatusInternalServerError, "failed to end session")
  482. return
  483. }
  484. // Send response
  485. resp := EndSessionResponse{}
  486. resp.Response.StatusCode = 200
  487. resp.Response.StatusText = "OK"
  488. // Send response in requested format (JSON, JSONP, or AMF)
  489. SendResponse(w, r, resp, h.Logger)
  490. h.Logger.DebugContext(ctx, "session ended",
  491. "aimsid", aimsid,
  492. "screen_name", session.ScreenName,
  493. )
  494. }
  495. // sendError is a convenience method that wraps the common SendError function.
  496. func (h *SessionHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
  497. SendError(w, statusCode, message)
  498. }