package webapi
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"golang.org/x/sync/errgroup"
"github.com/mk6i/open-oscar-server/server/webapi/handlers"
"github.com/mk6i/open-oscar-server/server/webapi/middleware"
"github.com/mk6i/open-oscar-server/state"
"github.com/mk6i/open-oscar-server/wire"
)
func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator middleware.APIKeyValidator, sessionManager *state.WebAPISessionManager) *Server {
servers := make([]*http.Server, 0, len(listeners))
authMiddleware := middleware.NewAuthMiddleware(apiKeyValidator, logger)
rateLimiter := handlers.NewRateLimitMiddleware(handler.SNACRateLimits, logger)
authHandler := &handlers.AuthHandler{
AuthService: handler.AuthService,
CookieBaker: handler.CookieBaker,
Logger: logger,
}
sessionHandler := &handlers.SessionHandler{
SessionManager: sessionManager,
OSCARAuthService: handler.AuthService,
FeedbagService: handler.FeedbagService,
BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
IconSource: handler.IconSource,
Logger: logger,
OServiceService: handler.OServiceService,
SNACRateLimits: handler.SNACRateLimits,
}
eventsHandler := &handlers.EventsHandler{
SessionManager: sessionManager,
Logger: logger,
}
presenceHandler := &handlers.PresenceHandler{
SessionManager: sessionManager,
FeedbagService: handler.FeedbagService,
BuddyBroadcaster: handler.BuddyBroadcaster,
LocateService: handler.LocateService,
IconSource: handler.IconSource,
Logger: logger,
}
buddyListHandler := &handlers.BuddyListHandler{
BuddyListManager: handler.BuddyListManager.(*handlers.BuddyListManager),
Logger: logger,
FeedbagService: handler.FeedbagService,
}
messagingHandler := &handlers.MessagingHandler{
SessionManager: sessionManager,
ICBMService: handler.ICBMService,
LocateService: handler.LocateService,
FeedbagService: handler.FeedbagService,
Logger: logger,
}
preferenceHandler := &handlers.PreferenceHandler{
SessionManager: sessionManager,
FeedbagService: handler.FeedbagService,
Logger: logger,
}
memberDirHandler := &handlers.MemberDirHandler{
DirSearchService: handler.DirSearchService,
LocateService: handler.LocateService,
Logger: logger,
}
oscarBridgeHandler := &handlers.OSCARBridgeHandler{
SessionManager: sessionManager,
OSCARAuthService: handler.AuthService,
CookieBaker: handler.CookieBaker,
Config: handler.OSCARConfig,
Logger: logger,
}
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
for _, l := range listeners {
mux := http.NewServeMux()
// CORSMiddleware wraps the auth layer rather than the other way around, so
// that responses the auth layer rejects (400 missing key, 403 bad key, 429
// rate limited) still carry Access-Control-Allow-Origin. A browser blocks a
// cross-origin response without that header, and the Web AIM client reads
// the resulting status-0 empty response as a CORS failure and permanently
// switches its whole request pipeline to JSONP.
//
// oscarRoute charges the request against the rate class for (foodGroup,
// subGroup) before the handler runs; sessionRoute and stubRoute reach no
// food group and so are not rate limited here.
oscarRoute := func(foodGroup uint16, subGroup uint16, h handlers.SessionHandlerFunc) http.Handler {
return authMiddleware.CORSMiddleware(
authMiddleware.AuthenticateFlexible(
authMiddleware.RequireSession(sessionManager,
rateLimiter.OSCAR(foodGroup, subGroup)(h))))
}
sessionRoute := func(h handlers.SessionHandlerFunc) http.Handler {
return authMiddleware.CORSMiddleware(
authMiddleware.AuthenticateFlexible(
authMiddleware.RequireSession(sessionManager, h)))
}
stubRoute := func(h http.HandlerFunc) http.Handler {
return authMiddleware.CORSMiddleware(
authMiddleware.AuthenticateFlexible(h))
}
// Exact root only. Pattern "GET /" matches every GET path in Go 1.22+ (prefix /), which
// would steal /getAggregated and other lifestream URLs before stubs/404.
mux.Handle("GET /{$}", http.HandlerFunc(handler.GetHelloWorldHandler))
// Authentication endpoint (public - no API key required for user login)
// Using pattern with explicit method for Go 1.22+ routing.
mux.Handle("POST /auth/clientLogin", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set CORS headers for public endpoint
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
authHandler.ClientLogin(w, r)
}))
// Handle OPTIONS for CORS preflight
mux.HandleFunc("OPTIONS /auth/clientLogin", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusNoContent)
})
mux.Handle("GET /auth/getToken", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
authHandler.GetToken(w, r)
}))
mux.HandleFunc("OPTIONS /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusNoContent)
})
// Web AIM navigates the browser here on File > Logout; clear SSO state
// and redirect to the login screen.
mux.Handle("GET /auth/logout", http.HandlerFunc(authHandler.Logout))
mux.Handle("GET /_cqr/login/login.psp", http.HandlerFunc(authHandler.LoginPSP))
mux.Handle("POST /_cqr/login/login.psp", http.HandlerFunc(authHandler.LoginPSP))
// Authenticated Web AIM API endpoints
// SessionInstance management - supports multiple auth methods (k, a, ts+sig_sha256).
mux.Handle("GET /aim/startSession",
authMiddleware.CORSMiddleware(
authMiddleware.AuthenticateFlexible(
http.HandlerFunc(sessionHandler.StartSession))))
// End session - uses aimsid for auth, no k required
mux.Handle("GET /aim/endSession", sessionRoute(sessionHandler.EndSession))
// Event fetching - uses aimsid for auth, no k required. This is the
// long-poll loop the client runs continuously.
mux.Handle("GET /aim/fetchEvents", sessionRoute(eventsHandler.FetchEvents))
// Temp buddies are session-local rather than feedbag-backed, but they
// are the Web API's equivalent of the BUDDY temp buddy SNACs and are
// charged as such.
mux.Handle("GET /aim/addTempBuddy", oscarRoute(wire.Buddy, wire.BuddyAddTempBuddies, buddyListHandler.AddTempBuddy))
mux.Handle("GET /aim/removeTempBuddy", oscarRoute(wire.Buddy, wire.BuddyDelTempBuddies, buddyListHandler.RemoveTempBuddy))
aimStub := &handlers.AimStubHandler{Logger: logger}
mux.Handle("GET /aim/setForwardDomain", stubRoute(aimStub.SetForwardDomain))
mux.Handle("GET /aim/getData", stubRoute(aimStub.GetData))
mux.Handle("GET /aim/reportAction", stubRoute(aimStub.ReportAction))
conversationStub := &handlers.ConversationStubHandler{
SessionManager: sessionManager,
Logger: logger,
}
mux.Handle("GET /conversation/update", stubRoute(conversationStub.Update))
mux.Handle("GET /conversation/close", stubRoute(conversationStub.Close))
mux.Handle("GET /imlog/markRead", stubRoute(conversationStub.MarkRead))
mux.Handle("GET /imlog/fetchStoredIMs", sessionRoute(conversationStub.FetchStoredIMs))
// Presence and buddy list
// GetPresence supports aimsid-based auth, so we use flexible auth
mux.Handle("GET /presence/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, presenceHandler.GetPresence))
mux.Handle("GET /buddylist/addBuddy", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddBuddy))
mux.Handle("GET /buddylist/addGroup", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddGroup))
mux.Handle("GET /buddylist/removeBuddy", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveBuddy))
mux.Handle("GET /buddylist/removeGroup", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveGroup))
mux.Handle("GET /buddylist/renameGroup", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.RenameGroup))
mux.Handle("GET /buddylist/moveBuddy", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.MoveBuddy))
mux.Handle("GET /buddylist/setBuddyAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetBuddyAttribute))
mux.Handle("GET /buddylist/setGroupAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetGroupAttribute))
// sendIM supports aimsid-based auth, so we use flexible auth.
// The Web AIM client POSTs the message body (non-IE browsers); IE uses GET.
sendIMHandler := oscarRoute(wire.ICBM, wire.ICBMChannelMsgToHost, messagingHandler.SendIM)
mux.Handle("GET /im/sendIM", sendIMHandler)
mux.Handle("POST /im/sendIM", sendIMHandler)
mux.Handle("GET /im/setTyping", oscarRoute(wire.ICBM, wire.ICBMClientEvent, messagingHandler.SetTyping))
// SetState only requires aimsid, no k parameter needed
mux.Handle("GET /presence/setState", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetState))
// These presence endpoints support aimsid-based auth where k is not required
mux.Handle("GET /presence/setStatus", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetStatus))
mux.Handle("GET /presence/setProfile", oscarRoute(wire.Locate, wire.LocateSetInfo, presenceHandler.SetProfile))
mux.Handle("GET /presence/getProfile", oscarRoute(wire.Locate, wire.LocateUserInfoQuery, presenceHandler.GetProfile))
// Unauthenticated, like /expressions/get below: buddy icons load as plain
//
sources that carry no aimsid.
mux.Handle("GET /presence/icon", http.HandlerFunc(presenceHandler.Icon))
// Member directory search and self directory-info retrieval. Both use
// aimsid-based auth, so we use flexible auth.
mux.Handle("GET /memberDir/search", oscarRoute(wire.ODir, wire.ODirInfoQuery, memberDirHandler.Search))
mux.Handle("GET /memberDir/get", oscarRoute(wire.Locate, wire.LocateGetDirInfo, memberDirHandler.Get))
mux.Handle("GET /memberDir/update", oscarRoute(wire.Locate, wire.LocateSetDirInfo, memberDirHandler.Update))
// These endpoints support aimsid-based auth, so we use a flexible auth approach
mux.Handle("GET /preference/set", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPreferences))
mux.Handle("GET /preference/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPreferences))
mux.Handle("GET /preference/setPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPermitDeny))
mux.Handle("GET /preference/getPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPermitDeny))
// OSCAR Bridge endpoint. Hands off to a BOS session rather than reaching
// a food group, so there is no OSCAR budget to charge.
mux.Handle("GET /aim/startOSCARSession",
authMiddleware.CORSMiddleware(
authMiddleware.Authenticate(
http.HandlerFunc(oscarBridgeHandler.StartOSCARSession))))
// Expressions endpoint (for buddy icons, etc.).
//
// Unauthenticated, like /presence/icon: the buddyIcon URLs this serves are
// published to the client and loaded as plain
sources, which carry
// neither an aimsid nor an API key. Threading a session token through them
// instead would leak it into the DOM and defeat caching, since these URLs
// outlive the session that produced them. Buddy icons are public assets.
expressionsHandler := handlers.NewExpressionsHandler(handler.IconSource, logger)
mux.Handle("GET /expressions/get",
authMiddleware.CORSMiddleware(
http.HandlerFunc(expressionsHandler.Get)))
// Web AIM calls lifestream/* on the API host (e.g. /lifestream/getUserDetails).
lifestreamStub := &handlers.UserInfoStubHandler{Logger: logger}
// getUserDetails returns a minimal AIM identity. Every other lifestream/*
// method is an unimplemented social-feed feature; the subtree catch-all
// acknowledges them with an empty 200 so the client doesn't error.
mux.Handle("GET /lifestream/getUserDetails", stubRoute(lifestreamStub.GetUserDetails))
mux.Handle("GET /lifestream/", stubRoute(lifestreamStub.EmptyOK))
// Go 1.22 patterns are method-exact, so an OPTIONS preflight matches none of
// the "GET /x" routes above and would otherwise fall through to the 404
// handler, failing the preflight. CORSMiddleware answers OPTIONS with a 204
// and the appropriate headers before ever reaching the handler below.
mux.Handle("OPTIONS /", authMiddleware.CORSMiddleware(
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
// Unmatched paths (pattern "/" matches anything not covered by routes above).
//
// Wrapped in CORS: the client probes endpoints this server does not
// implement (/service/getAttributes, /metrics/sendIM), and a 404 without
// Access-Control-Allow-Origin is blocked by the browser rather than read as
// a 404, which latches the client into JSONP for the rest of the session.
mux.Handle("/", authMiddleware.CORSMiddleware(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Debug("webapi 404", "method", r.Method, "path", r.URL.Path)
handlers.SendError(w, r, http.StatusNotFound, "not found")
})))
servers = append(servers, &http.Server{
Addr: l,
Handler: middleware.RequestLogger(logger, mux),
})
}
sessionHandler.FnSessCfg = func(sess *state.Session) {
sess.OnSessionClose(func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if !shuttingDown(shutdownCtx) {
if err := handler.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, sess.IdentScreenName()); err != nil {
logger.ErrorContext(ctx, "error sending buddy departure notifications", "err", err.Error())
}
}
// buddy list must be cleared before session is closed, otherwise
// there will be a race condition that could cause the buddy list
// be prematurely deleted.
if err := handler.BuddyListRegistry.UnregisterBuddyList(ctx, sess.IdentScreenName()); err != nil {
logger.ErrorContext(ctx, "error removing buddy list entry", "err", err.Error())
}
handler.ChatSessionManager.RemoveUserFromAllChats(sess.IdentScreenName())
handler.AuthService.Signout(ctx, sess)
})
}
sessionHandler.FnSessInit = func(instance *state.SessionInstance) func() error {
return func() error {
// make buddy list visible to other users
if err := handler.BuddyListRegistry.RegisterBuddyList(shutdownCtx, instance.IdentScreenName()); err != nil {
return fmt.Errorf("unable to init buddy list: %w", err)
}
// restore warning level from last session
if err := handler.RecalcWarning(shutdownCtx, instance); err != nil {
return fmt.Errorf("failed to recalculate warning level: %w", err)
}
// periodically decay warning level
go handler.LowerWarnLevel(shutdownCtx, instance)
// broadcast rate limit transitions to every instance on the account
go handler.OServiceService.MonitorRateLimits(shutdownCtx, instance.Session())
return nil
}
}
sessionHandler.FnInstanceClose = func(instance *state.SessionInstance) func() {
return func() {
if shuttingDown(shutdownCtx) {
return
}
if instance.Session().Invisible() {
if err := handler.BuddyBroadcaster.BroadcastBuddyDeparted(shutdownCtx, instance.IdentScreenName()); err != nil {
logger.ErrorContext(shutdownCtx, "error sending buddy departure notifications", "err", err.Error())
}
} else {
if err := handler.BuddyBroadcaster.BroadcastBuddyArrived(shutdownCtx, instance.IdentScreenName(), instance.Session().TLVUserInfo()); err != nil {
logger.ErrorContext(shutdownCtx, "error sending buddy arrival notifications", "err", err.Error())
}
}
}
}
return &Server{
servers: servers,
logger: logger,
sessionManager: sessionManager,
shutdownCtx: shutdownCtx,
shutdownCancel: shutdownCancel,
}
}
// Server hosts an HTTP endpoint capable of handling AIM-style Kerberos
// authentication. The messages are structured as SNACs transmitted over HTTP.
//
// shutdownCtx bounds the lifetime of the background session reaper: ListenAndServe
// drives it, and Shutdown (or a failed listener) calls shutdownCancel to unwind.
type Server struct {
servers []*http.Server
logger *slog.Logger
sessionManager *state.WebAPISessionManager
shutdownCtx context.Context
shutdownCancel context.CancelFunc
}
func (s *Server) ListenAndServe() error {
if len(s.servers) == 0 {
s.logger.Debug("no webapi listeners defined")
return nil
}
g, ctx := errgroup.WithContext(s.shutdownCtx)
g.Go(func() error {
s.sessionManager.Run(ctx)
return nil
})
for _, server := range s.servers {
g.Go(func() error {
s.logger.Info("starting server", "addr", server.Addr)
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
s.shutdownCancel()
return fmt.Errorf("unable to start webapi server: %w", err)
}
return nil
})
}
return g.Wait()
}
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Debug("Initiating graceful shutdown...")
s.shutdownCancel() // stop the session reaper so ListenAndServe's errgroup can drain
var errs []error
if err := s.sessionManager.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("draining webapi sessions: %w", err))
}
for _, srv := range s.servers {
if err := srv.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("stopping webapi listener %s: %w", srv.Addr, err))
}
}
if err := errors.Join(errs...); err != nil {
s.logger.Error("shutdown incomplete", "err", err.Error())
return err
}
s.logger.Info("shutdown complete")
return nil
}
func shuttingDown(ctx context.Context) bool {
select {
case <-ctx.Done():
// server is shutting down, don't send buddy notifications
return true
default:
}
return false
}