diff --git a/actions.go b/actions.go index a8e78fb..19e9bf3 100644 --- a/actions.go +++ b/actions.go @@ -4,9 +4,8 @@ import ( "bytes" "fmt" "github.com/Masterminds/sprig" - "os" - "text/template" "gopkg.in/gomail.v2" + "text/template" ) type templateBody string @@ -89,7 +88,7 @@ func UpdateMotion(decision *Decision, voter *Voter) (err error) { 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("Subject", fmt.Sprintf("Re: %s - %s", decision.Tag, decision.Title)) m.SetHeader("References", fmt.Sprintf("<%s>", decision.Tag)) m.SetBody("text/plain", mailText.String()) @@ -102,24 +101,31 @@ func UpdateMotion(decision *Decision, voter *Voter) (err error) { } func WithdrawMotion(decision *Decision, voter *Voter) (err error) { - // load template, fill name, tag, title, content + err = decision.UpdateStatus() + type mailContext struct { *Decision - Name string - Sender string - Recipient string + Name string } - context := mailContext{decision, voter.Name, config.NoticeSenderAddress, config.BoardMailAddress} + context := mailContext{decision, voter.Name} - // fill withdraw_mail.txt - t, err := template.New("withdraw_mail.txt").Funcs( - sprig.GenericFuncMap()).ParseFiles("templates/withdraw_mail.txt") + mailText, err := buildMail("withdraw_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("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) } - // TODO: send mail - t.Execute(os.Stdout, context) - // TODO: implement call decision.Close() return } diff --git a/boardvoting.go b/boardvoting.go index efa9ecc..187a867 100644 --- a/boardvoting.go +++ b/boardvoting.go @@ -18,6 +18,7 @@ import ( "os" "strconv" "strings" + "time" ) var logger *log.Logger @@ -216,10 +217,6 @@ func (authenticationRequiredHandler) NeedsAuth() bool { return true } -type withDrawMotionAction struct { - authenticationRequiredHandler -} - func getVoterFromRequest(r *http.Request) (voter *Voter, ok bool) { voter, ok = r.Context().Value(ctxVoter).(*Voter) return @@ -230,37 +227,71 @@ func getDecisionFromRequest(r *http.Request) (decision *DecisionForDisplay, ok b return } -func (withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) { - voter, voter_ok := getVoterFromRequest(r) - decision, decision_ok := getDecisionFromRequest(r) +type FlashMessageAction struct{} - if !voter_ok || !decision_ok || decision.Status != voteStatusPending { +func (a *FlashMessageAction) AddFlash(w http.ResponseWriter, r *http.Request, message interface{}, tags ...string) (err error) { + session, err := store.Get(r, sessionCookieName) + if err != nil { + logger.Println("ERROR getting session:", err) + return + } + session.AddFlash(message, tags...) + session.Save(r, w) + if err != nil { + logger.Println("ERROR saving session:", err) + return + } + return +} + +type withDrawMotionAction struct { + FlashMessageAction + authenticationRequiredHandler +} + +func (a *withDrawMotionAction) 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) + return + } + templates := []string{"withdraw_motion_form.html", "header.html", "footer.html", "motion_fragments.html"} + var templateContext struct { + PageTitle string + Decision *DecisionForDisplay + Flashes interface{} + } switch r.Method { case http.MethodPost: - if confirm, err := strconv.ParseBool(r.PostFormValue("confirm")); err != nil { - log.Println("could not parse confirm parameter:", err) - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - } else if confirm { - WithdrawMotion(&decision.Decision, voter) - } else { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + decision.Status = voteStatusWithdrawn + decision.Modified = time.Now().UTC() + if err := WithdrawMotion(&decision.Decision, voter); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + 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: - fmt.Fprintln(w, "Withdraw motion", decision.Tag) + templateContext.Decision = decision + renderTemplate(w, templates, templateContext) } } -type editMotionAction struct { - authenticationRequiredHandler +type newMotionHandler struct { + FlashMessageAction } -func newMotionHandler(w http.ResponseWriter, r *http.Request) { +func (h *newMotionHandler) Handle(w http.ResponseWriter, r *http.Request) { voter, ok := getVoterFromRequest(r) if !ok { http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) @@ -287,17 +318,15 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) { templateContext.Form = form renderTemplate(w, templates, templateContext) } else { + data.Proposed = time.Now().UTC() 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 { + if err := h.AddFlash(w, r, "The motion has been proposed!"); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - session.AddFlash("The motion has been proposed!") - session.Save(r, w) http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) } @@ -312,7 +341,12 @@ func newMotionHandler(w http.ResponseWriter, r *http.Request) { } } -func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) { +type editMotionAction struct { + FlashMessageAction + authenticationRequiredHandler +} + +func (a 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) @@ -321,6 +355,7 @@ func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) { voter, ok := getVoterFromRequest(r) if !ok { http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) + return } templates := []string{"edit_motion_form.html", "header.html", "footer.html"} var templateContext struct { @@ -344,21 +379,18 @@ func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) { templateContext.Form = form renderTemplate(w, templates, templateContext) } else { + data.Modified = time.Now().UTC() if err := UpdateMotion(data, voter); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - session, err := store.Get(r, sessionCookieName) - if err != nil { + if err := a.AddFlash(w, r, "The motion has been modified!"); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - session.AddFlash("The motion has been modified!") - session.Save(r, w) http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) } - return default: templateContext.Voter = voter @@ -382,14 +414,20 @@ func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { subURL := r.URL.Path var motionActionMap = map[string]motionActionHandler{ - "withdraw": withDrawMotionAction{}, - "edit": editMotionAction{}, + "withdraw": &withDrawMotionAction{}, + "edit": &editMotionAction{}, } switch { case subURL == "": authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler) return + case subURL == "/newmotion/": + handler := &newMotionHandler{} + authenticateRequest( + w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)), + handler.Handle) + return case strings.Count(subURL, "/") == 1: parts := strings.Split(subURL, "/") motionTag := parts[0] @@ -427,7 +465,7 @@ type Config struct { BaseURL string `yaml:"base_url"` MailServer struct { Host string `yaml:"host"` - Port int `yaml:"port"` + Port int `yaml:"port"` } `yaml:"mail_server"` } @@ -465,9 +503,7 @@ func main() { defer db.Close() http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{})) - http.HandleFunc("/newmotion/", func(w http.ResponseWriter, r *http.Request) { - authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)), newMotionHandler) - }) + http.Handle("/newmotion/", motionsHandler{}) http.Handle("/static/", http.FileServer(http.Dir("."))) http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently)) @@ -495,7 +531,18 @@ func main() { logger.Printf("Launching application on https://localhost%s/\n", server.Addr) + errs := make(chan error, 1) + go func() { + if err := http.ListenAndServe(":8080", http.RedirectHandler(config.BaseURL, http.StatusMovedPermanently)); err != nil { + errs <- err + } + close(errs) + }() + if err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil { logger.Fatal("ListenAndServerTLS: ", err) } + if err := <-errs; err != nil { + logger.Fatal("ListenAndServe: ", err) + } } diff --git a/config.yaml.example b/config.yaml.example index f5da83d..f971540 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -7,4 +7,6 @@ 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 -mail_server: localhost:smtp \ No newline at end of file +mail_server: + host: localhost + port: 25 \ No newline at end of file diff --git a/forms.go b/forms.go index 503e7e5..b7b7225 100644 --- a/forms.go +++ b/forms.go @@ -50,8 +50,6 @@ func (f *NewDecisionForm) Validate() (bool, *Decision) { data.Due = time.Date(year, month, day, 23, 59, 59, 0, time.UTC) } - data.Proposed = time.Now().UTC() - return len(f.Errors) == 0, data } @@ -92,7 +90,5 @@ func (f *EditDecisionForm) Validate() (bool, *Decision) { 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 8194dc9..d6eee15 100644 --- a/models.go +++ b/models.go @@ -20,6 +20,7 @@ const ( sqlCountOlderThanUnvotedDecision sqlCreateDecision sqlUpdateDecision + sqlUpdateDecisionStatus ) var sqlStatements = map[sqlKey]string{ @@ -98,6 +99,10 @@ UPDATE decisions SET proponent=:proponent, title=:title, content=:content, votetype=:votetype, due=:due, modified=:modified WHERE id=:id`, + sqlUpdateDecisionStatus: ` +UPDATE decisions +SET status=:status, modified=:modified WHERE id=:id +`, } var db *sqlx.DB @@ -417,6 +422,22 @@ func (d *Decision) Create() (err error) { return } +func (d *Decision) LoadWithId() (err error) { + 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 (d *Decision) Update() (err error) { updateDecisionStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecision]) if err != nil { @@ -438,18 +459,31 @@ func (d *Decision) Update() (err error) { logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows) } - getDecisionStmt, err := db.Preparex(sqlStatements[sqlLoadDecisionById]) + err = d.LoadWithId() + return +} + +func (d *Decision) UpdateStatus() (err error) { + updateStatusStmt, err := db.PrepareNamed(sqlStatements[sqlUpdateDecisionStatus]) if err != nil { logger.Println("Error preparing statement:", err) return } - defer getDecisionStmt.Close() + defer updateStatusStmt.Close() - err = getDecisionStmt.Get(d, d.Id) + result, err := updateStatusStmt.Exec(d) if err != nil { - logger.Println("Error loading updated motion:", err) + logger.Println("Error setting motion status:", err) + return + } + affectedRows, err := result.RowsAffected() + if err != nil { + logger.Print("Problem determining the affected rows") + } else if affectedRows != 1 { + logger.Printf("WARNING wrong number of affected rows: %d (1 expected)\n", affectedRows) } + err = d.LoadWithId() return } diff --git a/templates/withdraw_motion_form.html b/templates/withdraw_motion_form.html new file mode 100644 index 0000000..809ce28 --- /dev/null +++ b/templates/withdraw_motion_form.html @@ -0,0 +1,22 @@ +{{ template "header" . }} +Show all votes + + + + + + + + + + {{ with .Decision }} + {{ template "motion_fragment" .}} + {{ end}} + + +
StatusMotion
+ +
+ +
+{{ template "footer" . }} diff --git a/templates/withdraw_mail.txt b/templates/withdraw_motion_mail.txt similarity index 55% rename from templates/withdraw_mail.txt rename to templates/withdraw_motion_mail.txt index 4ccf4de..fb3ed87 100644 --- a/templates/withdraw_mail.txt +++ b/templates/withdraw_motion_mail.txt @@ -1,7 +1,3 @@ -From: {{ .Sender }} -To: {{ .Recipient }} -Subject: Re: {{ .Tag }} - {{ .Title }} - withdrawn - Dear Board, {{ .Name }} has withdrawn the motion {{ .Tag }} that was as follows: