diff --git a/boardvoting.go b/boardvoting.go index 152d969..d62b80e 100644 --- a/boardvoting.go +++ b/boardvoting.go @@ -1,10 +1,12 @@ package main import ( + "bytes" "context" "crypto/tls" "crypto/x509" "encoding/base64" + "encoding/pem" "fmt" "github.com/Masterminds/sprig" "github.com/gorilla/sessions" @@ -50,6 +52,7 @@ const ( ctxNeedsAuth contextKey = iota ctxVoter ctxDecision + ctxVote ) func authenticateRequest(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) { @@ -227,6 +230,11 @@ func getDecisionFromRequest(r *http.Request) (decision *DecisionForDisplay, ok b return } +func getVoteFromRequest(r *http.Request) (vote VoteChoice, ok bool) { + vote, ok = r.Context().Value(ctxVote).(VoteChoice) + return +} + type FlashMessageAction struct{} func (a *FlashMessageAction) AddFlash(w http.ResponseWriter, r *http.Request, message interface{}, tags ...string) (err error) { @@ -277,7 +285,7 @@ func (a *withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) { return } - notifyMail <- &NotificationWithDrawMotion{decision: decision.Decision, voter: *voter} + NotifyMailChannel <- NewNotificationWithDrawMotion(&(decision.Decision), voter) if err := a.AddFlash(w, r, fmt.Sprintf("Motion %s has been withdrawn!", decision.Tag)); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -330,7 +338,7 @@ func (h *newMotionHandler) Handle(w http.ResponseWriter, r *http.Request) { return } - notifyMail <- &NotificationCreateMotion{decision: *data, voter: *voter} + NotifyMailChannel <- &NotificationCreateMotion{decision: *data, voter: *voter} if err := h.AddFlash(w, r, "The motion has been proposed!"); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -395,7 +403,7 @@ func (a editMotionAction) Handle(w http.ResponseWriter, r *http.Request) { return } - notifyMail <- &NotificationUpdateMotion{decision: *data, voter: *voter} + NotifyMailChannel <- NewNotificationUpdateMotion(*data, *voter) if err := a.AddFlash(w, r, "The motion has been modified!"); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -467,17 +475,164 @@ func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +type voteHandler struct { + FlashMessageAction + authenticationRequiredHandler +} + +func (h *voteHandler) Handle(w http.ResponseWriter, r *http.Request) { + decision, ok := getDecisionFromRequest(r) + if !ok { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + voter, ok := getVoterFromRequest(r) + if !ok { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + vote, ok := getVoteFromRequest(r) + if !ok { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + fmt.Fprintln(w, "to be implemented") + fmt.Fprintln(w, "Decision:", decision) + fmt.Fprintln(w, "Voter:", voter) + fmt.Fprintln(w, "Vote:", vote) +} + +type proxyVoteHandler struct { + FlashMessageAction + authenticationRequiredHandler +} + +func getPEMClientCert(r *http.Request) string { + clientCertPEM := bytes.NewBufferString("") + pem.Encode(clientCertPEM, &pem.Block{Type: "CERTIFICATE", Bytes: r.TLS.PeerCertificates[0].Raw}) + return clientCertPEM.String() +} + +func (h *proxyVoteHandler) Handle(w http.ResponseWriter, r *http.Request) { + decision, ok := getDecisionFromRequest(r) + if !ok { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + proxy, ok := getVoterFromRequest(r) + if !ok { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + templates := []string{"proxy_vote_form.html", "header.html", "footer.html", "motion_fragments.html"} + var templateContext struct { + Form ProxyVoteForm + Decision *DecisionForDisplay + Voters *[]Voter + PageTitle string + Flashes interface{} + } + switch r.Method { + case http.MethodPost: + form := ProxyVoteForm{ + Voter: r.FormValue("Voter"), + Vote: r.FormValue("Vote"), + Justification: r.FormValue("Justification"), + } + + if valid, voter, data, justification := form.Validate(); !valid { + templateContext.Form = form + templateContext.Decision = decision + if voters, err := GetVotersForProxy(proxy, &decision.Decision); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } else { + templateContext.Voters = voters + } + renderTemplate(w, templates, templateContext) + } else { + data.DecisionId = decision.Id + data.Voted = time.Now().UTC() + data.Notes = fmt.Sprintf( + "Proxy-Vote by %s\n\n%s\n\n%s", + proxy.Name, justification, getPEMClientCert(r)) + + if err := data.Save(); err != nil { + logger.Println("Error saving vote:", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + NotifyMailChannel <- NewNotificationProxyVote(&decision.Decision, proxy, voter, data, justification) + + if err := h.AddFlash(w, r, "The vote has been registered."); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) + } + return + default: + templateContext.Form = ProxyVoteForm{} + templateContext.Decision = decision + if voters, err := GetVotersForProxy(proxy, &decision.Decision); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } else { + templateContext.Voters = voters + } + renderTemplate(w, templates, templateContext) + } +} + +type decisionVoteHandler struct{} + +func (h *decisionVoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := db.Ping(); err != nil { + logger.Fatal(err) + } + + switch { + case strings.HasPrefix(r.URL.Path, "/proxy/"): + motionTag := r.URL.Path[len("/proxy/"):] + handler := &proxyVoteHandler{} + authenticateRequest( + w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)), + func(w http.ResponseWriter, r *http.Request) { + singleDecisionHandler(w, r, motionTag, handler.Handle) + }) + case strings.HasPrefix(r.URL.Path, "/vote/"): + parts := strings.Split(r.URL.Path[len("/vote/"):], "/") + motionTag := parts[0] + voteValue, ok := VoteValues[parts[1]] + if !ok { + http.NotFound(w, r) + return + } + handler := &voteHandler{} + authenticateRequest( + w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)), + func(w http.ResponseWriter, r *http.Request) { + singleDecisionHandler( + w, r.WithContext(context.WithValue(r.Context(), ctxVote, voteValue)), + motionTag, handler.Handle) + }) + return + } +} + type Config struct { - BoardMailAddress string `yaml:"board_mail_address"` - NoticeSenderAddress string `yaml:"notice_sender_address"` - ReminderSenderAddress string `yaml:"reminder_sender_address"` - DatabaseFile string `yaml:"database_file"` - ClientCACertificates string `yaml:"client_ca_certificates"` - ServerCert string `yaml:"server_certificate"` - ServerKey string `yaml:"server_key"` - CookieSecret string `yaml:"cookie_secret"` - BaseURL string `yaml:"base_url"` - MailServer struct { + BoardMailAddress string `yaml:"board_mail_address"` + VoteNoticeAddress string `yaml:"notice_sender_address"` + NotificationSenderAddress string `yaml:"reminder_sender_address"` + DatabaseFile string `yaml:"database_file"` + ClientCACertificates string `yaml:"client_ca_certificates"` + ServerCert string `yaml:"server_certificate"` + ServerKey string `yaml:"server_key"` + CookieSecret string `yaml:"cookie_secret"` + BaseURL string `yaml:"base_url"` + MailServer struct { Host string `yaml:"host"` Port int `yaml:"port"` } `yaml:"mail_server"` @@ -516,8 +671,9 @@ func main() { defer db.Close() - go MailNotifier() - defer CloseMailNotifier() + quitMailChannel := make(chan int) + go MailNotifier(quitMailChannel) + defer func() { quitMailChannel <- 1 }() quitChannel := make(chan int) go JobScheduler(quitChannel) @@ -525,6 +681,8 @@ func main() { http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{})) http.Handle("/newmotion/", motionsHandler{}) + http.Handle("/proxy/", &decisionVoteHandler{}) + http.Handle("/vote/", &decisionVoteHandler{}) http.Handle("/static/", http.FileServer(http.Dir("."))) http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently)) diff --git a/forms.go b/forms.go index b7b7225..e0d430a 100644 --- a/forms.go +++ b/forms.go @@ -92,3 +92,40 @@ func (f *EditDecisionForm) Validate() (bool, *Decision) { return len(f.Errors) == 0, data } + +type ProxyVoteForm struct { + Voter string + Vote string + Justification string + Errors map[string]string +} + +func (f *ProxyVoteForm) Validate() (bool, *Voter, *Vote, string) { + f.Errors = make(map[string]string) + + data := &Vote{} + + var voter *Voter + if voterId, err := strconv.ParseInt(f.Voter, 10, 64); err != nil { + f.Errors["Voter"] = fmt.Sprint("Please choose a valid voter", err) + } else if voter, err = GetVoterById(voterId); err != nil { + f.Errors["Voter"] = fmt.Sprint("Please choose a valid voter", err) + } else { + data.VoterId = voter.Id + } + + if vote, err := strconv.ParseInt(f.Vote, 10, 8); err != nil { + f.Errors["Vote"] = fmt.Sprint("Please choose a valid vote", err) + } else if voteChoice, ok := VoteChoices[vote]; !ok { + f.Errors["Vote"] = fmt.Sprint("Please choose a valid vote") + } else { + data.Vote = voteChoice + } + + justification := strings.TrimSpace(f.Justification) + if len(justification) < 3 { + f.Errors["Justification"] = "Please enter at least 3 characters." + } + + return len(f.Errors) == 0, voter, data, justification +} diff --git a/jobs.go b/jobs.go index 3f15e2d..2535ef1 100644 --- a/jobs.go +++ b/jobs.go @@ -142,7 +142,7 @@ func (j *RemindVotersJob) Run() { return } if len(*decisions) > 0 { - voterMail <- &RemindVoterNotification{voter: voter, decisions: *decisions} + NotifyMailChannel <- &RemindVoterNotification{voter: voter, decisions: *decisions} } } } diff --git a/models.go b/models.go index 5297cf2..dfc054d 100644 --- a/models.go +++ b/models.go @@ -26,6 +26,10 @@ const ( sqlGetNextPendingDecisionDue sqlGetReminderVoters sqlFindUnvotedDecisionsForVoter + sqlGetEnabledVoterById + sqlCreateVote + sqlLoadVote + sqlGetVotersForProxy ) var sqlStatements = map[sqlKey]string{ @@ -67,6 +71,10 @@ SELECT voters.id, voters.name, voters.enabled, voters.reminder FROM voters JOIN emails ON voters.id=emails.voter WHERE emails.address=$1 AND voters.enabled=1`, + sqlGetEnabledVoterById: ` +SELECT id, name, enabled, reminder +FROM voters +WHERE enabled=1 AND id=$1`, sqlCountOlderThanDecision: ` SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1`, sqlCountOlderThanUnvotedDecision: ` @@ -95,14 +103,23 @@ FROM decisions WHERE decisions.status=0 AND :now > due`, sqlGetNextPendingDecisionDue: ` SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`, + sqlGetVotersForProxy: ` +SELECT id, name, reminder +FROM voters WHERE enabled=1 AND id != $1 AND id NOT IN (SELECT voter FROM votes WHERE decision=$2)`, sqlGetReminderVoters: ` SELECT id, name, reminder FROM voters WHERE enabled=1 AND reminder!='' AND reminder IS NOT NULL`, sqlFindUnvotedDecisionsForVoter: ` SELECT tag, title, votetype, due FROM decisions WHERE status = 0 AND id NOT IN (SELECT decision FROM votes WHERE voter = $1) -ORDER BY due ASC -`, +ORDER BY due ASC`, + sqlCreateVote: ` +INSERT INTO votes (decision, voter, vote, voted, notes) +VALUES (:decision, :voter, :vote, :voted, :notes)`, + sqlLoadVote: ` +SELECT decision, voter, vote, voted, notes +FROM votes +WHERE decision=$1 AND voter=$2`, } var db *sqlx.DB @@ -122,9 +139,9 @@ type VoteType uint8 type VoteStatus int8 type Decision struct { - Id int + Id int64 Proposed time.Time - ProponentId int `db:"proponent"` + ProponentId int64 `db:"proponent"` Title string Content string Quorum int @@ -137,12 +154,12 @@ type Decision struct { } type Email struct { - VoterId int `db:"voter"` + VoterId int64 `db:"voter"` Address string } type Voter struct { - Id int + Id int64 Name string Enabled bool Reminder string // reminder email address @@ -150,14 +167,6 @@ type Voter struct { type VoteChoice int -type Vote struct { - DecisionId int `db:"decision"` - VoterId int `db:"voter"` - Vote VoteChoice - Voted time.Time - Notes string -} - const ( voteAye = 1 voteNaye = -1 @@ -202,6 +211,18 @@ func (v VoteChoice) String() string { } } +var VoteValues = map[string]VoteChoice{ + "aye": voteAye, + "naye": voteNaye, + "abstain": voteAbstain, +} + +var VoteChoices = map[int64]VoteChoice{ + 1: voteAye, + 0: voteAbstain, + -1: voteNaye, +} + const ( voteStatusDeclined = -1 voteStatusPending = 0 @@ -224,6 +245,43 @@ func (v VoteStatus) String() string { } } +type Vote struct { + DecisionId int64 `db:"decision"` + VoterId int64 `db:"voter"` + Vote VoteChoice + Voted time.Time + Notes string +} + +func (v *Vote) Save() (err error) { + insertVoteStmt, err := db.PrepareNamed(sqlStatements[sqlCreateVote]) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer insertVoteStmt.Close() + + _, err = insertVoteStmt.Exec(v) + if err != nil { + logger.Println("Error saving vote:", err) + return + } + + getVoteStmt, err := db.Preparex(sqlStatements[sqlLoadVote]) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer getVoteStmt.Close() + + err = getVoteStmt.Get(v, v.DecisionId, v.VoterId) + if err != nil { + logger.Println("Error getting inserted vote:", err) + } + + return +} + type VoteSums struct { Ayes int Nayes int @@ -569,7 +627,7 @@ func (d *Decision) Close() (err error) { logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows) } - notifyMail <- &NotificationClosedDecision{decision: *d, voteSums: *voteSums} + NotifyMailChannel <- NewNotificationClosedDecision(d, voteSums) return } @@ -671,3 +729,39 @@ func FindUnvotedDecisionsForVoter(voter *Voter) (decisions *[]Decision, err erro return } + +func GetVoterById(id int64) (voter *Voter, err error) { + getVoterByIdStmt, err := db.Preparex(sqlStatements[sqlGetEnabledVoterById]) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer getVoterByIdStmt.Close() + + voter = &Voter{} + if err = getVoterByIdStmt.Get(voter, id); err != nil { + logger.Println("Error getting voter:", err) + return + } + + return +} + +func GetVotersForProxy(proxy *Voter, decision *Decision) (voters *[]Voter, err error) { + getVotersForProxyStmt, err := db.Preparex(sqlStatements[sqlGetVotersForProxy]) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer getVotersForProxyStmt.Close() + + votersSlice := make([]Voter, 0) + + if err = getVotersForProxyStmt.Select(&votersSlice, proxy.Id, decision.Id); err != nil { + logger.Println("Error getting voters for proxy:", err) + return + } + voters = &votersSlice + + return +} diff --git a/notifications.go b/notifications.go index d4b9715..499a689 100644 --- a/notifications.go +++ b/notifications.go @@ -13,28 +13,16 @@ type NotificationMail interface { GetTemplate() string GetSubject() string GetHeaders() map[string]string -} - -type VoterMail interface { - GetData() interface{} - GetTemplate() string - GetSubject() string GetRecipient() (string, string) } -var notifyMail = make(chan NotificationMail, 1) -var voterMail = make(chan VoterMail, 1) -var quitMailNotifier = make(chan int) +var NotifyMailChannel = make(chan NotificationMail, 1) -func CloseMailNotifier() { - quitMailNotifier <- 1 -} - -func MailNotifier() { +func MailNotifier(quitMailNotifier chan int) { logger.Println("Launched mail notifier") for { select { - case notification := <-notifyMail: + case notification := <-NotifyMailChannel: mailText, err := buildMail(notification.GetTemplate(), notification.GetData()) if err != nil { logger.Println("ERROR building mail:", err) @@ -42,32 +30,15 @@ func MailNotifier() { } m := gomail.NewMessage() - m.SetHeader("From", config.NoticeSenderAddress) - m.SetHeader("To", config.BoardMailAddress) + m.SetHeader("From", config.NotificationSenderAddress) + address, name := notification.GetRecipient() + m.SetAddressHeader("To", address, name) m.SetHeader("Subject", notification.GetSubject()) for header, value := range notification.GetHeaders() { m.SetHeader(header, value) } m.SetBody("text/plain", mailText.String()) - d := gomail.NewDialer(config.MailServer.Host, config.MailServer.Port, "", "") - if err := d.DialAndSend(m); err != nil { - logger.Println("ERROR sending mail:", err) - } - case notification := <-voterMail: - mailText, err := buildMail(notification.GetTemplate(), notification.GetData()) - if err != nil { - logger.Println("ERROR building mail:", err) - continue - } - - m := gomail.NewMessage() - m.SetHeader("From", config.ReminderSenderAddress) - address, name := notification.GetRecipient() - m.SetAddressHeader("To", address, name) - m.SetHeader("Subject", notification.GetSubject()) - m.SetBody("text/plain", mailText.String()) - d := gomail.NewDialer(config.MailServer.Host, config.MailServer.Port, "", "") if err := d.DialAndSend(m); err != nil { logger.Println("ERROR sending mail:", err) @@ -92,11 +63,36 @@ func buildMail(templateName string, context interface{}) (mailText *bytes.Buffer return } -type NotificationClosedDecision struct { +type notificationBase struct{} + +func (n *notificationBase) GetRecipient() (address string, name string) { + address, name = config.BoardMailAddress, "CAcert board mailing list" + return +} + +type decisionReplyBase struct { decision Decision +} + +func (n *decisionReplyBase) GetHeaders() map[string]string { + return map[string]string{ + "References": fmt.Sprintf("<%s>", n.decision.Tag), + "In-Reply-To": fmt.Sprintf("<%s>", n.decision.Tag), + } +} + +type NotificationClosedDecision struct { + notificationBase + decisionReplyBase voteSums VoteSums } +func NewNotificationClosedDecision(decision *Decision, voteSums *VoteSums) *NotificationClosedDecision { + notification := &NotificationClosedDecision{voteSums: *voteSums} + notification.decision = *decision + return notification +} + func (n *NotificationClosedDecision) GetData() interface{} { return struct { *Decision @@ -110,11 +106,8 @@ func (n *NotificationClosedDecision) GetSubject() string { return fmt.Sprintf("Re: %s - %s - finalised", n.decision.Tag, n.decision.Title) } -func (n *NotificationClosedDecision) GetHeaders() map[string]string { - return map[string]string{"References": fmt.Sprintf("<%s>", n.decision.Tag)} -} - type NotificationCreateMotion struct { + notificationBase decision Decision voter Voter } @@ -141,8 +134,15 @@ func (n *NotificationCreateMotion) GetHeaders() map[string]string { } type NotificationUpdateMotion struct { - decision Decision - voter Voter + notificationBase + decisionReplyBase + voter Voter +} + +func NewNotificationUpdateMotion(decision Decision, voter Voter) *NotificationUpdateMotion { + notification := NotificationUpdateMotion{voter: voter} + notification.decision = decision + return ¬ification } func (n *NotificationUpdateMotion) GetData() interface{} { @@ -162,13 +162,16 @@ func (n *NotificationUpdateMotion) GetSubject() string { return fmt.Sprintf("Re: %s - %s", n.decision.Tag, n.decision.Title) } -func (n *NotificationUpdateMotion) GetHeaders() map[string]string { - return map[string]string{"References": fmt.Sprintf("<%s>", n.decision.Tag)} +type NotificationWithDrawMotion struct { + notificationBase + decisionReplyBase + voter Voter } -type NotificationWithDrawMotion struct { - decision Decision - voter Voter +func NewNotificationWithDrawMotion(decision *Decision, voter *Voter) *NotificationWithDrawMotion { + notification := &NotificationWithDrawMotion{voter: *voter} + notification.decision = *decision + return notification } func (n *NotificationWithDrawMotion) GetData() interface{} { @@ -184,10 +187,6 @@ func (n *NotificationWithDrawMotion) GetSubject() string { return fmt.Sprintf("Re: %s - %s - withdrawn", n.decision.Tag, n.decision.Title) } -func (n *NotificationWithDrawMotion) GetHeaders() map[string]string { - return map[string]string{"References": fmt.Sprintf("<%s>", n.decision.Tag)} -} - type RemindVoterNotification struct { voter Voter decisions []Decision @@ -205,7 +204,49 @@ func (n *RemindVoterNotification) GetTemplate() string { return "remind_voter_ma func (n *RemindVoterNotification) GetSubject() string { return "Outstanding CAcert board votes" } +func (n *RemindVoterNotification) GetHeaders() map[string]string { + return map[string]string{} +} + func (n *RemindVoterNotification) GetRecipient() (address string, name string) { address, name = n.voter.Reminder, n.voter.Name return } + +type voteNotificationBase struct{} + +func (n *voteNotificationBase) GetRecipient() (address string, name string) { + address, name = config.VoteNoticeAddress, "CAcert board votes mailing list" + return +} + +type NotificationProxyVote struct { + voteNotificationBase + decisionReplyBase + proxy Voter + voter Voter + vote Vote + justification string +} + +func NewNotificationProxyVote(decision *Decision, proxy *Voter, voter *Voter, vote *Vote, justification string) *NotificationProxyVote { + notification := &NotificationProxyVote{proxy: *proxy, voter: *voter, vote: *vote, justification: justification} + notification.decision = *decision + return notification +} + +func (n *NotificationProxyVote) GetData() interface{} { + return struct { + Proxy string + Vote VoteChoice + Voter string + Decision *Decision + Justification string + }{n.proxy.Name, n.vote.Vote, n.voter.Name, &n.decision, n.justification} +} + +func (n *NotificationProxyVote) GetTemplate() string { return "proxy_vote_mail.txt" } + +func (n *NotificationProxyVote) GetSubject() string { + return fmt.Sprintf("Re: %s - %s", n.decision.Tag, n.decision.Title) +} diff --git a/proxy.php b/proxy.php deleted file mode 100644 index a860343..0000000 --- a/proxy.php +++ /dev/null @@ -1,154 +0,0 @@ -auth())) { - header("HTTP/1.0 302 Redirect"); - header("Location: denied.php"); - exit(); - } -?> - -
-Status | +Motion | +
---|---|