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
31 changes: 31 additions & 0 deletions .github/workflows/go-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Golang Linting

on:
push:
branches: [master, dev]
pull_request:
branches: [master, dev]

jobs:
golang-checks:
runs-on: ubuntu-latest

strategy:
matrix:
go-version: [1.24]

steps:
- uses: actions/checkout@v2
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Install dependencies
run: |
go mod download
- name: Tidy dependencies
run: |
go mod tidy -diff
- name: Check Format
run: |
gofmt -s -l database logging sse *.go
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ FROM docker.io/golang:1.24-alpine AS build

WORKDIR /src/
RUN apk add git
COPY go* .
COPY *.go .
COPY go.* .
RUN go mod download # do this before build for caching
COPY database database
COPY logging logging
COPY sse sse
COPY *.go .
RUN go build -v -o vote

FROM docker.io/alpine
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ Implementation

## 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!
If you're using the compose file, you'll need to ask an RTP for the vote-dev OIDC secret, and set it as `VOTE_OIDC_SECRET` in your environment

If you're not using the compose file, you'll need more of these

```
VOTE_HOST=http://localhost:8080
Expand All @@ -27,10 +29,25 @@ VOTE_SLACK_APP_TOKEN=
VOTE_SLACK_BOT_TOKEN=
```

### Dev Overrides
`DEV_DISABLE_ACTIVE_FILTERS="true"` will disable the requirements that you be active to vote
`DEV_FORCE_IS_EVALS="true"` will force vote to treat all users as the Evals director

## Linting
These will be checked by CI

```
# tidy dependencies
go mod tidy

# format all code according to go standards
gofmt -w -s *.go logging sse database
```

## To-Dos

- [ ] Don't let the user fuck it up
- [ ] Show E-Board polls with a higher priority
- [ ] Move Hide Vote to create instead of after you vote :skull:
- [x] Move Hide Vote to create instead of after you vote :skull:
- [ ] Display the reason why a user is on the results page of a running poll
- [ ] Display minimum time left that a poll is open
19 changes: 4 additions & 15 deletions database/poll.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ type Poll struct {
Gatekeep bool `bson:"gatekeep"`
QuorumType float64 `bson:"quorumType"`
AllowedUsers []string `bson:"allowedUsers"`
Hidden bool `bson:"hidden"`
AllowWriteIns bool `bson:"writeins"`

// Prevent this poll from having progress displayed
// This is important for events like elections where the results shouldn't be visible mid vote
Hidden bool `bson:"hidden"`
}

const POLL_TYPE_SIMPLE = "simple"
Expand Down Expand Up @@ -69,20 +72,6 @@ func (poll *Poll) Hide(ctx context.Context) error {
return nil
}

func (poll *Poll) Reveal(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

objId, _ := primitive.ObjectIDFromHex(poll.Id)

_, err := Client.Database(db).Collection("polls").UpdateOne(ctx, map[string]interface{}{"_id": objId}, map[string]interface{}{"$set": map[string]interface{}{"hidden": false}})
if err != nil {
return err
}

return nil
}

func CreatePoll(ctx context.Context, poll *Poll) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
Expand Down
1 change: 0 additions & 1 deletion database/ranked_vote.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ type RankedVote struct {
Options map[string]int `bson:"options"`
}


