diff --git a/main.go b/main.go index ecfe957..2274d3d 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,20 @@ import ( "mvdan.cc/xurls/v2" ) +// TemplateName stores the file name of each template file +type TemplateName string + +const ( + Closed TemplateName = "closed.tmpl" + Create TemplateName = "create.tmpl" + Hidden TemplateName = "hidden.tmpl" + Index TemplateName = "index.tmpl" + Nav TemplateName = "nav.tmpl" + Poll TemplateName = "poll.tmpl" + Result TemplateName = "result.tmpl" + Unauthorized TemplateName = "unauthorized.tmpl" +) + var VOTE_TOKEN = os.Getenv("VOTE_TOKEN") var CONDITIONAL_GATEKEEP_URL = os.Getenv("VOTE_CONDITIONAL_URL") var VOTE_HOST = os.Getenv("VOTE_HOST") @@ -34,30 +48,10 @@ var DEV_DISABLE_ACTIVE_FILTERS = os.Getenv("DEV_DISABLE_ACTIVE_FILTERS") == "tru var DEV_FORCE_IS_EVALS = os.Getenv("DEV_FORCE_IS_EVALS") == "true" var DEV_FORCE_IS_CHAIR = os.Getenv("DEV_FORCE_IS_CHAIR") == "true" -func inc(x int) string { - return strconv.Itoa(x + 1) -} - -// GetVoterCount Gets the number of people eligible to vote in a poll -func GetVoterCount(poll database.Poll) int { - return len(poll.AllowedUsers) -} - -// CalculateQuorum Calculates the number of votes required for quorum in a poll -func CalculateQuorum(poll database.Poll) int { - voterCount := GetVoterCount(poll) - return int(math.Ceil(float64(voterCount) * poll.QuorumType)) -} - -func MakeLinks(s string) template.HTML { - rx := xurls.Strict() - s = template.HTMLEscapeString(s) - safe := rx.ReplaceAllString(s, `$0`) - return template.HTML(safe) -} - var oidcClient = OIDCClient{} +var broker sse.Broker + func main() { godotenv.Load() database.Client = database.Connect() @@ -87,7 +81,6 @@ func main() { if DEV_DISABLE_ACTIVE_FILTERS { logging.Logger.WithFields(logrus.Fields{"method": "main init"}).Warning("Dev disable active filters is set!") } - if DEV_FORCE_IS_EVALS { logging.Logger.WithFields(logrus.Fields{"method": "main init"}).Warning("Dev force evals is set!") } @@ -96,499 +89,530 @@ func main() { r.GET("/auth/callback", csh.AuthCallback) r.GET("/auth/logout", csh.AuthLogout) - // TODO: change ALL the response codes to use http.(actual description) - r.GET("/", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) - // This is intentionally left unprotected - // A user may be unable to vote but should still be able to see a list of polls + r.GET("/", csh.AuthWrapper(getHomepage)) + r.GET("/closed", csh.AuthWrapper(getClosedPollsPage)) + r.GET("/create", csh.AuthWrapper(getCreatePage)) + r.POST("/create", csh.AuthWrapper(createPoll)) + r.GET("/poll/:id", csh.AuthWrapper(getPollById)) + r.POST("/poll/:id", csh.AuthWrapper(voteInPoll)) + r.GET("/results/:id", csh.AuthWrapper(displayResults)) + r.POST("/poll/:id/hide", csh.AuthWrapper(hidePoll)) + r.POST("/poll/:id/close", csh.AuthWrapper(closePoll)) + r.GET("/stream/:topic", csh.AuthWrapper(broker.ServeHTTP)) - polls, err := database.GetOpenPolls(c) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - sort.Slice(polls, func(i, j int) bool { - return polls[i].Id > polls[j].Id - }) + go broker.Listen() - c.HTML(http.StatusOK, "index.tmpl", gin.H{ - "Polls": polls, - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - }) - })) + r.Run() +} - r.GET("/closed", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) +// getHomepage Constructs and displays the home page to the user +func getHomepage(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + // This is intentionally left unprotected + // A user may be unable to vote but should still be able to see a list of polls - closedPolls, err := database.GetClosedVotedPolls(c, claims.UserInfo.Username) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - ownedPolls, err := database.GetClosedOwnedPolls(c, claims.UserInfo.Username) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - closedPolls = append(closedPolls, ownedPolls...) + polls, err := database.GetOpenPolls(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + sort.Slice(polls, func(i, j int) bool { + return polls[i].Id > polls[j].Id + }) - sort.Slice(closedPolls, func(i, j int) bool { - return closedPolls[i].Id > closedPolls[j].Id - }) - closedPolls = uniquePolls(closedPolls) + c.HTML(http.StatusOK, string(Index), gin.H{ + "Polls": polls, + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, + }) +} - c.HTML(http.StatusOK, "closed.tmpl", gin.H{ - "ClosedPolls": closedPolls, - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, +// getClosedPollsPage Constructs and displays the closed polls page to the user +func getClosedPollsPage(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + + closedPolls, err := database.GetClosedVotedPolls(c, claims.UserInfo.Username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ownedPolls, err := database.GetClosedOwnedPolls(c, claims.UserInfo.Username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + closedPolls = append(closedPolls, ownedPolls...) + + sort.Slice(closedPolls, func(i, j int) bool { + return closedPolls[i].Id > closedPolls[j].Id + }) + closedPolls = uniquePolls(closedPolls) + + c.HTML(http.StatusOK, string(Closed), gin.H{ + "ClosedPolls": closedPolls, + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, + }) +} + +func getCreatePage(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(claims.UserInfo.Groups, "active") { + c.HTML(http.StatusForbidden, string(Unauthorized), gin.H{ + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, }) - })) + return + } - r.GET("/create", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) - if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(claims.UserInfo.Groups, "active") { - c.HTML(http.StatusForbidden, "unauthorized.tmpl", gin.H{ - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - }) - return - } + c.HTML(http.StatusOK, string(Create), gin.H{ + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, + "IsEvals": isEvals(claims.UserInfo), + }) +} - c.HTML(http.StatusOK, "create.tmpl", gin.H{ +func createPoll(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(claims.UserInfo.Groups, "active") { + c.HTML(http.StatusForbidden, string(Unauthorized), gin.H{ "Username": claims.UserInfo.Username, "FullName": claims.UserInfo.FullName, - "IsEvals": isEvals(claims.UserInfo), }) - })) + return + } + + quorumType := c.PostForm("quorumType") + var quorum float64 + switch quorumType { + case "12": + quorum = 1.0 / 2.0 + case "23": + quorum = 2.0 / 3.0 + default: + quorum = 1.0 / 2.0 + } + + poll := &database.Poll{ + Id: "", + CreatedBy: claims.UserInfo.Username, + Title: c.PostForm("title"), + Description: c.PostForm("description"), + VoteType: database.POLL_TYPE_SIMPLE, + OpenedTime: time.Now(), + Open: true, + QuorumType: float64(quorum), + Gatekeep: c.PostForm("gatekeep") == "true", + AllowWriteIns: c.PostForm("allowWriteIn") == "true", + Hidden: c.PostForm("hidden") == "true", + } + switch c.PostForm("pollType") { + case "rankedChoice": + poll.VoteType = database.POLL_TYPE_RANKED + case "eboard": + eboard := oidcClient.GetEBoard() + var usernames []string + for _, member := range eboard { + usernames = append(usernames, member.Username) + } + poll.AllowedUsers = usernames + poll.AllowWriteIns = false + poll.Hidden = true + poll.Gatekeep = false + } - r.POST("/create", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) - if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(claims.UserInfo.Groups, "active") { - c.HTML(http.StatusForbidden, "unauthorized.tmpl", gin.H{ + switch c.PostForm("options") { + case "pass-fail-conditional": + poll.Options = []string{"Pass", "Fail/Conditional", "Abstain"} + case "fail-conditional": + poll.Options = []string{"Fail", "Conditional", "Abstain"} + case "custom": + poll.Options = []string{} + for opt := range strings.SplitSeq(c.PostForm("customOptions"), ",") { + poll.Options = append(poll.Options, strings.TrimSpace(opt)) + if !slices.Contains(poll.Options, "Abstain") && (poll.VoteType == database.POLL_TYPE_SIMPLE) { + poll.Options = append(poll.Options, "Abstain") + } + } + case "pass-fail": + default: + poll.Options = []string{"Pass", "Fail", "Abstain"} + } + if poll.Gatekeep { + if !isEvals(claims.UserInfo) { + c.HTML(http.StatusForbidden, string(Unauthorized), gin.H{ "Username": claims.UserInfo.Username, "FullName": claims.UserInfo.FullName, }) return } - - quorumType := c.PostForm("quorumType") - var quorum float64 - switch quorumType { - case "12": - quorum = 1.0 / 2.0 - case "23": - quorum = 2.0 / 3.0 - default: - quorum = 1.0 / 2.0 + poll.AllowedUsers = GetEligibleVoters() + for user := range strings.SplitSeq(c.PostForm("waivedUsers"), ",") { + poll.AllowedUsers = append(poll.AllowedUsers, strings.TrimSpace(user)) } + } - poll := &database.Poll{ - Id: "", - CreatedBy: claims.UserInfo.Username, - Title: c.PostForm("title"), - Description: c.PostForm("description"), - VoteType: database.POLL_TYPE_SIMPLE, - OpenedTime: time.Now(), - Open: true, - QuorumType: float64(quorum), - Gatekeep: c.PostForm("gatekeep") == "true", - AllowWriteIns: c.PostForm("allowWriteIn") == "true", - Hidden: c.PostForm("hidden") == "true", - } - switch c.PostForm("pollType") { - case "rankedChoice": - poll.VoteType = database.POLL_TYPE_RANKED - case "eboard": - eboard := oidcClient.GetEBoard() - var usernames []string - for _, member := range eboard { - usernames = append(usernames, member.Username) - } - poll.AllowedUsers = usernames - poll.AllowWriteIns = false - poll.Hidden = true - poll.Gatekeep = false - } + pollId, err := database.CreatePoll(c, poll) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - switch c.PostForm("options") { - case "pass-fail-conditional": - poll.Options = []string{"Pass", "Fail/Conditional", "Abstain"} - case "fail-conditional": - poll.Options = []string{"Fail", "Conditional", "Abstain"} - case "custom": - poll.Options = []string{} - for opt := range strings.SplitSeq(c.PostForm("customOptions"), ",") { - poll.Options = append(poll.Options, strings.TrimSpace(opt)) - if !slices.Contains(poll.Options, "Abstain") && (poll.VoteType == database.POLL_TYPE_SIMPLE) { - poll.Options = append(poll.Options, "Abstain") - } - } - case "pass-fail": - default: - poll.Options = []string{"Pass", "Fail", "Abstain"} - } - if poll.Gatekeep { - if !isEvals(claims.UserInfo) { - c.HTML(http.StatusForbidden, "unauthorized.tmpl", gin.H{ - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - }) - return - } - poll.AllowedUsers = GetEligibleVoters() - for user := range strings.SplitSeq(c.PostForm("waivedUsers"), ",") { - poll.AllowedUsers = append(poll.AllowedUsers, strings.TrimSpace(user)) - } - } + c.Redirect(http.StatusFound, "/poll/"+pollId) +} - pollId, err := database.CreatePoll(c, poll) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } +func getPollById(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + // This is intentionally left unprotected + // We will check if a user can vote and redirect them to results if not later - c.Redirect(http.StatusFound, "/poll/"+pollId) - })) + poll, err := database.GetPoll(c, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - r.GET("/poll/:id", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) - // This is intentionally left unprotected - // We will check if a user can vote and redirect them to results if not later + // If the user can't vote, just show them results + if canVote(claims.UserInfo, *poll, poll.AllowedUsers) > 0 || !poll.Open { + c.Redirect(http.StatusFound, "/results/"+poll.Id) + return + } - poll, err := database.GetPoll(c, c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } + writeInAdj := 0 + if poll.AllowWriteIns { + writeInAdj = 1 + } - // If the user can't vote, just show them results - if canVote(claims.UserInfo, *poll, poll.AllowedUsers) > 0 || !poll.Open { - c.Redirect(http.StatusFound, "/results/"+poll.Id) - return - } + canModify := slices.Contains(claims.UserInfo.Groups, "active_rtp") || slices.Contains(claims.UserInfo.Groups, "eboard") || poll.CreatedBy == claims.UserInfo.Username + + c.HTML(200, string(Poll), gin.H{ + "Id": poll.Id, + "Title": poll.Title, + "Description": poll.Description, + "Options": poll.Options, + "PollType": poll.VoteType, + "RankedMax": fmt.Sprint(len(poll.Options) + writeInAdj), + "AllowWriteIns": poll.AllowWriteIns, + "CanModify": canModify, + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, + }) +} - writeInAdj := 0 - if poll.AllowWriteIns { - writeInAdj = 1 - } +func voteInPoll(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) - canModify := slices.Contains(claims.UserInfo.Groups, "active_rtp") || slices.Contains(claims.UserInfo.Groups, "eboard") || poll.CreatedBy == claims.UserInfo.Username - - c.HTML(200, "poll.tmpl", gin.H{ - "Id": poll.Id, - "Title": poll.Title, - "Description": poll.Description, - "Options": poll.Options, - "PollType": poll.VoteType, - "RankedMax": fmt.Sprint(len(poll.Options) + writeInAdj), - "AllowWriteIns": poll.AllowWriteIns, - "CanModify": canModify, - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - }) - })) + poll, err := database.GetPoll(c, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - r.POST("/poll/:id", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) + if canVote(claims.UserInfo, *poll, poll.AllowedUsers) > 0 || !poll.Open { + c.Redirect(http.StatusFound, "/results/"+poll.Id) + return + } - poll, err := database.GetPoll(c, c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } + pId, err := primitive.ObjectIDFromHex(poll.Id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - if canVote(claims.UserInfo, *poll, poll.AllowedUsers) > 0 || !poll.Open { - c.Redirect(http.StatusFound, "/results/"+poll.Id) - return + if poll.VoteType == database.POLL_TYPE_SIMPLE { + vote := database.SimpleVote{ + Id: "", + PollId: pId, + Option: c.PostForm("option"), + } + voter := database.Voter{ + PollId: pId, + UserId: claims.UserInfo.Username, } - pId, err := primitive.ObjectIDFromHex(poll.Id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + if hasOption(poll, c.PostForm("option")) { + vote.Option = c.PostForm("option") + } else if poll.AllowWriteIns && c.PostForm("option") == "writein" { + vote.Option = c.PostForm("writeinOption") + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Option"}) return } + database.CastSimpleVote(c, &vote, &voter) + } else if poll.VoteType == database.POLL_TYPE_RANKED { + vote := database.RankedVote{ + Id: "", + PollId: pId, + Options: make(map[string]int), + } + voter := database.Voter{ + PollId: pId, + UserId: claims.UserInfo.Username, + } - if poll.VoteType == database.POLL_TYPE_SIMPLE { - vote := database.SimpleVote{ - Id: "", - PollId: pId, - Option: c.PostForm("option"), - } - voter := database.Voter{ - PollId: pId, - UserId: claims.UserInfo.Username, - } + for _, option := range poll.Options { + optionRankStr := c.PostForm(option) + optionRank, err := strconv.Atoi(optionRankStr) - if hasOption(poll, c.PostForm("option")) { - vote.Option = c.PostForm("option") - } else if poll.AllowWriteIns && c.PostForm("option") == "writein" { - vote.Option = c.PostForm("writeinOption") - } else { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Option"}) - return - } - database.CastSimpleVote(c, &vote, &voter) - } else if poll.VoteType == database.POLL_TYPE_RANKED { - vote := database.RankedVote{ - Id: "", - PollId: pId, - Options: make(map[string]int), + if len(optionRankStr) < 1 { + continue } - voter := database.Voter{ - PollId: pId, - UserId: claims.UserInfo.Username, + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "non-number ranking"}) + return } - for _, option := range poll.Options { - optionRankStr := c.PostForm(option) - optionRank, err := strconv.Atoi(optionRankStr) + vote.Options[option] = optionRank + } - if len(optionRankStr) < 1 { - continue - } - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "non-number ranking"}) + // process write-in + if c.PostForm("writeinOption") != "" && c.PostForm("writein") != "" { + for candidate := range vote.Options { + if strings.EqualFold(candidate, strings.TrimSpace(c.PostForm("writeinOption"))) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Write-in is already an option"}) return } - - vote.Options[option] = optionRank } - - // process write-in - if c.PostForm("writeinOption") != "" && c.PostForm("writein") != "" { - for candidate := range vote.Options { - if strings.EqualFold(candidate, strings.TrimSpace(c.PostForm("writeinOption"))) { - c.JSON(http.StatusBadRequest, gin.H{"error": "Write-in is already an option"}) - return - } - } - rank, err := strconv.Atoi(c.PostForm("writein")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Write-in rank is not numerical"}) - return - } - if rank < 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Write-in rank is not positive"}) - return - } - vote.Options[c.PostForm("writeinOption")] = rank + rank, err := strconv.Atoi(c.PostForm("writein")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Write-in rank is not numerical"}) + return + } + if rank < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Write-in rank is not positive"}) + return } + vote.Options[c.PostForm("writeinOption")] = rank + } - maxNum := len(vote.Options) - voted := make([]bool, maxNum) - - for _, rank := range vote.Options { - if rank > 0 && rank <= maxNum { - if voted[rank-1] { - c.JSON(http.StatusBadRequest, gin.H{"error": "You ranked two or more candidates at the same level"}) - return - } - voted[rank-1] = true - } else { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("votes must be from 1 - %d", maxNum)}) + maxNum := len(vote.Options) + voted := make([]bool, maxNum) + + for _, rank := range vote.Options { + if rank > 0 && rank <= maxNum { + if voted[rank-1] { + c.JSON(http.StatusBadRequest, gin.H{"error": "You ranked two or more candidates at the same level"}) return } + voted[rank-1] = true + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("votes must be from 1 - %d", maxNum)}) + return } + } - rankedCandidates := len(vote.Options) - for _, voteOpt := range vote.Options { - if voteOpt > rankedCandidates { - c.JSON(http.StatusBadRequest, gin.H{"error": "Rank choice is more than the amount of candidates ranked"}) - return - } + rankedCandidates := len(vote.Options) + for _, voteOpt := range vote.Options { + if voteOpt > rankedCandidates { + c.JSON(http.StatusBadRequest, gin.H{"error": "Rank choice is more than the amount of candidates ranked"}) + return } - database.CastRankedVote(c, &vote, &voter) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Unknown Poll Type"}) - return } + database.CastRankedVote(c, &vote, &voter) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Unknown Poll Type"}) + return + } - if poll, err := database.GetPoll(c, c.Param("id")); err == nil { - if results, err := poll.GetResult(c); err == nil { - if bytes, err := json.Marshal(results); err == nil { - broker.Notifier <- sse.NotificationEvent{ - EventName: poll.Id, - Payload: string(bytes), - } + if poll, err := database.GetPoll(c, c.Param("id")); err == nil { + if results, err := poll.GetResult(c); err == nil { + if bytes, err := json.Marshal(results); err == nil { + broker.Notifier <- sse.NotificationEvent{ + EventName: poll.Id, + Payload: string(bytes), } - } - } - c.Redirect(http.StatusFound, "/results/"+poll.Id) - })) + } + } - r.GET("/results/:id", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) - // This is intentionally left unprotected - // A user may be unable to vote but still interested in the results of a poll + c.Redirect(http.StatusFound, "/results/"+poll.Id) +} - poll, err := database.GetPoll(c, c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } +// displayResults calculates and displays the results of the poll on the results page +func displayResults(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + // This is intentionally left unprotected + // A user may be unable to vote but still interested in the results of a poll - results, err := poll.GetResult(c) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } + poll, err := database.GetPoll(c, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - canModify := slices.Contains(claims.UserInfo.Groups, "active_rtp") || slices.Contains(claims.UserInfo.Groups, "eboard") || poll.CreatedBy == claims.UserInfo.Username - - votesNeededForQuorum := int(poll.QuorumType * float64(len(poll.AllowedUsers))) - c.HTML(http.StatusOK, "result.tmpl", gin.H{ - "Id": poll.Id, - "Title": poll.Title, - "Description": poll.Description, - "VoteType": poll.VoteType, - "Results": results, - "IsOpen": poll.Open, - "IsHidden": poll.Hidden, - "CanModify": canModify, - "CanVote": canVote(claims.UserInfo, *poll, poll.AllowedUsers), - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - "Gatekeep": poll.Gatekeep, - "Quorum": strconv.FormatFloat(poll.QuorumType*100.0, 'f', 0, 64), - "EligibleVoters": poll.AllowedUsers, - "VotesNeededForQuorum": votesNeededForQuorum, - }) - })) + results, err := poll.GetResult(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - r.POST("/poll/:id/hide", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) + canModify := slices.Contains(claims.UserInfo.Groups, "active_rtp") || slices.Contains(claims.UserInfo.Groups, "eboard") || poll.CreatedBy == claims.UserInfo.Username + + votesNeededForQuorum := int(poll.QuorumType * float64(len(poll.AllowedUsers))) + c.HTML(http.StatusOK, string(Result), gin.H{ + "Id": poll.Id, + "Title": poll.Title, + "Description": poll.Description, + "VoteType": poll.VoteType, + "Results": results, + "IsOpen": poll.Open, + "IsHidden": poll.Hidden, + "CanModify": canModify, + "CanVote": canVote(claims.UserInfo, *poll, poll.AllowedUsers), + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, + "Gatekeep": poll.Gatekeep, + "Quorum": strconv.FormatFloat(poll.QuorumType*100.0, 'f', 0, 64), + "EligibleVoters": poll.AllowedUsers, + "VotesNeededForQuorum": votesNeededForQuorum, + }) +} - poll, err := database.GetPoll(c, c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } +// hidePoll makes the results of a specific poll hidden until the poll closes. +// +// Results are automatically unhidden when the poll closes +func hidePoll(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) - if poll.CreatedBy != claims.UserInfo.Username { - c.JSON(http.StatusForbidden, gin.H{"error": "Only the creator can hide a poll result"}) - return - } + poll, err := database.GetPoll(c, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - err = poll.Hide(c) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - pId, _ := primitive.ObjectIDFromHex(poll.Id) - action := database.Action{ - Id: "", - PollId: pId, - Date: primitive.NewDateTimeFromTime(time.Now()), - User: claims.UserInfo.Username, - Action: "Hide Results", - } - err = database.WriteAction(c, &action) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } + if poll.CreatedBy != claims.UserInfo.Username { + c.JSON(http.StatusForbidden, gin.H{"error": "Only the creator can hide a poll result"}) + return + } - c.Redirect(http.StatusFound, "/results/"+poll.Id) - })) + err = poll.Hide(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + pId, _ := primitive.ObjectIDFromHex(poll.Id) + action := database.Action{ + Id: "", + PollId: pId, + Date: primitive.NewDateTimeFromTime(time.Now()), + User: claims.UserInfo.Username, + Action: "Hide Results", + } + err = database.WriteAction(c, &action) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - r.POST("/poll/:id/close", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) - // This is intentionally left unprotected - // A user should be able to end their own polls, regardless of if they can vote + c.Redirect(http.StatusFound, "/results/"+poll.Id) +} - poll, err := database.GetPoll(c, c.Param("id")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } +// closePoll Ends the voting period on a particular poll +func closePoll(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + // This is intentionally left unprotected + // A user should be able to end their own polls, regardless of if they can vote - if poll.Gatekeep { - c.JSON(http.StatusForbidden, gin.H{"error": "This poll cannot be closed manually"}) - return - } + poll, err := database.GetPoll(c, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - if poll.CreatedBy != claims.UserInfo.Username { - if !(slices.Contains(claims.UserInfo.Groups, "active_rtp") || slices.Contains(claims.UserInfo.Groups, "eboard")) { - c.JSON(http.StatusForbidden, gin.H{"error": "You cannot end this poll."}) - return - } - } + if poll.Gatekeep { + c.JSON(http.StatusForbidden, gin.H{"error": "This poll cannot be closed manually"}) + return + } - err = poll.Close(c) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - pId, _ := primitive.ObjectIDFromHex(poll.Id) - action := database.Action{ - Id: "", - PollId: pId, - Date: primitive.NewDateTimeFromTime(time.Now()), - User: claims.UserInfo.Username, - Action: "Close/End Poll", - } - err = database.WriteAction(c, &action) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + if poll.CreatedBy != claims.UserInfo.Username { + if !(slices.Contains(claims.UserInfo.Groups, "active_rtp") || slices.Contains(claims.UserInfo.Groups, "eboard")) { + c.JSON(http.StatusForbidden, gin.H{"error": "You cannot end this poll."}) return } + } - c.Redirect(http.StatusFound, "/results/"+poll.Id) - })) - - r.GET("/stream/:topic", csh.AuthWrapper(broker.ServeHTTP)) + err = poll.Close(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + pId, _ := primitive.ObjectIDFromHex(poll.Id) + action := database.Action{ + Id: "", + PollId: pId, + Date: primitive.NewDateTimeFromTime(time.Now()), + User: claims.UserInfo.Username, + Action: "Close/End Poll", + } + err = database.WriteAction(c, &action) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - go broker.Listen() + c.Redirect(http.StatusFound, "/results/"+poll.Id) +} - r.Run() +func inc(x int) string { + return strconv.Itoa(x + 1) } -// isEvals determines if the current user is evals, allowing for a dev mode override +// isEvals Determines if the current user is evals, allowing for a dev mode override func isEvals(user cshAuth.CSHUserInfo) bool { return DEV_FORCE_IS_EVALS || slices.Contains(user.Groups, "eboard-evaluations") } +// isChair Determines if the current user is chair, allowing for a dev mode override func isChair(user cshAuth.CSHUserInfo) bool { return DEV_FORCE_IS_CHAIR || slices.Contains(user.Groups, "eboard-chairman") } -// canVote determines whether a user can cast a vote. +// canVote Determines whether a user can cast a vote. // -// returns an integer value: 0 is success, 1 is database error, 3 is not active, 4 is gatekept, 9 is already voted -// TODO: use the return value to influence messages shown on results page +// 0 -> Allowed to vote +// 1 -> Database error +// 3 -> Not active +// 4 -> Gatekept +// 9 -> Already voted func canVote(user cshAuth.CSHUserInfo, poll database.Poll, allowedUsers []string) int { // always false if user is not active if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(user.Groups, "active") { return 3 } + voted, err := database.HasVoted(context.Background(), poll.Id, user.Username) + if err != nil { logging.Logger.WithFields(logrus.Fields{"method": "canVote"}).Error(err) return 1 } + if voted { return 9 } - if poll.Gatekeep { //if gatekeep is enabled, but they aren't allowed to vote in the poll, false + + // If gatekeep is enabled, but they aren't allowed to vote in the poll, false + if poll.Gatekeep { if !slices.Contains(allowedUsers, user.Username) { return 4 } - } //otherwise true + } + + // Otherwise true return 0 } +// uniquePolls Retrieves a list of all unique polls that exist in the database func uniquePolls(polls []*database.Poll) []*database.Poll { var unique []*database.Poll for _, poll := range polls { @@ -599,6 +623,7 @@ func uniquePolls(polls []*database.Poll) []*database.Poll { return unique } +// containsPoll Determines whether a poll with a specific id exists in the database func containsPoll(polls []*database.Poll, poll *database.Poll) bool { for _, p := range polls { if p.Id == poll.Id { @@ -608,6 +633,7 @@ func containsPoll(polls []*database.Poll, poll *database.Poll) bool { return false } +// hasOption Determines whether a particular poll contains a specified option func hasOption(poll *database.Poll, option string) bool { for _, opt := range poll.Options { if opt == option { @@ -616,3 +642,21 @@ func hasOption(poll *database.Poll, option string) bool { } return false } + +// GetVoterCount Gets the number of people eligible to vote in a poll +func GetVoterCount(poll database.Poll) int { + return len(poll.AllowedUsers) +} + +// CalculateQuorum Calculates the number of votes required for quorum in a poll +func CalculateQuorum(poll database.Poll) int { + voterCount := GetVoterCount(poll) + return int(math.Ceil(float64(voterCount) * poll.QuorumType)) +} + +func MakeLinks(s string) template.HTML { + rx := xurls.Strict() + s = template.HTMLEscapeString(s) + safe := rx.ReplaceAllString(s, `$0`) + return template.HTML(safe) +}