Skip to content

Commit 236062d

Browse files
committed
feat: implement Phase 1 & 2 backend features + frontend integration
Phase 1 - Backend Quick Fixes: - Wire streak achievement into CheckAndAwardAchievements - Add writeup upvote display (vote status endpoint + sort by upvotes) - Add challenge search & filter API (category, difficulty, tag, search) - Add submission history endpoint (GET /submissions/my) - Add team leadership transfer endpoint - Add account deletion (soft delete) + GDPR data export Phase 2 - New Backend Features: - Solo leaderboard for users without teams (GET /leaderboard/solo) - CTFd format export for interoperable challenges (GET /admin/export/ctfd) - Scoreboard export as CSV/JSON for collaborators - Discord/Slack webhook integration service - Practice mode / archive for unscored past challenges - Per-contest analytics for collaborators (solve rates, category breakdown) - Challenge difficulty rating by users (1-5 stars, solver-only) - Email notifications for solve/contest events Frontend: - Updated ChallengeService, TeamService, AuthService, ScoreboardService, CollaboratorService with new API methods - Added Danger Zone UI to account settings (delete + export) Lint fixes: - Remove vestigial nil!=nil checks from MongoDB migration - Replace loop copy with copy() builtin (S1001) - Remove redundant non-nil check after early return
1 parent 54165ff commit 236062d

35 files changed

Lines changed: 2113 additions & 39 deletions

backend/internal/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ type Config struct {
5555
R2SecretAccessKey string
5656
R2BucketName string
5757
R2PublicURL string // e.g. "https://pub-xxx.r2.dev" or custom domain — used as URL prefix for uploaded files
58+
// Webhook integration
59+
DiscordWebhookURL string
60+
SlackWebhookURL string
5861
}
5962

6063
func LoadConfig() *Config {
@@ -116,6 +119,8 @@ func LoadConfig() *Config {
116119
R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""),
117120
R2BucketName: getEnv("R2_BUCKET_NAME", "rootaccess"),
118121
R2PublicURL: getEnv("R2_PUBLIC_URL", ""),
122+
DiscordWebhookURL: getEnv("DISCORD_WEBHOOK_URL", ""),
123+
SlackWebhookURL: getEnv("SLACK_WEBHOOK_URL", ""),
119124
}
120125
}
121126

