server.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. package webapi
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "net/http"
  9. "time"
  10. "golang.org/x/sync/errgroup"
  11. "github.com/mk6i/open-oscar-server/config"
  12. "github.com/mk6i/open-oscar-server/state"
  13. "github.com/mk6i/open-oscar-server/wire"
  14. )
  15. func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator APIKeyValidator, sessionManager *SessionManager) *Server {
  16. servers := make([]*http.Server, 0, len(listeners))
  17. authMiddleware := NewAuthMiddleware(apiKeyValidator, logger)
  18. rateLimiter := NewRateLimitMiddleware(handler.SNACRateLimits, logger)
  19. authHandler := &AuthHandler{
  20. AuthService: handler.AuthService,
  21. Logger: logger,
  22. }
  23. aimHandler := &AimHandler{
  24. SessionManager: sessionManager,
  25. AuthService: handler.AuthService,
  26. FeedbagService: handler.FeedbagService,
  27. ICBMService: handler.ICBMService,
  28. OServiceService: handler.OServiceService,
  29. BuddyListManager: handler.BuddyListManager,
  30. IconSource: handler.IconSource,
  31. BOSListener: handler.BOSListener,
  32. SNACRateLimits: handler.SNACRateLimits,
  33. Logger: logger,
  34. }
  35. presenceHandler := &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 := &BuddyListHandler{
  44. BuddyListManager: handler.BuddyListManager,
  45. Logger: logger,
  46. FeedbagService: handler.FeedbagService,
  47. }
  48. messagingHandler := &MessagingHandler{
  49. ICBMService: handler.ICBMService,
  50. LocateService: handler.LocateService,
  51. FeedbagService: handler.FeedbagService,
  52. Logger: logger,
  53. }
  54. preferenceHandler := &PreferenceHandler{
  55. SessionManager: sessionManager,
  56. FeedbagService: handler.FeedbagService,
  57. Logger: logger,
  58. }
  59. memberDirHandler := &MemberDirHandler{
  60. DirSearchService: handler.DirSearchService,
  61. LocateService: handler.LocateService,
  62. Logger: logger,
  63. }
  64. shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
  65. for _, l := range listeners {
  66. mux := http.NewServeMux()
  67. // CORSMiddleware wraps the auth layer rather than the other way around, so
  68. // that responses the auth layer rejects (400 missing key, 403 bad key, 429
  69. // rate limited) still carry Access-Control-Allow-Origin. A browser blocks a
  70. // cross-origin response without that header, and the Web AIM client reads
  71. // the resulting status-0 empty response as a CORS failure and permanently
  72. // switches its whole request pipeline to JSONP.
  73. //
  74. // oscarRoute charges the request against the rate class for (foodGroup,
  75. // subGroup) before the handler runs; sessionRoute and stubRoute reach no
  76. // food group and so are not rate limited here.
  77. oscarRoute := func(foodGroup uint16, subGroup uint16, h SessionHandlerFunc) http.Handler {
  78. return authMiddleware.CORSMiddleware(
  79. authMiddleware.AuthenticateFlexible(
  80. authMiddleware.RequireSession(sessionManager,
  81. rateLimiter.OSCAR(foodGroup, subGroup)(h))))
  82. }
  83. sessionRoute := func(h SessionHandlerFunc) http.Handler {
  84. return authMiddleware.CORSMiddleware(
  85. authMiddleware.AuthenticateFlexible(
  86. authMiddleware.RequireSession(sessionManager, h)))
  87. }
  88. stubRoute := func(h http.HandlerFunc) http.Handler {
  89. return authMiddleware.CORSMiddleware(
  90. authMiddleware.AuthenticateFlexible(h))
  91. }
  92. mux.Handle("GET /{$}", http.HandlerFunc(handler.GetHelloWorldHandler))
  93. // Unauthenticated and outside every middleware: Flash Player fetches the
  94. // policy before it has a session, and refuses to look at a redirect or an
  95. // error envelope.
  96. mux.Handle("GET /crossdomain.xml", &CrossDomainPolicyHandler{Logger: logger})
  97. mux.Handle("POST /auth/clientLogin", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  98. // Set CORS headers for public endpoint
  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. authHandler.ClientLogin(w, r)
  103. }))
  104. // Handle OPTIONS for CORS preflight
  105. mux.HandleFunc("OPTIONS /auth/clientLogin", func(w http.ResponseWriter, r *http.Request) {
  106. w.Header().Set("Access-Control-Allow-Origin", "*")
  107. w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
  108. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  109. w.WriteHeader(http.StatusNoContent)
  110. })
  111. mux.Handle("GET /auth/getToken", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  112. w.Header().Set("Access-Control-Allow-Origin", "*")
  113. w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
  114. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  115. authHandler.GetToken(w, r)
  116. }))
  117. mux.HandleFunc("OPTIONS /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
  118. w.Header().Set("Access-Control-Allow-Origin", "*")
  119. w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
  120. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  121. w.WriteHeader(http.StatusNoContent)
  122. })
  123. // No SSO cookie is involved, so this sits outside the session middleware.
  124. // Both methods, since clients differ on which they use.
  125. getInfo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  126. w.Header().Set("Access-Control-Allow-Origin", "*")
  127. w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
  128. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  129. authHandler.GetInfo(w, r)
  130. })
  131. mux.Handle("GET /auth/getInfo", getInfo)
  132. mux.Handle("POST /auth/getInfo", getInfo)
  133. mux.HandleFunc("OPTIONS /auth/getInfo", func(w http.ResponseWriter, r *http.Request) {
  134. w.Header().Set("Access-Control-Allow-Origin", "*")
  135. w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
  136. w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
  137. w.WriteHeader(http.StatusNoContent)
  138. })
  139. // Web AIM navigates the browser here on File > Logout; clear SSO state
  140. // and redirect to the login screen.
  141. mux.Handle("GET /auth/logout", http.HandlerFunc(authHandler.Logout))
  142. // Wrapped in CORS: besides the browser navigation that renders the form,
  143. // the client fetches this cross-origin for its client2Web SSO handoff, and
  144. // a response without Access-Control-Allow-Origin reaches it as an ioError.
  145. loginPSP := authMiddleware.CORSMiddleware(http.HandlerFunc(authHandler.LoginPSP))
  146. mux.Handle("GET /_cqr/login/login.psp", loginPSP)
  147. mux.Handle("POST /_cqr/login/login.psp", loginPSP)
  148. startSession := authMiddleware.CORSMiddleware(
  149. authMiddleware.AuthenticateFlexible(
  150. http.HandlerFunc(aimHandler.StartSession)))
  151. mux.Handle("GET /aim/startSession", startSession)
  152. mux.Handle("POST /aim/startSession", startSession)
  153. // End session - uses aimsid for auth, no k required
  154. mux.Handle("GET /aim/endSession", sessionRoute(aimHandler.EndSession))
  155. // Event fetching - uses aimsid for auth, no k required. This is the
  156. // long-poll loop the client runs continuously.
  157. mux.Handle("GET /aim/fetchEvents", sessionRoute(aimHandler.FetchEvents))
  158. // Temp buddies are session-local rather than feedbag-backed, but they
  159. // are the Web API's equivalent of the BUDDY temp buddy SNACs and are
  160. // charged as such.
  161. mux.Handle("GET /aim/addTempBuddy", oscarRoute(wire.Buddy, wire.BuddyAddTempBuddies, aimHandler.AddTempBuddy))
  162. mux.Handle("GET /aim/removeTempBuddy", oscarRoute(wire.Buddy, wire.BuddyDelTempBuddies, aimHandler.RemoveTempBuddy))
  163. mux.Handle("GET /aim/setForwardDomain", stubRoute(aimHandler.SetForwardDomain))
  164. mux.Handle("GET /aim/getData", stubRoute(aimHandler.GetData))
  165. mux.Handle("GET /aim/reportAction", stubRoute(aimHandler.ReportAction))
  166. // OSCAR Bridge endpoint. Hands off to a BOS session rather than reaching
  167. // a food group, so there is no OSCAR budget to charge.
  168. mux.Handle("GET /aim/startOSCARSession",
  169. authMiddleware.CORSMiddleware(
  170. authMiddleware.Authenticate(
  171. http.HandlerFunc(aimHandler.StartOSCARSession))))
  172. conversationStub := &ConversationStubHandler{
  173. Logger: logger,
  174. }
  175. mux.Handle("GET /conversation/update", stubRoute(conversationStub.Update))
  176. mux.Handle("GET /conversation/close", stubRoute(conversationStub.Close))
  177. mux.Handle("GET /imlog/markRead", stubRoute(conversationStub.MarkRead))
  178. mux.Handle("GET /imlog/fetchStoredIMs", sessionRoute(conversationStub.FetchStoredIMs))
  179. // Presence and buddy list
  180. // GetPresence supports aimsid-based auth, so we use flexible auth
  181. mux.Handle("GET /presence/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, presenceHandler.GetPresence))
  182. mux.Handle("GET /buddylist/addBuddy", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddBuddy))
  183. mux.Handle("GET /buddylist/addGroup", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddGroup))
  184. mux.Handle("GET /buddylist/removeBuddy", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveBuddy))
  185. mux.Handle("GET /buddylist/removeGroup", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveGroup))
  186. mux.Handle("GET /buddylist/renameGroup", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.RenameGroup))
  187. mux.Handle("GET /buddylist/moveBuddy", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.MoveBuddy))
  188. mux.Handle("GET /buddylist/setBuddyAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetBuddyAttribute))
  189. mux.Handle("GET /buddylist/setGroupAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetGroupAttribute))
  190. // sendIM supports aimsid-based auth, so we use flexible auth.
  191. // The Web AIM client POSTs the message body (non-IE browsers); IE uses GET.
  192. sendIMHandler := oscarRoute(wire.ICBM, wire.ICBMChannelMsgToHost, messagingHandler.SendIM)
  193. mux.Handle("GET /im/sendIM", sendIMHandler)
  194. mux.Handle("POST /im/sendIM", sendIMHandler)
  195. mux.Handle("GET /im/setTyping", oscarRoute(wire.ICBM, wire.ICBMClientEvent, messagingHandler.SetTyping))
  196. // SetState only requires aimsid, no k parameter needed
  197. mux.Handle("GET /presence/setState", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetState))
  198. // These presence endpoints support aimsid-based auth where k is not required
  199. mux.Handle("GET /presence/setStatus", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetStatus))
  200. mux.Handle("GET /presence/setProfile", oscarRoute(wire.Locate, wire.LocateSetInfo, presenceHandler.SetProfile))
  201. mux.Handle("GET /presence/getProfile", oscarRoute(wire.Locate, wire.LocateUserInfoQuery, presenceHandler.GetProfile))
  202. // Unauthenticated, like /expressions/get below: buddy icons load as plain
  203. // <img> sources that carry no aimsid.
  204. mux.Handle("GET /presence/icon", http.HandlerFunc(presenceHandler.Icon))
  205. mux.Handle("GET /memberDir/search", oscarRoute(wire.ODir, wire.ODirInfoQuery, memberDirHandler.Search))
  206. mux.Handle("GET /memberDir/get", oscarRoute(wire.Locate, wire.LocateGetDirInfo, memberDirHandler.Get))
  207. memberDirUpdate := oscarRoute(wire.Locate, wire.LocateSetDirInfo, memberDirHandler.Update)
  208. mux.Handle("GET /memberDir/update", memberDirUpdate)
  209. mux.Handle("POST /memberDir/update", memberDirUpdate)
  210. // These endpoints support aimsid-based auth, so we use a flexible auth approach
  211. mux.Handle("GET /preference/set", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPreferences))
  212. mux.Handle("GET /preference/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPreferences))
  213. mux.Handle("GET /preference/setPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPermitDeny))
  214. mux.Handle("GET /preference/getPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPermitDeny))
  215. // Expressions endpoint (for buddy icons, etc.).
  216. expressionsHandler := NewExpressionsHandler(
  217. handler.IconSource, handler.BARTService, handler.FeedbagService, logger)
  218. mux.Handle("GET /expressions/get",
  219. authMiddleware.CORSMiddleware(
  220. http.HandlerFunc(expressionsHandler.Get)))
  221. // WithBinaryBody: the body is the raw image, and a missed parameter lookup
  222. // would otherwise feed it to ParseForm and consume it.
  223. mux.Handle("POST /expressions/upload",
  224. WithBinaryBody(oscarRoute(wire.BART, wire.BARTUploadQuery, expressionsHandler.Upload)))
  225. // Web AIM calls lifestream/* on the API host (e.g. /lifestream/getUserDetails).
  226. lifestreamStub := &UserInfoStubHandler{Logger: logger}
  227. // getUserDetails returns a minimal AIM identity and getServices the service
  228. // list behind it. Every other lifestream/* method is an unimplemented
  229. // social-feed feature; the subtree catch-all acknowledges them with an
  230. // empty 200 so the client doesn't error.
  231. mux.Handle("GET /lifestream/getUserDetails", stubRoute(lifestreamStub.GetUserDetails))
  232. mux.Handle("GET /lifestream/getServices", stubRoute(lifestreamStub.GetServices))
  233. mux.Handle("GET /lifestream/heyGetNotifications", stubRoute(lifestreamStub.HeyGetNotifications))
  234. mux.Handle("GET /lifestream/", stubRoute(lifestreamStub.EmptyOK))
  235. // The client probes for a linked Google Talk account as soon as the
  236. // session comes up, and its callback dereferences response.data unless
  237. // the status says the service is absent.
  238. serviceStub := &ServiceStubHandler{Logger: logger}
  239. mux.Handle("GET /service/getAttributes", stubRoute(serviceStub.GetAttributes))
  240. mux.Handle("OPTIONS /", authMiddleware.CORSMiddleware(
  241. http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
  242. mux.Handle("/", authMiddleware.CORSMiddleware(
  243. http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  244. logger.Debug("webapi 404", "method", r.Method, "path", r.URL.Path)
  245. SendError(w, r, http.StatusNotFound, "not found")
  246. })))
  247. servers = append(servers, &http.Server{
  248. Addr: l,
  249. Handler: RequestLogger(logger, mux),
  250. })
  251. }
  252. aimHandler.FnSessCfg = func(sess *state.Session) {
  253. sess.OnSessionClose(func() {
  254. ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
  255. defer cancel()
  256. if !shuttingDown(shutdownCtx) {
  257. if err := handler.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, sess.IdentScreenName()); err != nil {
  258. logger.ErrorContext(ctx, "error sending buddy departure notifications", "err", err.Error())
  259. }
  260. }
  261. // buddy list must be cleared before session is closed, otherwise
  262. // there will be a race condition that could cause the buddy list
  263. // be prematurely deleted.
  264. if err := handler.BuddyListRegistry.UnregisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
  265. logger.ErrorContext(ctx, "error removing buddy list entry", "err", err.Error())
  266. }
  267. handler.ChatSessionManager.RemoveUserFromAllChats(sess.IdentScreenName())
  268. handler.AuthService.Signout(ctx, sess)
  269. })
  270. }
  271. aimHandler.FnSessInit = func(instance *state.SessionInstance) func() error {
  272. return func() error {
  273. // make buddy list visible to other users
  274. if err := handler.BuddyListRegistry.RegisterBuddyList(shutdownCtx, instance.IdentScreenName()); err != nil {
  275. return fmt.Errorf("unable to init buddy list: %w", err)
  276. }
  277. // restore warning level from last session
  278. if err := handler.RecalcWarning(shutdownCtx, instance); err != nil {
  279. return fmt.Errorf("failed to recalculate warning level: %w", err)
  280. }
  281. // periodically decay warning level
  282. go handler.LowerWarnLevel(shutdownCtx, instance)
  283. // broadcast rate limit transitions to every instance on the account
  284. go handler.OServiceService.MonitorRateLimits(shutdownCtx, instance.Session())
  285. return nil
  286. }
  287. }
  288. aimHandler.FnInstanceClose = func(instance *state.SessionInstance) func() {
  289. return func() {
  290. if shuttingDown(shutdownCtx) {
  291. return
  292. }
  293. if instance.Session().Invisible() {
  294. if err := handler.BuddyBroadcaster.BroadcastBuddyDeparted(shutdownCtx, instance.IdentScreenName()); err != nil {
  295. logger.ErrorContext(shutdownCtx, "error sending buddy departure notifications", "err", err.Error())
  296. }
  297. } else {
  298. if err := handler.BuddyBroadcaster.BroadcastBuddyArrived(shutdownCtx, instance.IdentScreenName(), instance.Session().TLVUserInfo()); err != nil {
  299. logger.ErrorContext(shutdownCtx, "error sending buddy arrival notifications", "err", err.Error())
  300. }
  301. }
  302. }
  303. }
  304. return &Server{
  305. servers: servers,
  306. logger: logger,
  307. sessionManager: sessionManager,
  308. shutdownCtx: shutdownCtx,
  309. shutdownCancel: shutdownCancel,
  310. }
  311. }
  312. // Server hosts an HTTP endpoint capable of handling AIM-style Kerberos
  313. // authentication. The messages are structured as SNACs transmitted over HTTP.
  314. //
  315. // shutdownCtx bounds the lifetime of the background session reaper: ListenAndServe
  316. // drives it, and Shutdown (or a failed listener) calls shutdownCancel to unwind.
  317. type Server struct {
  318. servers []*http.Server
  319. logger *slog.Logger
  320. sessionManager *SessionManager
  321. shutdownCtx context.Context
  322. shutdownCancel context.CancelFunc
  323. }
  324. func (s *Server) ListenAndServe() error {
  325. if len(s.servers) == 0 {
  326. s.logger.Debug("no webapi listeners defined")
  327. return nil
  328. }
  329. g, ctx := errgroup.WithContext(s.shutdownCtx)
  330. g.Go(func() error {
  331. s.sessionManager.Run(ctx)
  332. return nil
  333. })
  334. for _, server := range s.servers {
  335. g.Go(func() error {
  336. s.logger.Info("starting server", "addr", server.Addr)
  337. if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
  338. s.shutdownCancel()
  339. return fmt.Errorf("unable to start webapi server: %w", err)
  340. }
  341. return nil
  342. })
  343. }
  344. return g.Wait()
  345. }
  346. func (s *Server) Shutdown(ctx context.Context) error {
  347. s.logger.Debug("Initiating graceful shutdown...")
  348. s.shutdownCancel() // stop the session reaper so ListenAndServe's errgroup can drain
  349. var errs []error
  350. if err := s.sessionManager.Shutdown(ctx); err != nil {
  351. errs = append(errs, fmt.Errorf("draining webapi sessions: %w", err))
  352. }
  353. for _, srv := range s.servers {
  354. if err := srv.Shutdown(ctx); err != nil {
  355. errs = append(errs, fmt.Errorf("stopping webapi listener %s: %w", srv.Addr, err))
  356. }
  357. }
  358. if err := errors.Join(errs...); err != nil {
  359. s.logger.Error("shutdown incomplete", "err", err.Error())
  360. return err
  361. }
  362. s.logger.Info("shutdown complete")
  363. return nil
  364. }
  365. func shuttingDown(ctx context.Context) bool {
  366. select {
  367. case <-ctx.Done():
  368. // server is shutting down, don't send buddy notifications
  369. return true
  370. default:
  371. }
  372. return false
  373. }
  374. type Handler struct {
  375. AuthService AuthService
  376. BuddyListRegistry BuddyListRegistry
  377. ICBMService ICBMService
  378. LocateService LocateService
  379. Logger *slog.Logger
  380. OServiceService OServiceService
  381. BuddyBroadcaster BuddyBroadcaster
  382. BOSListener config.ListenerGroup
  383. BuddyListManager *BuddyListManager
  384. RecalcWarning func(ctx context.Context, instance *state.SessionInstance) error
  385. LowerWarnLevel func(ctx context.Context, instance *state.SessionInstance)
  386. ChatSessionManager ChatSessionManager
  387. FeedbagService FeedbagService
  388. DirSearchService DirSearchService
  389. IconSource BuddyIconSource
  390. BARTService BARTService
  391. SNACRateLimits wire.SNACRateLimits
  392. }
  393. func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {
  394. _, _ = fmt.Fprintf(w, "WebAPI Server Running\n")
  395. // Must return the same JSON envelope as other Web AIM APIs.
  396. h.Logger.Info("webapi root GET", "remote", r.RemoteAddr, "host", r.Host, "path", r.URL.Path)
  397. w.Header().Set("Content-Type", "application/json; charset=utf-8")
  398. resp := map[string]interface{}{
  399. "response": map[string]interface{}{
  400. "statusCode": 200,
  401. "statusText": "Ok",
  402. "data": map[string]interface{}{},
  403. },
  404. }
  405. _ = json.NewEncoder(w).Encode(resp)
  406. }