func CastRankedVote(ctx context.Context, vote *RankedVote, voter *Voter) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ services:
VOTE_OIDC_ID: vote-dev
VOTE_OIDC_SECRET: "${VOTE_OIDC_SECRET}"
VOTE_STATE: 27a28540e47ec786b7bdad03f83171b3
DEV_DISABLE_ACTIVE_FILTERS: "${DEV_DISABLE_ACTIVE_FILTERS}"
DEV_FORCE_IS_EVALS: "${DEV_FORCE_IS_EVALS}"
ports:
- "127.0.0.1:8080:8080"

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/computersciencehouse/csh-auth v0.1.0
github.com/gin-gonic/gin v1.11.0
github.com/sirupsen/logrus v1.9.3
github.com/slack-go/slack v0.17.3
go.mongodb.org/mongo-driver v1.17.6
mvdan.cc/xurls/v2 v2.6.0
)
Expand Down Expand Up @@ -38,7 +39,6 @@ require (
github.com/pquerna/cachecontrol v0.2.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/slack-go/slack v0.17.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
Expand Down Expand Up @@ -97,8 +99,6 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
Expand Down
75 changes: 17 additions & 58 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ var VOTE_TOKEN = os.Getenv("VOTE_TOKEN")
var CONDITIONAL_GATEKEEP_URL = os.Getenv("VOTE_CONDITIONAL_URL")
var VOTE_HOST = os.Getenv("VOTE_HOST")

// Dev mode flags
var DEV_DISABLE_ACTIVE_FILTERS bool = os.Getenv("DEV_DISABLE_ACTIVE_FILTERS") == "true"
var DEV_FORCE_IS_EVALS bool = os.Getenv("DEV_FORCE_IS_EVALS") == "true"

func inc(x int) string {
return strconv.Itoa(x + 1)
}
Expand Down Expand Up @@ -67,7 +71,7 @@ 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)
// 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)
Expand Down Expand Up @@ -111,7 +115,7 @@ func main() {
r.GET("/create", csh.AuthWrapper(func(c *gin.Context) {
cl, _ := c.Get("cshauth")
claims := cl.(cshAuth.CSHClaims)
if !slices.Contains(claims.UserInfo.Groups, "active") {
if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(claims.UserInfo.Groups, "active") {
c.HTML(403, "unauthorized.tmpl", gin.H{
"Username": claims.UserInfo.Username,
"FullName": claims.UserInfo.FullName,
Expand All @@ -122,14 +126,14 @@ func main() {
c.HTML(200, "create.tmpl", gin.H{
"Username": claims.UserInfo.Username,
"FullName": claims.UserInfo.FullName,
"IsEvals": containsString(claims.UserInfo.Groups, "eboard-evaluations"),
"IsEvals": isEvals(claims.UserInfo),
})
}))

r.POST("/create", csh.AuthWrapper(func(c *gin.Context) {
cl, _ := c.Get("cshauth")
claims := cl.(cshAuth.CSHClaims)
if !slices.Contains(claims.UserInfo.Groups, "active") {
if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(claims.UserInfo.Groups, "active") {
c.HTML(403, "unauthorized.tmpl", gin.H{
"Username": claims.UserInfo.Username,
"FullName": claims.UserInfo.FullName,
Expand Down Expand Up @@ -157,9 +161,9 @@ func main() {
OpenedTime: time.Now(),
Open: true,
QuorumType: quorum,
Hidden: false,
Gatekeep: c.PostForm("gatekeep") == "true",
AllowWriteIns: c.PostForm("allowWriteIn") == "true",
Hidden: c.PostForm("hidden") == "true",
}
if c.PostForm("rankedChoice") == "true" {
poll.VoteType = database.POLL_TYPE_RANKED
Expand All @@ -183,7 +187,7 @@ func main() {
poll.Options = []string{"Pass", "Fail", "Abstain"}
}
if poll.Gatekeep {
if !slices.Contains(claims.UserInfo.Groups, "eboard-evaluations") {
if !isEvals(claims.UserInfo) {
c.HTML(403, "unauthorized.tmpl", gin.H{
"Username": claims.UserInfo.Username,
"FullName": claims.UserInfo.FullName,
Expand Down Expand Up @@ -229,9 +233,6 @@ func main() {
}

canModify := containsString(claims.UserInfo.Groups, "active_rtp") || containsString(claims.UserInfo.Groups, "eboard") || poll.CreatedBy == claims.UserInfo.Username
if poll.Gatekeep {
canModify = false
}

c.HTML(200, "poll.tmpl", gin.H{
"Id": poll.Id,
Expand Down Expand Up @@ -392,24 +393,13 @@ func main() {
return
}

if poll.Hidden && poll.CreatedBy != claims.UserInfo.Username {
c.HTML(403, "hidden.tmpl", gin.H{
"Username": claims.UserInfo.Username,
"FullName": claims.UserInfo.FullName,
})
return
}

results, err := poll.GetResult(c)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}

canModify := containsString(claims.UserInfo.Groups, "active_rtp") || containsString(claims.UserInfo.Groups, "eboard") || poll.CreatedBy == claims.UserInfo.Username
if poll.Gatekeep {
canModify = false
}

c.HTML(200, "result.tmpl", gin.H{
"Id": poll.Id,
Expand All @@ -422,6 +412,7 @@ func main() {
"CanModify": canModify,
"Username": claims.UserInfo.Username,
"FullName": claims.UserInfo.FullName,
"Gatekeep": poll.Gatekeep,
})
}))

Expand Down Expand Up @@ -462,43 +453,6 @@ func main() {
c.Redirect(302, "/results/"+poll.Id)
}))

r.POST("/poll/:id/reveal", csh.AuthWrapper(func(c *gin.Context) {
cl, _ := c.Get("cshauth")
claims := cl.(cshAuth.CSHClaims)

poll, err := database.GetPoll(c, c.Param("id"))
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}

if poll.CreatedBy != claims.UserInfo.Username {
c.JSON(403, gin.H{"error": "Only the creator can reveal a poll result"})
return
}

err = poll.Reveal(c)
if err != nil {
c.JSON(500, 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: "Reveal Results",
}
err = database.WriteAction(c, &action)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}

c.Redirect(302, "/results/"+poll.Id)
}))

r.POST("/poll/:id/close", csh.AuthWrapper(func(c *gin.Context) {
cl, _ := c.Get("cshauth")
claims := cl.(cshAuth.CSHClaims)
Expand Down Expand Up @@ -553,13 +507,18 @@ func main() {
r.Run()
}

// 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 || containsString(user.Groups, "eboard-evaluations")
}

// 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
func canVote(user cshAuth.CSHUserInfo, poll database.Poll, allowedUsers []string) int {
// always false if user is not active
if !slices.Contains(user.Groups, "active") {
if !DEV_DISABLE_ACTIVE_FILTERS && !slices.Contains(user.Groups, "active") {
return 3
}
voted, err := database.HasVoted(context.Background(), poll.Id, user.Username)
Expand Down
10 changes: 9 additions & 1 deletion templates/create.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@
>
<span>Ranked Choice Vote</span>
</div>
<div class="form-group">
<input
type="checkbox"
name="hidden"
value="true"
>
<span>Hide Results Till Vote is Complete</span>
</div>
{{ if .IsEvals }}
<div class="form-group">
<input
Expand All @@ -81,7 +89,7 @@
value="true"
onchange="onGatekeepChange()"
>
<span> Gatekeep Required</span>
<span> Gatekeep Required (Require Quorum, Limit Voters, Force Automatic Close)</span>
</div>
<div id="quorumTypeGroup" class="form-group" style="display:none">
<span>Quorum Type</span>
Expand Down
11 changes: 5 additions & 6 deletions templates/result.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
<br />
<br />

{{ if and $.IsHidden $.IsOpen }}
<div id="results">
<h4>Results will be available once the poll closes</h4>
</div>
{{ else }}
<div id="results">
{{ range $i, $val := .Results }}
{{ if eq $.VoteType "ranked" }}
Expand All @@ -46,12 +51,6 @@
{{ end }}
{{ end }}
</div>
{{ if and (.CanModify) (.IsHidden) }}
<br />
<br />
<form action="/poll/{{ .Id }}/reveal" method="POST">
<button type="submit" class="btn btn-success">Reveal Votes</button>
</form>
{{ end }}
{{ if and (.CanModify) (not .IsHidden) }}
<br />
Expand Down