Initial Go code for reimplementation
This commit is contained in:
parent
37c6f2efe6
commit
74987ce184
6 changed files with 519 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
*.crt
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
.*.swp
|
||||||
|
.idea/
|
||||||
|
cacert-board_vote
|
||||||
|
config.yaml
|
||||||
|
database.sqlite
|
392
boardvoting.go
Normal file
392
boardvoting.go
Normal file
|
@ -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()
|
||||||
|
}
|
7
config.yaml.example
Normal file
7
config.yaml.example
Normal file
|
@ -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
|
31
static/styles.css
Normal file
31
static/styles.css
Normal file
|
@ -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;
|
||||||
|
}
|
13
templates/denied.html
Normal file
13
templates/denied.html
Normal file
|
@ -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>
|
68
templates/motions.html
Normal file
68
templates/motions.html
Normal file
|
@ -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>
|
Loading…
Reference in a new issue