server.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. package webapi
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "time"
  9. "golang.org/x/sync/errgroup"
  10. "github.com/mk6i/open-oscar-server/server/webapi/handlers"
  11. "github.com/mk6i/open-oscar-server/server/webapi/middleware"
  12. "github.com/mk6i/open-oscar-server/state"
  13. )
  14. func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator middleware.APIKeyValidator, sessionManager *state.WebAPISessionManager) *Server {
  15. servers := make([]*http.Server, 0, len(listeners))
  16. authMiddleware := middleware.NewAuthMiddleware(apiKeyValidator, logger)
  17. authHandler := &handlers.AuthHandler{
  18. AuthService: handler.AuthService,
  19. CookieBaker: handler.CookieBaker,
  20. Logger: logger,
  21. }
  22. sessionHandler := &handlers.SessionHandler{
  23. SessionManager: sessionManager,
  24. OSCARAuthService: handler.AuthService,
  25. FeedbagService: handler.FeedbagService,
  26. BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
  27. IconSource: handler.IconSource,
  28. Logger: logger,
  29. OServiceService: handler.OServiceService,
  30. }
  31. eventsHandler := &handlers.EventsHandler{
  32. SessionManager: sessionManager,
  33. Logger: logger,
  34. }
  35. presenceHandler := &handlers.PresenceHandler{
  36. SessionManager: sessionManager,
  37. FeedbagService: handler.FeedbagService,
  38. BuddyBroadcaster: handler.BuddyBroadcaster,
  39. LocateService: handler.LocateService,
  40. IconSource: handler.IconSource,
  41. Logger: logger,
  42. }
  43. buddyListHandler := &handlers.BuddyListHandler{
  44. BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
  45. Logger: logger,
  46. FeedbagService: handler.FeedbagService,
  47. }
  48. messagingHandler := &handlers.MessagingHandler{
  49. SessionManager: sessionManager,
  50. ICBMService: handler.ICBMService,
  51. ChatService: handler.ChatService,
  52. LocateService: handler.LocateService,
  53. FeedbagService: handler.FeedbagService,
  54. Logger: logger,
  55. }
  56. imservHandler := &handlers.ImservHandler{
  57. SessionManager: sessionManager,
  58. ChatNavService: handler.ChatNavService,
  59. OServiceService: handler.OServiceService,
  60. AuthService: handler.AuthService,
  61. ICBMService: handler.ICBMService,
  62. Participants: handler.ChatSessionManager,
  63. Logger: logger,
  64. }
  65. preferenceHandler := &handlers.PreferenceHandler{
  66. SessionManager: sessionManager,
  67. FeedbagService: handler.FeedbagService,
  68. Logger: logger,
  69. }
  70. memberDirHandler := &handlers.MemberDirHandler{
  71. DirSearchService: handler.DirSearchService,
  72. LocateService: handler.LocateService,
  73. Logger: logger,
  74. }
  75. oscarBridgeHandler := &handlers.OSCARBridgeHandler{
  76. SessionManager: sessionManager,
  77. OSCARAuthService: handler.AuthService,
  78. CookieBaker: handler.CookieBaker,
  79. Config: handler.OSCARConfig,
  80. Logger: logger,
  81. }
  82. shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
  83. for _, l := range listeners {
  84. mux := http.NewServeMux()
  85. // Exact root only. Pattern "GET /" matches every GET path in Go 1.22+ (prefix /), which
  86. // would steal /getAggregated and other lifestream URLs before stubs/404.
  87. mux.HandleFunc("GET /{$}", handler.GetHelloWorldHandler)
  88. // Authentication endpoint (public - no API key required for user login)
  89. // Using pattern with explicit method for Go 1.22+ routing
  90. mux.HandleFunc("POST /auth/clientLogin", func(w http.ResponseWriter, r *http.Request) {
  91. // Set CORS headers for public endpoint
  92. w.Header().Set("Access-Control-Allow-Origin", "*")
  93. w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
  94. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  95. authHandler.ClientLogin(w, r)
  96. })
  97. // Handle OPTIONS for CORS preflight
  98. mux.HandleFunc("OPTIONS /auth/clientLogin", func(w http.ResponseWriter, r *http.Request) {
  99. w.Header().Set("Access-Control-Allow-Origin", "*")
  100. w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
  101. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  102. w.WriteHeader(http.StatusNoContent)
  103. })
  104. mux.HandleFunc("GET /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
  105. w.Header().Set("Access-Control-Allow-Origin", "*")
  106. w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
  107. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  108. authHandler.GetToken(w, r)
  109. })
  110. mux.HandleFunc("OPTIONS /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
  111. w.Header().Set("Access-Control-Allow-Origin", "*")
  112. w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
  113. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  114. w.WriteHeader(http.StatusNoContent)
  115. })
  116. // Web AIM navigates the browser here on File > Logout; clear SSO state
  117. // and redirect to the login screen.
  118. mux.HandleFunc("GET /auth/logout", authHandler.Logout)
  119. mux.HandleFunc("GET /_cqr/login/login.psp", authHandler.LoginPSP)
  120. mux.HandleFunc("POST /_cqr/login/login.psp", authHandler.LoginPSP)
  121. // Authenticated Web AIM API endpoints
  122. // SessionInstance management - supports multiple auth methods (k, a, ts+sig_sha256)
  123. mux.Handle("GET /aim/startSession", authMiddleware.AuthenticateFlexible(
  124. authMiddleware.CORSMiddleware(
  125. http.HandlerFunc(sessionHandler.StartSession))))
  126. // End session - uses aimsid for auth, no k required
  127. mux.Handle("GET /aim/endSession", authMiddleware.AuthenticateFlexible(
  128. authMiddleware.CORSMiddleware(
  129. authMiddleware.RequireSession(sessionManager, sessionHandler.EndSession))))
  130. // Event fetching - uses aimsid for auth, no k required
  131. mux.Handle("GET /aim/fetchEvents", authMiddleware.AuthenticateFlexible(
  132. authMiddleware.CORSMiddleware(
  133. authMiddleware.RequireSession(sessionManager, eventsHandler.FetchEvents))))
  134. // Add temp buddy - uses aimsid for auth
  135. mux.Handle("GET /aim/addTempBuddy", authMiddleware.AuthenticateFlexible(
  136. authMiddleware.CORSMiddleware(
  137. authMiddleware.RequireSession(sessionManager, buddyListHandler.AddTempBuddy))))
  138. mux.Handle("GET /aim/removeTempBuddy", authMiddleware.AuthenticateFlexible(
  139. authMiddleware.CORSMiddleware(
  140. authMiddleware.RequireSession(sessionManager, buddyListHandler.RemoveTempBuddy))))
  141. aimStub := &handlers.AimStubHandler{Logger: logger}
  142. aimRoute := func(h http.HandlerFunc) http.Handler {
  143. return authMiddleware.AuthenticateFlexible(
  144. authMiddleware.CORSMiddleware(http.HandlerFunc(h)))
  145. }
  146. mux.Handle("GET /aim/setForwardDomain", aimRoute(aimStub.SetForwardDomain))
  147. mux.Handle("GET /aim/getData", aimRoute(aimStub.GetData))
  148. conversationStub := &handlers.ConversationStubHandler{
  149. SessionManager: sessionManager,
  150. Logger: logger,
  151. }
  152. mux.Handle("GET /conversation/update", aimRoute(conversationStub.Update))
  153. mux.Handle("GET /conversation/close", aimRoute(conversationStub.Close))
  154. mux.Handle("GET /imlog/markRead", aimRoute(conversationStub.MarkRead))
  155. mux.Handle("GET /imlog/fetchStoredIMs", authMiddleware.AuthenticateFlexible(
  156. authMiddleware.CORSMiddleware(
  157. authMiddleware.RequireSession(sessionManager, conversationStub.FetchStoredIMs))))
  158. // Presence and buddy list
  159. // GetPresence supports aimsid-based auth, so we use flexible auth
  160. mux.Handle("GET /presence/get", authMiddleware.AuthenticateFlexible(
  161. authMiddleware.CORSMiddleware(
  162. authMiddleware.RequireSession(sessionManager, presenceHandler.GetPresence))))
  163. buddyListRoute := func(h func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
  164. return authMiddleware.AuthenticateFlexible(
  165. authMiddleware.CORSMiddleware(
  166. authMiddleware.RequireSession(sessionManager, h)))
  167. }
  168. mux.Handle("GET /buddylist/addBuddy", buddyListRoute(buddyListHandler.AddBuddy))
  169. mux.Handle("GET /buddylist/addGroup", buddyListRoute(buddyListHandler.AddGroup))
  170. mux.Handle("GET /buddylist/removeBuddy", buddyListRoute(buddyListHandler.RemoveBuddy))
  171. mux.Handle("GET /buddylist/removeGroup", buddyListRoute(buddyListHandler.RemoveGroup))
  172. mux.Handle("GET /buddylist/renameGroup", buddyListRoute(buddyListHandler.RenameGroup))
  173. mux.Handle("GET /buddylist/moveBuddy", buddyListRoute(buddyListHandler.MoveBuddy))
  174. mux.Handle("GET /buddylist/setBuddyAttribute", buddyListRoute(buddyListHandler.SetBuddyAttribute))
  175. mux.Handle("GET /buddylist/setGroupAttribute", buddyListRoute(buddyListHandler.SetGroupAttribute))
  176. // sendIM supports aimsid-based auth, so we use flexible auth.
  177. // The Web AIM client POSTs the message body (non-IE browsers); IE uses GET.
  178. sendIMHandler := authMiddleware.AuthenticateFlexible(
  179. authMiddleware.CORSMiddleware(
  180. authMiddleware.RequireSession(sessionManager, messagingHandler.SendIM)))
  181. mux.Handle("GET /im/sendIM", sendIMHandler)
  182. mux.Handle("POST /im/sendIM", sendIMHandler)
  183. mux.Handle("GET /im/setTyping", authMiddleware.AuthenticateFlexible(
  184. authMiddleware.CORSMiddleware(
  185. authMiddleware.RequireSession(sessionManager, messagingHandler.SetTyping))))
  186. // Group chat (imserv). The client POSTs these like im/sendIM, so accept
  187. // both methods. MVP: create/join/leave; the rest of the imserv surface
  188. // (invite, getMembers, settings) is not yet implemented.
  189. imservRoute := func(h func(http.ResponseWriter, *http.Request, *state.WebAPISession)) http.Handler {
  190. return authMiddleware.AuthenticateFlexible(
  191. authMiddleware.CORSMiddleware(
  192. authMiddleware.RequireSession(sessionManager, h)))
  193. }
  194. mux.Handle("GET /imserv/create", imservRoute(imservHandler.Create))
  195. mux.Handle("POST /imserv/create", imservRoute(imservHandler.Create))
  196. mux.Handle("GET /imserv/join", imservRoute(imservHandler.Join))
  197. mux.Handle("POST /imserv/join", imservRoute(imservHandler.Join))
  198. mux.Handle("GET /imserv/delete", imservRoute(imservHandler.Leave))
  199. mux.Handle("POST /imserv/delete", imservRoute(imservHandler.Leave))
  200. mux.Handle("GET /imserv/invite", imservRoute(imservHandler.Invite))
  201. mux.Handle("POST /imserv/invite", imservRoute(imservHandler.Invite))
  202. mux.Handle("GET /imserv/reject", imservRoute(imservHandler.Reject))
  203. mux.Handle("POST /imserv/reject", imservRoute(imservHandler.Reject))
  204. mux.Handle("GET /imserv/getMembers", imservRoute(imservHandler.GetMembers))
  205. mux.Handle("POST /imserv/getMembers", imservRoute(imservHandler.GetMembers))
  206. mux.Handle("GET /imserv/getSettings", imservRoute(imservHandler.GetSettings))
  207. mux.Handle("POST /imserv/getSettings", imservRoute(imservHandler.GetSettings))
  208. mux.Handle("GET /imserv/getRecentActivity", imservRoute(imservHandler.GetRecentActivity))
  209. mux.Handle("POST /imserv/getRecentActivity", imservRoute(imservHandler.GetRecentActivity))
  210. // SetState only requires aimsid, no k parameter needed
  211. mux.Handle("GET /presence/setState", authMiddleware.AuthenticateFlexible(
  212. authMiddleware.CORSMiddleware(
  213. authMiddleware.RequireSession(sessionManager, presenceHandler.SetState))))
  214. // These presence endpoints support aimsid-based auth where k is not required
  215. mux.Handle("GET /presence/setStatus", authMiddleware.AuthenticateFlexible(
  216. authMiddleware.CORSMiddleware(
  217. authMiddleware.RequireSession(sessionManager, presenceHandler.SetStatus))))
  218. mux.Handle("GET /presence/setProfile", authMiddleware.AuthenticateFlexible(
  219. authMiddleware.CORSMiddleware(
  220. authMiddleware.RequireSession(sessionManager, presenceHandler.SetProfile))))
  221. mux.Handle("GET /presence/getProfile", authMiddleware.AuthenticateFlexible(
  222. authMiddleware.CORSMiddleware(
  223. authMiddleware.RequireSession(sessionManager, presenceHandler.GetProfile))))
  224. mux.HandleFunc("GET /presence/icon", presenceHandler.Icon)
  225. // Member directory search and self directory-info retrieval. Both use
  226. // aimsid-based auth, so we use flexible auth.
  227. mux.Handle("GET /memberDir/search", authMiddleware.AuthenticateFlexible(
  228. authMiddleware.CORSMiddleware(
  229. authMiddleware.RequireSession(sessionManager, memberDirHandler.Search))))
  230. mux.Handle("GET /memberDir/get", authMiddleware.AuthenticateFlexible(
  231. authMiddleware.CORSMiddleware(
  232. authMiddleware.RequireSession(sessionManager, memberDirHandler.Get))))
  233. mux.Handle("GET /memberDir/update", authMiddleware.AuthenticateFlexible(
  234. authMiddleware.CORSMiddleware(
  235. authMiddleware.RequireSession(sessionManager, memberDirHandler.Update))))
  236. // These endpoints support aimsid-based auth, so we use a flexible auth approach
  237. mux.Handle("GET /preference/set", authMiddleware.AuthenticateFlexible(
  238. authMiddleware.CORSMiddleware(
  239. authMiddleware.RequireSession(sessionManager, preferenceHandler.SetPreferences))))
  240. mux.Handle("GET /preference/get", authMiddleware.AuthenticateFlexible(
  241. authMiddleware.CORSMiddleware(
  242. authMiddleware.RequireSession(sessionManager, preferenceHandler.GetPreferences))))
  243. mux.Handle("GET /preference/setPermitDeny", authMiddleware.AuthenticateFlexible(
  244. authMiddleware.CORSMiddleware(
  245. authMiddleware.RequireSession(sessionManager, preferenceHandler.SetPermitDeny))))
  246. mux.Handle("GET /preference/getPermitDeny", authMiddleware.AuthenticateFlexible(
  247. authMiddleware.CORSMiddleware(
  248. authMiddleware.RequireSession(sessionManager, preferenceHandler.GetPermitDeny))))
  249. // OSCAR Bridge endpoint
  250. mux.Handle("GET /aim/startOSCARSession", authMiddleware.Authenticate(
  251. authMiddleware.CORSMiddleware(
  252. http.HandlerFunc(oscarBridgeHandler.StartOSCARSession))))
  253. // Expressions endpoint (for buddy icons, etc.).
  254. //
  255. // Unauthenticated, like /presence/icon: the buddyIcon URLs this serves are
  256. // published to the client and loaded as plain <img> sources, which carry
  257. // neither an aimsid nor an API key. Threading a session token through them
  258. // instead would leak it into the DOM and defeat caching, since these URLs
  259. // outlive the session that produced them. Buddy icons are public assets.
  260. expressionsHandler := handlers.NewExpressionsHandler(handler.IconSource, logger)
  261. mux.Handle("GET /expressions/get", authMiddleware.CORSMiddleware(
  262. http.HandlerFunc(expressionsHandler.Get)))
  263. // Web AIM calls lifestream/* on the API host (e.g. /lifestream/getUserDetails).
  264. lifestreamStub := &handlers.UserInfoStubHandler{Logger: logger}
  265. lifestreamRoute := func(h http.HandlerFunc) http.Handler {
  266. return authMiddleware.AuthenticateFlexible(
  267. authMiddleware.CORSMiddleware(http.HandlerFunc(h)))
  268. }
  269. // getUserDetails returns a minimal AIM identity. Every other lifestream/*
  270. // method is an unimplemented social-feed feature; the subtree catch-all
  271. // acknowledges them with an empty 200 so the client doesn't error.
  272. mux.Handle("GET /lifestream/getUserDetails", lifestreamRoute(lifestreamStub.GetUserDetails))
  273. mux.Handle("GET /lifestream/", lifestreamRoute(lifestreamStub.EmptyOK))
  274. // Unmatched paths (pattern "/" matches anything not covered by routes above).
  275. mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  276. logger.Debug("webapi 404", "method", r.Method, "path", r.URL.Path)
  277. handlers.SendError(w, http.StatusNotFound, "not found")
  278. })
  279. servers = append(servers, &http.Server{
  280. Addr: l,
  281. Handler: middleware.RequestLogger(logger, mux),
  282. })
  283. }
  284. sessionHandler.FnSessCfg = func(sess *state.Session) {
  285. sess.OnSessionClose(func() {
  286. ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
  287. defer cancel()
  288. if !shuttingDown(shutdownCtx) {
  289. if err := handler.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, sess.IdentScreenName()); err != nil {
  290. logger.ErrorContext(ctx, "error sending buddy departure notifications", "err", err.Error())
  291. }
  292. }
  293. // buddy list must be cleared before session is closed, otherwise
  294. // there will be a race condition that could cause the buddy list
  295. // be prematurely deleted.
  296. if err := handler.BuddyListRegistry.UnregisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
  297. logger.ErrorContext(ctx, "error removing buddy list entry", "err", err.Error())
  298. }
  299. handler.ChatSessionManager.RemoveUserFromAllChats(sess.IdentScreenName())
  300. handler.AuthService.Signout(ctx, sess)
  301. })
  302. }
  303. sessionHandler.FnSessInit = func(instance *state.SessionInstance) func() error {
  304. return func() error {
  305. // make buddy list visible to other users
  306. if err := handler.BuddyListRegistry.RegisterBuddyList(shutdownCtx, instance.IdentScreenName()); err != nil {
  307. return fmt.Errorf("unable to init buddy list: %w", err)
  308. }
  309. // restore warning level from last session
  310. if err := handler.RecalcWarning(shutdownCtx, instance); err != nil {
  311. return fmt.Errorf("failed to recalculate warning level: %w", err)
  312. }
  313. // periodically decay warning level
  314. go handler.LowerWarnLevel(shutdownCtx, instance)
  315. return nil
  316. }
  317. }
  318. sessionHandler.FnInstanceClose = func(instance *state.SessionInstance) func() {
  319. return func() {
  320. if shuttingDown(shutdownCtx) {
  321. return
  322. }
  323. if instance.Session().Invisible() {
  324. if err := handler.BuddyBroadcaster.BroadcastBuddyDeparted(shutdownCtx, instance.IdentScreenName()); err != nil {
  325. logger.ErrorContext(shutdownCtx, "error sending buddy departure notifications", "err", err.Error())
  326. }
  327. } else {
  328. if err := handler.BuddyBroadcaster.BroadcastBuddyArrived(shutdownCtx, instance.IdentScreenName(), instance.Session().TLVUserInfo()); err != nil {
  329. logger.ErrorContext(shutdownCtx, "error sending buddy arrival notifications", "err", err.Error())
  330. }
  331. }
  332. }
  333. }
  334. return &Server{
  335. servers: servers,
  336. logger: logger,
  337. sessionManager: sessionManager,
  338. shutdownCtx: shutdownCtx,
  339. shutdownCancel: shutdownCancel,
  340. }
  341. }
  342. // Server hosts an HTTP endpoint capable of handling AIM-style Kerberos
  343. // authentication. The messages are structured as SNACs transmitted over HTTP.
  344. //
  345. // shutdownCtx bounds the lifetime of the background session reaper: ListenAndServe
  346. // drives it, and Shutdown (or a failed listener) calls shutdownCancel to unwind.
  347. type Server struct {
  348. servers []*http.Server
  349. logger *slog.Logger
  350. sessionManager *state.WebAPISessionManager
  351. shutdownCtx context.Context
  352. shutdownCancel context.CancelFunc
  353. }
  354. func (s *Server) ListenAndServe() error {
  355. if len(s.servers) == 0 {
  356. s.logger.Debug("no webapi listeners defined")
  357. return nil
  358. }
  359. g, ctx := errgroup.WithContext(s.shutdownCtx)
  360. g.Go(func() error {
  361. s.sessionManager.Run(ctx)
  362. return nil
  363. })
  364. for _, server := range s.servers {
  365. g.Go(func() error {
  366. s.logger.Info("starting server", "addr", server.Addr)
  367. if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
  368. s.shutdownCancel()
  369. return fmt.Errorf("unable to start webapi server: %w", err)
  370. }
  371. return nil
  372. })
  373. }
  374. return g.Wait()
  375. }
  376. func (s *Server) Shutdown(ctx context.Context) error {
  377. s.logger.Debug("Initiating graceful shutdown...")
  378. s.shutdownCancel() // stop the session reaper so ListenAndServe's errgroup can drain
  379. var errs []error
  380. if err := s.sessionManager.Shutdown(ctx); err != nil {
  381. errs = append(errs, fmt.Errorf("draining webapi sessions: %w", err))
  382. }
  383. for _, srv := range s.servers {
  384. if err := srv.Shutdown(ctx); err != nil {
  385. errs = append(errs, fmt.Errorf("stopping webapi listener %s: %w", srv.Addr, err))
  386. }
  387. }
  388. if err := errors.Join(errs...); err != nil {
  389. s.logger.Error("shutdown incomplete", "err", err.Error())
  390. return err
  391. }
  392. s.logger.Info("shutdown complete")
  393. return nil
  394. }
  395. func shuttingDown(ctx context.Context) bool {
  396. select {
  397. case <-ctx.Done():
  398. // server is shutting down, don't send buddy notifications
  399. return true
  400. default:
  401. }
  402. return false
  403. }