diff --git a/actions.go b/actions.go index 0619587..a8e78fb 100644 --- a/actions.go +++ b/actions.go @@ -1,15 +1,32 @@ package main import ( + "bytes" "fmt" "github.com/Masterminds/sprig" "os" "text/template" + "gopkg.in/gomail.v2" ) +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.Save() + err = decision.Create() if err != nil { logger.Println("Error saving motion:", err) return @@ -18,26 +35,69 @@ func CreateMotion(decision *Decision, voter *Voter) (err error) { type mailContext struct { Decision Name string - Sender string - Recipient 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, config.NoticeSenderAddress, - config.BoardMailAddress, voteURL, - unvotedURL} + context := mailContext{*decision, voter.Name, voteURL, unvotedURL} - t, err := template.New("create_motion_mail.txt").Funcs( - sprig.GenericFuncMap()).ParseFiles("templates/create_motion_mail.txt") + mailText, err := buildMail("create_motion_mail.txt", context) if err != nil { - logger.Fatal(err) + 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("%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) } - t.Execute(os.Stdout, context) - // TODO: implement mail sending return } diff --git a/boardvoting.go b/boardvoting.go index 367179f..efa9ecc 100644 --- a/boardvoting.go +++ b/boardvoting.go @@ -47,8 +47,8 @@ type contextKey int const ( ctxNeedsAuth contextKey = iota - ctxVoter contextKey = iota - ctxDecision contextKey = iota + ctxVoter + ctxDecision ) func authenticateRequest(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) { @@ -260,82 +260,83 @@ type editMotionAction struct { authenticationRequiredHandler } -func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) { - decision, ok := getDecisionFromRequest(r) - if !ok || decision.Status != voteStatusPending { +func newMotionHandler(w http.ResponseWriter, r *http.Request) { + voter, ok := getVoterFromRequest(r) + if !ok { http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) - return } - fmt.Fprintln(w, "Edit motion", decision.Tag) - // TODO: implement -} - -type motionsHandler struct{} -func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if err := db.Ping(); err != nil { - logger.Fatal(err) + templates := []string{"create_motion_form.html", "header.html", "footer.html"} + var templateContext struct { + Form NewDecisionForm + PageTitle string + Voter *Voter + Flashes interface{} } + switch r.Method { + case http.MethodPost: + form := NewDecisionForm{ + Title: r.FormValue("Title"), + Content: r.FormValue("Content"), + VoteType: r.FormValue("VoteType"), + Due: r.FormValue("Due"), + } - subURL := r.URL.Path - - var motionActionMap = map[string]motionActionHandler{ - "withdraw": withDrawMotionAction{}, - "edit": editMotionAction{}, - } + if valid, data := form.Validate(); !valid { + templateContext.Voter = voter + templateContext.Form = form + renderTemplate(w, templates, templateContext) + } else { + if err := CreateMotion(data, voter); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + session, err := store.Get(r, sessionCookieName) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + session.AddFlash("The motion has been proposed!") + session.Save(r, w) - switch { - case subURL == "": - authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler) - return - case strings.Count(subURL, "/") == 1: - parts := strings.Split(subURL, "/") - logger.Printf("handle %v\n", parts) - motionTag := parts[0] - action, ok := motionActionMap[parts[1]] - if !ok { - http.NotFound(w, r) - return + http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) } - authenticateRequest( - w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, action.NeedsAuth())), - func(w http.ResponseWriter, r *http.Request) { - singleDecisionHandler(w, r, motionTag, action.Handle) - }) - logger.Printf("motion: %s, action: %s\n", motionTag, action) - return - case strings.Count(subURL, "/") == 0: - authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), - func(w http.ResponseWriter, r *http.Request) { - singleDecisionHandler(w, r, subURL, motionHandler) - }) + return default: - http.NotFound(w, r) - return + templateContext.Voter = voter + templateContext.Form = NewDecisionForm{ + VoteType: strconv.FormatInt(voteTypeMotion, 10), + } + renderTemplate(w, templates, templateContext) } } -func newMotionHandler(w http.ResponseWriter, r *http.Request) { +func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) { + decision, ok := getDecisionFromRequest(r) + if !ok || decision.Status != voteStatusPending { + http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) + return + } voter, ok := getVoterFromRequest(r) if !ok { http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) } - - templates := []string{"newmotion_form.html", "header.html", "footer.html"} + templates := []string{"edit_motion_form.html", "header.html", "footer.html"} var templateContext struct { - Form NewDecisionForm + Form EditDecisionForm PageTitle string Voter *Voter Flashes interface{} } switch r.Method { case http.MethodPost: - form := NewDecisionForm{ + form := EditDecisionForm{ Title: r.FormValue("Title"), Content: r.FormValue("Content"), VoteType: r.FormValue("VoteType"), Due: r.FormValue("Due"), + Decision: &decision.Decision, } if valid, data := form.Validate(); !valid { @@ -343,7 +344,7 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) { templateContext.Form = form renderTemplate(w, templates, templateContext) } else { - if err := CreateMotion(data, voter); err != nil { + if err := UpdateMotion(data, voter); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -352,7 +353,7 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - session.AddFlash("The motion has been proposed!") + session.AddFlash("The motion has been modified!") session.Save(r, w) http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) @@ -361,13 +362,60 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) { return default: templateContext.Voter = voter - templateContext.Form = NewDecisionForm{ - VoteType: strconv.FormatInt(voteTypeMotion, 10), + templateContext.Form = EditDecisionForm{ + Title: decision.Title, + Content: decision.Content, + VoteType: fmt.Sprintf("%d", decision.VoteType), + Decision: &decision.Decision, } renderTemplate(w, templates, templateContext) } } +type motionsHandler struct{} + +func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := db.Ping(); err != nil { + logger.Fatal(err) + } + + subURL := r.URL.Path + + var motionActionMap = map[string]motionActionHandler{ + "withdraw": withDrawMotionAction{}, + "edit": editMotionAction{}, + } + + switch { + case subURL == "": + authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler) + return + case strings.Count(subURL, "/") == 1: + parts := strings.Split(subURL, "/") + motionTag := parts[0] + action, ok := motionActionMap[parts[1]] + if !ok { + http.NotFound(w, r) + return + } + authenticateRequest( + w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, action.NeedsAuth())), + func(w http.ResponseWriter, r *http.Request) { + singleDecisionHandler(w, r, motionTag, action.Handle) + }) + return + case strings.Count(subURL, "/") == 0: + authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), + func(w http.ResponseWriter, r *http.Request) { + singleDecisionHandler(w, r, subURL, motionHandler) + }) + return + default: + http.NotFound(w, r) + return + } +} + type Config struct { BoardMailAddress string `yaml:"board_mail_address"` NoticeSenderAddress string `yaml:"notice_sender_address"` @@ -377,6 +425,10 @@ type Config struct { 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"` } func init() { diff --git a/config.yaml.example b/config.yaml.example index 7a9390c..f5da83d 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -6,4 +6,5 @@ client_ca_certificates: cacert_class3.pem server_certificate: server.crt server_key: server.key cookie_secret: base64encoded_random_byte_value_of_at_least_32_bytes -base_url: https://motions.cacert.org \ No newline at end of file +base_url: https://motions.cacert.org +mail_server: localhost:smtp \ No newline at end of file diff --git a/forms.go b/forms.go index f3cd942..503e7e5 100644 --- a/forms.go +++ b/forms.go @@ -54,3 +54,45 @@ func (f *NewDecisionForm) Validate() (bool, *Decision) { return len(f.Errors) == 0, data } + +type EditDecisionForm struct { + Title string + Content string + VoteType string + Due string + Decision *Decision + Errors map[string]string +} + +func (f *EditDecisionForm) Validate() (bool, *Decision) { + f.Errors = make(map[string]string) + + data := f.Decision + + data.Title = strings.TrimSpace(f.Title) + if len(data.Title) < 3 { + f.Errors["Title"] = "Please enter at least 3 characters." + } + + data.Content = strings.TrimSpace(f.Content) + if len(strings.Fields(data.Content)) < 3 { + f.Errors["Content"] = "Please enter at least 3 words." + } + + if voteType, err := strconv.ParseUint(f.VoteType, 10, 8); err != nil || (voteType != 0 && voteType != 1) { + f.Errors["VoteType"] = fmt.Sprint("Please choose a valid vote type", err) + } else { + data.VoteType = VoteType(uint8(voteType)) + } + + if dueDuration, ok := validDueDurations[f.Due]; !ok { + f.Errors["Due"] = "Please choose a valid due date" + } else { + year, month, day := time.Now().UTC().Add(dueDuration).Date() + data.Due = time.Date(year, month, day, 23, 59, 59, 0, time.UTC) + } + + data.Modified = time.Now().UTC() + + return len(f.Errors) == 0, data +} diff --git a/models.go b/models.go index a8d24ad..8194dc9 100644 --- a/models.go +++ b/models.go @@ -19,6 +19,7 @@ const ( sqlCountOlderThanDecision sqlCountOlderThanUnvotedDecision sqlCreateDecision + sqlUpdateDecision ) var sqlStatements = map[sqlKey]string{ @@ -92,6 +93,11 @@ INSERT INTO decisions ( BETWEEN date(:proposed) AND date(:proposed, '1 day') ) )`, + sqlUpdateDecision: ` +UPDATE decisions +SET proponent=:proponent, title=:title, content=:content, + votetype=:votetype, due=:due, modified=:modified +WHERE id=:id`, } var db *sqlx.DB @@ -376,7 +382,7 @@ func (d *Decision) OlderExists(unvoted bool, voter *Voter) (result bool, err err return } -func (d *Decision) Save() (err error) { +func (d *Decision) Create() (err error) { insertDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlCreateDecision]) if err != nil { logger.Println("Error preparing statement:", err) @@ -393,8 +399,8 @@ func (d *Decision) Save() (err error) { lastInsertId, err := result.LastInsertId() if err != nil { logger.Println("Error getting id of inserted motion:", err) + return } - logger.Println("DEBUG new motion has id", lastInsertId) getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById]) if err != nil { @@ -411,6 +417,42 @@ func (d *Decision) Save() (err error) { return } +func (d *Decision) Update() (err error) { + updateDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecision]) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer updateDecisionStmt.Close() + + result, err := updateDecisionStmt.Exec(d) + if err != nil { + logger.Println("Error updating motion:", err) + return + } + affectedRows, err := result.RowsAffected() + if err != nil { + logger.Print("Problem determining the affected rows") + return + } else if affectedRows != 1 { + logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows) + } + + getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById]) + if err != nil { + logger.Println("Error preparing statement:", err) + return + } + defer getDecisionStmt.Close() + + err = getDecisionStmt.Get(d, d.Id) + if err != nil { + logger.Println("Error loading updated motion:", err) + } + + return +} + func FindVoterByAddress(emailAddress string) (voter *Voter, err error) { findVoterStmt, err := db.Preparex(sqlStatements[sqlLoadEnabledVoterByEmail]) if err != nil { diff --git a/templates/newmotion_form.html b/templates/create_motion_form.html similarity index 100% rename from templates/newmotion_form.html rename to templates/create_motion_form.html diff --git a/templates/create_motion_mail.txt b/templates/create_motion_mail.txt index f26577e..b526615 100644 --- a/templates/create_motion_mail.txt +++ b/templates/create_motion_mail.txt @@ -1,13 +1,10 @@ -From: {{ .Sender }} -To: {{ .Recipient }} -Subject: {{ .Tag }} - {{ .Title }} - Dear Board, {{ .Name }} has made the following motion: {{ .Title }} -{{ .Content }} + +{{ wrap 76 .Content }} Vote type: {{ .VoteType }} diff --git a/templates/edit_motion_form.html b/templates/edit_motion_form.html new file mode 100644 index 0000000..d17389c --- /dev/null +++ b/templates/edit_motion_form.html @@ -0,0 +1,73 @@ +{{ template "header" . }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID:{{ .Form.Decision.Tag }}
Proponent:{{ .Voter.Name }}
Proposed date/time:{{ .Form.Decision.Proposed }}
Title: + {{ with .Form.Errors.Title }} + {{ . }} + {{ end }} +
Text: + {{ with .Form.Errors.Content }} + {{ . }} + {{ end }} +
Vote type: + + {{ with .Form.Errors.VoteType }} + {{ . }} + {{ end }} +
Due:(autofilled from option below)
+ + {{ with .Form.Errors.Due }} + {{ . }} + {{ end }} +
 
+
+Back to motions +{{ template "footer" . }} \ No newline at end of file diff --git a/templates/update_motion_mail.txt b/templates/update_motion_mail.txt new file mode 100644 index 0000000..ddb0996 --- /dev/null +++ b/templates/update_motion_mail.txt @@ -0,0 +1,26 @@ +Dear Board, + +{{ .Name }} has modified motion {{ .Tag }} to the following: + +{{ .Title }} + +{{ wrap 76 .Content }} + +Vote type: {{ .VoteType }} + +Voting will close {{ .Due }} + +To vote please choose: + +Aye: {{ .VoteURL }}/aye +Naye: {{ .VoteURL }}/naye +Abstain: {{ .VoteURL }}/abstain + +Please be aware, that if you have voted already your vote is still +registered and valid. If this modification has an impact on how you wish to +vote, you are responsible for voting again. + +To see all your pending votes: {{ .UnvotedURL }} + +Kind regards, +the voting system \ No newline at end of file