cacert-boardvoting/boardvoting.go

392 lines
8 KiB
Go

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()
}