diff --git a/README.md b/README.md index ae35812..e050819 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,19 @@ # vote + because paper ballots are so 2019 Imagine this. You're a somehow still functioning student organization of computer nerds. You've been using paper ballots to vote for the last 40 years. But then, disaster strikes! Global ~~ligma~~ COVID takes over, and if you so much look at a slip of paper, The Virus will take you. Enter vote, a 🚀 blazingly fast 🚀... Wait. This is Go, not Rust. It can't be blazingly fast. Uhh... Enter vote, a reasonably fast voting app with less memory safety than if it was written in Rust. But hey, gotta Pokemon _Go_ to the polls somehow, right? Right...? This is why I'm a software engineer and not a comedian. Anyways, now we can vote online. It's cool, I guess? We have things such as: - - **Server-side rendering**. That's right, this site (should) (mostly) work without JavaScript. - - **Server Sent Events** for real-time vote results - - **~~Limited~~ voting options**. It's now just as good as Google Forms, but a lot less safe! That's what you get when a bored college student does this in their free time + +- **Server-side rendering**. That's right, this site (should) (mostly) work without JavaScript. +- **Server Sent Events** for real-time vote results +- **~~Limited~~ voting options**. It's now just as good as Google Forms, but a lot less safe! That's what you get when a bored college student does this in their free time ## Configuration + You'll need to set up these values in your environment. Ask an RTP for OIDC credentials. A docker-compose file is provided for convenience. Otherwise, I trust you to figure it out! + ``` VOTE_HOST=http://localhost:8080 VOTE_JWT_SECRET= @@ -18,9 +22,12 @@ VOTE_MONGODB_URI= VOTE_OIDC_ID=vote VOTE_OIDC_SECRET= VOTE_STATE= +VOTE_TOKEN= +VOTE_CONDITIONAL_URL=https://conditional.csh.rit.edu/gatekeep/ ``` ## To-Dos + - [x] Custom vote options - [x] Write-in votes - [x] Ranked choice voting diff --git a/database/poll.go b/database/poll.go index 699d95f..7a0c22d 100644 --- a/database/poll.go +++ b/database/poll.go @@ -17,6 +17,8 @@ type Poll struct { VoteType string `bson:"voteType"` Options []string `bson:"options"` Open bool `bson:"open"` + Gatekeep bool `bson:"gatekeep"` + WaivedUsers []string `bson:"waivedUsers"` Hidden bool `bson:"hidden"` AllowWriteIns bool `bson:"writeins"` } diff --git a/main.go b/main.go index 3510b22..ff40017 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "html/template" "net/http" "os" + "slices" "sort" "strconv" "strings" @@ -13,12 +14,18 @@ import ( cshAuth "github.com/computersciencehouse/csh-auth" "github.com/computersciencehouse/vote/database" + "github.com/computersciencehouse/vote/logging" "github.com/computersciencehouse/vote/sse" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" "go.mongodb.org/mongo-driver/bson/primitive" "mvdan.cc/xurls/v2" ) +var VOTE_TOKEN string = os.Getenv("VOTE_TOKEN") + +var CONDITIONAL_GATEKEEP_URL string = os.Getenv("VOTE_CONDITIONAL_URL") + func inc(x int) string { return strconv.Itoa(x + 1) } @@ -99,7 +106,7 @@ func main() { r.GET("/create", csh.AuthWrapper(func(c *gin.Context) { cl, _ := c.Get("cshauth") claims := cl.(cshAuth.CSHClaims) - if !canVote(claims.UserInfo.Groups) { + if !canVote(claims.UserInfo.Groups, claims.UserInfo.Username, false, []string{}) { c.HTML(403, "unauthorized.tmpl", gin.H{ "Username": claims.UserInfo.Username, "FullName": claims.UserInfo.FullName, @@ -116,7 +123,7 @@ func main() { r.POST("/create", csh.AuthWrapper(func(c *gin.Context) { cl, _ := c.Get("cshauth") claims := cl.(cshAuth.CSHClaims) - if !canVote(claims.UserInfo.Groups) { + if !canVote(claims.UserInfo.Groups, claims.UserInfo.Username, false, []string{}) { c.HTML(403, "unauthorized.tmpl", gin.H{ "Username": claims.UserInfo.Username, "FullName": claims.UserInfo.FullName, @@ -132,6 +139,7 @@ func main() { VoteType: database.POLL_TYPE_SIMPLE, Open: true, Hidden: false, + Gatekeep: c.PostForm("gatekeep") == "true", AllowWriteIns: c.PostForm("allowWriteIn") == "true", } if c.PostForm("rankedChoice") == "true" { @@ -145,7 +153,7 @@ func main() { poll.Options = []string{"Fail", "Conditional", "Abstain"} case "custom": poll.Options = []string{} - for _, opt := range strings.Split(c.PostForm("customOptions"), ",") { + for opt := range strings.SplitSeq(c.PostForm("customOptions"), ",") { poll.Options = append(poll.Options, strings.TrimSpace(opt)) if !containsString(poll.Options, "Abstain") && (poll.VoteType == database.POLL_TYPE_SIMPLE) { poll.Options = append(poll.Options, "Abstain") @@ -155,6 +163,12 @@ func main() { default: poll.Options = []string{"Pass", "Fail", "Abstain"} } + if poll.Gatekeep { + poll.WaivedUsers = []string{} + for user := range strings.SplitSeq(c.PostForm("waivedUsers"), ",") { + poll.WaivedUsers = append(poll.WaivedUsers, strings.TrimSpace(user)) + } + } pollId, err := database.CreatePoll(c, poll) if err != nil { @@ -178,7 +192,7 @@ func main() { } // If the user can't vote, just show them results - if !canVote(claims.UserInfo.Groups) { + if !canVote(claims.UserInfo.Groups, claims.UserInfo.Username, poll.Gatekeep, poll.WaivedUsers) { c.Redirect(302, "/results/"+poll.Id) return } @@ -221,13 +235,6 @@ func main() { r.POST("/poll/:id", csh.AuthWrapper(func(c *gin.Context) { cl, _ := c.Get("cshauth") claims := cl.(cshAuth.CSHClaims) - if !canVote(claims.UserInfo.Groups) { - c.HTML(403, "unauthorized.tmpl", gin.H{ - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - }) - return - } poll, err := database.GetPoll(c, c.Param("id")) if err != nil { @@ -235,6 +242,14 @@ func main() { return } + if !canVote(claims.UserInfo.Groups, claims.UserInfo.Username, poll.Gatekeep, poll.WaivedUsers) { + c.HTML(403, "unauthorized.tmpl", gin.H{ + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, + }) + return + } + hasVoted, err := database.HasVoted(c, poll.Id, claims.UserInfo.Username) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) @@ -297,7 +312,7 @@ func main() { } if rank > 0 && rank <= max_num { vote.Options[opt] = rank - voted[rank - 1] = true + voted[rank-1] = true } else { c.JSON(400, gin.H{"error": fmt.Sprintf("votes must be from 1 - %d", max_num)}) return @@ -513,7 +528,17 @@ func main() { r.Run() } -func canVote(groups []string) bool { +type Result struct { + Result bool `json:"result"` + H_Meetings int `json:"h_meetings"` + D_Meetings int `json:"d_meetings"` + T_Seminars int `json:"t_seminars"` +} + +func canVote(groups []string, username string, gatekeepEnforcedPoll bool, waivedUsers []string) bool { + if slices.Contains(waivedUsers, username) { + return true + } var active, fallCoop, springCoop bool for _, group := range groups { if group == "active" { @@ -530,11 +555,38 @@ func canVote(groups []string) bool { } } + passesGatekeep := true + // check if the user passes gatekeep + if gatekeepEnforcedPoll { + endpointURL := CONDITIONAL_GATEKEEP_URL + username + req, err := http.NewRequest("GET", endpointURL, nil) + if err != nil { + logging.Logger.WithFields(logrus.Fields{"method": "canVote"}).Error(err) + return false + } + req.Header.Add("X-VOTE-TOKEN", VOTE_TOKEN) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + logging.Logger.WithFields(logrus.Fields{"method": "canVote"}).Error(err) + return false + } + defer resp.Body.Close() + + var result Result + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + logging.Logger.WithFields(logrus.Fields{"method": "canVote"}).Error(err) + return false + } + passesGatekeep = result.Result + } if time.Now().Month() > time.July { - return active && !fallCoop + return active && !fallCoop && passesGatekeep } else { - return active && !springCoop + return active && !springCoop && passesGatekeep } + } func uniquePolls(polls []*database.Poll) []*database.Poll { diff --git a/templates/create.tmpl b/templates/create.tmpl index eaa6c40..7021880 100644 --- a/templates/create.tmpl +++ b/templates/create.tmpl @@ -3,7 +3,7 @@