backend/internal/database/schema.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,14 @@ func BootstrapSchema(db *sql.DB) {
292292
used_at TEXT,
293293
created_at TEXT NOT NULL
294294
);`,
295+
// Challenge Difficulty Ratings
296+
`CREATE TABLE IF NOT EXISTS challenge_ratings (
297+
id TEXT PRIMARY KEY,
298+
challenge_id TEXT NOT NULL REFERENCES challenges(id) ON DELETE CASCADE,
299+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
300+
rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5),
301+
UNIQUE(challenge_id, user_id)
302+
);`,
295303
}
296304

297305
for _, stmt := range schemas {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package handlers
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
7+
"github.com/Uttam-Mahata/RootAccess/backend/internal/repositories"
8+
"github.com/Uttam-Mahata/RootAccess/backend/internal/utils"
9+
"github.com/gin-gonic/gin"
10+
)
11+
12+
type AccountHandler struct {
13+
userRepo *repositories.UserRepository
14+
teamRepo *repositories.TeamRepository
15+
submissionRepo *repositories.SubmissionRepository
16+
achievementRepo *repositories.AchievementRepository
17+
writeupRepo *repositories.WriteupRepository
18+
}
19+
20+
func NewAccountHandler(
21+
userRepo *repositories.UserRepository,
22+
teamRepo *repositories.TeamRepository,
23+
submissionRepo *repositories.SubmissionRepository,
24+
achievementRepo *repositories.AchievementRepository,
25+
writeupRepo *repositories.WriteupRepository,
26+
) *AccountHandler {
27+
return &AccountHandler{
28+
userRepo: userRepo,
29+
teamRepo: teamRepo,
30+
submissionRepo: submissionRepo,
31+
achievementRepo: achievementRepo,
32+
writeupRepo: writeupRepo,
33+
}
34+
}
35+
36+
// DeleteAccount allows a user to soft-delete their own account
37+
// @Summary Delete my account
38+
// @Description Self-service account deletion. Sets the account status to 'deleted'. This is a soft delete.
39+
// @Tags Account
40+
// @Produce json
41+
// @Success 200 {object} map[string]string
42+
// @Failure 400 {object} map[string]string
43+
// @Security ApiKeyAuth
44+
// @Router /auth/account [delete]
45+
func (h *AccountHandler) DeleteAccount(c *gin.Context) {
46+
userIDStr, exists := c.Get("user_id")
47+
if !exists {
48+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
49+
return
50+
}
51+
userID := userIDStr.(string)
52+
53+
// Check if user is a team leader — they must transfer leadership first
54+
if h.teamRepo != nil {
55+
team, err := h.teamRepo.FindTeamByMemberID(userID)
56+
if err == nil && team != nil && team.LeaderID == userID {
57+
memberCount, _ := h.teamRepo.GetTeamMemberCount(team.ID)
58+
if memberCount > 1 {
59+
c.JSON(http.StatusBadRequest, gin.H{
60+
"error": "You are the leader of a team with other members. Please transfer leadership or disband your team before deleting your account.",
61+
})
62+
return
63+
}
64+
}
65+
}
66+
67+
// Prevent admins from self-deleting
68+
role, _ := c.Get("role")
69+
if role == "admin" {
70+
c.JSON(http.StatusBadRequest, gin.H{"error": "Admins cannot self-delete their account. Please ask another admin to demote you first."})
71+
return
72+
}
73+
74+
// Soft delete — set status to "deleted"
75+
err := h.userRepo.UpdateFields(userID, map[string]interface{}{"status": "deleted"})
76+
if err != nil {
77+
utils.RespondWithError(c, http.StatusInternalServerError, "Failed to delete account", err)
78+
return
79+
}
80+
81+
// Clear the auth cookie
82+
isProd := c.GetHeader("X-Forwarded-Proto") == "https"
83+
if isProd {
84+
c.SetSameSite(http.SameSiteNoneMode)
85+
} else {
86+
c.SetSameSite(http.SameSiteLaxMode)
87+
}
88+
c.SetCookie("auth_token", "", -1, "/", "", isProd, true)
89+
90+
c.JSON(http.StatusOK, gin.H{"message": "Account deleted successfully. Your data has been deactivated."})
91+
}
92+
93+
// ExportAccountData exports the user's data as JSON (GDPR-compliant data export)
94+
// @Summary Export my account data
95+
// @Description Export all personal data associated with the authenticated user's account in JSON format.
96+
// @Tags Account
97+
// @Produce json
98+
// @Success 200 {object} map[string]interface{}
99+
// @Security ApiKeyAuth
100+
// @Router /auth/account/export [get]
101+
func (h *AccountHandler) ExportAccountData(c *gin.Context) {
102+
userIDStr, exists := c.Get("user_id")
103+
if !exists {
104+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
105+
return
106+
}
107+
userID := userIDStr.(string)
108+
109+
export := make(map[string]interface{})
110+
111+
// User profile
112+
user, err := h.userRepo.FindByID(userID)
113+
if err != nil {
114+
utils.RespondWithError(c, http.StatusInternalServerError, "Failed to retrieve user data", err)
115+
return
116+
}
117+
export["profile"] = map[string]interface{}{
118+
"id": user.ID,
119+
"username": user.Username,
120+
"email": user.Email,
121+
"role": user.Role,
122+
"email_verified": user.EmailVerified,
123+
"status": user.Status,
124+
"oauth_provider": user.OAuthProvider,
125+
"created_at": user.CreatedAt,
126+
"updated_at": user.UpdatedAt,
127+
}
128+
129+
// Team membership
130+
if h.teamRepo != nil {
131+
team, err := h.teamRepo.FindTeamByMemberID(userID)
132+
if err == nil && team != nil {
133+
export["team"] = map[string]interface{}{
134+
"id": team.ID,
135+
"name": team.Name,
136+
"is_leader": team.LeaderID == userID,
137+
}
138+
}
139+
}
140+
141+
// Submissions
142+
if h.submissionRepo != nil {
143+
subs, err := h.submissionRepo.GetUserSubmissions(userID)
144+
if err == nil {
145+
subEntries := make([]map[string]interface{}, 0, len(subs))
146+
for _, s := range subs {
147+
subEntries = append(subEntries, map[string]interface{}{
148+
"challenge_id": s.ChallengeID,
149+
"contest_id": s.ContestID,
150+
"is_correct": s.IsCorrect,
151+
"timestamp": s.Timestamp,
152+
})
153+
}
154+
export["submissions"] = subEntries
155+
}
156+
}
157+
158+
// Achievements
159+
if h.achievementRepo != nil {
160+
achievements, err := h.achievementRepo.GetByUserID(userID)
161+
if err == nil {
162+
export["achievements"] = achievements
163+
}
164+
}
165+
166+
// Writeups
167+
if h.writeupRepo != nil {
168+
writeups, err := h.writeupRepo.GetWriteupsByUser(userID)
169+
if err == nil {
170+
export["writeups"] = writeups
171+
}
172+
}
173+
174+
// Set content disposition for download
175+
c.Header("Content-Disposition", "attachment; filename=account_export.json")
176+
c.Header("Content-Type", "application/json")
177+
178+
encoder := json.NewEncoder(c.Writer)
179+
encoder.SetIndent("", " ")
180+
encoder.Encode(export)
181+
}

backend/internal/handlers/challenge_handler.go

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,92 @@ type ChallengePublicResponse struct {
360360
OfficialWriteupFormat string `json:"official_writeup_format,omitempty"`
361361
}
362362

363+
// SearchChallenges searches challenges with query params: category, difficulty, tag, search
364+
// @Summary Search challenges
365+
// @Description Search and filter challenges by category, difficulty, tag, and text search.
366+
// @Tags Challenges
367+
// @Produce json
368+
// @Param category query string false "Filter by category"
369+
// @Param difficulty query string false "Filter by difficulty"
370+
// @Param tag query string false "Filter by tag"
371+
// @Param search query string false "Text search in title and description"
372+
// @Success 200 {array} ChallengePublicResponse
373+
// @Failure 500 {object} map[string]string
374+
// @Security ApiKeyAuth
375+
// @Router /challenges/search [get]
376+
func (h *ChallengeHandler) SearchChallenges(c *gin.Context) {
377+
filter := repositories.ChallengeFilter{
378+
Category: c.Query("category"),
379+
Difficulty: c.Query("difficulty"),
380+
Tag: c.Query("tag"),
381+
Search: c.Query("search"),
382+
}
383+
384+
challenges, err := h.challengeService.SearchChallenges(filter)
385+
if err != nil {
386+
utils.RespondWithError(c, http.StatusInternalServerError, err.Error(), err)
387+
return
388+
}
389+
390+
// Determine current user
391+
var userID string
392+
if userIDStr, exists := c.Get("user_id"); exists {
393+
userID = userIDStr.(string)
394+
}
395+
396+
var result []ChallengePublicResponse
397+
for _, ch := range challenges {
398+
if !ch.IsPublished {
399+
continue
400+
}
401+
isSolved := false
402+
if h.submissionRepo != nil && userID != "" {
403+
if sub, _ := h.submissionRepo.FindByChallengeAndUser(ch.ID, userID); sub != nil {
404+
isSolved = true
405+
}
406+
}
407+
408+
result = append(result, ChallengePublicResponse{
409+
ID: ch.ID,
410+
Title: ch.Title,
411+
Description: ch.Description,
412+
DescriptionFormat: ch.DescriptionFormat,
413+
Category: ch.Category,
414+
Difficulty: ch.Difficulty,
415+
MaxPoints: ch.MaxPoints,
416+
CurrentPoints: ch.CurrentPoints(),
417+
ScoringType: ch.ScoringType,
418+
SolveCount: ch.SolveCount,
419+
Files: ch.Files,
420+
Tags: ch.Tags,
421+
HintCount: len(ch.Hints),
422+
IsSolved: isSolved,
423+
})
424+
}
425+
426+
c.JSON(http.StatusOK, result)
427+
}
428+
429+
// GetCategories returns all distinct challenge categories
430+
// @Summary Get challenge categories
431+
// @Description Retrieve a list of all unique challenge categories for filtering.
432+
// @Tags Challenges
433+
// @Produce json
434+
// @Success 200 {array} string
435+
// @Security ApiKeyAuth
436+
// @Router /challenges/categories [get]
437+
func (h *ChallengeHandler) GetCategories(c *gin.Context) {
438+
categories, err := h.challengeService.GetDistinctCategories()
439+
if err != nil {
440+
utils.RespondWithError(c, http.StatusInternalServerError, err.Error(), err)
441+
return
442+
}
443+
if categories == nil {
444+
categories = []string{}
445+
}
446+
c.JSON(http.StatusOK, categories)
447+
}
448+
363449
// GetAllChallenges returns all challenges for users (filtered by active contest/round visibility)
364450
// @Summary Get all challenges
365451
// @Description Retrieve a list of all published challenges with public details (no flags).
@@ -819,7 +905,7 @@ func (h *ChallengeHandler) GetContestChallenges(c *gin.Context) {
819905
if sub, _ := h.submissionRepo.FindByChallengeAndUserInContest(ch.ID, userID, contestID); sub != nil {
820906
isSolved = true
821907
}
822-
if !isSolved && teamID != nil && *teamID != "" {
908+
if !isSolved && *teamID != "" {
823909
if teamSub, _ := h.submissionRepo.FindByChallengeAndTeamInContest(ch.ID, *teamID, contestID); teamSub != nil {
824910
isSolved = true
825911
}

0 commit comments

Comments
 (0)