diff options
Diffstat (limited to 'backend/internal/server')
-rw-r--r-- | backend/internal/server/api/ibd/creds/creds.go | 51 | ||||
-rw-r--r-- | backend/internal/server/api/ibd/ibd50/ibd50.go | 27 | ||||
-rw-r--r-- | backend/internal/server/api/ibd/scrape/scrape.go | 27 | ||||
-rw-r--r-- | backend/internal/server/auth/callback/callback.go | 93 | ||||
-rw-r--r-- | backend/internal/server/auth/login/login.go | 28 | ||||
-rw-r--r-- | backend/internal/server/auth/user/user.go | 45 | ||||
-rw-r--r-- | backend/internal/server/middleware/auth.go | 46 | ||||
-rw-r--r-- | backend/internal/server/server.go | 130 |
8 files changed, 447 insertions, 0 deletions
diff --git a/backend/internal/server/api/ibd/creds/creds.go b/backend/internal/server/api/ibd/creds/creds.go new file mode 100644 index 0000000..a8a05ab --- /dev/null +++ b/backend/internal/server/api/ibd/creds/creds.go @@ -0,0 +1,51 @@ +package creds + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "ibd-trader/internal/database" +) + +func Handler( + logger *slog.Logger, + db database.UserStore, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var b body + err := json.NewDecoder(r.Body).Decode(&b) + if err != nil { + logger.Error("unable to decode request body", "error", err) + http.Error(w, "unable to decode request body", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // Get session from context + session, ok := ctx.Value("session").(*database.Session) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Add IBD creds to user + err = db.AddIBDCreds(ctx, session.Subject, b.Username, b.Password) + if err != nil { + logger.ErrorContext(ctx, "unable to add IBD creds", "error", err) + http.Error(w, "unable to add IBD creds", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + } +} + +type body struct { + Username string `json:"username"` + Password string `json:"password"` +} diff --git a/backend/internal/server/api/ibd/ibd50/ibd50.go b/backend/internal/server/api/ibd/ibd50/ibd50.go new file mode 100644 index 0000000..fc13bdf --- /dev/null +++ b/backend/internal/server/api/ibd/ibd50/ibd50.go @@ -0,0 +1,27 @@ +package ibd50 + +import ( + "encoding/json" + "log/slog" + "net/http" + + "ibd-trader/internal/ibd" +) + +func Handler( + logger *slog.Logger, + client *ibd.Client, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + list, err := client.GetIBD50(r.Context()) + if err != nil { + logger.Error("unable to get IBD50", "error", err) + http.Error(w, "unable to get IBD50", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(list) + } +} diff --git a/backend/internal/server/api/ibd/scrape/scrape.go b/backend/internal/server/api/ibd/scrape/scrape.go new file mode 100644 index 0000000..59ad0a7 --- /dev/null +++ b/backend/internal/server/api/ibd/scrape/scrape.go @@ -0,0 +1,27 @@ +package scrape + +import ( + "log/slog" + "net/http" + + "ibd-trader/internal/leader/manager/ibd/scrape" + + "github.com/redis/go-redis/v9" +) + +func Handler( + logger *slog.Logger, + client *redis.Client, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Publish to the scrape channel to force a scrape. + err := client.Publish(r.Context(), scrape.Channel, "").Err() + if err != nil { + logger.Error("failed to publish to scrape channel", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + } +} diff --git a/backend/internal/server/auth/callback/callback.go b/backend/internal/server/auth/callback/callback.go new file mode 100644 index 0000000..f0a3413 --- /dev/null +++ b/backend/internal/server/auth/callback/callback.go @@ -0,0 +1,93 @@ +package callback + +import ( + "context" + "log/slog" + "net/http" + "time" + + "ibd-trader/internal/auth" + "ibd-trader/internal/database" + "ibd-trader/internal/server/middleware" +) + +func Handler( + logger *slog.Logger, + userStore database.UserStore, + sessionStore database.SessionStore, + auth *auth.Authenticator, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Timeout callback operations after 10 seconds + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // Check state + state := r.URL.Query().Get("state") + if state == "" { + http.Error(w, "No state provided", http.StatusBadRequest) + return + } + + exists, err := sessionStore.CheckState(ctx, state) + if err != nil { + logger.ErrorContext(ctx, "Failed to check state", "error", err) + http.Error(w, "Failed to check state", http.StatusInternalServerError) + return + } + if !exists { + http.Error(w, "Invalid state", http.StatusBadRequest) + return + } + + // Exchange code for token + token, err := auth.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + logger.ErrorContext(ctx, "Failed to exchange code", "error", err) + http.Error(w, "Failed to exchange code", http.StatusUnauthorized) + return + } + + // Verify token + idToken, err := auth.VerifyIDToken(ctx, token) + if err != nil { + logger.ErrorContext(ctx, "Failed to verify ID token", "error", err) + http.Error(w, "Failed to verify ID token", http.StatusInternalServerError) + return + } + + // Add user to database + if err := userStore.AddUser(ctx, idToken.Subject); err != nil { + logger.ErrorContext(ctx, "Failed to add user", "error", err) + http.Error(w, "Failed to add user", http.StatusInternalServerError) + return + } + + // Create session + session, err := sessionStore.CreateSession(ctx, token, idToken) + if err != nil { + logger.ErrorContext(ctx, "Failed to create session", "error", err) + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + + // Set session cookie + http.SetCookie(w, &http.Cookie{ + Name: middleware.SessionCookie, + Value: session, + Path: "/", + Domain: "", + Expires: token.Expiry, + RawExpires: "", + MaxAge: 0, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Raw: "", + Unparsed: nil, + }) + + // Redirect + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + } +} diff --git a/backend/internal/server/auth/login/login.go b/backend/internal/server/auth/login/login.go new file mode 100644 index 0000000..102e3d4 --- /dev/null +++ b/backend/internal/server/auth/login/login.go @@ -0,0 +1,28 @@ +package login + +import ( + "context" + "log/slog" + "net/http" + "time" + + "ibd-trader/internal/auth" + "ibd-trader/internal/database" +) + +func Handler(logger *slog.Logger, store database.SessionStore, auth *auth.Authenticator) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Save state in session table w/o user id + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + state, err := store.CreateState(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to create state", "error", err) + http.Error(w, "Failed to create state", http.StatusInternalServerError) + return + } + + // Redirect to oauth provider + http.Redirect(w, r, auth.AuthCodeURL(state), http.StatusTemporaryRedirect) + } +} diff --git a/backend/internal/server/auth/user/user.go b/backend/internal/server/auth/user/user.go new file mode 100644 index 0000000..526329d --- /dev/null +++ b/backend/internal/server/auth/user/user.go @@ -0,0 +1,45 @@ +package user + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "ibd-trader/internal/auth" + "ibd-trader/internal/database" +) + +func Handler( + logger *slog.Logger, + auth *auth.Authenticator, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // Get session from context + session, ok := ctx.Value("session").(*database.Session) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Create token source + ts := auth.TokenSource(ctx, &session.OAuthToken) + + // Get user info + userInfo, err := auth.UserInfo(ctx, ts) + if err != nil { + logger.ErrorContext(ctx, "Failed to get user info", "error", err) + http.Error(w, "Failed to get user info", http.StatusInternalServerError) + return + } + + // Write user info to response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(userInfo) + } +} diff --git a/backend/internal/server/middleware/auth.go b/backend/internal/server/middleware/auth.go new file mode 100644 index 0000000..f01e4b9 --- /dev/null +++ b/backend/internal/server/middleware/auth.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "context" + "net/http" + "time" + + "ibd-trader/internal/database" +) + +const SessionCookie = "_session" + +func Auth(store database.SessionStore) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get session cookie + cookie, err := r.Cookie(SessionCookie) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Check session + session, err := store.GetSession(r.Context(), cookie.Value) + if err != nil { + http.Error(w, "Error getting session", http.StatusInternalServerError) + return + } + if session == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Check session expiry + if session.OAuthToken.Expiry.Before(time.Now()) { + http.Error(w, "Session expired", http.StatusUnauthorized) + return + } + + // Add session to context + ctx := context.WithValue(r.Context(), "session", session) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 0000000..7270b56 --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,130 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "ibd-trader/internal/auth" + "ibd-trader/internal/config" + "ibd-trader/internal/database" + "ibd-trader/internal/ibd" + "ibd-trader/internal/server/api/ibd/creds" + "ibd-trader/internal/server/api/ibd/ibd50" + "ibd-trader/internal/server/api/ibd/scrape" + "ibd-trader/internal/server/auth/callback" + "ibd-trader/internal/server/auth/login" + "ibd-trader/internal/server/auth/user" + middleware2 "ibd-trader/internal/server/middleware" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/redis/go-redis/v9" +) + +func StartServer( + ctx context.Context, + cfg *config.Config, + logger *slog.Logger, + db database.Database, + auth *auth.Authenticator, + client *ibd.Client, + rClient *redis.Client, +) error { + r := chi.NewRouter() + + r.Use(middleware.RealIP) + r.Use(middleware.RequestID) + r.Use(middleware.Recoverer) + r.Use(middleware.Heartbeat("/healthz")) + + _ = NewMainHandler(logger, db, r) + r.Route("/auth", func(r chi.Router) { + r.Get("/login", login.Handler(logger, db, auth)) + r.Get("/callback", callback.Handler(logger, db, db, auth)) + r.Route("/user", func(r chi.Router) { + r.Use(middleware2.Auth(db)) + r.Get("/", user.Handler(logger, auth)) + }) + }) + r.Route("/api", func(r chi.Router) { + r.Use(middleware.NoCache) + r.Use(middleware2.Auth(db)) + r.Route("/ibd", func(r chi.Router) { + r.Put("/creds", creds.Handler(logger, db)) + r.Get("/ibd50", ibd50.Handler(logger, client)) + r.Put("/scrape", scrape.Handler(logger, rClient)) + }) + }) + + logger.Info("Starting server", "port", cfg.Server.Port) + srv := &http.Server{ + Addr: fmt.Sprintf("0.0.0.0:%d", cfg.Server.Port), + Handler: r, + //ReadTimeout: 1 * time.Minute, + //WriteTimeout: 1 * time.Minute, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + } + + finishedCh := make(chan error) + go func() { + err := srv.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("Server failed", "error", err) + } + finishedCh <- err + close(finishedCh) + }() + + select { + case err := <-finishedCh: + // Server failed + return err + case <-ctx.Done(): + logger.Info("Shutting down server") + } + + // Shutdown server + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + logger.Error("Failed to shutdown server", "error", err) + return err + } + + // Wait for the server to finish + err := <-finishedCh + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +type MainHandler struct { + logger *slog.Logger + db database.Database +} + +func NewMainHandler(logger *slog.Logger, db database.Database, r *chi.Mux) *MainHandler { + h := &MainHandler{logger, db} + r.Get("/readyz", h.Ready) + + return h +} + +func (h *MainHandler) Ready(w http.ResponseWriter, r *http.Request) { + // Check we can ping DB + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + err := h.db.Ping(ctx) + if err != nil { + http.Error(w, "DB not ready", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} |