package rest import ( "fmt" "log/slog" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/gomodule/redigo/redis" "github.com/sasha-s/go-deadlock" uuid "github.com/satori/go.uuid" "github.com/shopspring/decimal" "quantex.com/qfixdpl/src/app" "quantex.com/qfixdpl/src/app/version" "quantex.com/qfixdpl/src/client/store" "quantex.com/qfixdpl/src/common/tracerr" "quantex.com/qfixdpl/src/domain" ) const ( ProdEnv = "prod" TokenExpireTime = 1200 * time.Second ) const ( responseKey = "responseKey" sessionTokenKey = "sessionTokenKey" ) type Controller struct { pool *redis.Pool userData app.UserDataProvider store *store.Store tradeProvider TradeProvider config Config notify domain.Notifier authMutex deadlock.Mutex } func newController(pool *redis.Pool, userData app.UserDataProvider, s *store.Store, tradeProvider TradeProvider, config Config, n domain.Notifier, ) *Controller { return &Controller{ pool: pool, userData: userData, store: s, tradeProvider: tradeProvider, config: config, notify: n, } } func (cont *Controller) GetUser(ctx *gin.Context) app.User { // This is set on the AuthRequired middleware response, ok := ctx.Get(responseKey) if !ok { // TODO log this issue ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "internal server error"}) } val, ok := response.([]uint8) if !ok { // TODO log this issue ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "internal server error"}) } return cont.userData.GetUserByEmail(string(val)) } // Login godoc // @Summary Login // @Description Authenticate a User using credentials // @Tags auth // @Accept json // @Produce json // @Param credentials body Credentials true "Authentication" // @Success 200 {object} Session // @Failure 400 {object} HTTPError // @Failure 401 {object} HTTPError // @Failure 404 {object} HTTPError // @Failure 500 {object} HTTPError // @Router /auth/login [post] func (cont *Controller) Login(ctx *gin.Context) { defer cont.authMutex.Unlock() cont.authMutex.Lock() setHeaders(ctx, cont.config) // Get the JSON body and decode into credentials var creds Credentials if err := ctx.ShouldBindJSON(&creds); err != nil { ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()}) return } // Get the expected Password from our in memory map expectedUser := cont.userData.GetUserByEmail(creds.Email) // If a Password exists for the given User // AND, if it is the same as the Password we received, the we can move ahead // if NOT, then we return an "Unauthorized" status if expectedUser.Email == "" || expectedUser.Password != creds.Password { ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Invalid credentials"}) return } // Create a new random session token sessionToken := uuid.NewV4().String() // Set the token in the cache, along with the User whom it represents // The token has an expiry time of 120 seconds conn := cont.pool.Get() defer func() { err := conn.Close() if err != nil { e := tracerr.Errorf("error closing connection: %w", err) slog.Error(e.Error()) } }() _, err := conn.Do("SETEX", sessionToken, TokenExpireTime, creds.Email) if err != nil { slog.Error(tracerr.Errorf("Error setting token in redis cache: %w", err).Error()) // If there is an error in setting the cache, return an internal server error ctx.JSON(http.StatusInternalServerError, HTTPError{Error: err.Error()}) return } // Finally, we set the client cookie for "session_token" as the session token we just generated // we also set an expiry time of 120 seconds, the same as the cache // Finally, we set the client cookie for "session_token" as the session token we just generated // we also set an expiry time of TokenExpireTime, the same as the cache cookie := &http.Cookie{ Name: "session_token", Value: sessionToken, HttpOnly: true, Path: "/", Secure: true, Expires: time.Now().Add(TokenExpireTime), } if version.Environment() == version.EnvironmentTypeDev { cookie.SameSite = http.SameSiteNoneMode } http.SetCookie(ctx.Writer, cookie) ctx.JSON(http.StatusOK, Session{Email: creds.Email}) } // Refresh godoc // @Summary Refresh the authorization token // @Description This endpoint must be called periodically to get a new token before the current one expires // @Tags auth // @Accept json // @Produce json // @Success 200 {object} Msg // @Failure 400 {object} HTTPError // @Failure 401 {object} HTTPError // @Failure 404 {object} HTTPError // @Failure 500 {object} HTTPError // @Router /auth/refresh [get] func (cont *Controller) Refresh(ctx *gin.Context) { defer cont.authMutex.Unlock() cont.authMutex.Lock() setHeaders(ctx, cont.config) // (BEGIN) The code until this point is the same as the first part of the `Welcome` route response, ok1 := ctx.Get(responseKey) if !ok1 { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) } // (END) The code uptil this point is the same as the first part of the `Welcome` route // Now, create a new session token for the current User newSessionToken := uuid.NewV4().String() conn := cont.pool.Get() defer func() { err := conn.Close() if err != nil { e := tracerr.Errorf("error closing connection: %w", err) slog.Error(e.Error()) } }() _, err := conn.Do("SETEX", newSessionToken, "120", fmt.Sprintf("%s", response)) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Set the new token as the Users `session_token` cookie cookie := &http.Cookie{ Name: "session_token", Value: newSessionToken, HttpOnly: true, Path: "/", Secure: true, Expires: time.Now().Add(TokenExpireTime), } if version.Environment() == version.EnvironmentTypeDev { cookie.SameSite = http.SameSiteNoneMode } http.SetCookie(ctx.Writer, cookie) ctx.JSON(http.StatusOK, Msg{Text: "Token updated"}) } // HealthCheck godoc // @Summary Health check // @Description Return service health // @Tags health // @Produce json // @Success 200 {object} map[string]string // @Router /health [get] func (cont *Controller) HealthCheck(ctx *gin.Context) { // ensure CORS and other headers are set consistently setHeaders(ctx, cont.config) status := struct { Status string `json:"status"` Build string `json:"build"` Sha string `json:"sha"` JwtAuthentications string `json:"jwtAuthentications,omitempty"` }{ Status: "ok", Build: version.BuildBranch(), Sha: version.BuildHash(), } // Only check JWT authentication if enabled if cont.config.EnableJWTAuth { status.JwtAuthentications = "ok" user, err := cont.store.UserByEmail("fede") if err != nil || user == nil { status.JwtAuthentications = "error" status.Status = "degraded" err = tracerr.Errorf("error fetching user: %w", err) slog.Error(err.Error()) ctx.JSON(http.StatusInternalServerError, status) return } } // return a minimal JSON health response ctx.JSON(http.StatusOK, status) } // revive:disable:cyclomatic // We need this complexity func setHeaders(ctx *gin.Context, config Config) { origin := ctx.Request.Header.Get("Origin") if allowed(origin, config) { ctx.Writer.Header().Set("Access-Control-Allow-Origin", origin) ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true") ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin,"+ "UserCache-Control, X-Requested-With") ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") } } func allowed(origin string, config Config) bool { if version.Environment() == version.EnvironmentTypeProd { return origin == "https://monitor.quantex.com.ar" } for _, o := range config.AllowedOrigins { if o == origin { return true } } return false } // GetTrades godoc // @Summary List active trades // @Description Returns all active List Trading trades // @Tags fix // @Produce json // @Success 200 {array} domain.ListTrade // @Router /qfixdpl/v1/trades [get] func (cont *Controller) GetTrades(ctx *gin.Context) { trades := cont.tradeProvider.GetTrades() ctx.JSON(http.StatusOK, trades) } // GetLogs godoc // @Summary Get raw FIX logs for a trade // @Description Returns raw FIX message logs for a given QuoteReqID // @Tags fix // @Produce json // @Param quoteReqID path string true "QuoteReqID" // @Success 200 {object} domain.Logs // @Router /qfixdpl/v1/trades/{quoteReqID}/logs [get] func (cont *Controller) GetLogs(ctx *gin.Context) { quoteReqID := ctx.Param("quoteReqID") logs, err := cont.store.GetLogsByQuoteReqID(quoteReqID) if err != nil { err = tracerr.Errorf("GetLogs: error fetching logs (quoteReqID=%s): %w", quoteReqID, err) slog.Error(err.Error()) ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "error fetching logs"}) return } ctx.JSON(http.StatusOK, logs) } // AllMessages godoc // @Summary List FIX application messages of the day after the caller's last-seen sequence // @Description Returns today's FIX application messages (no admin: heartbeats/logon/logout/etc.) with MsgSeqNum greater than the caller's last-seen sequence per direction. "In" is the last MsgSeqNum the caller received on the IN side; "Out" is the same for OUT. Pass 0 to receive everything on that side. Sorted by CreatedAt ascending. // @Tags fix // @Accept json // @Produce json // @Param body body AllMessagesRequest true "API key and last-seen MsgSeqNum per direction" // @Success 200 {array} domain.Message // @Failure 400 {object} HTTPError // @Failure 401 {object} HTTPError // @Router /qfixdpl/v1/messages [post] func (cont *Controller) AllMessages(ctx *gin.Context) { setHeaders(ctx, cont.config) var req AllMessagesRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()}) return } if !cont.checkServiceAPIKey(req.APIKey) { ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"}) return } ctx.JSON(http.StatusOK, cont.tradeProvider.GetAllMessages(req.In, req.Out)) } // checkServiceAPIKey returns true when the provided key matches the configured // service-to-service shared secret. Empty configured key is always rejected // to avoid open authentication when misconfigured. func (cont *Controller) checkServiceAPIKey(key string) bool { return cont.config.ServiceAPIKey != "" && key == cont.config.ServiceAPIKey } // GetPendingQuoteRequests godoc // @Summary List pending QuoteRequests // @Description Returns all QuoteRequests received from TW that have not been quoted yet by the dealer // @Tags fix // @Produce json // @Success 200 {array} domain.ListTrade // @Router /qfixdpl/v1/quote-requests [get] func (cont *Controller) GetPendingQuoteRequests(ctx *gin.Context) { pending := cont.tradeProvider.GetPendingQuoteRequests() ctx.JSON(http.StatusOK, pending) } // SendQuote godoc // @Summary Send a Quote for a pending QuoteRequest // @Description Builds and sends a Quote (35=S) to TW for an existing QuoteRequest at the given price // @Tags fix // @Accept json // @Produce json // @Param body body SendQuoteRequest true "Quote to send" // @Success 200 {object} Msg // @Failure 400 {object} HTTPError // @Failure 404 {object} HTTPError // @Failure 409 {object} HTTPError // @Failure 500 {object} HTTPError // @Router /qfixdpl/v1/quotes [post] func (cont *Controller) SendQuote(ctx *gin.Context) { setHeaders(ctx, cont.config) var req SendQuoteRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()}) return } if !cont.checkServiceAPIKey(req.APIKey) { ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"}) return } price, err := decimal.NewFromString(req.Price) if err != nil { ctx.JSON(http.StatusBadRequest, HTTPError{Error: "invalid price: " + err.Error()}) return } if err := cont.tradeProvider.SendQuote(req.QuoteReqID, price); err != nil { msg := err.Error() switch { case strings.Contains(msg, "not found"): ctx.JSON(http.StatusNotFound, HTTPError{Error: "quoteReqID not found"}) case strings.Contains(msg, "already sent"): ctx.JSON(http.StatusConflict, HTTPError{Error: "quote already sent for this quoteReqID"}) default: ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "failed to send quote"}) } return } ctx.JSON(http.StatusOK, Msg{Text: "Quote sent"}) } // CancelQuote godoc // @Summary Cancel a Quote for a QuoteRequest // @Description Builds and sends a QuoteCancel (35=Z) to TW for an existing QuoteRequest // @Tags fix // @Accept json // @Produce json // @Param body body CancelQuoteRequest true "Quote cancel request" // @Success 200 {object} Msg // @Failure 400 {object} HTTPError // @Failure 401 {object} HTTPError // @Failure 404 {object} HTTPError // @Failure 409 {object} HTTPError // @Failure 500 {object} HTTPError // @Router /qfixdpl/v1/quotes/cancel [post] func (cont *Controller) CancelQuote(ctx *gin.Context) { setHeaders(ctx, cont.config) var req CancelQuoteRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()}) return } if !cont.checkServiceAPIKey(req.APIKey) { ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Not allowed to perform this request"}) return } if err := cont.tradeProvider.CancelQuote(req.QuoteReqID, req.Text); err != nil { msg := err.Error() switch { case strings.Contains(msg, "not found"): ctx.JSON(http.StatusNotFound, HTTPError{Error: "quoteReqID not found"}) case strings.Contains(msg, "cannot respond"): ctx.JSON(http.StatusConflict, HTTPError{Error: "quote request cannot be cancelled"}) default: ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "failed to cancel quote"}) } return } ctx.JSON(http.StatusOK, Msg{Text: "Quote cancel sent"}) }