diff --git a/actions.go b/actions.go deleted file mode 100644 index 19e9bf3..0000000 --- a/actions.go +++ /dev/null @@ -1,131 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "github.com/Masterminds/sprig" - "gopkg.in/gomail.v2" - "text/template" -) - -type templateBody string - -func buildMail(templateName string, context interface{}) (mailText *bytes.Buffer, err error) { - t, err := template.New(templateName).Funcs( - sprig.GenericFuncMap()).ParseFiles(fmt.Sprintf("templates/%s", templateName)) - if err != nil { - return - } - - mailText = bytes.NewBufferString("") - t.Execute(mailText, context) - - return -} - -func CreateMotion(decision *Decision, voter *Voter) (err error) { - decision.ProponentId = voter.Id - err = decision.Create() - if err != nil { - logger.Println("Error saving motion:", err) - return - } - - type mailContext struct { - Decision - Name string - VoteURL string - UnvotedURL string - } - voteURL := fmt.Sprintf("%s/vote", config.BaseURL) - unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL) - context := mailContext{*decision, voter.Name, voteURL, unvotedURL} - - mailText, err := buildMail("create_motion_mail.txt", context) - if err != nil { - logger.Println("Error", err) - return - } - - m := gomail.NewMessage() - m.SetHeader("From", config.NoticeSenderAddress) - m.SetHeader("To", config.BoardMailAddress) - m.SetHeader("Subject", fmt.Sprintf("%s - %s", decision.Tag, decision.Title)) - m.SetHeader("Message-ID", fmt.Sprintf("<%s>", decision.Tag)) - 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) - } - - return -} - -func UpdateMotion(decision *Decision, voter *Voter) (err error) { - err = decision.Update() - if err != nil { - logger.Println("Error updating motion:", err) - return - } - - type mailContext struct { - Decision - Name string - VoteURL string - UnvotedURL string - } - voteURL := fmt.Sprintf("%s/vote", config.BaseURL) - unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL) - context := mailContext{*decision, voter.Name, voteURL, unvotedURL} - - mailText, err := buildMail("update_motion_mail.txt", context) - if err != nil { - logger.Println("Error", err) - return - } - - m := gomail.NewMessage() - m.SetHeader("From", config.NoticeSenderAddress) - m.SetHeader("To", config.BoardMailAddress) - m.SetHeader("Subject", fmt.Sprintf("Re: %s - %s", decision.Tag, decision.Title)) - m.SetHeader("References", fmt.Sprintf("<%s>", decision.Tag)) - 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) - } - - return -} - -func WithdrawMotion(decision *Decision, voter *Voter) (err error) { - err = decision.UpdateStatus() - - type mailContext struct { - *Decision - Name string - } - context := mailContext{decision, voter.Name} - - mailText, err := buildMail("withdraw_motion_mail.txt", context) - if err != nil { - logger.Println("Error", err) - return - } - - m := gomail.NewMessage() - m.SetHeader("From", config.NoticeSenderAddress) - m.SetHeader("To", config.BoardMailAddress) - m.SetHeader("Subject", fmt.Sprintf("Re: %s - %s - withdrawn", decision.Tag, decision.Title)) - m.SetHeader("References", fmt.Sprintf("<%s>", decision.Tag)) - 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) - } - - return -} diff --git a/boardvoting.go b/boardvoting.go index 187a867..30de17e 100644 --- a/boardvoting.go +++ b/boardvoting.go @@ -271,16 +271,20 @@ func (a *withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) { case http.MethodPost: decision.Status = voteStatusWithdrawn decision.Modified = time.Now().UTC() - if err := WithdrawMotion(&decision.Decision, voter); err != nil { + if err := decision.UpdateStatus(); err != nil { + logger.Println("Error withdrawing motion:", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + + notifyMail <- &NotificationWithDrawMotion{decision: decision.Decision, voter: *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) return } + http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect) - return default: templateContext.Decision = decision renderTemplate(w, templates, templateContext) @@ -319,10 +323,15 @@ func (h *newMotionHandler) Handle(w http.ResponseWriter, r *http.Request) { renderTemplate(w, templates, templateContext) } else { data.Proposed = time.Now().UTC() - if err := CreateMotion(data, voter); err != nil { + data.ProponentId = voter.Id + if err := data.Create(); err != nil { + logger.Println("Error saving motion:", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + + notifyMail <- &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) return @@ -380,10 +389,14 @@ func (a editMotionAction) Handle(w http.ResponseWriter, r *http.Request) { renderTemplate(w, templates, templateContext) } else { data.Modified = time.Now().UTC() - if err := UpdateMotion(data, voter); err != nil { + if err := data.Update(); err != nil { + logger.Println("Error updating motion:", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + + notifyMail <- &NotificationUpdateMotion{decision: *data, voter: *voter} + if err := a.AddFlash(w, r, "The motion has been modified!"); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return @@ -502,6 +515,9 @@ func main() { defer db.Close() + go MailNotifier() + defer CloseMailNotifier() + http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{})) http.Handle("/newmotion/", motionsHandler{}) http.Handle("/static/", http.FileServer(http.Dir("."))) diff --git a/denied.php b/denied.php deleted file mode 100644 index 9bb72d6..0000000 --- a/denied.php +++ /dev/null @@ -1,12 +0,0 @@ - - - CAcert Board Decisions - - - - - You are not authorized to act here!
- If you think this is in error, please contact the administrator - If you don't know who that is, it is definitely not an error ;) - - \ No newline at end of file diff --git a/index.php b/index.php deleted file mode 100644 index 3363496..0000000 --- a/index.php +++ /dev/null @@ -1,5 +0,0 @@ - \ No newline at end of file diff --git a/models.go b/models.go index d6eee15..5df7d95 100644 --- a/models.go +++ b/models.go @@ -21,6 +21,7 @@ const ( sqlCreateDecision sqlUpdateDecision sqlUpdateDecisionStatus + sqlSelectClosableDecisions ) var sqlStatements = map[sqlKey]string{ @@ -103,6 +104,12 @@ WHERE id=:id`, UPDATE decisions SET status=:status, modified=:modified WHERE id=:id `, + sqlSelectClosableDecisions: ` +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.status=0 AND :now > due`, } var db *sqlx.DB @@ -506,3 +513,81 @@ func FindVoterByAddress(emailAddress string) (voter *Voter, err error) { } return } + +func (d *Decision) Close() (err error) { + closeDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecisionStatus]) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer closeDecisionStmt.Close() + + quorum, majority := d.VoteType.QuorumAndMajority() + + voteSums, err := d.VoteSums() + + if err != nil { + logger.Println("Error getting vote sums") + return + } + votes := voteSums.VoteCount() + + if votes < quorum { + d.Status = voteStatusDeclined + } else { + votes = voteSums.Ayes + voteSums.Nayes + if (voteSums.Ayes / votes) > (majority / 100) { + d.Status = voteStatusApproved + } else { + d.Status = voteStatusDeclined + } + } + + result, err := closeDecisionStmt.Exec(d) + if err != nil { + logger.Println("Error closing vote:", err) + return + } + affectedRows, err := result.RowsAffected() + if err != nil { + logger.Println("Error getting affected rows:", err) + } + if affectedRows != 1 { + logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows) + } + + notifyMail <- &NotificationClosedDecision{decision: *d, voteSums: *voteSums} + + return +} + +func CloseDecisions() (err error) { + getClosedDecisionsStmt, err := db.PrepareNamed(sqlStatements[sqlSelectClosableDecisions]) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer getClosedDecisionsStmt.Close() + + params := make(map[string]interface{}, 1) + params["now"] = time.Now().UTC() + rows, err := getClosedDecisionsStmt.Queryx(params) + if err != nil { + logger.Println("Error fetching closed decisions", err) + return + } + defer rows.Close() + for rows.Next() { + d := &Decision{} + if err = rows.StructScan(d); err != nil { + logger.Println("Error filling decision from database row", err) + return + } + if err = d.Close(); err != nil { + logger.Printf("Error closing decision %s: %s\n", d.Tag, err) + return + } + } + + return +} diff --git a/motion.php b/motion.php deleted file mode 100644 index 2dec354..0000000 --- a/motion.php +++ /dev/null @@ -1,198 +0,0 @@ -auth())) { - header("HTTP/1.0 302 Redirect"); - header("Location: denied.php"); - exit(); - } - $db->getStatement("stats")->execute(); - $stats = $db->getStatement("stats")->fetch(); -?> - - - CAcert Board Decisions - - - - - getStatement("update decision"); - $stmt->bindParam(":id",$_POST['motion']); - $stmt->bindParam(":proponent",$user['id']); - $stmt->bindParam(":title",$_POST['title']); - $stmt->bindParam(":content",$_POST['content']); - $stmt->bindParam(":due",$_POST['due']); - $stmt->bindParam(":votetype",$_POST['votetype']); - if ($stmt->execute()) { - ?> - The motion has been proposed!
- Back to motions
-
-
- getStatement("get decision")->execute(array($_POST['motion']))?$db->getStatement("get decision")->fetch():array(); - $name = $user['name']; - $tag = $decision['tag']; - $title = $decision['title']; - $content =$decision['content']; - $due = $decision['due']." UTC"; - $votetype = !$decision['votetype'] ? 'motion' : 'veto'; - $baseurl = "https://".$_SERVER['HTTP_HOST'].":".$_SERVER['SERVER_PORT'].preg_replace('/motion\.php/','',$_SERVER['REQUEST_URI']); - $voteurl = $baseurl."vote.php?motion=".$decision['id']; - $unvoted = $baseurl."motions.php?unvoted=1"; - $body = <<notify("Re: $tag - $title - modified",$body,$tag); - } else { - ?> - The motion has NOT been proposed!
- Back to motions
- \n",$stmt->errorInfo()); ?>
-
-
- getStatement("create decision"); - $stmt->bindParam(":proponent",$user['id']); - $stmt->bindParam(":title",$_POST['title']); - $stmt->bindParam(":content",$_POST['content']); - $stmt->bindParam(":votetype",$_POST['votetype']); - $stmt->bindParam(":due",$_POST['due']); - if ($stmt->execute()) { - ?> - The motion has been proposed!
- Back to motions
-
-
- getStatement("get new decision")->execute()?$db->getStatement("get new decision")->fetch():array(); - $name = $user['name']; - $tag = $decision['tag']; - $title = $decision['title']; - $content =$decision['content']; - $due = $decision['due']." UTC"; - $votetype = !$decision['votetype'] ? 'motion' : 'veto'; - $baseurl = "https://".$_SERVER['HTTP_HOST'].":".$_SERVER['SERVER_PORT'].preg_replace('/motion\.php/','',$_SERVER['REQUEST_URI']); - $voteurl = $baseurl."vote.php?motion=".$decision['id']; - $unvoted = $baseurl."motions.php?unvoted=1"; - $body = <<notify("$tag - $title",$body,$tag,TRUE); - } else { - ?> - The motion has NOT been proposed!
- Back to motions
- \n",$stmt->errorInfo()); ?>
-
-
- getStatement("get decision"); - if ($stmt->execute(array($_REQUEST['motion']))) { - $motion = $stmt->fetch(); - } - if (!is_numeric($motion['id'])) { - $motion = array(); - foreach (array("title","content") as $column) { - $motion[$column] = ""; - } - $motion["proposer"] = $user['name']; - $motion["votetype"] = 0; // defaults to motion - } - } else { - $motion = array(); - foreach (array("title","content") as $column) { - $motion[$column] = ""; - } - $motion["proposer"] = $user['name']; - $motion["votetype"] = 0; // defaults to motion - } - ?> -
method="POST"> - - " /> - - - - - - - - - - -
ID:
Proponent:
Proposed date/time:
Title:
Text:
Vote type:
Due:
 
-
-
- Back to motions - - diff --git a/motions.php b/motions.php deleted file mode 100644 index 548731f..0000000 --- a/motions.php +++ /dev/null @@ -1,167 +0,0 @@ -auth(); - - if ($_REQUEST['withdrawl'] && $_REQUEST['confirm'] && $_REQUEST['id']) { - if (!$user) { - header("HTTP/1.0 302 Redirect"); - header("Location: denied.php"); - exit(); - } - $stmt = $db->getStatement("get decision"); - $stmt->bindParam(":decision",$_REQUEST['id']); - if ($stmt->execute() && ($decision=$stmt->fetch())) { - $name = $user['name']; - $tag = $decision['tag']; - $title = $decision['title']; - $content = $decision['content']; - $body = <<notify("Re: $tag - $title - withdrawn",$body,$tag); - } - $stmt = $db->getStatement("close decision"); - $status = -2; - $stmt->bindParam(":status",$status); - $stmt->bindParam(":decision",$_REQUEST['id']); - $stmt->execute(); - } -?> - - - CAcert Board Decisions - - - - - Show my outstanding votes
'; - ?> - - - - - - - getStatement("list decision"); - $stmt->execute(array($_REQUEST['motion'])); - } else { - if ($user && $_REQUEST['unvoted']) { - $stmt = $db->getStatement("list my unvoted decisions"); - $stmt->bindParam(":id",$user['id']); - } else { - $stmt = $db->getStatement("list decisions"); - } - $stmt->bindParam(":page",$page); - $stmt->execute(); - } - $items = 0; - $id = -1; - while ($row = $stmt->fetch()) { - $items++; - $id = $row['id']; - ?> - - - - - - - - - - - - - -
StatusMotionActions
"> - ".$row['due']." UTC"; break; - case 1: echo "Approved
".$row['modified']." UTC"; break; - case -1: echo "Declined
".$row['modified']." UTC"; break; - case -2: echo "Withdrawn
".$row['modified']." UTC"; break; - } - ?> -
- '.$row['tag']; ?>
-
-
-
- Due: UTC
- Proposed: ( UTC)
- Vote type:
- Aye|Naye|Abstain: ||
- getStatement("list votes"); - $vstmt->execute(array($row['id'])); - echo "Votes:
"; - while ($vrow = $vstmt->fetch()) { - echo "".$vrow['name'].": ".$state[$vrow['vote']+1]."
"; - } - } else { - echo 'Show Votes
'; - } - ?> -
- - - -   - -
-
  • New Motion
  • '); ?> -
    - - Motion Withdrawn - -
    - -
    - -
    - - diff --git a/notifications.go b/notifications.go new file mode 100644 index 0000000..beb9b3f --- /dev/null +++ b/notifications.go @@ -0,0 +1,171 @@ +package main + +import ( + "bytes" + "fmt" + "github.com/Masterminds/sprig" + "gopkg.in/gomail.v2" + "text/template" +) + +type NotificationMail interface { + GetData() interface{} + GetTemplate() string + GetSubject() string + GetHeaders() map[string]string +} + +var notifyMail = make(chan NotificationMail, 1) +var quitMailNotifier = make(chan int) + +func CloseMailNotifier() { + quitMailNotifier <- 1 +} + +func MailNotifier() { + logger.Println("Launched mail notifier") + for { + select { + case notification := <-notifyMail: + mailText, err := buildMail(notification.GetTemplate(), notification.GetData()) + if err != nil { + logger.Println("ERROR building mail:", err) + continue + } + + m := gomail.NewMessage() + m.SetHeader("From", config.NoticeSenderAddress) + m.SetHeader("To", config.BoardMailAddress) + 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 <-quitMailNotifier: + fmt.Println("Ending mail notifier") + return + } + } +} + +func buildMail(templateName string, context interface{}) (mailText *bytes.Buffer, err error) { + t, err := template.New(templateName).Funcs( + sprig.GenericFuncMap()).ParseFiles(fmt.Sprintf("templates/%s", templateName)) + if err != nil { + return + } + + mailText = bytes.NewBufferString("") + t.Execute(mailText, context) + + return +} + +type NotificationClosedDecision struct { + decision Decision + voteSums VoteSums +} + +func (n *NotificationClosedDecision) GetData() interface{} { + return struct { + *Decision + *VoteSums + }{&n.decision, &n.voteSums} +} + +func (n *NotificationClosedDecision) GetTemplate() string { + return "closed_motion_mail.txt" +} + +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 { + decision Decision + voter Voter +} + +func (n *NotificationCreateMotion) GetData() interface{} { + voteURL := fmt.Sprintf("%s/vote", config.BaseURL) + unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL) + return struct { + *Decision + Name string + VoteURL string + UnvotedURL string + }{&n.decision, n.voter.Name, voteURL, unvotedURL} +} + +func (n *NotificationCreateMotion) GetTemplate() string { + return "create_motion_mail.txt" +} + +func (n *NotificationCreateMotion) GetSubject() string { + return fmt.Sprintf("%s - %s", n.decision.Tag, n.decision.Title) +} + +func (n *NotificationCreateMotion) GetHeaders() map[string]string { + return map[string]string{"Message-ID": fmt.Sprintf("<%s>", n.decision.Tag)} +} + +type NotificationUpdateMotion struct { + decision Decision + voter Voter +} + +func (n *NotificationUpdateMotion) GetData() interface{} { + voteURL := fmt.Sprintf("%s/vote", config.BaseURL) + unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL) + return struct { + *Decision + Name string + VoteURL string + UnvotedURL string + }{&n.decision, n.voter.Name, voteURL, unvotedURL} +} + +func (n *NotificationUpdateMotion) GetTemplate() string { + return "update_motion_mail.txt" +} + +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 { + decision Decision + voter Voter +} + +func (n *NotificationWithDrawMotion) GetData() interface{} { + return struct { + *Decision + Name string + }{&n.decision, n.voter.Name} +} + +func (n *NotificationWithDrawMotion) GetTemplate() string { + return "withdraw_motion_mail.txt" +} + +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)} +}