From 5166f5c488ff56f2573a197fbc1c8dc3ec959c28 Mon Sep 17 00:00:00 2001 From: sukhman Date: Thu, 14 Aug 2025 05:50:34 +0530 Subject: [PATCH 1/3] Make JSON Responses compatible --- _examples/service/beast.toml | 2 +- _examples/web-php-mysql/beast.toml | 3 +- _examples/web-php/beast.toml | 2 +- _examples/xinetd-service/beast.toml | 3 +- api/info.go | 362 ++++++++++++++-------------- api/response.go | 58 +++-- api/router.go | 12 +- core/config/challenge.go | 7 +- core/database/challenges.go | 106 ++++++++ core/database/tag.go | 21 ++ core/database/user.go | 14 ++ core/manager/utils.go | 5 + templates/templates.go | 5 +- 13 files changed, 376 insertions(+), 224 deletions(-) diff --git a/_examples/service/beast.toml b/_examples/service/beast.toml index 18615782..e95a4a2d 100644 --- a/_examples/service/beast.toml +++ b/_examples/service/beast.toml @@ -8,7 +8,7 @@ name = "service" flag = "CTF{sample_flag}" type = "service" points = 100 -max_attempt_limit=10 +maxAttemptLimit = 10 [[challenge.metadata.hints]] text = "simple_hint_1" diff --git a/_examples/web-php-mysql/beast.toml b/_examples/web-php-mysql/beast.toml index adef61ef..f09b6e0e 100644 --- a/_examples/web-php-mysql/beast.toml +++ b/_examples/web-php-mysql/beast.toml @@ -8,7 +8,7 @@ name = "web-php-mysql" flag = "CTF{sample_flag}" type = "web:php:7.1:cli" points = 200 -max_attempt_limit=10 +maxAttemptLimit = 10 [[challenge.metadata.hints]] text = "simple_hint_1" @@ -19,7 +19,6 @@ text = "simple_hint_2" points = 20 - [challenge.env] apt_deps = ["gcc", "php*-mysql"] setup_scripts = ["setup.sh"] diff --git a/_examples/web-php/beast.toml b/_examples/web-php/beast.toml index b82a5d11..3caf7425 100644 --- a/_examples/web-php/beast.toml +++ b/_examples/web-php/beast.toml @@ -7,7 +7,7 @@ ssh_key = "ssh-rsa AAAAB3NzaC1y" name = "web-php" flag = "BACKDOOR{SAMPLE_WEB_FLAG}" type = "web:php:7.1:cli" -max_attempt_limit = 3 +maxAttemptLimit = 3 points = 200 [[challenge.metadata.hints]] diff --git a/_examples/xinetd-service/beast.toml b/_examples/xinetd-service/beast.toml index f0f113ca..f4c7bbf7 100644 --- a/_examples/xinetd-service/beast.toml +++ b/_examples/xinetd-service/beast.toml @@ -8,7 +8,7 @@ name = "xinetd-service" flag = "CTF{not_the_flag}" type = "service" points = 500 -max_attempt_limit=10 +maxAttemptLimit = 10 [[challenge.metadata.hints]] text = "simple_hint_1" @@ -21,7 +21,6 @@ points = 20 preReqs = ["simple", "web-php"] - [challenge.env] apt_deps = ["gcc", "socat"] setup_scripts = ["setup.sh"] diff --git a/api/info.go b/api/info.go index 1a3db5cd..8186831c 100644 --- a/api/info.go +++ b/api/info.go @@ -36,6 +36,7 @@ var ( // @Param Authorization header string true "Bearer" // @Success 200 {object} api.PortsInUseResp // @Router /api/info/ports/used [get] + func usedPortsInfoHandler(c *gin.Context) { c.JSON(http.StatusOK, PortsInUseResp{ MinPortValue: core.ALLOWED_MIN_PORT_VALUE, @@ -170,7 +171,7 @@ func challengeInfoHandler(c *gin.Context) { name := c.Param("name") if name == "" { c.JSON(http.StatusBadRequest, HTTPErrorResp{ - Error: fmt.Sprintf("Challenge name cannot be empty"), + Error: "Challenge name cannot be empty", }) return } @@ -183,10 +184,53 @@ func challengeInfoHandler(c *gin.Context) { return } - if len(challenges) > 0 { - challenge := challenges[0] + // Get user ID from token + authHeader := c.GetHeader("Authorization") + username, err := coreUtils.GetUser(authHeader) + if err != nil { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{ + Error: "No Token Provided", + }) + return + } - users, err := database.GetRelatedUsers(&challenge) + user, err := database.QueryFirstUserEntry("username", username) + if err != nil { + c.JSON(http.StatusUnauthorized, HTTPErrorResp{ + Error: "Unauthorized user", + }) + return + } + + if len(challenges) > 0 || challenges[0].Status == "Undeployed" { + challenge := challenges[0] + // users, err := database.GetRelatedUsers(&challenge) + // if err != nil { + // log.Error(err) + // c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + // Error: "DATABASE ERROR while processing the request.", + // }) + // return + // } + + // var challSolves int + // solveStatus := false + // challengeUser := make([]UserSolveResp, 0) + // for _, usr := range users { + // if usr.Role == core.USER_ROLES["contestant"] { + // userResp := UserSolveResp{ + // UserID: usr.ID, + // Username: usr.Username, + // SolvedAt: usr.CreatedAt, + // } + // if usr.ID == user.ID { + // solveStatus = true + // } + // challengeUser = append(challengeUser, userResp) + // challSolves++ + // } + // } + totalSolves, solveStatus, err := database.GetChallengeSolveInfo(challenge.ID, user.ID) if err != nil { log.Error(err) c.JSON(http.StatusInternalServerError, HTTPErrorResp{ @@ -194,26 +238,6 @@ func challengeInfoHandler(c *gin.Context) { }) return } - - challengePorts := make([]uint32, len(challenge.Ports)) - for index, port := range challenge.Ports { - challengePorts[index] = port.PortNo - } - - var challSolves int - challengeUser := make([]UserSolveResp, 0) - for _, user := range users { - if user.Role == core.USER_ROLES["contestant"] { - userResp := UserSolveResp{ - UserID: user.ID, - Username: user.Username, - SolvedAt: user.CreatedAt, - } - challengeUser = append(challengeUser, userResp) - challSolves++ - } - } - challengeTags := make([]string, len(challenge.Tags)) for index, tags := range challenge.Tags { @@ -236,73 +260,55 @@ func challengeInfoHandler(c *gin.Context) { } } - authHeader := c.GetHeader("Authorization") - - values := strings.Split(authHeader, " ") + // Get previous tries for the current user and challenge + previousTries, err := database.GetUserPreviousTries(user.ID, challenge.ID) + if err != nil { + log.Error(err) + previousTries = 0 - if len(values) < 2 || values[0] != "Bearer" { - c.JSON(http.StatusUnauthorized, HTTPPlainResp{ - Message: "No Token Provided", - }) - c.Abort() - return } - - autherr := auth.Authorize(values[1], core.ADMIN) - - if autherr != nil { - c.JSON(http.StatusOK, ChallengeInfoResp{ - Name: name, - ChallId: challenge.ID, - Category: challenge.Type, - CreatedAt: challenge.CreatedAt, - Tags: challengeTags, - Status: challenge.Status, - Ports: challengePorts, - Hints: hintInfos, - MaxAttemptLimit: challenge.MaxAttemptLimit, - Desc: challenge.Description, - Assets: strings.Split(challenge.Assets, core.DELIMITER), - AdditionalLinks: strings.Split(challenge.AdditionalLinks, core.DELIMITER), - Points: challenge.Points, - SolvesNumber: challSolves, - Solves: challengeUser, - DeployedLink: challenge.ServerDeployed, - }) + challMetadata := ChallengeMetadata{ + Name: challenge.Name, + ChallId: challenge.ID, + Tags: challengeTags, + CreatedAt: challenge.CreatedAt, + Points: challenge.Points, + SolvesNumber: totalSolves, + SolveStatus: solveStatus, + Difficulty: challenge.Difficulty, + PreRequisite: strings.Split(challenge.PreReqs, core.DELIMITER), + DeployedStatus: challenge.Status, + } + challengeInfo := Challenge{ + ChallengeMetadata: challMetadata, + Description: challenge.Description, + Hints: hintInfos, + Category: challenge.Type, + Assets: strings.Split(challenge.Assets, core.DELIMITER), + AdditionalLinks: strings.Split(challenge.AdditionalLinks, core.DELIMITER), + PreviousTries: previousTries, + MaxAttemptLimit: challenge.MaxAttemptLimit, + DeployedLink: challenge.ServerDeployed, + } + if user.Role == core.USER_ROLES["contestant"] { + c.JSON(http.StatusOK, challengeInfo) return } - c.JSON(http.StatusOK, ChallengeInfoResp{ - Name: name, - ChallId: challenge.ID, - Category: challenge.Type, - DynamicFlag: challenge.DynamicFlag, - Flag: challenge.Flag, - CreatedAt: challenge.CreatedAt, - Tags: challengeTags, - Status: challenge.Status, - Ports: challengePorts, - Hints: hintInfos, - MaxAttemptLimit: challenge.MaxAttemptLimit, - Desc: challenge.Description, - Assets: strings.Split(challenge.Assets, core.DELIMITER), - AdditionalLinks: strings.Split(challenge.AdditionalLinks, core.DELIMITER), - Points: challenge.Points, - SolvesNumber: challSolves, - Solves: challengeUser, - DeployedLink: challenge.ServerDeployed, + c.JSON(http.StatusOK, AdminChallenge{ + Challenge: challengeInfo, + DynamicFlag: challenge.DynamicFlag, + Flag: challenge.Flag, }) } else { c.JSON(http.StatusNotFound, HTTPErrorResp{ Error: "No challenge found with name: " + name, }) } - - return } -// Returns information about all challenges with and without filters -// @Summary Returns information about all challenges with and without filters. +// Returns metadata about all challenges with and without filters +// @Summary Returns metadata about all challenges with and without filters. // @Description Returns information about all the challenges present in the database with and without filters. // @Tags info // @Accept json @@ -314,7 +320,7 @@ func challengeInfoHandler(c *gin.Context) { // @Failure 400 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp // @Router /api/info/challenges [get] -func challengesInfoHandler(c *gin.Context) { +func challengesMetadataHandler(c *gin.Context) { filter := c.Query("filter") value := c.Query("value") @@ -359,51 +365,48 @@ func challengesInfoHandler(c *gin.Context) { } // If comp in ongoing if state == 1 || autherr == nil { - if value == "" || filter == "" { - challenges, err = database.QueryAllChallenges() - if err != nil { - c.JSON(http.StatusBadRequest, HTTPErrorResp{ - Error: err.Error(), - }) - return - } - if challenges == nil { - c.JSON(http.StatusOK, HTTPPlainResp{ - Message: "No challenges currently in the database", - }) - return - } - } - - if filter == "name" || filter == "author" || filter == "score" { - challenges, err = database.QueryChallengeEntries(filter, value) + if (filter == "name" || filter == "author" || filter == "score") && value != "" { + challenges, err = database.QueryChallengeEntriesMetadata(filter, value) if err != nil { c.JSON(http.StatusInternalServerError, HTTPErrorResp{ Error: "DATABASE ERROR while processing the request.", }) } - } - - if filter == "tag" { + } else if filter == "tag" && value != "" { tag := database.Tag{ TagName: value, } - challenges, err = database.QueryRelatedChallenges(&tag) + challenges, err = database.QueryRelatedChallengesMetadata(&tag) if err != nil { log.Error(err) c.JSON(http.StatusInternalServerError, HTTPErrorResp{ Error: "DATABASE ERROR while processing the request.", }) } + } else { + challenges, err = database.QueryAllChallengesMetadata() + if err != nil { + c.JSON(http.StatusBadRequest, HTTPErrorResp{ + Error: err.Error(), + }) + return + } + if challenges == nil { + c.JSON(http.StatusOK, HTTPPlainResp{ + Message: "No challenges currently in the database", + }) + return + } } - availableChallenges := make([]ChallengeInfoResp, len(challenges)) + availableChallenges := make([]ChallengeMetadata, len(challenges)) + authHeader := c.GetHeader("Authorization") // Get user ID from token - username, err := coreUtils.GetUser(c.GetHeader("Authorization")) + username, err := coreUtils.GetUser(authHeader) if err != nil { c.JSON(http.StatusUnauthorized, HTTPErrorResp{ - Error: "Unauthorized user", + Error: "No Token Provided", }) return } @@ -417,7 +420,10 @@ func challengesInfoHandler(c *gin.Context) { } for index, challenge := range challenges { - users, err := database.GetRelatedUsers(&challenge) + if challenge.Status == "Undeployed" && user.Role == core.USER_ROLES["contestant"] { + continue + } + totalSolves, solveStatus, err := database.GetChallengeSolveInfo(challenge.ID, user.ID) if err != nil { log.Error(err) c.JSON(http.StatusInternalServerError, HTTPErrorResp{ @@ -425,76 +431,23 @@ func challengesInfoHandler(c *gin.Context) { }) return } - - challengePorts := make([]uint32, len(challenge.Ports)) - for index, port := range challenge.Ports { - challengePorts[index] = port.PortNo - } - - var challSolves int - challengeUser := make([]UserSolveResp, 0) - - for _, user := range users { - if user.Role == core.USER_ROLES["contestant"] { - userResp := UserSolveResp{ - UserID: user.ID, - Username: user.Username, - SolvedAt: user.CreatedAt, - } - challengeUser = append(challengeUser, userResp) - challSolves++ - } - } - challengeTags := make([]string, len(challenge.Tags)) for index, tags := range challenge.Tags { challengeTags[index] = tags.TagName } - // Get hints for this challenge - hints, err := database.QueryHintsByChallengeID(challenge.ID) - if err != nil { - log.Error(err) - c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - Error: "DATABASE ERROR while processing the request.", - }) - return - } - - hintInfos := make([]HintInfo, len(hints)) - for i, hint := range hints { - hintInfos[i] = HintInfo{ - ID: hint.HintID, - Points: hint.Points, - } - } - // Get previous tries for the current user and challenge - previousTries, err := database.GetUserPreviousTries(user.ID, challenge.ID) - if err != nil { - log.Error(err) - previousTries = 0 - - } - - availableChallenges[index] = ChallengeInfoResp{ - Name: challenge.Name, - ChallId: challenge.ID, - Category: challenge.Type, - Tags: challengeTags, - CreatedAt: challenge.CreatedAt, - Status: challenge.Status, - Ports: challengePorts, - Hints: hintInfos, - MaxAttemptLimit: challenge.MaxAttemptLimit, - Desc: challenge.Description, - Points: challenge.Points, - Assets: strings.Split(challenge.Assets, core.DELIMITER), - AdditionalLinks: strings.Split(challenge.AdditionalLinks, core.DELIMITER), - SolvesNumber: challSolves, - Solves: challengeUser, - PreviousTries: previousTries, - DeployedLink: challenge.ServerDeployed, + availableChallenges[index] = ChallengeMetadata{ + Name: challenge.Name, + ChallId: challenge.ID, + Tags: challengeTags, + CreatedAt: challenge.CreatedAt, + Points: challenge.Points, + SolvesNumber: totalSolves, + SolveStatus: solveStatus, + Difficulty: challenge.Difficulty, + PreRequisite: strings.Split(challenge.PreReqs, core.DELIMITER), + DeployedStatus: challenge.Status, } } @@ -961,25 +914,18 @@ func competitionInfoHandler(c *gin.Context) { // @Failure 400 {object} api.HTTPErrorResp // @Router /api/admin/statistics [get] func tagHandler(c *gin.Context) { - challenges, _ := database.QueryAllChallenges() - var tags []string - for _, challenge := range challenges { - for _, tag := range challenge.Tags { - tags = append(tags, tag.TagName) - } - } - keys := make(map[string]bool) - uniqueTags := []string{} - for _, entry := range tags { - if _, value := keys[entry]; !value { - keys[entry] = true - uniqueTags = append(uniqueTags, entry) - } + // Optimized: Query unique tags directly from the database + tags, err := database.QueryAllUniqueTags() + if err != nil { + c.JSON(http.StatusInternalServerError, HTTPErrorResp{ + Error: "DATABASE ERROR while fetching tags.", + }) + return } + c.JSON(http.StatusOK, TagInfoResp{ - Tags: uniqueTags, + Tags: tags, }) - return } // @Tags info @@ -1312,3 +1258,49 @@ func unfreezeLeaderboardHandler(c *gin.Context) { Message: "User leaderboard unfrozen successfully", }) } + +// Fetch all the attempts on this challenge +// @Summary Get challenge attempts +// @Description Returns all user attempts for a given challenge. +// @Tags info +// @Accept json +// @Produce json +// @Param challenge_id path int true "Challenge ID" +// @Param Authorization header string true "Bearer" +// @Success 200 {array} api.UserSolveResp +// @Failure 400 {object} api.HTTPErrorResp +// @Failure 500 {object} api.HTTPErrorResp +// @Router /api/challenges/{challenge_id}/attempts [get] +func getChallengeAttempts(c *gin.Context) { + challengeIDStr := c.Param("challenge_id") + challengeID, err := strconv.ParseUint(challengeIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, HTTPPlainResp{ + Message: "Invalid challenge_id", + }) + return + } + + attempts, err := database.QueryChallAttempts(challengeID) + if err != nil { + log.Errorf("DATABASE ERROR while fetching challenge attempts: %s", err.Error()) + c.JSON(http.StatusInternalServerError, HTTPPlainResp{ + Message: "DATABASE ERROR while processing the request.", + }) + return + } + var NewUserSolveResp UserSolveResp + resp := make([]UserSolveResp, 0, len(attempts)) + for _, attempt := range attempts { + NewUserSolveResp = UserSolveResp{ + Id: attempt.Id, + Username: attempt.Username, + SolvedAt: attempt.SolvedAt, + Flag: attempt.Flag, + Correct: attempt.Correct, + } + resp = append(resp, NewUserSolveResp) + } + + c.JSON(http.StatusOK, resp) +} diff --git a/api/response.go b/api/response.go index d109495c..77dd5284 100644 --- a/api/response.go +++ b/api/response.go @@ -93,9 +93,11 @@ type ChallengeSolveResp struct { } type UserSolveResp struct { - UserID uint `json:"id" example:"5"` + Id uint `json:"id" example:"5"` Username string `json:"username" example:"fristonio"` SolvedAt time.Time `json:"solvedAt"` + Flag string `json:"flag" example:"flag{example_flag}"` + Correct bool `json:"correct" example:"true"` } type HintInfo struct { @@ -108,27 +110,37 @@ type HintResponse struct { Points uint `json:"points" example:"10"` } -type ChallengeInfoResp struct { - Name string `json:"name" example:"Web Challenge"` - ChallId uint `json:"id" example:"0"` - Category string `json:"category" example:"bare"` - Tags []string `json:"tags" example:"['pwn','misc']"` - Assets []string `json:"assets" example:"['image1.png', 'zippy.zip']"` - AdditionalLinks []string `json:"additionalLinks" example:"['http://link1.abc:8080','http://link2.abc:8081']"` - CreatedAt time.Time `json:"createdAt"` - Status string `json:"status" example:"deployed"` - MaxAttemptLimit int `json:"maxAttemptLimit" example:"5"` - PreReqs []string `json:"preReqs" example:"['web-php','simple']"` - Ports []uint32 `json:"ports" example:"[3001, 3002]"` - Hints []HintInfo `json:"hints"` - Desc string `json:"description" example:"A simple web challenge"` - Points uint `json:"points" example:"50"` - SolvesNumber int `json:"solvesNumber" example:"100"` - Solves []UserSolveResp `json:"solves"` - PreviousTries int `json:"previous_tries" example:"3"` - DynamicFlag bool `json:"dynamicFlag" example:"true"` - Flag string `json:"flag"` - DeployedLink string `json:"deployedLink" example:"beast.sdslabs.co"` +type ChallengeMetadata struct { + ChallId uint `json:"id" example:"0"` + Name string `json:"name" example:"Web Challenge"` + Tags []string `json:"tags" example:"['pwn','misc']"` + Points uint `json:"points" example:"50"` + Difficulty string `json:"difficulty" example:"easy"` // e.g., "easy", "medium", "hard" + SolvesNumber uint16 `json:"solvesNumber" example:"100"` + SolveStatus bool `json:"solveStatus" example:"True"` // e.g., True: "solved", False: "unsolved" + CreatedAt time.Time `json:"createdAt"` + DeployedStatus string `json:"deployedStatus" example:"deployed"` + PreRequisite []string `json:"preRequisite" example:"['chall1', chall2]"` +} + +type Challenge struct { + ChallengeMetadata + + Description string `json:"description" example:"A simple web challenge"` + Hints []HintInfo `json:"hints"` + Category string `json:"category" example:"web"` + Assets []string `json:"assets" example:"['image1.png', 'zippy.zip']"` + AdditionalLinks []string `json:"additionalLinks" example:"['http://link1.abc:8080','http://link2.abc:8081']"` + PreviousTries int `json:"previousTries" example:"3"` + MaxAttemptLimit int `json:"maxAttemptLimit" example:"5"` + DeployedLink string `json:"deployedLink" example:"beast.sdslabs.co or ip:port"` +} + +// ChallengeDetails is an extended struct for admin use, containing additional fields. +type AdminChallenge struct { + Challenge + DynamicFlag bool `json:"dynamicFlag" example:"true"` + Flag string `json:"flag"` } type ChallengePreviewResp struct { @@ -138,7 +150,7 @@ type ChallengePreviewResp struct { Assets []string `json:"assets" example:"['image1.png', 'zippy.zip']"` AdditionalLinks []string `json:"additionalLinks" example:"['http://link1.abc:8080','http://link2.abc:8081']"` MaxAttemptLimit int `json:"maxAttemptLimit" example:"5"` - PreReqs []string `json:"preReqs" example:"['web-php','simple']"` + PreReqs []string `json:"preRequisite" example:"['web-php','simple']"` Ports []uint32 `json:"ports" example:"[3001, 3002]"` Desc string `json:"description" example:"A simple web challenge"` Points uint `json:"points" example:"50"` diff --git a/api/router.go b/api/router.go index 559cf13d..7f3fe129 100644 --- a/api/router.go +++ b/api/router.go @@ -36,8 +36,8 @@ func initGinRouter() *gin.Engine { authGroup.POST("/login", login) authGroup.POST("/reset-password", authorize, resetPasswordHandler) authGroup.POST("/send-otp", sendOTPHandler) - authGroup.POST("/send-otp-forget", sendOTPForForgetHandler) authGroup.POST("/verify-otp", verifyOTPHandler) + authGroup.POST("/send-otp-forget", sendOTPForForgetHandler) authGroup.POST("/verify-otp-forget", verifyOTPForForgetHandler) } @@ -65,6 +65,7 @@ func initGinRouter() *gin.Engine { manageGroup.POST("/schedule/:action", manageScheduledAction) manageGroup.POST("/challenge/upload", manageUploadHandler) manageGroup.POST("/challenge/validateflag", validateFlagHandler) + manageGroup.GET("/logs", challengeLogsHandler) } // Status route group @@ -79,10 +80,9 @@ func initGinRouter() *gin.Engine { infoGroup := apiGroup.Group("/info") { infoGroup.GET("/challenge/:name", challengeInfoHandler) - infoGroup.GET("/challenges", challengesInfoHandler) - infoGroup.GET("/images/available", availableImagesHandler) - infoGroup.GET("/ports/used", usedPortsInfoHandler) - infoGroup.GET("/logs", challengeLogsHandler) + infoGroup.GET("/challenges", challengesMetadataHandler) + // infoGroup.GET("/images/available", availableImagesHandler) + // infoGroup.GET("/ports/used", usedPortsInfoHandler) infoGroup.GET("/user/:username", userInfoHandler) infoGroup.GET("/users", getAllUsersInfoHandler) infoGroup.GET("/leaderboard", leaderboardHandler) @@ -127,6 +127,8 @@ func initGinRouter() *gin.Engine { adminPanelGroup.GET("/leaderboard", adminLeaderboardHandler) adminPanelGroup.POST("/freezeLeaderboard", freezeLeaderboardHandler) adminPanelGroup.POST("/unfreezeLeaderboard", unfreezeLeaderboardHandler) + adminPanelGroup.GET("/challenges/:challenge_id/attempts", getChallengeAttempts) + } } diff --git a/core/config/challenge.go b/core/config/challenge.go index 1fcfe113..33c93ee4 100644 --- a/core/config/challenge.go +++ b/core/config/challenge.go @@ -140,14 +140,15 @@ type ChallengeMetadata struct { Text string `toml:"text"` Points uint `toml:"points"` } `toml:"hints"` - MaxAttemptLimit int `toml:"max_attempt_limit"` + MaxAttemptLimit int `toml:"maxAttemptLimit"` PreReqs []string `toml:"preReqs"` - DynamicFlag bool `toml:"dynamic_flag"` + DynamicFlag bool `toml:"dynamicFlag"` Points uint `toml:"points"` MaxPoints uint `toml:"maxPoints"` MinPoints uint `toml:"minPoints"` Assets []string `toml:"assets"` AdditionalLinks []string `toml:"additionalLinks"` + Difficulty string `toml:"difficulty"` } // In this validation returned boolean value represents if the challenge type is @@ -430,7 +431,7 @@ func (config *ChallengeEnv) ValidateRequiredFields(challType string, challdir st } // Run command is only a required value in case of bare challenge types. - if config.RunCmd == "" && config.Entrypoint == "" && config.DockerCtx=="" && challType == core.BARE_CHALLENGE_TYPE_NAME { + if config.RunCmd == "" && config.Entrypoint == "" && config.DockerCtx == "" && challType == core.BARE_CHALLENGE_TYPE_NAME { return fmt.Errorf("a valid run_cmd should be provided for the challenge environment") } diff --git a/core/database/challenges.go b/core/database/challenges.go index a2251fd9..dbf79c94 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -46,6 +46,7 @@ type Challenge struct { DynamicFlag bool `gorm:"not null;default:false"` Flag string `gorm:"type:text"` Type string `gorm:"type:varchar(64)"` + Difficulty string `gorm:"not null;default:'medium'"` MaxAttemptLimit int `gorm:"default:-1"` PreReqs string `gorm:"type:text"` Sidecar string `gorm:"type:varchar(64)"` @@ -79,6 +80,14 @@ type UserChallenges struct { Flag string `gorm:"type:text"` } +type ChallengeAttempt struct { + Id uint `json:"id"` + Username string `json:"username"` + SolvedAt time.Time `json:"solvedAt"` + Flag string `json:"flag"` + Correct bool `json:"correct"` +} + // The `DynamicFlags` table has the following columns // name // flag @@ -129,6 +138,23 @@ func QueryAllChallenges() ([]Challenge, error) { return challenges, tx.Error } +func QueryAllChallengesMetadata() ([]Challenge, error) { + var challenges []Challenge + + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Select("id", "name", "created_at", "points", "difficulty"). + Preload("Tags"). + Find(&challenges) + + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + + return challenges, tx.Error +} + // Queries all the challenges entries where the column represented by key // have the value in value. func QueryChallengeEntries(key string, value string) ([]Challenge, error) { @@ -151,6 +177,32 @@ func QueryChallengeEntries(key string, value string) ([]Challenge, error) { return challenges, nil } +// QueryChallengeEntriesMetadata returns only selected columns: Name, ID, Tags, CreatedAt, Points, Difficulty +func QueryChallengeEntriesMetadata(key string, value string) ([]Challenge, error) { + queryKey := fmt.Sprintf("%s = ?", key) + + var challenges []Challenge + + DBMux.Lock() + defer DBMux.Unlock() + + // Only select the required columns, but preload Tags for tag names + tx := Db.Select("id", "name", "created_at", "points", "difficulty"). + Preload("Tags"). + Where(queryKey, value). + Find(&challenges) + + if errors.Is(tx.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + + if tx.Error != nil { + return challenges, tx.Error + } + + return challenges, nil +} + // Queries all the challenges entries where the column matches func QueryChallengeEntriesMap(whereMap map[string]interface{}) ([]Challenge, error) { @@ -324,6 +376,39 @@ func GetRelatedUsers(challenge *Challenge) ([]User, error) { return users, nil } +// Function fetches total number of solves for a challenge and +// Did the user solve this challenge +func GetChallengeSolveInfo(challengeID uint, userID uint) (uint16, bool, error) { + type result struct { + TotalSolves int64 + UserSolved bool + } + var res result + + DBMux.Lock() + defer DBMux.Unlock() + + err := Db.Raw(` + SELECT + COUNT(*) FILTER (WHERE uc.solved = true) AS total_solves, + EXISTS ( + SELECT 1 + FROM user_challenges uc2 + JOIN users u2 ON u2.id = uc2.user_id + WHERE uc2.challenge_id = ? AND uc2.user_id = ? AND uc2.solved = true AND u2.role = 'contestant' + ) AS user_solved + FROM user_challenges uc + JOIN users u ON u.id = uc.user_id + WHERE uc.challenge_id = ? AND u.role = 'contestant' + `, challengeID, userID, challengeID).Scan(&res).Error + + if err != nil { + return 0, false, err + } + + return uint16(res.TotalSolves), res.UserSolved, nil +} + func DeleteChallengeEntry(challenge *Challenge) error { DBMux.Lock() defer DBMux.Unlock() @@ -561,3 +646,24 @@ func DeleteAllUserChallenges(challengeID uint) error { return tx.Commit().Error } + +// QueryChallAttempts queries all attempts for a given challenge ID +func QueryChallAttempts(chall_id uint64) ([]ChallengeAttempt, error) { + var attempts []ChallengeAttempt + + DBMux.Lock() + defer DBMux.Unlock() + + err := Db.Table("user_challenges"). + Select("user_challenges.id as id, users.username as username, user_challenges.created_at as solved_at, user_challenges.flag as flag, user_challenges.solved as correct"). + Joins("JOIN users ON users.id = user_challenges.user_id"). + Where("user_challenges.challenge_id = ?", chall_id). + Order("user_challenges.created_at ASC"). + Scan(&attempts).Error + + if err != nil { + return nil, err + } + + return attempts, nil +} diff --git a/core/database/tag.go b/core/database/tag.go index ed04d647..0408a1c3 100644 --- a/core/database/tag.go +++ b/core/database/tag.go @@ -50,6 +50,27 @@ func QueryRelatedChallenges(tag *Tag) ([]Challenge, error) { return challenges, nil } +// Query Related Challenges Metadata +func QueryRelatedChallengesMetadata(tag *Tag) ([]Challenge, error) { + var challenges []Challenge + var tagName Tag + + DBMux.Lock() + defer DBMux.Unlock() + + Db.Where(&Tag{TagName: tag.TagName}).First(&tagName) + + if err := Db.Model(&tagName). + Select("id", "name", "created_at", "points", "difficulty"). + Preload("Tags"). + Association("Challenges"). + Find(&challenges); err != nil { + return challenges, err + } + + return challenges, nil +} + // Query using map func QueryTags(whereMap map[string]interface{}) ([]*Tag, error) { var tags []*Tag diff --git a/core/database/user.go b/core/database/user.go index e2d78a4d..410c533d 100644 --- a/core/database/user.go +++ b/core/database/user.go @@ -414,3 +414,17 @@ func GetUserCount() (int64, error) { } return count, tx.Error } + +func QueryAllUniqueTags() ([]string, error) { + var tags []string + DBMux.Lock() + defer DBMux.Unlock() + + tx := Db.Model(&Challenge{}).Distinct().Pluck("tag", &tags) + if tx.Error != nil { + return nil, tx.Error + } + return tags, nil +} + + diff --git a/core/manager/utils.go b/core/manager/utils.go index a31e878b..83ff5f17 100644 --- a/core/manager/utils.go +++ b/core/manager/utils.go @@ -493,6 +493,10 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B availableServer, _ := remoteManager.ServerQueue.GetNextAvailableInstance() availableServerHostname = availableServer.Host } + if config.Challenge.Metadata.Difficulty == "" { + log.Debug("Setting difficulty to default(medium)") + config.Challenge.Metadata.Difficulty = "medium" + } *challEntry = database.Challenge{ Name: config.Challenge.Metadata.Name, AuthorID: userEntry.ID, @@ -512,6 +516,7 @@ func UpdateOrCreateChallengeDbEntry(challEntry *database.Challenge, config cfg.B Points: config.Challenge.Metadata.Points, MinPoints: config.Challenge.Metadata.MinPoints, MaxPoints: config.Challenge.Metadata.MaxPoints, + Difficulty: config.Challenge.Metadata.Difficulty, ServerDeployed: availableServerHostname, } diff --git a/templates/templates.go b/templates/templates.go index f897ddac..6c6a9fe9 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -9,9 +9,10 @@ ssh_key = {{.Author.SSHKey}} # Required: Public SSH key for [challenge.metadata] name = {{.Challenge.Metadata.Name}} # Required: Name of the challenge, should be same as directory. type = {{.Challenge.Metadata.Type}} # Required: Type of challenge -> [web::: static service] -dynamic_flag = {{.Challenge.Metadata.DynamicFlag}} # Required: Dynamic flag or not -> [true/false] -flag = {{.Challenge.Metadata.Flag}} # Challenge Flag if dynamic_flag is false +dynamicFlag = {{.Challenge.Metadata.DynamicFlag}} # Required: Dynamic flag or not -> [true/false] +flag = {{.Challenge.Metadata.Flag}} # Challenge Flag if dynamicFlag is false sidecar = {{.Challenge.Metadata.Sidecar}} # Specify helper sidecar container for example mysql +difficulty = {{.Challenge.Metadata.Difficulty}} # Specify the difficulty of the challenge [challenge.env] apt_deps = {{.Challenge.Env.AptDeps}} # Custom apt-dependencies for challenge From 03d51041dae7a2fe13308c3afcdddf1c16576a1d Mon Sep 17 00:00:00 2001 From: sukhman Date: Sat, 16 Aug 2025 05:13:08 +0530 Subject: [PATCH 2/3] Add graph support to beast backend --- api/admin.go | 1 + api/info.go | 38 ++++++- api/response.go | 9 +- api/router.go | 3 +- core/constants.go | 1 + core/database/challenges.go | 218 ++++++++++++++++++++++++++++++++++-- core/utils/cleanup.go | 1 + 7 files changed, 255 insertions(+), 16 deletions(-) diff --git a/api/admin.go b/api/admin.go index 2435864b..4d5a248c 100644 --- a/api/admin.go +++ b/api/admin.go @@ -70,6 +70,7 @@ func userActionHandler(c *gin.Context) { } if val, _:= database.IsFrozenScoreSet(); !val { leaderboardStale = true + graphCacheStale = true; } c.JSON(http.StatusOK, HTTPPlainResp{ Message: fmt.Sprintf("Successfully %sned the user with id %s", action, userId), diff --git a/api/info.go b/api/info.go index 8186831c..cc6c4415 100644 --- a/api/info.go +++ b/api/info.go @@ -25,6 +25,8 @@ var ( leaderboardStale = true adminLeaderboardCache []UserResp adminLeaderboardStale = true + graphCache []database.UserLeaderboardResp + graphCacheStale = true ) // Returns port in use by beast. @@ -992,7 +994,7 @@ func getUserCountHandler(c *gin.Context) { // @Failure 400 {object} api.HTTPErrorResp // @Failure 500 {object} api.HTTPErrorResp // @Router /api/info/leaderboard [get] -func leaderboardHandler(c *gin.Context) { +func getLeaderboardHandler(c *gin.Context) { pageStr := c.Query("page") log.Print(pageStr) if pageStr == "" { @@ -1229,6 +1231,7 @@ func freezeLeaderboardHandler(c *gin.Context) { }) } leaderboardStale = true + graphCacheStale = true c.JSON(http.StatusOK, HTTPPlainResp{ Message: "User leaderboard frozen successfully", }) @@ -1254,6 +1257,7 @@ func unfreezeLeaderboardHandler(c *gin.Context) { }) } leaderboardStale = true + graphCacheStale = true c.JSON(http.StatusOK, HTTPPlainResp{ Message: "User leaderboard unfrozen successfully", }) @@ -1304,3 +1308,35 @@ func getChallengeAttempts(c *gin.Context) { c.JSON(http.StatusOK, resp) } + +func getLeaderboardGraphHandler(c *gin.Context) { + var topUsers []uint + // TODO: Add a check for leaderboard stale to prevent stale graphs + // Try if graphCache and leaderboardCache can be merged. + // Right now graph cache gets invalidated whenver leaderboardCache gets invalidated even if no user under top 10 are changed. Fix later. + if leaderboardStale { + users, err := database.QueryTopUsersByFrozenScore(core.LEADERBOARD_SIZE) + if err == nil { + for i := 0; i < 10 && i < len(users); i++ { + user := users[i] + topUsers = append(topUsers, user.ID) + } + } + } else { + for i := 0; i < 10 && i < len(leaderboardCache); i++ { + user := leaderboardCache[i] + topUsers = append(topUsers, user.Id) + } + } + // If leaderboard is frozen then directly send the last graph instance without updating + isLeaderboardFrozen, _ := database.IsFrozenScoreSet() + if !graphCacheStale || isLeaderboardFrozen { + c.JSON(http.StatusOK, graphCache) + } else { + // TODO: Add a fallback for frozen leaderboard as graphcache is in memory and not persistent. SO, might get lost if server got down in between. + graphCache = database.QueryTimeSeriesForTopUsers(topUsers) + graphCacheStale = false + c.JSON(http.StatusOK, graphCache) + } + +} diff --git a/api/response.go b/api/response.go index 77dd5284..dcad44c8 100644 --- a/api/response.go +++ b/api/response.go @@ -39,11 +39,6 @@ type ChallengeStatusResp struct { UpdatedAt time.Time `json:"updated_at" example:"2018-12-31T22:20:08.948096189+05:30"` } -type ChallengesResp struct { - Message string - Challenges []string -} - type LogsInfoResp struct { Stdout string `json:"stdout" example:"[INFO] Challenge is starting to deploy"` Stderr string `json:"stderr" example:"[ERROR] Challenge deployment failed."` @@ -139,8 +134,8 @@ type Challenge struct { // ChallengeDetails is an extended struct for admin use, containing additional fields. type AdminChallenge struct { Challenge - DynamicFlag bool `json:"dynamicFlag" example:"true"` - Flag string `json:"flag"` + DynamicFlag bool `json:"dynamicFlag" example:"true"` + Flag string `json:"flag"` } type ChallengePreviewResp struct { diff --git a/api/router.go b/api/router.go index 7f3fe129..cd98d4e8 100644 --- a/api/router.go +++ b/api/router.go @@ -85,7 +85,8 @@ func initGinRouter() *gin.Engine { // infoGroup.GET("/ports/used", usedPortsInfoHandler) infoGroup.GET("/user/:username", userInfoHandler) infoGroup.GET("/users", getAllUsersInfoHandler) - infoGroup.GET("/leaderboard", leaderboardHandler) + infoGroup.GET("/leaderboard", getLeaderboardHandler) + infoGroup.GET("leaderboard-graph", getLeaderboardGraphHandler) infoGroup.GET("/usercount", getUserCountHandler) infoGroup.GET("/submissions", submissionsHandler) infoGroup.GET("/tags", tagHandler) diff --git a/core/constants.go b/core/constants.go index bda671ac..89d7732d 100644 --- a/core/constants.go +++ b/core/constants.go @@ -207,4 +207,5 @@ var USER_STATUS = map[string]string{ const ( LEADERBOARD_SIZE = 25 + LEADERBOARD_GRAPH_SIZE = 12 ) \ No newline at end of file diff --git a/core/database/challenges.go b/core/database/challenges.go index dbf79c94..93cb867b 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -10,8 +10,10 @@ import ( "path/filepath" "strings" "time" + "github.com/araddon/dateparse" "github.com/sdslabs/beastv4/core" + "github.com/sdslabs/beastv4/core/config" tools "github.com/sdslabs/beastv4/templates" "gorm.io/gorm" @@ -46,7 +48,7 @@ type Challenge struct { DynamicFlag bool `gorm:"not null;default:false"` Flag string `gorm:"type:text"` Type string `gorm:"type:varchar(64)"` - Difficulty string `gorm:"not null;default:'medium'"` + Difficulty string `gorm:"not null;default:'medium'"` MaxAttemptLimit int `gorm:"default:-1"` PreReqs string `gorm:"type:text"` Sidecar string `gorm:"type:varchar(64)"` @@ -87,6 +89,28 @@ type ChallengeAttempt struct { Flag string `json:"flag"` Correct bool `json:"correct"` } +type UserLeaderboardResp struct { + Id uint `json:"id" example:"5"` + Username string `json:"username" example:"ABCD"` + Score uint `json:"score" example:"750"` + Rank int64 `json:"rank" example:"15"` + TimeSeriesdata []TimeSeries `json:"timeSeriesData"` +} +type TimeSeries struct { + Timestamp time.Time `json:"timestamp" example:"2018-12-31T22:20:08"` + Score uint `json:"score" example:"750"` +} + +type TimeAggBucket int +const ( + LessThan10Min TimeAggBucket = iota + Between10And100Min + Between100MinAnd12Hr + Between12HrAnd24Hr + Between1DayAnd1Month + Between1MonthAnd1Year + MoreThan1Year +) // The `DynamicFlags` table has the following columns // name @@ -111,7 +135,7 @@ func CreateChallengeEntry(challenge *Challenge) error { tx := Db.Begin() if tx.Error != nil { - return fmt.Errorf("Error while starting transaction", tx.Error) + return fmt.Errorf("error while starting transaction %e", tx.Error) } if err := tx.FirstOrCreate(challenge, *challenge).Error; err != nil { @@ -332,7 +356,7 @@ func BatchUpdateChallenge(whereMap map[string]interface{}, chall Challenge) erro tx := Db.Where(whereMap).First(&challenge) if errors.Is(tx.Error, gorm.ErrRecordNotFound) { - return fmt.Errorf("No challenge entry to update : WhereClause : %s", whereMap) + return fmt.Errorf("no challenge entry to update : WhereClause : %s", whereMap) } if tx.Error != nil { @@ -416,7 +440,7 @@ func DeleteChallengeEntry(challenge *Challenge) error { tx := Db.Begin() if tx.Error != nil { - return fmt.Errorf("Error while starting transaction : %s", tx.Error) + return fmt.Errorf("error while starting transaction : %s", tx.Error) } if err := tx.Unscoped().Delete(challenge).Error; err != nil { @@ -533,7 +557,7 @@ func updateScript(user *User) error { scriptPath := filepath.Join(core.BEAST_GLOBAL_DIR, core.BEAST_SCRIPTS_DIR, fmt.Sprintf("%x", SHA256.Sum(nil))) challs, err := GetRelatedChallenges(user) if err != nil { - return fmt.Errorf("Error while getting related challenges : %v", err) + return fmt.Errorf("error while getting related challenges : %v", err) } mapOfChall := make(map[string]string) @@ -550,12 +574,12 @@ func updateScript(user *User) error { var script bytes.Buffer scriptTemplate, err := template.New("script").Parse(tools.SSH_LOGIN_SCRIPT_TEMPLATE) if err != nil { - return fmt.Errorf("Error while parsing script template :: %s", err) + return fmt.Errorf("error while parsing script template :: %s", err) } err = scriptTemplate.Execute(&script, data) if err != nil { - return fmt.Errorf("Error while executing script template :: %s", err) + return fmt.Errorf("error while executing script template :: %s", err) } return ioutil.WriteFile(scriptPath, script.Bytes(), 0755) @@ -667,3 +691,183 @@ func QueryChallAttempts(chall_id uint64) ([]ChallengeAttempt, error) { return attempts, nil } + +// timeElapsed returns the aggregation bucket for the given duration +func timeElapsed() TimeAggBucket { + startStr := config.Cfg.CompetitionInfo.StartingTime + // The format is "16:31:23 UTC: +05:30, 03 February 2025, Monday" + // We'll parse only the part up to the date, ignoring the weekday + // e.g. "16:31:23 UTC: +05:30, 03 February 2025" + parts := strings.Split(startStr, ",") + if len(parts) < 2 { + panic("Invalid StartingTime format") + } + startTimePart := strings.TrimSpace(parts[0]) + startDatePart := strings.TrimSpace(parts[1]) + startParseStr := startTimePart + ", " + startDatePart + start, err := dateparse.ParseLocal(startParseStr) + if err != nil { + panic("Failed to parse StartingTime: " + err.Error()) + } + end := time.Now() + diff := end.Sub(start) + minutes := diff.Minutes() + hours := diff.Hours() + days := hours / 24 + months := days / 30 + + switch { + case minutes < 10: + return LessThan10Min + case minutes < 100: + return Between10And100Min + case hours < 12: + return Between100MinAnd12Hr + case hours < 24: + return Between12HrAnd24Hr + case days < 30: + return Between1DayAnd1Month + case months < 12: + return Between1MonthAnd1Year + default: + return MoreThan1Year + } +} + +// aggregateTimeSeries aggregates the time series data based on the bucket +func aggregateTimeSeries(ts []TimeSeries, bucket TimeAggBucket) []TimeSeries { + if len(ts) <= 10 { + return ts + } + + var interval time.Duration + switch bucket { + case LessThan10Min: + return ts + case Between10And100Min: + interval = 10 * time.Minute + case Between100MinAnd12Hr: + interval = 1 * time.Hour + case Between12HrAnd24Hr: + interval = 2 * time.Hour + case Between1DayAnd1Month: + interval = 72 * time.Hour // 3 days + case Between1MonthAnd1Year: + interval = 30 * 24 * time.Hour // 1 month + case MoreThan1Year: + interval = 365 * 24 * time.Hour // 1 year + default: + return ts + } + + var agg []TimeSeries + var lastAdded time.Time + for i, point := range ts { + if i == 0 { + agg = append(agg, point) + lastAdded = point.Timestamp + continue + } + if point.Timestamp.Sub(lastAdded) >= interval { + agg = append(agg, point) + lastAdded = point.Timestamp + } + } + + if len(agg) == 0 || !agg[len(agg)-1].Timestamp.Equal(ts[len(ts)-1].Timestamp) { + agg = append(agg, ts[len(ts)-1]) + } + return agg +} + + +/* +QueryTimeSeriesForTopUsers returns a slice of UserLeaderboardResp for the given list of user IDs, +containing each user's cumulative score time series data. + +Time Buckets: +- The function uses time buckets to aggregate time series data for each user, reducing the number of data points for visualization. +- The time buckets are determined by the timeElapsed() function, which calculates the elapsed time since the competition started and selects a bucket: + - LessThan10Min: No aggregation, all points shown. + - Between10And100Min: 10-minute intervals. + - Between100MinAnd12Hr: 1-hour intervals. + - Between12HrAnd24Hr: 2-hour intervals. + - Between1DayAnd1Month: 3-day intervals. + - Between1MonthAnd1Year: 1-month intervals. + - MoreThan1Year: 1-year intervals. + +This approach ensures that the returned time series is concise and suitable for plotting, while still reflecting the user's progress over time. +*/ +func QueryTimeSeriesForTopUsers(topUserId []uint) []UserLeaderboardResp { + var results []UserLeaderboardResp + + if len(topUserId) == 0 { + return results + } + + DBMux.Lock() + defer DBMux.Unlock() + + type userChallengeRow struct { + UserID uint + Username string + CreatedAt time.Time + Points uint + } + var allRows []userChallengeRow + + if err := Db.Table("user_challenges"). + Select("user_challenges.user_id, users.username, user_challenges.created_at, challenges.points"). + Joins("JOIN challenges ON user_challenges.challenge_id = challenges.id"). + Joins("JOIN users ON user_challenges.user_id = users.id"). + Where("user_challenges.user_id IN ? AND user_challenges.solved = ?", topUserId, true). + Order("user_challenges.user_id ASC, user_challenges.created_at ASC"). + Scan(&allRows).Error; err != nil { + return results + } + + userRows := make(map[uint][]userChallengeRow) + userMap := make(map[uint]string) + for _, row := range allRows { + userRows[row.UserID] = append(userRows[row.UserID], row) + userMap[row.UserID] = row.Username + } + + for _, userId := range topUserId { + rows := userRows[userId] + username, ok := userMap[userId] + if !ok { + continue + } + + var timeSeriesRaw []TimeSeries + var cumulativeScore uint = 0 + for _, r := range rows { + cumulativeScore += r.Points + timeSeriesRaw = append(timeSeriesRaw, TimeSeries{ + Timestamp: r.CreatedAt, + Score: cumulativeScore, + }) + } + + var timeSeries []TimeSeries + if len(timeSeriesRaw) <= 10 { + timeSeries = timeSeriesRaw + } else if len(timeSeriesRaw) > 0 { + bucket := timeElapsed() + timeSeries = aggregateTimeSeries(timeSeriesRaw, bucket) + } + + var rank int64 = 0 + + results = append(results, UserLeaderboardResp{ + Id: userId, + Username: username, + Score: cumulativeScore, + Rank: rank, + TimeSeriesdata: timeSeries, + }) + } + + return results +} \ No newline at end of file diff --git a/core/utils/cleanup.go b/core/utils/cleanup.go index 54263e35..0bb66e21 100644 --- a/core/utils/cleanup.go +++ b/core/utils/cleanup.go @@ -28,6 +28,7 @@ func Cleanup() { // - Clean up temporary files // - Close network connections // - Stop background goroutines + // - Store leaderboard cache and graph cache if leaderboard is frozen and competition not ended. and make sure to fill it abck on restart. // Backup the database to ensure no data loss err := database.BackupDatabase() From 68732931b380bc0539f604a19e3a0467ea4cdd8d Mon Sep 17 00:00:00 2001 From: sukhman Date: Sat, 16 Aug 2025 05:31:49 +0530 Subject: [PATCH 3/3] Resolve copilot suggestions --- api/admin.go | 2 +- api/info.go | 64 ++++++++++--------------------------- core/database/challenges.go | 30 +++++++++-------- 3 files changed, 34 insertions(+), 62 deletions(-) diff --git a/api/admin.go b/api/admin.go index 4d5a248c..ddf835a3 100644 --- a/api/admin.go +++ b/api/admin.go @@ -70,7 +70,7 @@ func userActionHandler(c *gin.Context) { } if val, _:= database.IsFrozenScoreSet(); !val { leaderboardStale = true - graphCacheStale = true; + graphCacheStale = true } c.JSON(http.StatusOK, HTTPPlainResp{ Message: fmt.Sprintf("Successfully %sned the user with id %s", action, userId), diff --git a/api/info.go b/api/info.go index cc6c4415..6c578298 100644 --- a/api/info.go +++ b/api/info.go @@ -25,8 +25,8 @@ var ( leaderboardStale = true adminLeaderboardCache []UserResp adminLeaderboardStale = true - graphCache []database.UserLeaderboardResp - graphCacheStale = true + graphCache []database.UserLeaderboardResp + graphCacheStale = true ) // Returns port in use by beast. @@ -186,7 +186,6 @@ func challengeInfoHandler(c *gin.Context) { return } - // Get user ID from token authHeader := c.GetHeader("Authorization") username, err := coreUtils.GetUser(authHeader) if err != nil { @@ -204,34 +203,8 @@ func challengeInfoHandler(c *gin.Context) { return } - if len(challenges) > 0 || challenges[0].Status == "Undeployed" { + if len(challenges) > 0 && challenges[0].Status != "Undeployed" { challenge := challenges[0] - // users, err := database.GetRelatedUsers(&challenge) - // if err != nil { - // log.Error(err) - // c.JSON(http.StatusInternalServerError, HTTPErrorResp{ - // Error: "DATABASE ERROR while processing the request.", - // }) - // return - // } - - // var challSolves int - // solveStatus := false - // challengeUser := make([]UserSolveResp, 0) - // for _, usr := range users { - // if usr.Role == core.USER_ROLES["contestant"] { - // userResp := UserSolveResp{ - // UserID: usr.ID, - // Username: usr.Username, - // SolvedAt: usr.CreatedAt, - // } - // if usr.ID == user.ID { - // solveStatus = true - // } - // challengeUser = append(challengeUser, userResp) - // challSolves++ - // } - // } totalSolves, solveStatus, err := database.GetChallengeSolveInfo(challenge.ID, user.ID) if err != nil { log.Error(err) @@ -262,7 +235,6 @@ func challengeInfoHandler(c *gin.Context) { } } - // Get previous tries for the current user and challenge previousTries, err := database.GetUserPreviousTries(user.ID, challenge.ID) if err != nil { log.Error(err) @@ -404,7 +376,6 @@ func challengesMetadataHandler(c *gin.Context) { availableChallenges := make([]ChallengeMetadata, len(challenges)) authHeader := c.GetHeader("Authorization") - // Get user ID from token username, err := coreUtils.GetUser(authHeader) if err != nil { c.JSON(http.StatusUnauthorized, HTTPErrorResp{ @@ -490,7 +461,7 @@ func challengeLogsHandler(c *gin.Context) { chall := c.Query("challenge") if chall == "" { c.JSON(http.StatusBadRequest, HTTPPlainResp{ - Message: fmt.Sprintf("Challenge name cannot be empty"), + Message: fmt.Sprint("challenge name cannot be empty"), }) return } @@ -1293,27 +1264,24 @@ func getChallengeAttempts(c *gin.Context) { }) return } - var NewUserSolveResp UserSolveResp resp := make([]UserSolveResp, 0, len(attempts)) for _, attempt := range attempts { - NewUserSolveResp = UserSolveResp{ + resp = append(resp, UserSolveResp{ Id: attempt.Id, Username: attempt.Username, SolvedAt: attempt.SolvedAt, Flag: attempt.Flag, Correct: attempt.Correct, - } - resp = append(resp, NewUserSolveResp) + }) } - c.JSON(http.StatusOK, resp) } func getLeaderboardGraphHandler(c *gin.Context) { var topUsers []uint // TODO: Add a check for leaderboard stale to prevent stale graphs - // Try if graphCache and leaderboardCache can be merged. - // Right now graph cache gets invalidated whenver leaderboardCache gets invalidated even if no user under top 10 are changed. Fix later. + // Try if graphCache and leaderboardCache can be merged. + // Right now graph cache gets invalidated whenver leaderboardCache gets invalidated even if no user under top 10 are changed. Fix later. if leaderboardStale { users, err := database.QueryTopUsersByFrozenScore(core.LEADERBOARD_SIZE) if err == nil { @@ -1322,21 +1290,21 @@ func getLeaderboardGraphHandler(c *gin.Context) { topUsers = append(topUsers, user.ID) } } - } else { - for i := 0; i < 10 && i < len(leaderboardCache); i++ { - user := leaderboardCache[i] - topUsers = append(topUsers, user.Id) - } + } else { + for i := 0; i < 10 && i < len(leaderboardCache); i++ { + user := leaderboardCache[i] + topUsers = append(topUsers, user.Id) } + } // If leaderboard is frozen then directly send the last graph instance without updating isLeaderboardFrozen, _ := database.IsFrozenScoreSet() - if !graphCacheStale || isLeaderboardFrozen { + if !graphCacheStale || isLeaderboardFrozen { c.JSON(http.StatusOK, graphCache) } else { - // TODO: Add a fallback for frozen leaderboard as graphcache is in memory and not persistent. SO, might get lost if server got down in between. + // TODO: Add a fallback for frozen leaderboard as graphcache is in memory and not persistent. SO, might get lost if server got down in between. graphCache = database.QueryTimeSeriesForTopUsers(topUsers) graphCacheStale = false c.JSON(http.StatusOK, graphCache) } - + } diff --git a/core/database/challenges.go b/core/database/challenges.go index 93cb867b..dda0a300 100644 --- a/core/database/challenges.go +++ b/core/database/challenges.go @@ -10,11 +10,13 @@ import ( "path/filepath" "strings" "time" + "github.com/araddon/dateparse" "github.com/sdslabs/beastv4/core" "github.com/sdslabs/beastv4/core/config" tools "github.com/sdslabs/beastv4/templates" + log "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -102,6 +104,7 @@ type TimeSeries struct { } type TimeAggBucket int + const ( LessThan10Min TimeAggBucket = iota Between10And100Min @@ -135,7 +138,7 @@ func CreateChallengeEntry(challenge *Challenge) error { tx := Db.Begin() if tx.Error != nil { - return fmt.Errorf("error while starting transaction %e", tx.Error) + return fmt.Errorf("error while starting transaction %w", tx.Error) } if err := tx.FirstOrCreate(challenge, *challenge).Error; err != nil { @@ -700,14 +703,16 @@ func timeElapsed() TimeAggBucket { // e.g. "16:31:23 UTC: +05:30, 03 February 2025" parts := strings.Split(startStr, ",") if len(parts) < 2 { - panic("Invalid StartingTime format") + log.Errorf("invalid StartingTime format: %s", startStr) + return Between1MonthAnd1Year } startTimePart := strings.TrimSpace(parts[0]) startDatePart := strings.TrimSpace(parts[1]) startParseStr := startTimePart + ", " + startDatePart start, err := dateparse.ParseLocal(startParseStr) if err != nil { - panic("Failed to parse StartingTime: " + err.Error()) + log.Errorf("failed to parse StartingTime: %v", err) + return Between1MonthAnd1Year } end := time.Now() diff := end.Sub(start) @@ -773,14 +778,13 @@ func aggregateTimeSeries(ts []TimeSeries, bucket TimeAggBucket) []TimeSeries { lastAdded = point.Timestamp } } - + if len(agg) == 0 || !agg[len(agg)-1].Timestamp.Equal(ts[len(ts)-1].Timestamp) { agg = append(agg, ts[len(ts)-1]) } return agg } - /* QueryTimeSeriesForTopUsers returns a slice of UserLeaderboardResp for the given list of user IDs, containing each user's cumulative score time series data. @@ -788,13 +792,13 @@ containing each user's cumulative score time series data. Time Buckets: - The function uses time buckets to aggregate time series data for each user, reducing the number of data points for visualization. - The time buckets are determined by the timeElapsed() function, which calculates the elapsed time since the competition started and selects a bucket: - - LessThan10Min: No aggregation, all points shown. - - Between10And100Min: 10-minute intervals. - - Between100MinAnd12Hr: 1-hour intervals. - - Between12HrAnd24Hr: 2-hour intervals. - - Between1DayAnd1Month: 3-day intervals. - - Between1MonthAnd1Year: 1-month intervals. - - MoreThan1Year: 1-year intervals. + - LessThan10Min: No aggregation, all points shown. + - Between10And100Min: 10-minute intervals. + - Between100MinAnd12Hr: 1-hour intervals. + - Between12HrAnd24Hr: 2-hour intervals. + - Between1DayAnd1Month: 3-day intervals. + - Between1MonthAnd1Year: 1-month intervals. + - MoreThan1Year: 1-year intervals. This approach ensures that the returned time series is concise and suitable for plotting, while still reflecting the user's progress over time. */ @@ -870,4 +874,4 @@ func QueryTimeSeriesForTopUsers(topUserId []uint) []UserLeaderboardResp { } return results -} \ No newline at end of file +}