diff --git a/boardvoting.go b/boardvoting.go index 68210d9..a49b34e 100644 --- a/boardvoting.go +++ b/boardvoting.go @@ -1,25 +1,26 @@ package main import ( + "crypto/tls" + "crypto/x509" + "database/sql" "fmt" - "log" - "strings" - "net/http" - "io/ioutil" - "time" + "github.com/Masterminds/sprig" + "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "gopkg.in/yaml.v2" - "github.com/jmoiron/sqlx" - "github.com/Masterminds/sprig" - "os" - "crypto/x509" - "crypto/tls" - "database/sql" "html/template" + "io/ioutil" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" ) const ( - list_decisions_sql = ` + sqlGetDecisions = ` SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, @@ -27,54 +28,63 @@ SELECT decisions.id, decisions.tag, decisions.proponent, FROM decisions JOIN voters ON decisions.proponent=voters.id ORDER BY proposed DESC -LIMIT 10 OFFSET 10 * ($1 - 1)` - get_decision_sql = ` +LIMIT 10 OFFSET 10 * $1` + sqlGetDecision = ` SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title, decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified FROM decisions JOIN voters ON decisions.proponent=voters.id -WHERE decisions.id=$1;` - get_voter = ` +WHERE decisions.tag=$1;` + sqlGetVoter = ` SELECT voters.id, voters.name FROM voters JOIN emails ON voters.id=emails.voter WHERE emails.address=$1 AND voters.enabled=1` - vote_count_sql = ` + sqlVoteCount = ` SELECT vote, COUNT(vote) FROM votes -WHERE decision=$1` +WHERE decision=$1 GROUP BY vote` + sqlCountNewerOlderThanMotion = ` +SELECT "newer" AS label, COUNT(*) AS value FROM decisions WHERE proposed > $1 +UNION +SELECT "older", COUNT(*) FROM decisions WHERE proposed < $2` ) var db *sqlx.DB var logger *log.Logger const ( - voteAye = 1 - voteNaye = -1 + voteAye = 1 + voteNaye = -1 voteAbstain = 0 ) const ( voteTypeMotion = 0 - voteTypeVeto = 1 + voteTypeVeto = 1 ) type VoteType int func (v VoteType) String() string { switch v { - case voteTypeMotion: return "motion" - case voteTypeVeto: return "veto" - default: return "unknown" + case voteTypeMotion: + return "motion" + case voteTypeVeto: + return "veto" + default: + return "unknown" } } func (v VoteType) QuorumAndMajority() (int, int) { switch v { - case voteTypeMotion: return 3, 50 - default: return 1, 99 + case voteTypeMotion: + return 3, 50 + default: + return 1, 99 } } @@ -92,11 +102,16 @@ type VoteStatus int func (v VoteStatus) String() string { switch v { - case -1: return "declined" - case 0: return "pending" - case 1: return "approved" - case -2: return "withdrawn" - default: return "unknown" + case -1: + return "declined" + case 0: + return "pending" + case 1: + return "approved" + case -2: + return "withdrawn" + default: + return "unknown" } } @@ -104,10 +119,14 @@ type VoteKind int func (v VoteKind) String() string { switch v { - case voteAye: return "Aye" - case voteNaye: return "Naye" - case voteAbstain: return "Abstain" - default: return "unknown" + case voteAye: + return "Aye" + case voteNaye: + return "Naye" + case voteAbstain: + return "Abstain" + default: + return "unknown" } } @@ -131,7 +150,7 @@ type Decision struct { Due time.Time Modified time.Time VoteSums - Votes []Vote + Votes []Vote } func (d *Decision) parseVote(vote int, count int) { @@ -150,134 +169,298 @@ type Voter struct { Name string } -func authenticateVoter(emailAddress string, voter *Voter) bool { +func withDrawMotion(tag string, voter *Voter, config *Config) { err := db.Ping() if err != nil { logger.Fatal(err) } - auth_stmt, err := db.Preparex(get_voter) + decision_stmt, err := db.Preparex(sqlGetDecision) if err != nil { logger.Fatal(err) } - defer auth_stmt.Close() - var found = false - err = auth_stmt.Get(voter, emailAddress) + defer decision_stmt.Close() + + var d Decision + err = decision_stmt.Get(&d, tag) if err == nil { - found = true - } else { + logger.Println(d) + } + + type MailContext struct { + Decision + Name string + Sender string + Recipient string + } + + context := MailContext{d, voter.Name, config.NoticeSenderAddress, config.BoardMailAddress} + + // TODO: implement + // fill withdraw_mail.txt + t, err := template.New("withdraw_mail.txt").Funcs(sprig.FuncMap()).ParseFiles("templates/withdraw_mail.txt") + if err != nil { + logger.Fatal(err) + } + t.Execute(os.Stdout, context) +} + +func authenticateVoter(emailAddress string) *Voter { + if err := db.Ping(); err != nil { + logger.Fatal(err) + } + + auth_stmt, err := db.Preparex(sqlGetVoter) + if err != nil { + logger.Println("Problem getting voter", err) + return nil + } + defer auth_stmt.Close() + + var voter = &Voter{} + if err = auth_stmt.Get(voter, emailAddress); err != nil { if err != sql.ErrNoRows { - logger.Fatal(err) + logger.Println("Problem getting voter", err) } + return nil } - return found + return voter } func redirectToMotionsHandler(w http.ResponseWriter, _ *http.Request) { - w.Header().Add("Location", "/motions") + w.Header().Set("Location", "/motions/") w.WriteHeader(http.StatusMovedPermanently) } func renderTemplate(w http.ResponseWriter, tmpl string, context interface{}) { - t := template.New("motions.html") - t.Funcs(sprig.FuncMap()) - t, err := t.ParseFiles(fmt.Sprintf("templates/%s.html", tmpl)) + t, err := template.New(fmt.Sprintf("%s.html", tmpl)).Funcs(sprig.FuncMap()).ParseFiles(fmt.Sprintf("templates/%s.html", tmpl)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } - err = t.Execute(w, context) - if err != nil { + if err := t.Execute(w, context); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } -func authenticateRequest( -w http.ResponseWriter, r *http.Request, -handler func(http.ResponseWriter, *http.Request, *Voter)) { - var voter Voter - var found = false - authLoop: for _, cert := range r.TLS.PeerCertificates { - var isClientCert = false +func authenticateRequest(w http.ResponseWriter, r *http.Request, authRequired bool, handler func(http.ResponseWriter, *http.Request, *Voter)) { + for _, cert := range r.TLS.PeerCertificates { for _, extKeyUsage := range cert.ExtKeyUsage { if extKeyUsage == x509.ExtKeyUsageClientAuth { - isClientCert = true - break - } - } - if !isClientCert { - continue - } - - for _, emailAddress := range cert.EmailAddresses { - if authenticateVoter(emailAddress, &voter) { - found = true - break authLoop + for _, emailAddress := range cert.EmailAddresses { + if voter := authenticateVoter(emailAddress); voter != nil { + handler(w, r, voter) + return + } + } } } } - if !found { + if authRequired { w.WriteHeader(http.StatusForbidden) renderTemplate(w, "denied", nil) return } - handler(w, r, &voter) + handler(w, r, nil) } -func motionsHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) { - err := db.Ping() - if err != nil { - logger.Fatal(err) +type motionParameters struct { + ShowVotes bool +} + +type motionListParameters struct { + Page int64 + Flags struct { + Confirmed, Withdraw, Unvoted bool } +} - // $page = is_numeric($_REQUEST['page'])?$_REQUEST['page']:1; +func parseMotionParameters(r *http.Request) motionParameters { + var m = motionParameters{} + m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes")) + logger.Printf("parsed parameters: %+v\n", m) + return m +} - motion_stmt, err := db.Preparex(list_decisions_sql) - votes_stmt, err := db.Preparex(vote_count_sql) +func parseMotionListParameters(r *http.Request) motionListParameters { + var m = motionListParameters{} + if page, err := strconv.ParseInt(r.URL.Query().Get("page"), 10, 0); err != nil { + m.Page = 1 + } else { + m.Page = page + } + m.Flags.Withdraw, _ = strconv.ParseBool(r.URL.Query().Get("withdraw")) + m.Flags.Unvoted, _ = strconv.ParseBool(r.URL.Query().Get("unvoted")) + + if r.Method == http.MethodPost { + m.Flags.Confirmed, _ = strconv.ParseBool(r.PostFormValue("confirm")) + } + logger.Printf("parsed parameters: %+v\n", m) + return m +} + +func motionListHandler(w http.ResponseWriter, r *http.Request, voter *Voter) { + params := parseMotionListParameters(r) + + votes_stmt, err := db.Preparex(sqlVoteCount) if err != nil { logger.Fatal(err) } - defer motion_stmt.Close() defer votes_stmt.Close() - - rows, err := motion_stmt.Queryx(1) + beforeAfterStmt, err := db.Preparex(sqlCountNewerOlderThanMotion) if err != nil { logger.Fatal(err) } - defer rows.Close() + defer beforeAfterStmt.Close() - var page struct { - Decisions []Decision - Voter *Voter + var context struct { + Decisions []Decision + Voter *Voter + Params *motionListParameters + PrevPage, NextPage int64 } - page.Voter = voter + context.Voter = voter + context.Params = ¶ms + motion_stmt, err := db.Preparex(sqlGetDecisions) + if err != nil { + logger.Fatal(err) + } + defer motion_stmt.Close() + rows, err := motion_stmt.Queryx(params.Page - 1) + if err != nil { + logger.Fatal(err) + } for rows.Next() { var d Decision err := rows.StructScan(&d) if err != nil { + rows.Close() logger.Fatal(err) } voteRows, err := votes_stmt.Queryx(d.Id) if err != nil { + rows.Close() logger.Fatal(err) } for voteRows.Next() { - var vote, count int - err = voteRows.Scan(&vote, &count) - if err != nil { + var vote int + var count int + if err := voteRows.Scan(&vote, &count); err != nil { voteRows.Close() - logger.Fatal(err) + logger.Fatalf("Error fetching counts for motion %s: %s", d.Tag, err) } d.parseVote(vote, count) } - page.Decisions = append(page.Decisions, d) + context.Decisions = append(context.Decisions, d) voteRows.Close() } + rows.Close() + rows, err = beforeAfterStmt.Queryx( + context.Decisions[0].Proposed, + context.Decisions[len(context.Decisions)-1].Proposed) + if err != nil { + logger.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var key string + var value int + if err := rows.Scan(&key, &value); err != nil { + rows.Close() + logger.Fatal(err) + } + if key == "older" && value > 0 { + context.NextPage = params.Page + 1 + } + } + if params.Page > 1 { + context.PrevPage = params.Page - 1 + } - renderTemplate(w, "motions", page) + renderTemplate(w, "motions", context) +} + +func motionHandler(w http.ResponseWriter, r *http.Request, voter *Voter, decision *Decision) { + params := parseMotionParameters(r) + + var context struct { + Decisions []Decision + Voter *Voter + Params *motionParameters + PrevPage, NextPage int64 + } + context.Voter = voter + context.Params = ¶ms + context.Decisions = append(context.Decisions, *decision) + renderTemplate(w, "motions", context) +} + +func singleDecisionHandler(w http.ResponseWriter, r *http.Request, v *Voter, tag string, handler func(http.ResponseWriter, *http.Request, *Voter, *Decision)) { + votes_stmt, err := db.Preparex(sqlVoteCount) + if err != nil { + logger.Fatal(err) + } + defer votes_stmt.Close() + motion_stmt, err := db.Preparex(sqlGetDecision) + if err != nil { + logger.Fatal(err) + } + defer motion_stmt.Close() + var d *Decision = &Decision{} + err = motion_stmt.Get(d, tag) + if err != nil { + logger.Fatal(err) + } + voteRows, err := votes_stmt.Queryx(d.Id) + if err != nil { + logger.Fatal(err) + } + + for voteRows.Next() { + var vote, count int + err = voteRows.Scan(&vote, &count) + if err != nil { + voteRows.Close() + logger.Fatal(err) + } + d.parseVote(vote, count) + } + voteRows.Close() + + handler(w, r, v, d) +} + +func motionsHandler(w http.ResponseWriter, r *http.Request) { + if err := db.Ping(); err != nil { + logger.Fatal(err) + } + + subURL := r.URL.Path[len("/motions/"):] + + switch { + case subURL == "": + authenticateRequest(w, r, false, motionListHandler) + return + case strings.Count(subURL, "/") == 1: + parts := strings.Split(subURL, "/") + logger.Printf("handle %v\n", parts) + fmt.Fprintf(w, "No handler for '%s'", subURL) + motionTag := parts[0] + action := parts[1] + logger.Printf("motion: %s, action: %s\n", motionTag, action) + return + case strings.Count(subURL, "/") == 0: + authenticateRequest(w, r, false, func(w http.ResponseWriter, r *http.Request, v *Voter) { + singleDecisionHandler(w, r, v, subURL, motionHandler) + }) + return + default: + fmt.Fprintf(w, "No handler for '%s'", subURL) + return + } } func votersHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) { @@ -325,7 +508,7 @@ type Config struct { } func main() { - logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags | log.LUTC) + logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile) var filename = "config.yaml" if len(os.Args) == 2 { @@ -352,13 +535,11 @@ func main() { logger.Fatal(err) } - http.HandleFunc("/motions", func(w http.ResponseWriter, r *http.Request) { - authenticateRequest(w, r, motionsHandler) - }) - http.HandleFunc("/voters", func(w http.ResponseWriter, r *http.Request) { - authenticateRequest(w, r, votersHandler) + http.HandleFunc("/motions/", motionsHandler) + http.HandleFunc("/voters/", func(w http.ResponseWriter, r *http.Request) { + authenticateRequest(w, r, true, votersHandler) }) - http.HandleFunc("/static/", http.FileServer(http.Dir(".")).ServeHTTP) + http.Handle("/static/", http.FileServer(http.Dir("."))) http.HandleFunc("/", redirectToMotionsHandler) // load CA certificates for client authentication @@ -373,14 +554,14 @@ func main() { // setup HTTPS server tlsConfig := &tls.Config{ - ClientCAs:caCertPool, - ClientAuth:tls.RequireAndVerifyClientCert, + ClientCAs: caCertPool, + ClientAuth: tls.RequireAndVerifyClientCert, } tlsConfig.BuildNameToCertificate() server := &http.Server{ - Addr: ":8443", - TLSConfig:tlsConfig, + Addr: ":8443", + TLSConfig: tlsConfig, } err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey) diff --git a/templates/motions.html b/templates/motions.html index fa1c07c..f717d78 100644 --- a/templates/motions.html +++ b/templates/motions.html @@ -6,14 +6,15 @@ -Show my outstanding votes
+Show my outstanding votes
+{{ $voter := .Voter }} {{ if .Decisions }} - + {{ if $voter }}{{ end }} @@ -28,7 +29,7 @@ {{ end }} + {{ if $voter }} + {{ end }} {{end}} + + +
Status MotionActionsActions
- {{ .Tag}}
+ {{ .Tag}}
{{ .Title}}
{{ wrap 76 .Content }}

@@ -42,9 +43,10 @@ {{ .Name }}: {{ .Vote}}
{{ end }} {{ else}} - Show Votes + Show Votes {{ end }}
{{ if eq .Status 0 }} {{ end }} -
{{else}}

There are no motions in the system yet.

{{end}} - \ No newline at end of file + diff --git a/templates/withdraw_mail.txt b/templates/withdraw_mail.txt new file mode 100644 index 0000000..4ccf4de --- /dev/null +++ b/templates/withdraw_mail.txt @@ -0,0 +1,14 @@ +From: {{ .Sender }} +To: {{ .Recipient }} +Subject: Re: {{ .Tag }} - {{ .Title }} - withdrawn + +Dear Board, + +{{ .Name }} has withdrawn the motion {{ .Tag }} that was as follows: + +{{ .Title }} + +{{ wrap 76 .Content }} + +Kind regards, +the voting system \ No newline at end of file