Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions api/lock_manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package api

import (
"sync"
log "github.com/sirupsen/logrus"
)

var (
lockManagerInstance *UserLockManager
once sync.Once
)

type UserLockManager struct {
locks sync.Map
}

func GetUserLockManager() *UserLockManager {
once.Do(func() {
lockManagerInstance = &UserLockManager{}
})
return lockManagerInstance
}

func (m *UserLockManager) getLock(lockId string) *sync.Mutex {
lock, _ := m.locks.LoadOrStore(lockId, &sync.Mutex{})
return lock.(*sync.Mutex)
}

func (m *UserLockManager) TryLock(lockId string) bool {
lock := m.getLock(lockId)
return lock.TryLock()
}

func (m *UserLockManager) Unlock(userID string) {
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameter name is misleading and inconsistent. The function receives userID but should be lockId to match the usage in the calling code (line 124 of submit.go where lockId is passed) and to be consistent with the TryLock function which uses lockId.

Copilot uses AI. Check for mistakes.
lockVal, exists := m.locks.Load(userID)
if !exists {
log.Errorf("unlock called for non-existent lock, user_id: %s", userID)
Comment on lines +35 to +37
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter name should be lockId instead of userID to match the usage throughout the code and maintain consistency with TryLock.

Copilot uses AI. Check for mistakes.
return
}

lock := lockVal.(*sync.Mutex)

// Catch panic from double-unlock
defer func() {
if r := recover(); r != nil {
log.Errorf("double unlock detected for user_id: %s", userID)
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter name should be lockId instead of userID to match the usage throughout the code.

Copilot uses AI. Check for mistakes.
}
}()

Comment on lines +43 to +49
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The panic recovery for double-unlock is insufficient. In Go, calling Unlock() on an unlocked mutex results in a panic that says "sync: unlock of unlocked mutex", but the defer/recover only logs an error without propagating the issue to the caller. This could mask serious bugs. Additionally, double-unlock is usually a programming error that should be fixed rather than caught and logged. Consider removing the panic recovery and letting the panic occur to surface the bug, or at minimum, log at FATAL level rather than ERROR.

Suggested change
// Catch panic from double-unlock
defer func() {
if r := recover(); r != nil {
log.Errorf("double unlock detected for user_id: %s", userID)
}
}()
// Unlock the mutex; let any panic propagate to surface programming errors

Copilot uses AI. Check for mistakes.
lock.Unlock()
}

// Run a scheduler for periodically cleanup locks for inactive users
func (m *UserLockManager) Cleanup() {
m.locks.Range(func(key, value interface{}) bool {
mutex := value.(*sync.Mutex)
// Only delete if mutex is unlocked (not in use)
if mutex.TryLock() {
mutex.Unlock()
m.locks.Delete(key)
}
return true
})
Comment on lines +54 to +63
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Cleanup function has a logical issue: it attempts to TryLock to check if the mutex is unlocked, then immediately unlocks it, and then deletes it from the map. However, if the mutex is currently locked by another goroutine (TryLock returns false), the mutex won't be deleted, which is correct. But if TryLock succeeds (returns true), you're essentially stealing the lock from any goroutine that might be about to use it between TryLock and Delete. A safer approach would be to track lock metadata (last used timestamp) separately and clean up locks that haven't been used for a certain period, rather than trying to determine lock state through TryLock.

Copilot uses AI. Check for mistakes.
}
86 changes: 56 additions & 30 deletions api/submit.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"fmt"
"math"
"net/http"
"strconv"
Expand Down Expand Up @@ -96,7 +97,6 @@ func submitFlagHandler(c *gin.Context) {
})
return
}

chall, err := database.QueryChallengeEntries("id", strconv.Itoa(int(parsedChallId)))
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Expand All @@ -105,6 +105,24 @@ func submitFlagHandler(c *gin.Context) {
return
}

if len(chall) == 0 {
c.JSON(http.StatusBadRequest, HTTPErrorResp{
Error: "Challenge not found.",
})
return
}

lockManager := GetUserLockManager()
lockId := fmt.Sprintf("%d_%s", user.ID, challId)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have a non-string approach for the key?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeswe can use hb fir userid and lb for challid in that case

if !lockManager.TryLock(lockId) {
c.JSON(http.StatusOK, FlagSubmitResp{
Message: "Request already in process",
Success: false,
})
return
}
defer lockManager.Unlock(lockId)
Comment on lines +115 to +124
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock is acquired too early in the request flow. It's acquired before lightweight validation checks (challenge status, prerequisites check). This means that users will be blocked from making concurrent requests even when those requests would fail fast on basic validation. Consider moving the lock acquisition to just before line 152 (before CheckPreviousSubmissions), after all the cheap validation checks have passed.

