diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 1cd3021..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,19 +0,0 @@ -## What - -_what the PR changes_ - -## Why - -_why these changes were made_ - -## Test Plan - -_how did you verify these changes did what you expected_ - -## Env Vars - -_did you add, remove, or rename any environment variables_ - -## Checklist - -- [ ] Tested all changes locally diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 78b4c46..923bf30 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -4,6 +4,7 @@ on: push: branches: - dev + pull_request: jobs: diff --git a/README.md b/README.md index e3ae5f0..306981a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ VOTE_SLACK_BOT_TOKEN= ### Dev Overrides `DEV_DISABLE_ACTIVE_FILTERS="true"` will disable the requirements that you be active to vote +`DEV_FORCE_IS_EBOARD="true"` will force vote to treat all users as E-Board members `DEV_FORCE_IS_EVALS="true"` will force vote to treat all users as the Evals director ## Linting @@ -56,7 +57,7 @@ go vet *.go - [ ] Don't let the user fuck it up - [ ] Show E-Board polls with a higher priority - [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 +- [X] Display the reason why a user is on the results page of a running poll - [ ] Display minimum time left that a poll is open - [ ] Move routes to their own functions -- [ ] Change HTTP resposne codes to be `http.something` instead of just a number +- [X] Change HTTP resposne codes to be `http.something` instead of just a number diff --git a/api.go b/api.go new file mode 100644 index 0000000..488ec4f --- /dev/null +++ b/api.go @@ -0,0 +1,577 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "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" +) + +// Gets the number of people eligible to vote in a poll +func GetVoterCount(poll database.Poll) int { + return len(poll.AllowedUsers) +} + +// 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)) +} + +// GetHomepage Displays the main page of the application, containing a list of all currently open polls +func GetHomepage(c *gin.Context) { + // This is intentionally left unprotected + // A user may be unable to vote but should still be able to see a list of polls + user := GetUserData(c) + + 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 + }) + + c.HTML(http.StatusOK, "index.tmpl", gin.H{ + "Polls": polls, + "Username": user.Username, + "FullName": user.FullName, + "EBoard": IsEboard(user), + }) +} + +// GetClosedPolls Displays a page containing a list of all closed polls that the user created or voted in +func GetClosedPolls(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, "closed.tmpl", gin.H{ + "ClosedPolls": closedPolls, + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, + }) +} + +// GetPollById Retreives the information about a specific poll and displays it on the page, allowing the user to cast a ballot +// +// If the user is not eligible to vote in a particular poll, they are automatically redirected to the results page for that poll +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 + + poll, err := database.GetPoll(c, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // 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 + } + + writeInAdj := 0 + if poll.AllowWriteIns { + writeInAdj = 1 + } + + canModify := IsActiveRTP(claims.UserInfo) || IsEboard(claims.UserInfo) || ownsPoll(poll, claims) + + 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, + }) +} + +// CreatePoll Submits the specific details of a new poll that a user wants to create to the database +func CreatePoll(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + + // If user is not active, display the unauthorized screen + if !IsActive(claims.UserInfo) { + c.HTML(http.StatusForbidden, "unauthorized.tmpl", 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 := &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", + } + if c.PostForm("rankedChoice") == "true" { + poll.VoteType = database.POLL_TYPE_RANKED + } + + 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 !IsEboard(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)) + } + } + + pollId, err := database.CreatePoll(c, poll) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.Redirect(http.StatusFound, "/poll/"+pollId) +} + +// GetCreatePage Displays the poll creation page to the user +func GetCreatePage(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + + // If the user is not active, display the unauthorized page + if !IsActive(claims.UserInfo) { + c.HTML(http.StatusForbidden, "unauthorized.tmpl", gin.H{ + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, + }) + return + } + + c.HTML(http.StatusOK, "create.tmpl", gin.H{ + "Username": claims.UserInfo.Username, + "FullName": claims.UserInfo.FullName, + "IsEboard": IsEboard(claims.UserInfo), + }) +} + +// GetPollResults Displays the results page for a specific poll +func GetPollResults(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 + + poll, err := database.GetPoll(c, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + results, err := poll.GetResult(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + canModify := IsActiveRTP(claims.UserInfo) || IsEboard(claims.UserInfo) || ownsPoll(poll, claims) + + 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, + }) +} + +// VoteInPoll Submits a users' vote in a specific poll +func VoteInPoll(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + + poll, err := database.GetPoll(c, c.Param("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 + } + + pId, err := primitive.ObjectIDFromHex(poll.Id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if poll.VoteType == database.POLL_TYPE_SIMPLE { + processSimpleVote(c, poll, pId, claims) + } else if poll.VoteType == database.POLL_TYPE_RANKED { + processRankedVote(c, poll, pId, claims) + } 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), + } + } + + } + } + + c.Redirect(http.StatusFound, "/results/"+poll.Id) +} + +// HidePollResults Makes the results for a particular poll hidden until the poll closes +// +// If results are hidden, navigating to the results page of that poll will show +// a page informing the user that the results are hidden, instead of the actual results +func HidePollResults(c *gin.Context) { + cl, _ := c.Get("cshauth") + claims := cl.(cshAuth.CSHClaims) + + poll, err := database.GetPoll(c, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if !ownsPoll(poll, claims) { + c.JSON(http.StatusForbidden, gin.H{"error": "Only the creator can hide a poll result"}) + 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 + } + + c.Redirect(http.StatusFound, "/results/"+poll.Id) +} + +// ClosePoll Sets a poll to no longer allow votes to be cast +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 + + poll, err := database.GetPoll(c, c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if poll.Gatekeep { + c.JSON(http.StatusForbidden, gin.H{"error": "This poll cannot be closed manually"}) + return + } + + if !ownsPoll(poll, claims) { + if !IsActiveRTP(claims.UserInfo) && !IsEboard(claims.UserInfo) { + c.JSON(http.StatusForbidden, gin.H{"error": "You cannot end this poll."}) + 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()}) + return + } + + c.Redirect(http.StatusFound, "/results/"+poll.Id) +} + +// ProcessSimpleVote Parses a simple ballot, validates it, and sends it to the database +func processSimpleVote(c *gin.Context, poll *database.Poll, pId primitive.ObjectID, claims cshAuth.CSHClaims) { + vote := database.SimpleVote{ + Id: "", + PollId: pId, + Option: c.PostForm("option"), + } + voter := database.Voter{ + PollId: pId, + UserId: claims.UserInfo.Username, + } + + 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) +} + +// ProcessRankedVote Parses the ranked choice ballot, validates it, and then sends it to the database +func processRankedVote(c *gin.Context, poll *database.Poll, pId primitive.ObjectID, claims cshAuth.CSHClaims) { + vote := database.RankedVote{ + Id: "", + PollId: pId, + Options: make(map[string]int), + } + voter := database.Voter{ + PollId: pId, + UserId: claims.UserInfo.Username, + } + + // Populate vote + for _, option := range poll.Options { + optionRankStr := c.PostForm(option) + optionRank, err := strconv.Atoi(optionRankStr) + + if len(optionRankStr) < 1 { + continue + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "non-number ranking"}) + 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 + } + + validateRankedBallot(c, vote) + + // Submit Vote + database.CastRankedVote(c, &vote, &voter) +} + +// ValidateRankedBallot Verifies that the ranked choice ballot a user is attempting to submit is a valid ranked choice vote +// +// Specifically, it checks that the ballot is not empty, that there are no duplicate rankings, and that all rankings are between 1 and the total number of candidates +func validateRankedBallot(c *gin.Context, vote database.RankedVote) { + // Perform checks, vote does not change beyond this + optionCount := len(vote.Options) + voted := make([]bool, optionCount) + + // Make sure vote is not empty + if optionCount == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "You did not rank any options"}) + return + } + + // Duplicate ranks and range check + for _, rank := range vote.Options { + if rank > 0 && rank <= optionCount { + if rank > optionCount { + c.JSON(http.StatusBadRequest, gin.H{"error": "Rank choice is more than the amount of candidates ranked"}) + return + } + 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("Candidates chosen must be from 1 to %d", optionCount)}) + return + } + } +} + +// canVote determines whether a user can cast a vote. +// +// Returns an integer value that indicates what the result is +// 0 -> User is allowed to vote (success) +// 1 -> Database error +// 3 -> User is not active +// 4 -> User doesnt meet gatekeep +// 9 -> User has 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 && !IsActive(user) { + 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 !slices.Contains(allowedUsers, user.Username) { + return 4 + } + } //otherwise true + return 0 +} + +// ownsPoll Returns whether a user is the owner of a particular poll +func ownsPoll(poll *database.Poll, claims cshAuth.CSHClaims) bool { + return poll.CreatedBy == claims.UserInfo.Username +} + +func uniquePolls(polls []*database.Poll) []*database.Poll { + var unique []*database.Poll + for _, poll := range polls { + if !containsPoll(unique, poll) { + unique = append(unique, poll) + } + } + return unique +} + +func containsPoll(polls []*database.Poll, poll *database.Poll) bool { + for _, p := range polls { + if p.Id == poll.Id { + return true + } + } + return false +} + +func hasOption(poll *database.Poll, option string) bool { + for _, opt := range poll.Options { + if opt == option { + return true + } + } + return false +} diff --git a/eboard.go b/eboard.go new file mode 100644 index 0000000..9bb1568 --- /dev/null +++ b/eboard.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "net/http" + "slices" + "strings" + + "github.com/gin-gonic/gin" +) + +var votes map[string]float32 +var voters []string + +var OPTIONS = []string{"Pass", "Fail", "Abstain"} + +func HandleGetEboardVote(c *gin.Context) { + user := GetUserData(c) + if !IsEboard(user) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "You need to be E-Board to access this page"}) + return + } + if votes == nil { + votes = make(map[string]float32) + } + if voters == nil { + voters = []string{} + } + fmt.Println(votes) + c.HTML(http.StatusOK, "eboard.tmpl", gin.H{ + "Username": user, + "Voted": slices.Contains(voters, user.Username), + "Results": votes, + "VoteCount": len(voters), + "Options": OPTIONS, + }) +} + +func HandlePostEboardVote(c *gin.Context) { + user := GetUserData(c) + if !IsEboard(user) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "You need to be E-Board to access this page"}) + return + } + if slices.Contains(voters, user.Username) { + c.JSON(http.StatusBadRequest, gin.H{"error": "You cannot vote again!"}) + return + } + i := slices.IndexFunc(user.Groups, func(s string) bool { + return strings.Contains(s, "eboard-") + }) + if i == -1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "You have the eboard group but not an eboard-[position] group. What is wrong with you?"}) + return + } + //get the eboard position, count the members, and divide one whole vote by the number of members in the position + position := user.Groups[i] + positionMembers := oidcClient.GetOIDCGroup(oidcClient.FindOIDCGroupID(position)) + weight := 1.0 / float32(len(positionMembers)) + fmt.Println(weight) + //post the vote + option := c.PostForm("option") + if option == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "You need to pick an option"}) + } + votes[option] = votes[option] + weight + voters = append(voters, user.Username) + c.Redirect(http.StatusFound, "/eboard") +} + +func HandleManageEboardVote(c *gin.Context) { + user := GetUserData(c) + if !IsEboard(user) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "You need to be E-Board to access this page"}) + return + } + if c.PostForm("clear_vote") != "" { + votes = make(map[string]float32) + voters = make([]string, 0) + } + c.Redirect(http.StatusFound, "/eboard") +} diff --git a/main.go b/main.go index 7e9239e..e53aad4 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,10 @@ package main import ( - "context" - "encoding/json" - "fmt" "html/template" - "math" "net/http" "os" - "slices" - "sort" "strconv" - "strings" - "time" cshAuth "github.com/computersciencehouse/csh-auth" "github.com/computersciencehouse/vote/database" @@ -21,7 +13,6 @@ import ( "github.com/gin-gonic/gin" "github.com/joho/godotenv" "github.com/sirupsen/logrus" - "go.mongodb.org/mongo-driver/bson/primitive" "mvdan.cc/xurls/v2" ) @@ -31,23 +22,13 @@ 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_EBOARD bool = os.Getenv("DEV_FORCE_IS_EBOARD") == "true" var DEV_FORCE_IS_EVALS bool = os.Getenv("DEV_FORCE_IS_EVALS") == "true" func inc(x int) string { return strconv.Itoa(x + 1) } -// Gets the number of people eligible to vote in a poll -func GetVoterCount(poll database.Poll) int { - return len(poll.AllowedUsers) -} - -// 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) @@ -56,6 +37,7 @@ func MakeLinks(s string) template.HTML { } var oidcClient = OIDCClient{} +var broker *sse.Broker func main() { godotenv.Load() @@ -67,7 +49,7 @@ func main() { "MakeLinks": MakeLinks, }) r.LoadHTMLGlob("templates/*") - broker := sse.NewBroker() + broker = sse.NewBroker() csh := cshAuth.CSHAuth{} csh.Init( @@ -86,7 +68,9 @@ 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_EBOARD { + logging.Logger.WithFields(logrus.Fields{"method": "main init"}).Warning("Dev force eboard is set!") + } if DEV_FORCE_IS_EVALS { logging.Logger.WithFields(logrus.Fields{"method": "main init"}).Warning("Dev force evals is set!") } @@ -95,464 +79,23 @@ 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 - - polls, err := database.GetOpenPolls(c) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - sort.Slice(polls, func(i, j int) bool { - return polls[i].Id > polls[j].Id - }) - - closedPolls, err := database.GetClosedVotedPolls(c, claims.UserInfo.Username) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - ownedPolls, err := database.GetClosedOwnedPolls(c, claims.UserInfo.Username) - if err != nil { - c.JSON(500, 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(200, "index.tmpl", gin.H{ - "Polls": polls, - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - }) - })) - - r.GET("/closed", csh.AuthWrapper(func(c *gin.Context) { - cl, _ := c.Get("cshauth") - claims := cl.(cshAuth.CSHClaims) - - closedPolls, err := database.GetClosedVotedPolls(c, claims.UserInfo.Username) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - ownedPolls, err := database.GetClosedOwnedPolls(c, claims.UserInfo.Username) - if err != nil { - c.JSON(500, 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(200, "closed.tmpl", gin.H{ - "ClosedPolls": closedPolls, - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - }) - })) - - 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(403, "unauthorized.tmpl", gin.H{ - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - }) - return - } - - c.HTML(200, "create.tmpl", gin.H{ - "Username": claims.UserInfo.Username, - "FullName": claims.UserInfo.FullName, - "IsEvals": isEvals(claims.UserInfo), - }) - })) - - 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(403, "unauthorized.tmpl", 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 := &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", - } - if c.PostForm("rankedChoice") == "true" { - poll.VoteType = database.POLL_TYPE_RANKED - } - - 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 !containsString(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(403, "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)) - } - } - - pollId, err := database.CreatePoll(c, poll) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.Redirect(302, "/poll/"+pollId) - })) - - 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 - - poll, err := database.GetPoll(c, c.Param("id")) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - // If the user can't vote, just show them results - if canVote(claims.UserInfo, *poll, poll.AllowedUsers) > 0 || !poll.Open { - c.Redirect(302, "/results/"+poll.Id) - return - } - - writeInAdj := 0 - if poll.AllowWriteIns { - writeInAdj = 1 - } - - canModify := containsString(claims.UserInfo.Groups, "active_rtp") || containsString(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, - }) - })) - - r.POST("/poll/:id", 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 canVote(claims.UserInfo, *poll, poll.AllowedUsers) > 0 || !poll.Open { - c.Redirect(302, "/results/"+poll.Id) - return - } - - pId, err := primitive.ObjectIDFromHex(poll.Id) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - 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, - } - - 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(400, 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, - } - - for _, option := range poll.Options { - optionRankStr := c.PostForm(option) - optionRank, err := strconv.Atoi(optionRankStr) - - if len(optionRankStr) < 1 { - continue - } - if err != nil { - c.JSON(400, gin.H{"error": "non-number ranking"}) - return - } - - vote.Options[option] = optionRank - } + r.GET("/", csh.AuthWrapper(GetHomepage)) + r.GET("/closed", csh.AuthWrapper(GetClosedPolls)) - // 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(500, gin.H{"error": "Write-in is already an option"}) - return - } - } - rank, err := strconv.Atoi(c.PostForm("writein")) - if err != nil { - c.JSON(500, gin.H{"error": "Write-in rank is not numerical"}) - return - } - if rank < 1 { - c.JSON(500, gin.H{"error": "Write-in rank is not positive"}) - return - } - vote.Options[c.PostForm("writeinOption")] = rank - } + r.GET("/create", csh.AuthWrapper(GetCreatePage)) + r.POST("/create", csh.AuthWrapper(CreatePoll)) - maxNum := len(vote.Options) - voted := make([]bool, maxNum) + r.GET("/poll/:id", csh.AuthWrapper(GetPollById)) + r.POST("/poll/:id", csh.AuthWrapper(VoteInPoll)) - for _, rank := range vote.Options { - if rank > 0 && rank <= maxNum { - if voted[rank-1] { - c.JSON(400, gin.H{"error": "You ranked two or more candidates at the same level"}) - return - } - voted[rank-1] = true - } else { - c.JSON(400, gin.H{"error": fmt.Sprintf("votes must be from 1 - %d", maxNum)}) - return - } - } + r.GET("/results/:id", csh.AuthWrapper(GetPollResults)) - rankedCandidates := len(vote.Options) - for _, voteOpt := range vote.Options { - if voteOpt > rankedCandidates { - c.JSON(400, gin.H{"error": "Rank choice is more than the amount of candidates ranked"}) - return - } - } - database.CastRankedVote(c, &vote, &voter) - } else { - c.JSON(500, gin.H{"error": "Unknown Poll Type"}) - return - } + r.POST("/poll/:id/hide", csh.AuthWrapper(HidePollResults)) + r.POST("/poll/:id/close", csh.AuthWrapper(ClosePoll)) - 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(302, "/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 - - poll, err := database.GetPoll(c, c.Param("id")) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - 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 - - votesNeededForQuorum := int(poll.QuorumType * float64(len(poll.AllowedUsers))) - c.HTML(200, "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, - }) - })) - - r.POST("/poll/:id/hide", 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 hide a poll result"}) - return - } - - err = poll.Hide(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: "Hide 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) - // This is intentionally left unprotected - // A user should be able to end their own polls, regardless of if they can vote - - poll, err := database.GetPoll(c, c.Param("id")) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - if poll.Gatekeep { - c.JSON(http.StatusForbidden, gin.H{"error": "This poll cannot be closed manually"}) - return - } - - if poll.CreatedBy != claims.UserInfo.Username { - if containsString(claims.UserInfo.Groups, "active_rtp") || containsString(claims.UserInfo.Groups, "eboard") { - } else { - c.JSON(403, gin.H{"error": "You cannot end this poll."}) - return - } - } - - err = poll.Close(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: "Close/End Poll", - } - err = database.WriteAction(c, &action) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.Redirect(302, "/results/"+poll.Id) - })) + r.GET("/eboard", csh.AuthWrapper(HandleGetEboardVote)) + r.POST("/eboard", csh.AuthWrapper(HandlePostEboardVote)) + r.POST("/eboard/manage", csh.AuthWrapper(HandleManageEboardVote)) r.GET("/stream/:topic", csh.AuthWrapper(broker.ServeHTTP)) @@ -560,70 +103,3 @@ 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 !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 !slices.Contains(allowedUsers, user.Username) { - return 4 - } - } //otherwise true - return 0 -} - -func uniquePolls(polls []*database.Poll) []*database.Poll { - var unique []*database.Poll - for _, poll := range polls { - if !containsPoll(unique, poll) { - unique = append(unique, poll) - } - } - return unique -} - -func containsPoll(polls []*database.Poll, poll *database.Poll) bool { - for _, p := range polls { - if p.Id == poll.Id { - return true - } - } - return false -} - -func hasOption(poll *database.Poll, option string) bool { - for _, opt := range poll.Options { - if opt == option { - return true - } - } - return false -} - -func containsString(arr []string, val string) bool { - for _, a := range arr { - if a == val { - return true - } - } - return false -} diff --git a/templates/closed.tmpl b/templates/closed.tmpl index 6d76647..b4c17bb 100644 --- a/templates/closed.tmpl +++ b/templates/closed.tmpl @@ -5,7 +5,7 @@ diff --git a/templates/create.tmpl b/templates/create.tmpl index fbfecef..41eacab 100644 --- a/templates/create.tmpl +++ b/templates/create.tmpl @@ -3,15 +3,15 @@ CSH Vote - + - + {{ template "nav" . }} - +

