From 74987ce184fb3c4ddc3d266aa19f7fcacd774bf0 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Sat, 15 Apr 2017 19:23:40 +0200 Subject: [PATCH] Initial Go code for reimplementation --- .gitignore | 8 + boardvoting.go | 392 +++++++++++++++++++++++++++++++++++++++++ config.yaml.example | 7 + static/styles.css | 31 ++++ templates/denied.html | 13 ++ templates/motions.html | 68 +++++++ 6 files changed, 519 insertions(+) create mode 100644 .gitignore create mode 100644 boardvoting.go create mode 100644 config.yaml.example create mode 100644 static/styles.css create mode 100644 templates/denied.html create mode 100644 templates/motions.html 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 @@ + + + CAcert Board Decisions + + + + +

CAcert Board Decisions

+You are not authorized to act here!
+If you think this is in error, please contact the administrator +If you don't know who that is, it is definitely not an error ;) + + \ 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 @@ + + + + CAcert Board Decisions + + + + +Show my outstanding votes
+{{ if .Decisions }} + + + + + + + + + + {{range .Decisions }} + + + + + + {{end}} + +
StatusMotionActions
+ {{ 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 }} + + {{ .Tag}}
+ {{ .Title}}
+
{{ wrap 76 .Content }}
+
+ Due: {{.Due}}
+ Proposed: {{.Proposer}} ({{.Proposed}})
+ Vote type: {{.VoteType}}
+ Aye|Naye|Abstain: {{.Ayes}}|{{.Nayes}}|{{.Abstains}}
+ {{ if .Votes }} + Votes:
+ {{ range .Votes}} + {{ .Name }}: {{ .Vote}}
+ {{ end }} + {{ else}} + Show Votes + {{ end }} +
+ {{ if eq .Status 0 }} + + {{ end }} +
+{{else}} +

There are no motions in the system yet.

+{{end}} + + \ No newline at end of file