Copilot uses AI. Check for mistakes.

challenge := chall[0]
if challenge.Status != core.DEPLOY_STATUS["deployed"] {
c.JSON(http.StatusOK, FlagSubmitResp{
Expand All @@ -116,14 +134,12 @@ func submitFlagHandler(c *gin.Context) {

if challenge.PreReqs != "" {
preReqsStatus, err := database.CheckPreReqsStatus(challenge, user.ID)

if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
})
return
}

if !preReqsStatus {
c.JSON(http.StatusOK, FlagSubmitResp{
Message: "You have not solved the prerequisites of this challenge.",
Expand All @@ -133,40 +149,13 @@ func submitFlagHandler(c *gin.Context) {
}
}

if challenge.MaxAttemptLimit > 0 {
previousTries, err := database.GetUserPreviousTries(user.ID, challenge.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request."})
return
}

if previousTries >= challenge.MaxAttemptLimit {
c.JSON(http.StatusOK, FlagSubmitResp{
Message: "You have reached the maximum number of tries for this challenge.",
Success: false,
})
return
}
}

// Increase user tries by 1
err = database.UpdateUserChallengeTries(user.ID, challenge.ID)

if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
})
return
}
solved, err := database.CheckPreviousSubmissions(user.ID, challenge.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
})
return
}

if solved {
c.JSON(http.StatusOK, FlagSubmitResp{
Message: "Challenge has already been solved.",
Expand All @@ -175,6 +164,29 @@ func submitFlagHandler(c *gin.Context) {
return
}

var previousTries int
if challenge.MaxAttemptLimit > 0 {
previousTries, err = database.GetUserPreviousTries(user.ID, challenge.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request."})
return
}
if previousTries >= challenge.MaxAttemptLimit {
c.JSON(http.StatusOK, FlagSubmitResp{
Message: "You have reached the maximum number of tries for this challenge.",
Success: false,
})
return
}
err = database.UpdateUserChallengeTries(user.ID, challenge.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
})
return
}
}
Comment on lines +182 to +189
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for updating user tries has been moved inside the MaxAttemptLimit > 0 check, which means tries will not be tracked for challenges without attempt limits. In the original code (deleted lines 151-178), UpdateUserChallengeTries was called unconditionally for all challenges. This changes the behavior and may cause incorrect try counts for challenges without limits. Consider moving line 182-188 outside of the if challenge.MaxAttemptLimit > 0 block to maintain the original behavior.

Suggested change
err = database.UpdateUserChallengeTries(user.ID, challenge.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
})
return
}
}
}
err = database.UpdateUserChallengeTries(user.ID, challenge.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
})
return
}

Copilot uses AI. Check for mistakes.
// If the challenge is dynamic, then the flag is not stored in the database
if challenge.DynamicFlag {
whereMap := map[string]interface{}{
Expand All @@ -191,6 +203,20 @@ func submitFlagHandler(c *gin.Context) {

// flag not present in validFlags table
if len(validFlags) == 0 {
UserChallengesEntry := database.UserChallenges{
CreatedAt: time.Now(),
UserID: user.ID,
ChallengeID: challenge.ID,
Solved: false,
Flag: flag,
}
err = database.SaveFlagSubmission(&UserChallengesEntry)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPErrorResp{
Error: "DATABASE ERROR while processing the request.",
})
return
}
c.JSON(http.StatusOK, FlagSubmitResp{
Message: "Your flag is incorrect",
Success: false,
Expand Down
12 changes: 11 additions & 1 deletion core/database/challenges.go
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,17 @@ func SaveFlagSubmission(user_challenges *UserChallenges) error {
return fmt.Errorf("error while saving record: %s", tx.Error)
}

if err := tx.FirstOrCreate(user_challenges, *user_challenges).Error; err != nil {
var solvedChallenge UserChallenges
querySolved := tx.Where("user_id = ? AND challenge_id = ? AND solved = ?", user_challenges.UserID, user_challenges.ChallengeID, true).First(&solvedChallenge)
if querySolved.Error == nil {
tx.Rollback()
return fmt.Errorf("already solved: user_id %v, challenge_id %v", user_challenges.UserID, user_challenges.ChallengeID)
} else if querySolved.Error != nil && !errors.Is(querySolved.Error, gorm.ErrRecordNotFound) {
tx.Rollback()
return querySolved.Error
}

if err := tx.Create(user_challenges).Error; err != nil {
tx.Rollback()
return err
}
Expand Down