Skip to content
Merged
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
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions database/poll.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
82 changes: 67 additions & 15 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,26 @@ import (
"html/template"
"net/http"
"os"
"slices"
"sort"
"strconv"
"strings"
"time"

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)
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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" {
Expand All @@ -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")
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -221,20 +235,21 @@ 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 {
c.JSON(500, gin.H{"error": err.Error()})
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()})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" {
Expand All @@ -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 {
Expand Down
42 changes: 35 additions & 7 deletions templates/create.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<title>CSH Vote</title>
<!-- <link rel="stylesheet" href="https://themeswitcher.csh.rit.edu/api/get" /> -->
<link rel="stylesheet" href="https://assets.csh.rit.edu/csh-material-bootstrap/4.3.1/dist/csh-material-bootstrap.min.css" media="screen"/>
<link rel="stylesheet" href="https://assets.csh.rit.edu/csh-material-bootstrap/4.3.1/dist/csh-material-bootstrap.min.css" media="screen">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
Expand All @@ -28,15 +28,15 @@
class="form-control"
name="shortDescription"
placeholder="Short Description"
/>
>
</div>
<div class="form-group">
<input
type="text"
name="longDescription"
class="form-control"
placeholder="Long Description (Optional)"
/>
>
</div>
<div class="form-group">
<select name="options" id="options" onChange="onOptionsChange()" class="form-control">
Expand All @@ -54,25 +54,44 @@
name="customOptions"
class="form-control"
placeholder="Custom Options (Comma-separated)"
/>
>
</div>
<div class="form-group">
<input
type="checkbox"
name="allowWriteIn"
value="true"
/>
>
<span>Allow Write-In Votes</span>
</div>
<div class="form-group">
<input
type="checkbox"
name="rankedChoice"
value="true"
/>
>
<span>Ranked Choice Vote</span>
</div>
<input type="submit" class="btn btn-primary" value="Create" />
<div class="form-group">
<input
type="checkbox"
name="gatekeep"
id="gatekeep"
value="true"
checked
onchange="onGatekeepChange()"
>
<span> Gatekeep Required</span>
</div>
<div id="waivedUsers" class="form-group">
<input
type="text"
name="waivedUsers"
class="form-control"
placeholder="Waive Usernames (Comma-separated)"
>
</div>
<input type="submit" class="btn btn-primary" value="Create">
</form>
</div>
<script>
Expand All @@ -83,6 +102,15 @@
document.getElementById("customOptions").style.display = "none";
}
}
function onGatekeepChange(){
const gatekeepBox = document.getElementById("gatekeep");
const waivedUsers = document.getElementById("waivedUsers");
if (gatekeepBox.checked){
document.getElementById("waivedUsers").style.display = null
} else {
document.getElementById("waivedUsers").style.display = "none"
}
}
</script>
</body>
</html>