aboutsummaryrefslogtreecommitdiff
path: root/backend/internal/server
diff options
context:
space:
mode:
Diffstat (limited to 'backend/internal/server')
-rw-r--r--backend/internal/server/api/ibd/creds/creds.go51
-rw-r--r--backend/internal/server/api/ibd/ibd50/ibd50.go27
-rw-r--r--backend/internal/server/api/ibd/scrape/scrape.go27
-rw-r--r--backend/internal/server/auth/callback/callback.go93
-rw-r--r--backend/internal/server/auth/login/login.go28
-rw-r--r--backend/internal/server/auth/user/user.go45
-rw-r--r--backend/internal/server/middleware/auth.go46
-rw-r--r--backend/internal/server/server.go130
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"))
+}