Initial Go code for reimplementation
authorJan Dittberner <jandd@cacert.org>
Sat, 15 Apr 2017 17:23:40 +0000 (19:23 +0200)
committerJan Dittberner <jandd@cacert.org>
Sat, 15 Apr 2017 17:23:40 +0000 (19:23 +0200)
.gitignore [new file with mode: 0644]
boardvoting.go [new file with mode: 0644]
config.yaml.example [new file with mode: 0644]
static/styles.css [new file with mode: 0644]
templates/denied.html [new file with mode: 0644]
templates/motions.html [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..146c03e
--- /dev/null
@@ -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 (file)
index 0000000..68210d9
--- /dev/null
@@ -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 (file)
index 0000000..d24977e
--- /dev/null
@@ -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 (file)
index 0000000..1cf3d72
--- /dev/null
@@ -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 (file)
index 0000000..f5a359c
--- /dev/null
@@ -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 (file)
index 0000000..fa1c07c
--- /dev/null
@@ -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