Create Poll

@@ -73,7 +73,7 @@ > Hide Results Until Vote is Complete
- {{ if .IsEvals }} + {{ if .IsEboard }}
+ + + CSH Vote + + + + + + + +{{ template "nav" . }} +
+

E-Board Vote

+ Current Votes Submitted: {{ .VoteCount }} +
+
+ {{ if .Voted }} + {{ range $option, $count := .Results }} +
+ {{ $option }}: {{ $count }} +
+
+ {{ end }} + {{ else }} + + {{ range $i, $option := .Options }} +
+ + +
+
+ {{ end }} + + + {{ end }} +
+
+
+ + +
+
+
+
+ + diff --git a/templates/hidden.tmpl b/templates/hidden.tmpl index c3f88a5..76b93c3 100644 --- a/templates/hidden.tmpl +++ b/templates/hidden.tmpl @@ -5,7 +5,7 @@ diff --git a/templates/index.tmpl b/templates/index.tmpl index c18468c..ba5202e 100644 --- a/templates/index.tmpl +++ b/templates/index.tmpl @@ -5,7 +5,7 @@ diff --git a/templates/nav.tmpl b/templates/nav.tmpl index 0942983..4fdacbe 100644 --- a/templates/nav.tmpl +++ b/templates/nav.tmpl @@ -1,24 +1,27 @@ {{ define "nav" }} - {{ end }} \ No newline at end of file diff --git a/templates/poll.tmpl b/templates/poll.tmpl index b744ba0..74e14bd 100644 --- a/templates/poll.tmpl +++ b/templates/poll.tmpl @@ -3,7 +3,7 @@ CSH Vote - + diff --git a/templates/result.tmpl b/templates/result.tmpl index acbe0a4..f007a35 100644 --- a/templates/result.tmpl +++ b/templates/result.tmpl @@ -5,7 +5,7 @@ diff --git a/templates/unauthorized.tmpl b/templates/unauthorized.tmpl index 2134be7..b06a319 100644 --- a/templates/unauthorized.tmpl +++ b/templates/unauthorized.tmpl @@ -5,7 +5,7 @@ diff --git a/users.go b/users.go index 12abfbf..597908e 100644 --- a/users.go +++ b/users.go @@ -2,14 +2,17 @@ package main import ( "encoding/json" + "fmt" "io" "net/http" "net/url" + "slices" "strings" "time" cshAuth "github.com/computersciencehouse/csh-auth" "github.com/computersciencehouse/vote/logging" + "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" ) @@ -29,6 +32,8 @@ type OIDCUser struct { SlackUID string `json:"slackuid"` } +var groupCache map[string]string + func (client *OIDCClient) setupOidcClient(oidcClientId, oidcClientSecret string) { client.oidcClientId = oidcClientId client.oidcClientSecret = oidcClientSecret @@ -37,6 +42,7 @@ func (client *OIDCClient) setupOidcClient(oidcClientId, oidcClientSecret string) logging.Logger.WithFields(logrus.Fields{"method": "setupOidcClient"}).Error(err) return } + groupCache = make(map[string]string) client.providerBase = parse.Scheme + "://" + parse.Host exp := client.getAccessToken() ticker := time.NewTicker(time.Duration(exp) * time.Second) @@ -87,24 +93,71 @@ func (client *OIDCClient) getAccessToken() int { } func (client *OIDCClient) GetActiveUsers() []OIDCUser { + return client.GetOIDCGroup("a97a191e-5668-43f5-bc0c-6eefc2b958a7") +} + +func (client *OIDCClient) GetEBoard() []OIDCUser { + return client.GetOIDCGroup("47dd1a94-853c-426d-b181-6d0714074892") +} + +func (client *OIDCClient) FindOIDCGroupID(name string) string { + if groupCache[name] != "" { + return groupCache[name] + } htclient := &http.Client{} //active - req, err := http.NewRequest("GET", client.providerBase+"/auth/admin/realms/csh/groups/a97a191e-5668-43f5-bc0c-6eefc2b958a7/members", nil) + req, err := http.NewRequest("GET", client.providerBase+"/auth/admin/realms/csh/groups?exact=true&search="+name, nil) + if err != nil { + logging.Logger.WithFields(logrus.Fields{"method": "FindOIDCGroupID"}).Error(err) + return "" + } + req.Header.Add("Authorization", "Bearer "+client.accessToken) + resp, err := htclient.Do(req) + if err != nil { + logging.Logger.WithFields(logrus.Fields{"method": "FindOIDCGroupID"}).Error(err) + return "" + } + defer resp.Body.Close() + ret := make([]map[string]any, 0) + err = json.NewDecoder(resp.Body).Decode(&ret) if err != nil { - logging.Logger.WithFields(logrus.Fields{"method": "GetActiveUsers"}).Error(err) + logging.Logger.WithFields(logrus.Fields{"method": "FindOIDCGroupID"}).Error(err) + return "" + } + //Example: + //[{"id":"47dd1a94-853c-426d-b181-6d0714074892","name":"eboard","path":"/eboard","subGroups":[{"id":"66b9578a-2b58-46a6-8040-59388e57e830","name":"eboard-opcomm","path":"/eboard/eboard-opcomm","subGroups":[]}]}] + //it returns as an array for SOME reason, so we cut to the group we want + group := ret[0] + // and now we have SUBgroups, so we do this fucked parse + subGroups := group["subGroups"].([]any) + fmt.Println(subGroups) + subGroup := subGroups[0].(map[string]interface{}) + fmt.Println(subGroup) + gid := subGroup["id"].(string) + groupCache[name] = gid + return gid + +} + +func (client *OIDCClient) GetOIDCGroup(groupID string) []OIDCUser { + htclient := &http.Client{} + //active + req, err := http.NewRequest("GET", client.providerBase+"/auth/admin/realms/csh/groups/"+groupID+"/members", nil) + if err != nil { + logging.Logger.WithFields(logrus.Fields{"method": "GetOIDCGroup"}).Error(err) return nil } req.Header.Add("Authorization", "Bearer "+client.accessToken) resp, err := htclient.Do(req) if err != nil { - logging.Logger.WithFields(logrus.Fields{"method": "GetActiveUsers"}).Error(err) + logging.Logger.WithFields(logrus.Fields{"method": "GetOIDCGroup"}).Error(err) return nil } defer resp.Body.Close() ret := make([]OIDCUser, 0) err = json.NewDecoder(resp.Body).Decode(&ret) if err != nil { - logging.Logger.WithFields(logrus.Fields{"method": "GetActiveUsers"}).Error(err) + logging.Logger.WithFields(logrus.Fields{"method": "GetOIDCGroup"}).Error(err) return nil } return ret @@ -152,6 +205,7 @@ func (client *OIDCClient) GetUserInfo(user *OIDCUser) { } } +// GetUserGatekeep Queries conditional to determine whether a user has met the gatekeep requirements func (client *OIDCClient) GetUserGatekeep(user *OIDCUser) { htclient := &http.Client{} req, err := http.NewRequest("GET", CONDITIONAL_GATEKEEP_URL+"/"+user.Username, nil) @@ -176,3 +230,30 @@ func (client *OIDCClient) GetUserGatekeep(user *OIDCUser) { logging.Logger.WithFields(logrus.Fields{"method": "GetUserGatekeep"}).Error(err) } } + +// GetUserData Retreives information about a specific CSH user account +func GetUserData(c *gin.Context) cshAuth.CSHUserInfo { + cl, _ := c.Get("cshauth") + user := cl.(cshAuth.CSHClaims).UserInfo + return user +} + +// IsActive determines if the user is an active member, and allows for a dev mode override +func IsActive(user cshAuth.CSHUserInfo) bool { + return DEV_DISABLE_ACTIVE_FILTERS || slices.Contains(user.Groups, "active") +} + +// IsEboard determines if the current user is on eboard, allowing for a dev mode override +func IsEboard(user cshAuth.CSHUserInfo) bool { + return DEV_FORCE_IS_EBOARD || slices.Contains(user.Groups, "eboard") +} + +// 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") +} + +// IsActiveRTP Determines whether the user is an active RTP, based on user groups from OIDC +func IsActiveRTP(user cshAuth.CSHUserInfo) bool { + return slices.Contains(user.Groups, "active-rtp") +}