573 lines
12 KiB
Go
573 lines
12 KiB
Go
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()
|
|
}
|