4
0

server.go 15 KB

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