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