package main import ( "crypto/tls" "crypto/x509" "database/sql" "fmt" "github.com/Masterminds/sprig" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "gopkg.in/yaml.v2" "html/template" "io/ioutil" "log" "net/http" "os" "strconv" "strings" "time" ) const ( sqlGetDecisions = ` 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` sqlGetDecision = ` 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.tag=$1;` sqlGetVoter = ` SELECT voters.id, voters.name FROM voters JOIN emails ON voters.id=emails.voter WHERE emails.address=$1 AND voters.enabled=1` sqlVoteCount = ` SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote` sqlCountNewerOlderThanMotion = ` SELECT "newer" AS label, COUNT(*) AS value FROM decisions WHERE proposed > $1 UNION SELECT "older", COUNT(*) FROM decisions WHERE proposed < $2` ) 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 withDrawMotion(tag string, voter *Voter, config *Config) { err := db.Ping() if err != nil { logger.Fatal(err) } decision_stmt, err := db.Preparex(sqlGetDecision) if err != nil { logger.Fatal(err) } defer decision_stmt.Close() var d Decision err = decision_stmt.Get(&d, tag) if err == nil { logger.Println(d) } type MailContext struct { Decision Name string Sender string Recipient string } context := MailContext{d, voter.Name, config.NoticeSenderAddress, config.BoardMailAddress} // TODO: implement // fill withdraw_mail.txt t, err := template.New("withdraw_mail.txt").Funcs(sprig.FuncMap()).ParseFiles("templates/withdraw_mail.txt") if err != nil { logger.Fatal(err) } t.Execute(os.Stdout, context) } func authenticateVoter(emailAddress string) *Voter { if err := db.Ping(); err != nil { logger.Fatal(err) } auth_stmt, err := db.Preparex(sqlGetVoter) if err != nil { logger.Println("Problem getting voter", err) return nil } defer auth_stmt.Close() var voter = &Voter{} if err = auth_stmt.Get(voter, emailAddress); err != nil { if err != sql.ErrNoRows { logger.Println("Problem getting voter", err) } return nil } return voter } func redirectToMotionsHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Location", "/motions/") w.WriteHeader(http.StatusMovedPermanently) } func renderTemplate(w http.ResponseWriter, tmpl string, context interface{}) { t, err := template.New(fmt.Sprintf("%s.html", tmpl)).Funcs(sprig.FuncMap()).ParseFiles(fmt.Sprintf("templates/%s.html", tmpl)) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } if err := t.Execute(w, context); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func authenticateRequest(w http.ResponseWriter, r *http.Request, authRequired bool, handler func(http.ResponseWriter, *http.Request, *Voter)) { for _, cert := range r.TLS.PeerCertificates { for _, extKeyUsage := range cert.ExtKeyUsage { if extKeyUsage == x509.ExtKeyUsageClientAuth { for _, emailAddress := range cert.EmailAddresses { if voter := authenticateVoter(emailAddress); voter != nil { handler(w, r, voter) return } } } } } if authRequired { w.WriteHeader(http.StatusForbidden) renderTemplate(w, "denied", nil) return } handler(w, r, nil) } type motionParameters struct { ShowVotes bool } type motionListParameters struct { Page int64 Flags struct { Confirmed, Withdraw, Unvoted bool } } func parseMotionParameters(r *http.Request) motionParameters { var m = motionParameters{} m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes")) logger.Printf("parsed parameters: %+v\n", m) return m } func parseMotionListParameters(r *http.Request) motionListParameters { var m = motionListParameters{} if page, err := strconv.ParseInt(r.URL.Query().Get("page"), 10, 0); err != nil { m.Page = 1 } else { m.Page = page } m.Flags.Withdraw, _ = strconv.ParseBool(r.URL.Query().Get("withdraw")) m.Flags.Unvoted, _ = strconv.ParseBool(r.URL.Query().Get("unvoted")) if r.Method == http.MethodPost { m.Flags.Confirmed, _ = strconv.ParseBool(r.PostFormValue("confirm")) } logger.Printf("parsed parameters: %+v\n", m) return m } func motionListHandler(w http.ResponseWriter, r *http.Request, voter *Voter) { params := parseMotionListParameters(r) votes_stmt, err := db.Preparex(sqlVoteCount) if err != nil { logger.Fatal(err) } defer votes_stmt.Close() beforeAfterStmt, err := db.Preparex(sqlCountNewerOlderThanMotion) if err != nil { logger.Fatal(err) } defer beforeAfterStmt.Close() var context struct { Decisions []Decision Voter *Voter Params *motionListParameters PrevPage, NextPage int64 } context.Voter = voter context.Params = ¶ms motion_stmt, err := db.Preparex(sqlGetDecisions) if err != nil { logger.Fatal(err) } defer motion_stmt.Close() rows, err := motion_stmt.Queryx(params.Page - 1) if err != nil { logger.Fatal(err) } for rows.Next() { var d Decision err := rows.StructScan(&d) if err != nil { rows.Close() logger.Fatal(err) } voteRows, err := votes_stmt.Queryx(d.Id) if err != nil { rows.Close() logger.Fatal(err) } for voteRows.Next() { var vote int var count int if err := voteRows.Scan(&vote, &count); err != nil { voteRows.Close() logger.Fatalf("Error fetching counts for motion %s: %s", d.Tag, err) } d.parseVote(vote, count) } context.Decisions = append(context.Decisions, d) voteRows.Close() } rows.Close() rows, err = beforeAfterStmt.Queryx( context.Decisions[0].Proposed, context.Decisions[len(context.Decisions)-1].Proposed) if err != nil { logger.Fatal(err) } defer rows.Close() for rows.Next() { var key string var value int if err := rows.Scan(&key, &value); err != nil { rows.Close() logger.Fatal(err) } if key == "older" && value > 0 { context.NextPage = params.Page + 1 } } if params.Page > 1 { context.PrevPage = params.Page - 1 } renderTemplate(w, "motions", context) } func motionHandler(w http.ResponseWriter, r *http.Request, voter *Voter, decision *Decision) { params := parseMotionParameters(r) var context struct { Decisions []Decision Voter *Voter Params *motionParameters PrevPage, NextPage int64 } context.Voter = voter context.Params = ¶ms context.Decisions = append(context.Decisions, *decision) renderTemplate(w, "motions", context) } func singleDecisionHandler(w http.ResponseWriter, r *http.Request, v *Voter, tag string, handler func(http.ResponseWriter, *http.Request, *Voter, *Decision)) { votes_stmt, err := db.Preparex(sqlVoteCount) if err != nil { logger.Fatal(err) } defer votes_stmt.Close() motion_stmt, err := db.Preparex(sqlGetDecision) if err != nil { logger.Fatal(err) } defer motion_stmt.Close() var d *Decision = &Decision{} err = motion_stmt.Get(d, tag) 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) } voteRows.Close() handler(w, r, v, d) } func motionsHandler(w http.ResponseWriter, r *http.Request) { if err := db.Ping(); err != nil { logger.Fatal(err) } subURL := r.URL.Path[len("/motions/"):] switch { case subURL == "": authenticateRequest(w, r, false, motionListHandler) return case strings.Count(subURL, "/") == 1: parts := strings.Split(subURL, "/") logger.Printf("handle %v\n", parts) fmt.Fprintf(w, "No handler for '%s'", subURL) motionTag := parts[0] action := parts[1] logger.Printf("motion: %s, action: %s\n", motionTag, action) return case strings.Count(subURL, "/") == 0: authenticateRequest(w, r, false, func(w http.ResponseWriter, r *http.Request, v *Voter) { singleDecisionHandler(w, r, v, subURL, motionHandler) }) return default: fmt.Fprintf(w, "No handler for '%s'", subURL) return } } 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|log.Lshortfile) 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/", motionsHandler) http.HandleFunc("/voters/", func(w http.ResponseWriter, r *http.Request) { authenticateRequest(w, r, true, votersHandler) }) http.Handle("/static/", http.FileServer(http.Dir("."))) 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() }