summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore8
-rw-r--r--boardvoting.go392
-rw-r--r--config.yaml.example7
-rw-r--r--static/styles.css31
-rw-r--r--templates/denied.html13
-rw-r--r--templates/motions.html68
6 files changed, 519 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..146c03e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+*.crt
+*.key
+*.pem
+.*.swp
+.idea/
+cacert-board_vote
+config.yaml
+database.sqlite
diff --git a/boardvoting.go b/boardvoting.go
new file mode 100644
index 0000000..68210d9
--- /dev/null
+++ b/boardvoting.go
@@ -0,0 +1,392 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "strings"
+ "net/http"
+ "io/ioutil"
+ "time"
+ _ "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"
+)
+
+const (
+ list_decisions_sql = `
+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
+ORDER BY proposed DESC
+LIMIT 10 OFFSET 10 * ($1 - 1)`
+ get_decision_sql = `
+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 = `
+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 = `
+SELECT vote, COUNT(vote)
+FROM votes
+WHERE decision=$1`
+)
+
+var db *sqlx.DB
+var logger *log.Logger
+
+const (
+ voteAye = 1
+ voteNaye = -1
+ voteAbstain = 0
+)
+
+const (
+ voteTypeMotion = 0
+ voteTypeVeto = 1
+)
+
+type VoteType int
+
+func (v VoteType) String() string {
+ switch v {
+ 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
+ }
+}
+
+type VoteSums struct {
+ Ayes int
+ Nayes int
+ Abstains int
+}
+
+func (v *VoteSums) voteCount() int {
+ return v.Ayes + v.Nayes + v.Abstains
+}
+
+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"
+ }
+}
+
+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"
+ }
+}
+
+type Vote struct {
+ Name string
+ Vote VoteKind
+}
+
+type Decision struct {
+ Id int
+ Tag string
+ Proponent int
+ Proposer string
+ Proposed time.Time
+ Title string
+ Content string
+ Majority int
+ Quorum int
+ VoteType VoteType
+ Status VoteStatus
+ Due time.Time
+ Modified time.Time
+ VoteSums
+ Votes []Vote
+}
+
+func (d *Decision) parseVote(vote int, count int) {
+ switch vote {
+ case voteAye:
+ d.Ayes = count
+ case voteAbstain:
+ d.Abstains = count
+ case voteNaye:
+ d.Nayes = count
+ }
+}
+
+type Voter struct {
+ Id int
+ Name string
+}
+
+func authenticateVoter(emailAddress string, voter *Voter) bool {
+ err := db.Ping()
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ auth_stmt, err := db.Preparex(get_voter)
+ if err != nil {
+ logger.Fatal(err)
+ }
+ defer auth_stmt.Close()
+ var found = false
+ err = auth_stmt.Get(voter, emailAddress)
+ if err == nil {
+ found = true
+ } else {
+ if err != sql.ErrNoRows {
+ logger.Fatal(err)
+ }
+ }
+ return found
+}
+
+func redirectToMotionsHandler(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Add("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))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ err = t.Execute(w, context)
+ if 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
+ 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
+ }
+ }
+ }
+ if !found {
+ w.WriteHeader(http.StatusForbidden)
+ renderTemplate(w, "denied", nil)
+ return
+ }
+ handler(w, r, &voter)
+}
+
+func motionsHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
+ err := db.Ping()
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ // $page = is_numeric($_REQUEST['page'])?$_REQUEST['page']:1;
+
+ motion_stmt, err := db.Preparex(list_decisions_sql)
+ votes_stmt, err := db.Preparex(vote_count_sql)
+ if err != nil {
+ logger.Fatal(err)
+ }
+ defer motion_stmt.Close()
+ defer votes_stmt.Close()
+
+ rows, err := motion_stmt.Queryx(1)
+ if err != nil {
+ logger.Fatal(err)
+ }
+ defer rows.Close()
+
+ var page struct {
+ Decisions []Decision
+ Voter *Voter
+ }
+ page.Voter = voter
+
+ for rows.Next() {
+ var d Decision
+ err := rows.StructScan(&d)
+ 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)
+ }
+ page.Decisions = append(page.Decisions, d)
+
+ voteRows.Close()
+ }
+
+ renderTemplate(w, "motions", page)
+}
+
+func votersHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
+ err := db.Ping()
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ fmt.Fprintln(w, "Hello", voter.Name)
+
+ sqlStmt := "SELECT name, reminder FROM voters WHERE enabled=1"
+
+ rows, err := db.Query(sqlStmt)
+ if err != nil {
+ logger.Fatal(err)
+ }
+ defer rows.Close()
+
+ fmt.Print("Enabled voters\n\n")
+ fmt.Printf("%-30s %-30s\n", "Name", "Reminder E-Mail address")
+ fmt.Printf("%s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 30))
+ for rows.Next() {
+ var name string
+ var reminder string
+
+ err = rows.Scan(&name, &reminder)
+ if err != nil {
+ logger.Fatal(err)
+ }
+ fmt.Printf("%-30s %s\n", name, reminder)
+ }
+ err = rows.Err()
+ if err != nil {
+ logger.Fatal(err)
+ }
+}
+
+type Config struct {
+ BoardMailAddress string `yaml:"board_mail_address"`
+ NoticeSenderAddress string `yaml:"notice_sender_address"`
+ DatabaseFile string `yaml:"database_file"`
+ ClientCACertificates string `yaml:"client_ca_certificates"`
+ ServerCert string `yaml:"server_certificate"`
+ ServerKey string `yaml:"server_key"`
+}
+
+func main() {
+ logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags | log.LUTC)
+
+ var filename = "config.yaml"
+ if len(os.Args) == 2 {
+ filename = os.Args[1]
+ }
+
+ var err error
+
+ var config Config
+ var source []byte
+
+ source, err = ioutil.ReadFile(filename)
+ if err != nil {
+ logger.Fatal(err)
+ }
+ err = yaml.Unmarshal(source, &config)
+ if err != nil {
+ logger.Fatal(err)
+ }
+ logger.Printf("read configuration %v", config)
+
+ db, err = sqlx.Open("sqlite3", config.DatabaseFile)
+ if err != nil {
+ 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("/static/", http.FileServer(http.Dir(".")).ServeHTTP)
+ http.HandleFunc("/", redirectToMotionsHandler)
+
+ // load CA certificates for client authentication
+ caCert, err := ioutil.ReadFile(config.ClientCACertificates)
+ if err != nil {
+ logger.Fatal(err)
+ }
+ caCertPool := x509.NewCertPool()
+ if !caCertPool.AppendCertsFromPEM(caCert) {
+ logger.Fatal("could not initialize client CA certificate pool")
+ }
+
+ // setup HTTPS server
+ tlsConfig := &tls.Config{
+ ClientCAs:caCertPool,
+ ClientAuth:tls.RequireAndVerifyClientCert,
+ }
+ tlsConfig.BuildNameToCertificate()
+
+ server := &http.Server{
+ Addr: ":8443",
+ TLSConfig:tlsConfig,
+ }
+
+ err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey)
+ if err != nil {
+ logger.Fatal("ListenAndServerTLS: ", err)
+ }
+
+ defer db.Close()
+}
diff --git a/config.yaml.example b/config.yaml.example
new file mode 100644
index 0000000..d24977e
--- /dev/null
+++ b/config.yaml.example
@@ -0,0 +1,7 @@
+---
+board_mail_address: cacert-board@lists.cacert.org
+notice_sender_address: cacert-board-votes@lists.cacert.org
+database_file: database.sqlite
+client_ca_certificates: cacert_class3.pem
+server_certificate: server.crt
+server_key: server.key \ No newline at end of file
diff --git a/static/styles.css b/static/styles.css
new file mode 100644
index 0000000..1cf3d72
--- /dev/null
+++ b/static/styles.css
@@ -0,0 +1,31 @@
+html, body, th, td {
+ font-family: Verdana, Arial, Sans-Serif;
+ font-size:10px;
+}
+table, tr, td, th {
+ vertical-align:top;
+ border:1px solid black;
+ border-collapse: collapse;
+}
+td.navigation {
+ text-align:center;
+}
+td.approved {
+ color:green;
+}
+td.declined {
+ color:red;
+}
+td.withdrawn {
+ color:red;
+}
+td.pending {
+ color:blue;
+}
+textarea {
+ width:400px;
+ height:150px;
+}
+input {
+ width:400px;
+}
diff --git a/templates/denied.html b/templates/denied.html
new file mode 100644
index 0000000..f5a359c
--- /dev/null
+++ b/templates/denied.html
@@ -0,0 +1,13 @@
+<html>
+<head>
+ <title>CAcert Board Decisions</title>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf8"/>
+ <link rel="stylesheet" type="text/css" href="/static/styles.css"/>
+</head>
+<body>
+<h1>CAcert Board Decisions</h1>
+<b>You are not authorized to act here!</b><br/>
+<i>If you think this is in error, please contact the administrator</i>
+<i>If you don't know who that is, it is definitely not an error ;)</i>
+</body>
+</html> \ No newline at end of file
diff --git a/templates/motions.html b/templates/motions.html
new file mode 100644
index 0000000..fa1c07c
--- /dev/null
+++ b/templates/motions.html
@@ -0,0 +1,68 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/html">
+<head>
+ <title>CAcert Board Decisions</title>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+ <link rel="stylesheet" type="text/css" href="/static/styles.css"/>
+</head>
+<body>
+<a href="?unvoted=1">Show my outstanding votes</a><br/>
+{{ if .Decisions }}
+<table class="list">
+ <thead>
+ <tr>
+ <th>Status</th>
+ <th>Motion</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{range .Decisions }}
+ <tr>
+ <td class="{{.Status}}">
+ {{ if eq .Status 0 }}Pending {{ .Due}}
+ {{ else if eq .Status 1}}Approved {{ .Modified}}
+ {{ else if eq .Status -1}}Declined {{ .Modified}}
+ {{ else if eq .Status -2}}Withdrawn {{ .Modified}}
+ {{ else }}Unknown
+ {{ end }}
+ </td>
+ <td>
+ <i><a href="/motions?motion={{ .Tag}}">{{ .Tag}}</a></i><br />
+ <b>{{ .Title}}</b><br />
+ <pre>{{ wrap 76 .Content }}</pre>
+ <br />
+ <i>Due: {{.Due}}</i><br/>
+ <i>Proposed: {{.Proposer}} ({{.Proposed}})</i><br/>
+ <i>Vote type: {{.VoteType}}</i><br/>
+ <i>Aye|Naye|Abstain: {{.Ayes}}|{{.Nayes}}|{{.Abstains}}</i><br />
+ {{ if .Votes }}
+ <i>Votes:</i><br/>
+ {{ range .Votes}}
+ <i>{{ .Name }}: {{ .Vote}}</i><br />
+ {{ end }}
+ {{ else}}
+ <i><a href="/motions?motion={{.Tag}}&showvotes=1">Show Votes</a></i>
+ {{ end }}
+ </td>
+ <td>
+ {{ if eq .Status 0 }}
+ <ul>
+ <li><a href="/vote/{{ .Tag }}/aye">Aye</a></li>
+ <li><a href="/vote/{{ .Tag }}/abstain">Abstain</a></li>
+ <li><a href="/vote/{{ .Tag }}/naye">Naye</a></li>
+ <li><a href="/proxy/{{ .Tag }}">Proxy Vote</a></li>
+ <li><a href="/motion/{{ .Tag }}">Modify</a></li>
+ <li><a href="/motions?motion={{ .Tag }}&withdraw=1">Withdraw</a></li>
+ </ul>
+ {{ end }}
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{else}}
+<p>There are no motions in the system yet.</p>
+{{end}}
+</body>
+</html> \ No newline at end of file