diff --git a/.gitignore b/.gitignore index bfc178e..ca0c831 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,11 @@ *.pem *.req.conf *.sqlite +*.sqlite-journal .*.swp .idea/ +/dist/ +/ui/semantic/dist/ cacert-boardvoting config.yaml node_modules/ -/dist/ -/ui/semantic/dist/ diff --git a/boardvoting.go b/boardvoting.go deleted file mode 100644 index 00c33c1..0000000 --- a/boardvoting.go +++ /dev/null @@ -1,1120 +0,0 @@ -/* -Copyright 2017-2022 CAcert Inc. -SPDX-License-Identifier: Apache-2.0 - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// The CAcert board voting software. -package main - -import ( - "bytes" - "context" - "crypto/tls" - "crypto/x509" - "database/sql" - "embed" - "encoding/base64" - "encoding/pem" - "errors" - "flag" - "fmt" - "html/template" - "io/ioutil" - "net/http" - "net/url" - "os" - "sort" - "strconv" - "strings" - "time" - - "github.com/Masterminds/sprig/v3" - "github.com/gorilla/csrf" - "github.com/gorilla/sessions" - _ "github.com/mattn/go-sqlite3" - log "github.com/sirupsen/logrus" - "github.com/vearutop/statigz" - "github.com/vearutop/statigz/brotli" - "gopkg.in/yaml.v2" -) - -var configFile string -var config *Config -var store *sessions.CookieStore -var csrfKey []byte -var version = "undefined" -var commit = "undefined" -var date = "undefined" - -const ( - cookieSecretMinLen = 32 - csrfKeyLength = 32 - httpIdleTimeout = 5 - httpReadHeaderTimeout = 10 - httpReadTimeout = 10 - httpWriteTimeout = 60 - sessionCookieName = "votesession" -) - -//go:embed boardvoting/templates -var fsTemplates embed.FS - -func renderTemplate(w http.ResponseWriter, r *http.Request, templates []string, context interface{}) { - funcMaps := sprig.FuncMap() - funcMaps["nl2br"] = func(text string) template.HTML { - // #nosec G203 input is sanitized - return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "
")) - } - funcMaps[csrf.TemplateTag] = func() template.HTML { - return csrf.TemplateField(r) - } - - var baseTemplate *template.Template - - for count, t := range templates { - var ( - err error - assetBytes []byte - ) - - if assetBytes, err = fsTemplates.ReadFile(fmt.Sprintf("boardvoting/templates/%s", t)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - - return - } - - if count == 0 { - if baseTemplate, err = template.New(t).Funcs(funcMaps).Parse(string(assetBytes)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - } else { - if _, err := baseTemplate.New(t).Funcs(funcMaps).Parse(string(assetBytes)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - } - } - - if err := baseTemplate.Execute(w, context); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -type contextKey int - -const ( - ctxNeedsAuth contextKey = iota - ctxVoter - ctxDecision - ctxVote - ctxAuthenticatedCert -) - -func authenticateRequest(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) { - emailsTried := make(map[string]bool) - - for _, cert := range r.TLS.PeerCertificates { - for _, extKeyUsage := range cert.ExtKeyUsage { - if extKeyUsage != x509.ExtKeyUsageClientAuth { - continue - } - - log.Infof( - "got a client certificate for the following email addresses: %s", - strings.Join(cert.EmailAddresses, ", "), - ) - - for _, emailAddress := range cert.EmailAddresses { - emailLower := strings.ToLower(emailAddress) - emailsTried[emailLower] = true - - voter, err := FindVoterByAddress(emailLower) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - if voter != nil { - requestContext := context.WithValue(r.Context(), ctxVoter, voter) - requestContext = context.WithValue(requestContext, ctxAuthenticatedCert, cert) - - log.Infof("authenticated as %s", voter.Name) - - handler(w, r.WithContext(requestContext)) - - return - } - } - } - } - - needsAuth, ok := r.Context().Value(ctxNeedsAuth).(bool) - if ok && needsAuth { - var templateContext struct { - PageTitle string - Voter *Voter - Flashes interface{} - Emails []string - } - - for k := range emailsTried { - templateContext.Emails = append(templateContext.Emails, k) - } - - sort.Strings(templateContext.Emails) - w.WriteHeader(http.StatusForbidden) - renderTemplate(w, r, []string{"denied.html", "header.html", "footer.html"}, templateContext) - - return - } - - handler(w, r) -} - -type motionParameters struct { - ShowVotes bool -} - -type motionListParameters struct { - Page int - Flags struct { - Confirmed, Withdraw, Unvoted bool - } -} - -func parseMotionParameters(r *http.Request) motionParameters { - var m = motionParameters{} - m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes")) - - return m -} - -func parseMotionListParameters(r *http.Request) motionListParameters { - var m = motionListParameters{} - if page, err := strconv.Atoi(r.URL.Query().Get("page")); 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")) - } - - return m -} - -func motionListHandler(w http.ResponseWriter, r *http.Request) { - params := parseMotionListParameters(r) - - session, err := store.Get(r, sessionCookieName) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - var templateContext struct { - Decisions []*DecisionForDisplay - Voter *Voter - Params *motionListParameters - PrevPage, NextPage int - PageTitle string - Flashes interface{} - } - - if voter, ok := r.Context().Value(ctxVoter).(*Voter); ok { - templateContext.Voter = voter - } - - if flashes := session.Flashes(); len(flashes) > 0 { - templateContext.Flashes = flashes - } - - err = session.Save(r, w) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - templateContext.Params = ¶ms - - if templateContext.Decisions, err = FindDecisionsForDisplayOnPage( - params.Page, params.Flags.Unvoted, templateContext.Voter, - ); err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - if len(templateContext.Decisions) > 0 { - olderExists, err := templateContext.Decisions[len(templateContext.Decisions)-1].OlderExists( - params.Flags.Unvoted, templateContext.Voter, - ) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - if olderExists { - templateContext.NextPage = params.Page + 1 - } - } - - if params.Page > 1 { - templateContext.PrevPage = params.Page - 1 - } - - renderTemplate(w, r, []string{ - "motions.html", "motion_fragments.html", "page_fragments.html", "header.html", "footer.html", - }, templateContext) -} - -func motionHandler(w http.ResponseWriter, r *http.Request) { - params := parseMotionParameters(r) - - decision, ok := getDecisionFromRequest(r) - if !ok { - http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) - - return - } - - var templateContext struct { - Decision *DecisionForDisplay - Voter *Voter - Params *motionParameters - PrevPage, NextPage int64 - PageTitle string - Flashes interface{} - } - - voter, ok := getVoterFromRequest(r) - if ok { - templateContext.Voter = voter - } - - templateContext.Params = ¶ms - - if params.ShowVotes { - if err := decision.LoadVotes(); err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - } - - templateContext.Decision = decision - templateContext.PageTitle = fmt.Sprintf("Motion %s: %s", decision.Tag, decision.Title) - - renderTemplate(w, r, []string{ - "motion.html", - "motion_fragments.html", - "page_fragments.html", - "header.html", - "footer.html", - }, templateContext) -} - -func singleDecisionHandler( - w http.ResponseWriter, - r *http.Request, - tag string, - handler func(http.ResponseWriter, *http.Request), -) { - decision, err := FindDecisionForDisplayByTag(tag) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - if decision == nil { - http.NotFound(w, r) - - return - } - - handler(w, r.WithContext(context.WithValue(r.Context(), ctxDecision, decision))) -} - -type motionActionHandler interface { - Handle(w http.ResponseWriter, r *http.Request) - NeedsAuth() bool -} - -type authenticationRequiredHandler struct{} - -func (authenticationRequiredHandler) NeedsAuth() bool { - return true -} - -func getVoterFromRequest(r *http.Request) (voter *Voter, ok bool) { - voter, ok = r.Context().Value(ctxVoter).(*Voter) - - return -} - -func getDecisionFromRequest(r *http.Request) (decision *DecisionForDisplay, ok bool) { - decision, ok = r.Context().Value(ctxDecision).(*DecisionForDisplay) - - 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) { - session, err := store.Get(r, sessionCookieName) - if err != nil { - log.Warnf("could not get session cookie: %v", err) - - return - } - - session.AddFlash(message, tags...) - - err = session.Save(r, w) - if err != nil { - log.Warnf("could not save flash message: %v", err) - - 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", - "page_fragments.html", - } - - var templateContext struct { - PageTitle string - Decision *DecisionForDisplay - Flashes interface{} - Voter *Voter - } - - switch r.Method { - case http.MethodPost: - decision.Status = voteStatusWithdrawn - decision.Modified = time.Now().UTC() - - if err := decision.UpdateStatus(); err != nil { - log.Errorf("withdrawing motion failed: %v", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - NotifyMailChannel <- NewNotificationWithDrawMotion(&(decision.Decision), voter) - - a.AddFlash(w, r, fmt.Sprintf("Motion %s has been withdrawn!", decision.Tag)) - - http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect) - default: - templateContext.Decision = decision - templateContext.Voter = voter - renderTemplate(w, r, templates, templateContext) - } -} - -type newMotionHandler struct { - FlashMessageAction -} - -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) - } - - templates := []string{"create_motion_form.html", "page_fragments.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"), - } - - if valid, data := form.Validate(); !valid { - templateContext.Voter = voter - templateContext.Form = form - renderTemplate(w, r, templates, templateContext) - } else { - data.Proposed = time.Now().UTC() - data.ProponentID = voter.ID - if err := data.Create(); err != nil { - log.Errorf("saving motion failed: %v", err) - http.Error(w, "Saving motion failed", http.StatusInternalServerError) - - return - } - - NotifyMailChannel <- &NotificationCreateMotion{decision: *data, voter: *voter} - - h.AddFlash(w, r, "The motion has been proposed!") - - http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) - } - - return - default: - templateContext.Voter = voter - templateContext.Form = NewDecisionForm{ - VoteType: strconv.Itoa(voteTypeMotion), - } - renderTemplate(w, r, templates, templateContext) - } -} - -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) - - return - } - - voter, ok := getVoterFromRequest(r) - if !ok { - http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) - - return - } - - templates := []string{"edit_motion_form.html", "page_fragments.html", "header.html", "footer.html"} - - var templateContext struct { - Form EditDecisionForm - PageTitle string - Voter *Voter - Flashes interface{} - } - - switch r.Method { - case http.MethodPost: - 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 { - templateContext.Voter = voter - templateContext.Form = form - renderTemplate(w, r, templates, templateContext) - } else { - data.Modified = time.Now().UTC() - if err := data.Update(); err != nil { - log.Errorf("updating motion failed: %v", err) - http.Error(w, "Updating the motion failed.", http.StatusInternalServerError) - - return - } - - NotifyMailChannel <- NewNotificationUpdateMotion(*data, *voter) - - a.AddFlash(w, r, "The motion has been modified!") - - http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) - } - - return - default: - templateContext.Voter = voter - templateContext.Form = EditDecisionForm{ - Title: decision.Title, - Content: decision.Content, - VoteType: fmt.Sprintf("%d", decision.VoteType), - Decision: &decision.Decision, - } - renderTemplate(w, r, templates, templateContext) - } -} - -type motionsHandler struct{} - -func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - 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 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] - - 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 directVoteHandler struct { - FlashMessageAction - authenticationRequiredHandler -} - -func (h *directVoteHandler) 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 - } - - switch r.Method { - case http.MethodPost: - clientCert, err := getPEMClientCert(r) - if err != nil { - log.Errorf("could not get client certificate from request: %v", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - voteResult := &Vote{ - VoterID: voter.ID, Vote: vote, DecisionID: decision.ID, Voted: time.Now().UTC(), - Notes: fmt.Sprintf("Direct Vote\n\n%s", clientCert)} - if err := voteResult.Save(); err != nil { - log.Errorf("Problem saving vote: %v", err) - http.Error(w, "Problem saving vote", http.StatusInternalServerError) - - return - } - - NotifyMailChannel <- NewNotificationDirectVote(&decision.Decision, voter, voteResult) - - h.AddFlash(w, r, "Your vote has been registered.") - - http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) - default: - templates := []string{ - "direct_vote_form.html", - "header.html", - "footer.html", - "motion_fragments.html", - "page_fragments.html", - } - - var templateContext struct { - Decision *DecisionForDisplay - VoteChoice VoteChoice - PageTitle string - Flashes interface{} - Voter *Voter - } - - templateContext.Decision = decision - templateContext.VoteChoice = vote - templateContext.Voter = voter - - renderTemplate(w, r, templates, templateContext) - } -} - -type proxyVoteHandler struct { - FlashMessageAction - authenticationRequiredHandler -} - -func getPEMClientCert(r *http.Request) (string, error) { - cert := r.Context().Value(ctxAuthenticatedCert) - - authenticatedCertificate, ok := cert.(*x509.Certificate) - if !ok { - return "", errors.New("could not handle certificate as x509.Certificate") - } - - clientCertPEM := bytes.NewBuffer(make([]byte, 0)) - - err := pem.Encode(clientCertPEM, &pem.Block{Type: "CERTIFICATE", Bytes: authenticatedCertificate.Raw}) - if err != nil { - return "", fmt.Errorf("error encoding client certificate: %w", err) - } - - return clientCertPEM.String(), nil -} - -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", - "page_fragments.html", - } - - var templateContext struct { - Form ProxyVoteForm - Decision *DecisionForDisplay - Voters *[]Voter - PageTitle string - Flashes interface{} - Voter *Voter - } - - templateContext.Voter = proxy - - 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 - - voters, err := GetVotersForProxy(proxy) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - templateContext.Voters = voters - - renderTemplate(w, r, templates, templateContext) - } else { - clientCert, err := getPEMClientCert(r) - if err != nil { - log.Errorf("could not get client certificate information: %v", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - 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, clientCert) - - if err := data.Save(); err != nil { - log.Errorf("Error saving vote: %s", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - NotifyMailChannel <- NewNotificationProxyVote(&decision.Decision, proxy, voter, data, justification) - - h.AddFlash(w, r, "The vote has been registered.") - - http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) - } - - return - default: - templateContext.Form = ProxyVoteForm{} - templateContext.Decision = decision - - voters, err := GetVotersForProxy(proxy) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - - return - } - - templateContext.Voters = voters - - renderTemplate(w, r, templates, templateContext) - } -} - -type decisionVoteHandler struct{} - -func (h *decisionVoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - 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/"): - const expectedParts = 2 - - parts := strings.Split(r.URL.Path[len("/vote/"):], "/") - if len(parts) != expectedParts { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - - return - } - - motionTag := parts[0] - - voteValue, ok := VoteValues[parts[1]] - if !ok { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - - return - } - - handler := &directVoteHandler{} - - 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 { - NoticeMailAddress string `yaml:"notice_mail_address"` - VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"` - NotificationSenderAddress string `yaml:"notification_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"` - CsrfKey string `yaml:"csrf_key"` - BaseURL string `yaml:"base_url"` - HTTPAddress string `yaml:"http_address"` - HTTPSAddress string `yaml:"https_address"` - MailServer struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - } `yaml:"mail_server"` -} - -func readConfig() { - source, err := ioutil.ReadFile(configFile) - if err != nil { - log.Panicf("Opening configuration file failed: %v", err) - } - - if err := yaml.Unmarshal(source, &config); err != nil { - log.Panicf("Loading configuration failed: %v", err) - } - - if config.HTTPSAddress == "" { - config.HTTPSAddress = "127.0.0.1:8443" - } - - if config.HTTPAddress == "" { - config.HTTPAddress = "127.0.0.1:8080" - } - - cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret) - if err != nil { - log.Panicf("Decoding cookie secret failed: %v", err) - panic(err) - } - - if len(cookieSecret) < cookieSecretMinLen { - log.Panicf("Cookie secret is less than %d bytes long", cookieSecretMinLen) - } - - csrfKey, err = base64.StdEncoding.DecodeString(config.CsrfKey) - if err != nil { - log.Panicf("Decoding csrf key failed: %v", err) - } - - if len(csrfKey) != csrfKeyLength { - log.Panicf( - "CSRF key must be exactly %d bytes long but is %d bytes long", - csrfKeyLength, - len(csrfKey), - ) - } - - store = sessions.NewCookieStore(cookieSecret) - store.Options.Secure = true - - log.Info("Read configuration") -} - -func setupDbConfig(ctx context.Context) { - database, err := sql.Open("sqlite3", config.DatabaseFile) - if err != nil { - log.Panicf("Opening database failed: %v", err) - } - - db = NewDB(database) - - go func() { - for range ctx.Done() { - if err := db.Close(); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Problem closing the database: %v", err) - } - } - }() - - log.Infof("opened database connection") -} - -func setupNotifications(ctx context.Context) { - quitMailChannel := make(chan int) - go MailNotifier(quitMailChannel) - - go func() { - for range ctx.Done() { - quitMailChannel <- 1 - } - }() -} - -func setupJobs(ctx context.Context) { - quitChannel := make(chan int) - go JobScheduler(quitChannel) - - go func() { - for range ctx.Done() { - quitChannel <- 1 - } - }() -} - -//go:embed ui/static -var uiStatic embed.FS - -func setupHandlers() { - http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{})) - http.Handle("/newmotion/", motionsHandler{}) - http.Handle("/proxy/", &decisionVoteHandler{}) - http.Handle("/vote/", &decisionVoteHandler{}) - http.Handle("/static/", addPrefix("/ui", statigz.FileServer(uiStatic, brotli.AddEncoding, statigz.EncodeOnInit))) - http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently)) -} - -func addPrefix(prefix string, h http.Handler) http.Handler { - if prefix == "" { - return h - } - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - newPath := prefix + r.URL.Path - newRawPath := prefix + r.URL.RawPath - - r2 := new(http.Request) - *r2 = *r - r2.URL = new(url.URL) - *r2.URL = *r.URL - r2.URL.Path = newPath - r2.URL.RawPath = newRawPath - - h.ServeHTTP(w, r2) - }) -} - -func setupTLSConfig() (tlsConfig *tls.Config) { - // load CA certificates for client authentication - caCert, err := ioutil.ReadFile(config.ClientCACertificates) - if err != nil { - log.Panicf("Error reading client certificate CAs %v", err) - } - - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - log.Panic("could not initialize client CA certificate pool") - } - - // setup HTTPS server - tlsConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - ClientCAs: caCertPool, - ClientAuth: tls.VerifyClientCertIfGiven, - } - - return -} - -func main() { - log.SetFormatter(&log.TextFormatter{FullTimestamp: true}) - log.Infof("CAcert Board Voting version %s, commit %s built at %s", version, commit, date) - - flag.StringVar( - &configFile, "config", "config.yaml", "Configuration file name") - - flag.Parse() - - var stopAll func() - - executionContext, stopAll := context.WithCancel(context.Background()) - - readConfig() - setupDbConfig(executionContext) - setupNotifications(executionContext) - setupJobs(executionContext) - setupHandlers() - - tlsConfig := setupTLSConfig() - - defer stopAll() - - server := &http.Server{ - Addr: config.HTTPSAddress, - TLSConfig: tlsConfig, - IdleTimeout: time.Second * httpIdleTimeout, - ReadHeaderTimeout: time.Second * httpReadHeaderTimeout, - ReadTimeout: time.Second * httpReadTimeout, - WriteTimeout: time.Second * httpWriteTimeout, - } - - server.Handler = csrf.Protect(csrfKey)(http.DefaultServeMux) - - log.Infof("Launching application on https://%s/", server.Addr) - - errs := make(chan error, 1) - - go func() { - httpRedirector := &http.Server{ - Addr: config.HTTPAddress, - Handler: http.RedirectHandler(config.BaseURL, http.StatusMovedPermanently), - IdleTimeout: time.Second * httpIdleTimeout, - ReadHeaderTimeout: time.Second * httpReadHeaderTimeout, - ReadTimeout: time.Second * httpReadTimeout, - WriteTimeout: time.Second * httpWriteTimeout, - } - if err := httpRedirector.ListenAndServe(); err != nil { - errs <- err - } - - close(errs) - }() - - if err := server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil { - log.Panicf("ListenAndServerTLS failed: %v", err) - } - - if err := <-errs; err != nil { - log.Panicf("ListenAndServe failed: %v", err) - } -} diff --git a/boardvoting/templates/create_motion_form.html b/boardvoting/templates/create_motion_form.html deleted file mode 100644 index 2d8db12..0000000 --- a/boardvoting/templates/create_motion_form.html +++ /dev/null @@ -1,66 +0,0 @@ -{{ template "header.html" . }} -{{ template "return_header" . }} -
-
- {{ csrfField }} -
-
-
- - (generated on submit) -
-
- - {{ .Voter.Name }} -
-
- - (auto filled to current date/time) -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- {{ with .Form.Errors }} -
- {{ with .Title }}

{{ . }}

{{ end }} - {{ with .Content }}

{{ . }}

{{ end }} - {{ with .VoteType }}

{{ . }}

{{ end }} - {{ with .Due }}

{{ . }}

{{ end }} -
- {{ end }} - -
-
-
-{{ template "footer.html" . }} \ No newline at end of file diff --git a/boardvoting/templates/denied.html b/boardvoting/templates/denied.html deleted file mode 100644 index fc89505..0000000 --- a/boardvoting/templates/denied.html +++ /dev/null @@ -1,23 +0,0 @@ -{{ template "header.html" . }} -
-
- -
-
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 ;)

- {{ if .Emails }} -

The following addresses were present in your certificate:

-

- {{ range .Emails }} -
- -
{{ . }}
-
- {{ end }} -
- {{ end }} -
-
-
-{{ template "footer.html" . }} \ No newline at end of file diff --git a/boardvoting/templates/direct_vote_form.html b/boardvoting/templates/direct_vote_form.html deleted file mode 100644 index fff77eb..0000000 --- a/boardvoting/templates/direct_vote_form.html +++ /dev/null @@ -1,23 +0,0 @@ -{{ template "header.html" . }} -{{ template "return_header" . }} -{{ with .Decision }} -
- {{ template "motion_fragment" . }} -
-{{ end }} -
- {{ csrfField }} -
- {{ if eq 1 .VoteChoice }} - - {{ else if eq -1 .VoteChoice }} - - {{ else }} - - {{ end }} -
-
-{{ template "footer.html" . }} \ No newline at end of file diff --git a/boardvoting/templates/edit_motion_form.html b/boardvoting/templates/edit_motion_form.html deleted file mode 100644 index 1798b27..0000000 --- a/boardvoting/templates/edit_motion_form.html +++ /dev/null @@ -1,66 +0,0 @@ -{{ template "header.html" . }} -{{ template "return_header" . }} -
-
- {{ csrfField }} -
-
- -
- - {{ .Voter.Name }} -
-
- - {{ .Form.Decision.Proposed|date "2006-01-02 15:04:05 UTC" }} -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- {{ with .Form.Errors }} -
- {{ with .Title }}

{{ . }}

{{ end }} - {{ with .Content }}

{{ . }}

{{ end }} - {{ with .VoteType }}

{{ . }}

{{ end }} - {{ with .Due }}

{{ . }}

{{ end }} -
- {{ end }} - -
-
-
-{{ template "footer.html" . }} \ No newline at end of file diff --git a/boardvoting/templates/footer.html b/boardvoting/templates/footer.html deleted file mode 100644 index da6699c..0000000 --- a/boardvoting/templates/footer.html +++ /dev/null @@ -1,12 +0,0 @@ -{{ define "footer.html" }} - - - - -{{ end }} \ No newline at end of file diff --git a/boardvoting/templates/header.html b/boardvoting/templates/header.html deleted file mode 100644 index e817ea3..0000000 --- a/boardvoting/templates/header.html +++ /dev/null @@ -1,42 +0,0 @@ -{{ define "header.html" -}} - - - - {{ block "pagetitle" . }}CAcert Board Decisions{{ end }}{{ if .PageTitle }} - {{ .PageTitle }}{{ end }} - - - - - - - - -
-
-
- CAcert -
-
-

- {{ template "pagetitle" . }} - {{ if .Voter }} -
Authenticated as {{ .Voter.Name }} <{{ .Voter.Reminder }}> -
{{ end }} -

-
-
-
-
- {{ with .Flashes }} -
-
- -
- {{ range . }} -
{{ . }}
- {{ end }} -
-
-
- {{ end }} -{{ end }} diff --git a/boardvoting/templates/motion.html b/boardvoting/templates/motion.html deleted file mode 100644 index a4c3a30..0000000 --- a/boardvoting/templates/motion.html +++ /dev/null @@ -1,20 +0,0 @@ -{{ template "header.html" . }} -{{ $voter := .Voter }} -
- -
-{{ with .Decision }} -
- {{ template "motion_fragment" . }} - {{ if $voter }}{{ template "motion_actions" . }}{{ end }} -
-{{ end}} -{{ template "footer.html" . }} \ No newline at end of file diff --git a/boardvoting/templates/motion_fragments.html b/boardvoting/templates/motion_fragments.html deleted file mode 100644 index b6fac50..0000000 --- a/boardvoting/templates/motion_fragments.html +++ /dev/null @@ -1,68 +0,0 @@ -{{ define "motion_fragment" }} - {{ .Status|toString|title }} - {{ .Modified|date "2006-01-02 15:04:05 UTC" }} -

{{ .Tag }}: {{ .Title }}

-

{{ wrap 76 .Content | nl2br }}

- - - - - - - - - - - - - - - - - - - -
Due{{.Due|date "2006-01-02 15:04:05 UTC"}}
Proposed{{.Proposer}} ({{.Proposed|date "2006-01-02 15:04:05 UTC"}})
Vote type:{{ .VoteType|toString|title }}
Votes: -
-
Aye -
{{.Ayes}}
-
-
Naye -
{{.Nayes}}
-
-
Abstain -
{{.Abstains}}
-
-
- {{ if .Votes }} -
- {{ range .Votes }} -
{{ .Name }}: {{ .Vote.Vote }}
- {{ end }} -
- Hide Votes - {{ else if or (ne 0 .Ayes) (ne 0 .Nayes) (ne 0 .Abstains) }} - Show Votes - {{ end }} -
-{{ end }} - -{{ define "status_class" }}{{ if eq . 0 }}blue{{ else if eq . 1 }}green{{ else if eq . -1 }}red{{ else if eq . -2 }}grey{{ end }}{{ end }} - -{{ define "motion_actions" }} - {{ if eq .Status 0 }} - Aye - Naye - - Abstain - Proxy - Vote - Modify - - Withdraw - {{ end }} -{{ end }} diff --git a/boardvoting/templates/motions.html b/boardvoting/templates/motions.html deleted file mode 100644 index 159cda5..0000000 --- a/boardvoting/templates/motions.html +++ /dev/null @@ -1,45 +0,0 @@ -{{ template "header.html" . }} -{{ $voter := .Voter }} -{{ $page := . }} -
- -
-{{ if .Decisions }} - - {{ range .Decisions }} -
- {{ template "motion_fragment" . }} - {{ if $voter }}{{ template "motion_actions" . }}{{ end }} -
- {{ end }} - -{{ else }} -
-
- -
-
No motions available
- {{ if .Params.Flags.Unvoted }} -

There are no motions requiring a vote from you.

- {{ else }} -

There are no motions in the system yet.

- {{ end }} -
-
-
-{{ end }} -{{ template "footer.html" . }} \ No newline at end of file diff --git a/boardvoting/templates/page_fragments.html b/boardvoting/templates/page_fragments.html deleted file mode 100644 index 6a6dedf..0000000 --- a/boardvoting/templates/page_fragments.html +++ /dev/null @@ -1,20 +0,0 @@ -{{ define "pagination_fragment" }} - {{ if .PrevPage -}} - - newer - - {{- end }} - {{ if .NextPage -}} - - older - - {{- end }} -{{ end }} - -{{ define "return_header" }} -
- -
-{{ end }} \ No newline at end of file diff --git a/boardvoting/templates/proxy_vote_form.html b/boardvoting/templates/proxy_vote_form.html deleted file mode 100644 index a6ab5ee..0000000 --- a/boardvoting/templates/proxy_vote_form.html +++ /dev/null @@ -1,47 +0,0 @@ -{{ template "header.html" . }} -{{ template "return_header" . }} -{{ $form := .Form }} -
- {{ with .Decision }} - {{ template "motion_fragment" . }} - {{ end }} -
- {{ csrfField }} -
-
-
- - -
-
- - -
-
-
- - -
- {{ with .Form.Errors }} -
- {{ with .Voter }}

{{ . }}

{{ end }} - {{ with .Vote }}

{{ . }}

{{ end }} - {{ with .Justification }}

{{ . }}

{{ end }} -
- {{ end }} - -
-
-
-{{ template "footer.html" . }} \ No newline at end of file diff --git a/boardvoting/templates/withdraw_motion_form.html b/boardvoting/templates/withdraw_motion_form.html deleted file mode 100644 index b549cea..0000000 --- a/boardvoting/templates/withdraw_motion_form.html +++ /dev/null @@ -1,17 +0,0 @@ -{{ template "header.html" . }} -{{ template "return_header" . }} -{{ with .Decision }} -
- {{ template "motion_fragment" . }} -
-{{ end }} -
-
- {{ csrfField }} -
- -
-
-
-{{ template "footer.html" . }} \ No newline at end of file diff --git a/cmd/boardvoting/config.go b/cmd/boardvoting/config.go index f6ffdba..023034f 100644 --- a/cmd/boardvoting/config.go +++ b/cmd/boardvoting/config.go @@ -30,15 +30,17 @@ const ( httpReadHeaderTimeout = 5 * time.Second httpReadTimeout = 5 * time.Second httpWriteTimeout = 10 * time.Second + smtpTimeout = 10 * time.Second ) type mailConfig struct { - SMTPHost string `yaml:"smtp_host"` - SMTPPort int `yaml:"smtp_port"` - NotificationSenderAddress string `yaml:"notification_sender_address"` - NoticeMailAddress string `yaml:"notice_mail_address"` - VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"` - BaseURL string `yaml:"base_url"` + SMTPHost string `yaml:"smtp_host"` + SMTPPort int `yaml:"smtp_port"` + SMTPTimeOut time.Duration `yaml:"smtp_timeout,omitempty"` + NotificationSenderAddress string `yaml:"notification_sender_address"` + NoticeMailAddress string `yaml:"notice_mail_address"` + VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"` + BaseURL string `yaml:"base_url"` } type httpTimeoutConfig struct { @@ -76,6 +78,11 @@ func parseConfig(configFile string) (*Config, error) { Read: httpReadTimeout, Write: httpWriteTimeout, }, + MailConfig: &mailConfig{ + SMTPHost: "localhost", + SMTPPort: 25, + SMTPTimeOut: smtpTimeout, + }, } if err := yaml.Unmarshal(source, config); err != nil { diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index be7881e..1dc0dc2 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -31,8 +31,8 @@ import ( "git.cacert.org/cacert-boardvoting/internal/models" ) -func checkRole(v *models.User, roles []string) (bool, error) { - hasRole, err := v.HasRole(roles) +func checkRole(v *models.User, roles ...models.RoleName) (bool, error) { + hasRole, err := v.HasRole(roles...) if err != nil { return false, fmt.Errorf("could not determine user roles: %w", err) } @@ -97,7 +97,7 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - motions, err := app.motions.GetMotions(ctx, listOptions) + motions, err := app.motions.List(ctx, listOptions) if err != nil { app.serverError(w, err) @@ -247,17 +247,19 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) return } - decision, err := app.motions.GetByID(r.Context(), decisionID) + decision, err := app.motions.ByID(r.Context(), decisionID) if err != nil { app.serverError(w, err) return } - app.mailNotifier.notifyChannel <- &NewDecisionNotification{ + app.mailNotifier.Notify(&NewDecisionNotification{ Decision: decision, Proposer: user, - } + }) + + app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters) app.sessionManager.Put(r.Context(), "flash", fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title)) @@ -341,17 +343,19 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) return } - decision, err := app.motions.GetByID(r.Context(), motion.ID) + decision, err := app.motions.ByID(r.Context(), motion.ID) if err != nil { app.serverError(w, err) return } - app.mailNotifier.notifyChannel <- &UpdateDecisionNotification{ + app.mailNotifier.Notify(&UpdateDecisionNotification{ Decision: decision, User: user, - } + }) + + app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters) app.sessionManager.Put( r.Context(), @@ -396,16 +400,17 @@ func (app *application) withdrawMotionSubmit(w http.ResponseWriter, r *http.Requ return } - err = app.motions.Update(r.Context(), motion.ID, func(m *models.Motion) { - m.Status = models.VoteStatusWithdrawn - }) + err = app.motions.Withdraw(r.Context(), motion.ID) + if err != nil { app.serverError(w, err) return } - app.mailNotifier.notifyChannel <- &WithDrawMotionNotification{motion, user} + app.mailNotifier.Notify(&WithDrawMotionNotification{motion, user}) + + app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters) app.sessionManager.Put( r.Context(), @@ -481,9 +486,9 @@ func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) { return } - app.mailNotifier.notifyChannel <- &DirectVoteNotification{ + app.mailNotifier.Notify(&DirectVoteNotification{ Decision: motion, User: user, Choice: choice, - } + }) app.sessionManager.Put( r.Context(), @@ -506,7 +511,7 @@ func (app *application) proxyVoteForm(w http.ResponseWriter, r *http.Request) { data.Motion = motion - potentialVoters, err := app.users.PotentialVoters(r.Context()) + potentialVoters, err := app.users.Voters(r.Context()) if err != nil { app.serverError(w, err) @@ -543,7 +548,7 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Motion = motion - potentialVoters, err := app.users.PotentialVoters(r.Context()) + potentialVoters, err := app.users.Voters(r.Context()) if err != nil { app.serverError(w, err) @@ -574,14 +579,14 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) return } - voter, err := app.users.LoadVoter(r.Context(), form.VoterID) + voter, err := app.users.ByID(r.Context(), form.Voter.ID) if err != nil { app.serverError(w, err) return } - if err := app.motions.UpdateVote(r.Context(), form.VoterID, motion.ID, func(v *models.Vote) { + if err := app.motions.UpdateVote(r.Context(), form.Voter.ID, motion.ID, func(v *models.Vote) { v.Vote = form.Choice v.Voted = time.Now().UTC() v.Notes = fmt.Sprintf("Proxy-Vote by %s\n\n%s\n\n%s", user.Name, form.Justification, clientCert) @@ -591,9 +596,9 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) return } - app.mailNotifier.notifyChannel <- &ProxyVoteNotification{ + app.mailNotifier.Notify(&ProxyVoteNotification{ Decision: motion, User: user, Voter: voter, Choice: form.Choice, Justification: form.Justification, - } + }) app.sessionManager.Put( r.Context(), @@ -638,19 +643,41 @@ func (app *application) deleteUserSubmit(_ http.ResponseWriter, _ *http.Request) } func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) { + const ( + ok = "OK" + failed = "FAILED" + ) + response := struct { - DB string `json:"database"` - }{DB: "ok"} + DB string `json:"database"` + Mail string `json:"mail"` + }{DB: ok, Mail: ok} enc := json.NewEncoder(w) w.Header().Set("Content-Type", "application/json") + w.Header().Set("Refresh", "10") + w.Header().Add("Cache-Control", "no-store") - err := app.motions.DB.Ping() - if err != nil { - w.WriteHeader(http.StatusInternalServerError) + var err error + var hasErrors = false + + if err = app.mailNotifier.Ping(); err != nil { + hasErrors = true - response.DB = "FAILED" + response.Mail = failed + } + + if err = app.motions.DB.Ping(); err != nil { + hasErrors = true + + response.DB = failed + } + + if hasErrors { + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusOK) } _ = enc.Encode(response) diff --git a/cmd/boardvoting/handlers_test.go b/cmd/boardvoting/handlers_test.go index c772a6d..a00b67a 100644 --- a/cmd/boardvoting/handlers_test.go +++ b/cmd/boardvoting/handlers_test.go @@ -19,12 +19,16 @@ package main import ( "database/sql" + "fmt" + "net" "net/http" "net/http/httptest" "path" "testing" + "time" "github.com/jmoiron/sqlx" + "github.com/lestrrat-go/tcputil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -44,7 +48,41 @@ func prepareTestDb(t *testing.T) *sqlx.DB { return dbx } +func StartTestTcpServer(t *testing.T) int { + t.Helper() + port, err := tcputil.EmptyPort() + require.NoError(t, err) + + go func(port int) { + l, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port)) + if err != nil { + t.Errorf("could not run test TCP listener: %v", err) + } + + defer func(l net.Listener) { + _ = l.Close() + }(l) + + for { + conn, err := l.Accept() + + if err != nil { + t.Errorf("could not accept connection: %v", err) + return + } + + if err = conn.Close(); err != nil { + t.Errorf("could not close connection: %v", err) + } + } + }(port) + + return port +} + func TestApplication_healthCheck(t *testing.T) { + port := StartTestTcpServer(t) + t.Run("check with valid DB", func(t *testing.T) { rr := httptest.NewRecorder() @@ -54,9 +92,12 @@ func TestApplication_healthCheck(t *testing.T) { testDB := prepareTestDb(t) app := &application{ - motions: &models.MotionModel{DB: testDB}, + motions: &models.MotionModel{DB: testDB}, + mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second}, } + app.NewMailNotifier() + app.healthCheck(rr, r) rs := rr.Result() @@ -80,9 +121,12 @@ func TestApplication_healthCheck(t *testing.T) { _ = db.Close() app := &application{ - motions: &models.MotionModel{DB: testDB}, + motions: &models.MotionModel{DB: testDB}, + mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second}, } + app.NewMailNotifier() + app.healthCheck(rr, r) rs := rr.Result() diff --git a/cmd/boardvoting/helpers.go b/cmd/boardvoting/helpers.go index 6a0a0ce..697e90b 100644 --- a/cmd/boardvoting/helpers.go +++ b/cmd/boardvoting/helpers.go @@ -89,13 +89,13 @@ func newTemplateCache() (map[string]*template.Template, error) { return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "
")) } funcMaps["canManageUsers"] = func(v *models.User) (bool, error) { - return checkRole(v, []string{models.RoleSecretary, models.RoleAdmin}) + return checkRole(v, models.RoleSecretary, models.RoleAdmin) } funcMaps["canVote"] = func(v *models.User) (bool, error) { - return checkRole(v, []string{models.RoleVoter}) + return checkRole(v, models.RoleVoter) } funcMaps["canStartVote"] = func(v *models.User) (bool, error) { - return checkRole(v, []string{models.RoleVoter}) + return checkRole(v, models.RoleVoter) } for _, page := range pages { @@ -180,7 +180,7 @@ func (app *application) motionFromRequestParam( withVotes := r.URL.Query().Has("showvotes") - motion, err := app.motions.GetMotionByTag(r.Context(), tag, withVotes) + motion, err := app.motions.ByTag(r.Context(), tag, withVotes) if err != nil { app.serverError(w, err) diff --git a/cmd/boardvoting/jobs.go b/cmd/boardvoting/jobs.go index 8e19fda..dd40476 100644 --- a/cmd/boardvoting/jobs.go +++ b/cmd/boardvoting/jobs.go @@ -36,11 +36,11 @@ type RemindVotersJob struct { timer *time.Timer voters *models.UserModel decisions *models.MotionModel - notify chan NotificationMail - reschedule chan Job + notifier *MailNotifier } func (r *RemindVotersJob) Schedule() { + // TODO: check logic. It would make more sense to remind at a specific interval before the next pending decision is closed const reminderDays = 3 year, month, day := time.Now().UTC().Date() @@ -65,7 +65,7 @@ func (r *RemindVotersJob) Schedule() { func (r *RemindVotersJob) Run() { r.infoLog.Print("running RemindVotersJob") - defer func(r *RemindVotersJob) { r.reschedule <- r }(r) + defer func(r *RemindVotersJob) { r.Schedule() }(r) var ( voters []*models.User @@ -75,7 +75,7 @@ func (r *RemindVotersJob) Run() { ctx := context.Background() - voters, err = r.voters.GetReminderVoters(ctx) + voters, err = r.voters.ReminderVoters(ctx) if err != nil { r.errorLog.Printf("problem getting voters: %v", err) @@ -85,7 +85,7 @@ func (r *RemindVotersJob) Run() { for _, voter := range voters { v := voter - decisions, err = r.decisions.UnVotedDecisionsForVoter(ctx, v) + decisions, err = r.decisions.UnvotedForVoter(ctx, v) if err != nil { r.errorLog.Printf("problem getting unvoted decisions: %v", err) @@ -93,7 +93,7 @@ func (r *RemindVotersJob) Run() { } if len(decisions) > 0 { - r.notify <- &RemindVoterNotification{voter: voter, decisions: decisions} + r.notifier.Notify(&RemindVoterNotification{voter: voter, decisions: decisions}) } } } @@ -106,26 +106,22 @@ func (r *RemindVotersJob) Stop() { } } -func (app *application) NewRemindVotersJob( - rescheduleChannel chan Job, -) Job { +func (app *application) NewRemindVotersJob() Job { return &RemindVotersJob{ - infoLog: app.infoLog, - errorLog: app.errorLog, - voters: app.users, - decisions: app.motions, - reschedule: rescheduleChannel, - notify: app.mailNotifier.notifyChannel, + infoLog: app.infoLog, + errorLog: app.errorLog, + voters: app.users, + decisions: app.motions, + notifier: app.mailNotifier, } } type CloseDecisionsJob struct { - timer *time.Timer - infoLog *log.Logger - errorLog *log.Logger - decisions *models.MotionModel - reschedule chan Job - notify chan NotificationMail + timer *time.Timer + infoLog *log.Logger + errorLog *log.Logger + decisions *models.MotionModel + notifier *MailNotifier } func (c *CloseDecisionsJob) Schedule() { @@ -136,7 +132,7 @@ func (c *CloseDecisionsJob) Schedule() { ctx := context.Background() - nextDue, err = c.decisions.NextPendingDecisionDue(ctx) + nextDue, err = c.decisions.NextPendingDue(ctx) if err != nil { c.errorLog.Printf("could not get next pending due date") @@ -167,16 +163,23 @@ func (c *CloseDecisionsJob) Schedule() { func (c *CloseDecisionsJob) Run() { c.infoLog.Printf("running CloseDecisionsJob") + defer func(c *CloseDecisionsJob) { c.Schedule() }(c) + results, err := c.decisions.CloseDecisions(context.Background()) if err != nil { c.errorLog.Printf("closing decisions failed: %v", err) } for _, res := range results { - c.notify <- &ClosedDecisionNotification{Decision: res} + c.infoLog.Printf( + "decision %s closed with result %s: reasoning '%s'", + res.Tag, + res.Status, + res.Reasoning, + ) + + c.notifier.Notify(&ClosedDecisionNotification{Decision: res}) } - - c.reschedule <- c } func (c *CloseDecisionsJob) Stop() { @@ -186,39 +189,43 @@ func (c *CloseDecisionsJob) Stop() { } } -func (app *application) NewCloseDecisionsJob( - rescheduleChannel chan Job, -) Job { +func (app *application) NewCloseDecisionsJob() Job { return &CloseDecisionsJob{ - infoLog: app.infoLog, - errorLog: app.errorLog, - decisions: app.motions, - reschedule: rescheduleChannel, - notify: app.mailNotifier.notifyChannel, + infoLog: app.infoLog, + errorLog: app.errorLog, + decisions: app.motions, + notifier: app.mailNotifier, } } +type JobIdentifier int + +const ( + JobIDCloseDecisions JobIdentifier = iota + JobIDRemindVoters +) + type JobScheduler struct { infoLogger *log.Logger errorLogger *log.Logger - jobs []Job - rescheduleChannel chan Job + jobs map[JobIdentifier]Job + rescheduleChannel chan JobIdentifier quitChannel chan struct{} } func (app *application) NewJobScheduler() { - rescheduleChannel := make(chan Job, 1) + rescheduleChannel := make(chan JobIdentifier, 1) app.jobScheduler = &JobScheduler{ infoLogger: app.infoLog, errorLogger: app.errorLog, - jobs: make([]Job, 0, 2), + jobs: make(map[JobIdentifier]Job, 2), rescheduleChannel: rescheduleChannel, quitChannel: make(chan struct{}), } - app.jobScheduler.addJob(app.NewCloseDecisionsJob(rescheduleChannel)) - app.jobScheduler.addJob(app.NewRemindVotersJob(rescheduleChannel)) + app.jobScheduler.addJob(JobIDCloseDecisions, app.NewCloseDecisionsJob()) + app.jobScheduler.addJob(JobIDRemindVoters, app.NewRemindVotersJob()) } func (js *JobScheduler) Schedule() { @@ -228,8 +235,8 @@ func (js *JobScheduler) Schedule() { for { select { - case job := <-js.rescheduleChannel: - job.Schedule() + case jobId := <-js.rescheduleChannel: + js.jobs[jobId].Schedule() case <-js.quitChannel: for _, job := range js.jobs { job.Stop() @@ -242,10 +249,16 @@ func (js *JobScheduler) Schedule() { } } -func (js *JobScheduler) addJob(job Job) { - js.jobs = append(js.jobs, job) +func (js *JobScheduler) addJob(jobID JobIdentifier, job Job) { + js.jobs[jobID] = job } func (js *JobScheduler) Quit() { js.quitChannel <- struct{}{} } + +func (js *JobScheduler) Reschedule(jobIDs ...JobIdentifier) { + for i := range jobIDs { + js.rescheduleChannel <- jobIDs[i] + } +} diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go index 73f26df..63693c2 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -19,6 +19,7 @@ limitations under the License. package main import ( + "context" "crypto/tls" "crypto/x509" "database/sql" @@ -30,6 +31,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "time" @@ -104,12 +106,11 @@ func main() { app := &application{ errorLog: errorLog, infoLog: infoLog, - motions: &models.MotionModel{DB: db, InfoLog: infoLog}, + motions: &models.MotionModel{DB: db}, users: &models.UserModel{DB: db}, mailConfig: config.MailConfig, templateCache: templateCache, sessionManager: sessionManager, - formDecoder: setupFormDecoder(), } err = internal.InitializeDb(db.DB, infoLog) @@ -117,15 +118,18 @@ func main() { errorLog.Fatal(err) } + app.setupFormDecoder() + app.NewMailNotifier() defer app.mailNotifier.Quit() - go app.StartMailNotifier() + go app.mailNotifier.Start() app.NewJobScheduler() defer app.jobScheduler.Quit() go app.jobScheduler.Schedule() + infoLog.Printf("Starting server on %s", config.HTTPAddress) errChan := make(chan error, 1) @@ -144,7 +148,7 @@ func main() { } } -func setupFormDecoder() *form.Decoder { +func (app *application) setupFormDecoder() { decoder := form.NewDecoder() decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) { @@ -163,8 +167,21 @@ func setupFormDecoder() *form.Decoder { return v, nil }, new(models.VoteChoice)) + decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) { + userID, err := strconv.Atoi(values[0]) + if err != nil { + return nil, fmt.Errorf("could not convert value %s to user ID: %w", values[0], err) + } + + u, err := app.users.ByID(context.Background(), int64(userID)) + if err != nil { + return nil, fmt.Errorf("could not convert value %s to user: %w", values[0], err) + } + + return u, nil + }, new(models.User)) - return decoder + app.formDecoder = decoder } func (app *application) startHTTPSServer(config *Config) error { diff --git a/cmd/boardvoting/middleware.go b/cmd/boardvoting/middleware.go index 4d173a4..9a51834 100644 --- a/cmd/boardvoting/middleware.go +++ b/cmd/boardvoting/middleware.go @@ -68,9 +68,24 @@ func (app *application) authenticateRequest(r *http.Request) (*models.User, *x50 } clientCert := r.TLS.PeerCertificates[0] + + allowClientAuth := false + for _, eku := range clientCert.ExtKeyUsage { + if eku == x509.ExtKeyUsageClientAuth { + allowClientAuth = true + + break + } + } + + if !allowClientAuth { + // presented certificate is not valid for client authentication + return nil, nil, nil + } + emails := clientCert.EmailAddresses - user, err := app.users.GetUser(r.Context(), emails) + user, err := app.users.ByEmails(r.Context(), emails) if err != nil { return nil, nil, fmt.Errorf("could not get user information from database: %w", err) } @@ -116,7 +131,7 @@ func (app *application) GetUser(r *http.Request) (*models.User, error) { return result, nil } -func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, error) { +func (app *application) HasRole(r *http.Request, roles ...models.RoleName) (bool, bool, error) { user, err := app.GetUser(r) if err != nil { return false, false, err @@ -126,16 +141,21 @@ func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, er return false, false, nil } - roleMatched, err := user.HasRole(roles) + roleMatched, err := user.HasRole(roles...) if err != nil { return false, true, fmt.Errorf("could not determin user role assignment: %w", err) } if !roleMatched { + roleNames := make([]string, len(roles)) + for idx := range roles { + roleNames[idx] = string(roles[idx]) + } + app.errorLog.Printf( "user %s does not have any of the required role(s) %s assigned", user.Name, - strings.Join(roles, ", "), + strings.Join(roleNames, ", "), ) return false, true, nil @@ -144,9 +164,9 @@ func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, er return true, true, nil } -func (app *application) requireRole(next http.Handler, roles []string) http.Handler { +func (app *application) requireRole(next http.Handler, roles ...models.RoleName) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hasRole, hasUser, err := app.HasRole(r, roles) + hasRole, hasUser, err := app.HasRole(r, roles...) if err != nil { app.serverError(w, err) @@ -170,15 +190,15 @@ func (app *application) requireRole(next http.Handler, roles []string) http.Hand } func (app *application) userCanVote(next http.Handler) http.Handler { - return app.requireRole(next, []string{models.RoleVoter}) + return app.requireRole(next, models.RoleVoter) } func (app *application) userCanEditVote(next http.Handler) http.Handler { - return app.requireRole(next, []string{models.RoleVoter}) + return app.requireRole(next, models.RoleVoter) } func (app *application) userCanChangeVoters(next http.Handler) http.Handler { - return app.requireRole(next, []string{models.RoleSecretary, models.RoleAdmin}) + return app.requireRole(next, models.RoleSecretary, models.RoleAdmin) } func noSurf(next http.Handler) http.Handler { diff --git a/cmd/boardvoting/middleware_test.go b/cmd/boardvoting/middleware_test.go index 70d44ca..d41ff37 100644 --- a/cmd/boardvoting/middleware_test.go +++ b/cmd/boardvoting/middleware_test.go @@ -96,7 +96,7 @@ func TestApplication_tryAuthenticate(t *testing.T) { users := &models.UserModel{DB: db} - _, err = users.CreateUser( + _, err = users.Create( context.Background(), "Test User", "test@example.org", @@ -151,7 +151,10 @@ func TestApplication_tryAuthenticate(t *testing.T) { r, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) - r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{EmailAddresses: []string{"test@example.org"}}}} + r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{ + EmailAddresses: []string{"test@example.org"}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + }}} app.tryAuthenticate(next).ServeHTTP(rr, r) diff --git a/cmd/boardvoting/notifications.go b/cmd/boardvoting/notifications.go index dbfb2e3..6ac7751 100644 --- a/cmd/boardvoting/notifications.go +++ b/cmd/boardvoting/notifications.go @@ -20,6 +20,8 @@ package main import ( "bytes" "fmt" + "log" + "net" "path" "text/template" @@ -47,10 +49,12 @@ type NotificationMail interface { } type MailNotifier struct { - notifyChannel chan NotificationMail - senderAddress string - dialer *mail.Dialer - quitChannel chan struct{} + notifyChannel chan NotificationMail + senderAddress string + dialer *mail.Dialer + quitChannel chan struct{} + infoLog, errorLog *log.Logger + mailConfig *mailConfig } func (app *application) NewMailNotifier() { @@ -59,20 +63,23 @@ func (app *application) NewMailNotifier() { senderAddress: app.mailConfig.NotificationSenderAddress, dialer: mail.NewDialer(app.mailConfig.SMTPHost, app.mailConfig.SMTPPort, "", ""), quitChannel: make(chan struct{}), + infoLog: app.infoLog, + errorLog: app.errorLog, + mailConfig: app.mailConfig, } } -func (app *application) StartMailNotifier() { - app.infoLog.Print("Launching mail notifier") +func (mn *MailNotifier) Start() { + mn.infoLog.Print("Launching mail notifier") for { select { - case notification := <-app.mailNotifier.notifyChannel: - content := notification.GetNotificationContent(app.mailConfig) + case notification := <-mn.notifyChannel: + content := notification.GetNotificationContent(mn.mailConfig) - mailText, err := content.buildMail(app.mailConfig.BaseURL) + mailText, err := content.buildMail(mn.mailConfig.BaseURL) if err != nil { - app.errorLog.Printf("building mail failed: %v", err) + mn.errorLog.Printf("building mail failed: %v", err) continue } @@ -80,7 +87,7 @@ func (app *application) StartMailNotifier() { m := mail.NewMessage() m.SetHeaders(content.headers) - m.SetAddressHeader("From", app.mailNotifier.senderAddress, "CAcert board voting system") + m.SetAddressHeader("From", mn.senderAddress, "CAcert board voting system") for _, recipient := range content.recipients { m.SetAddressHeader(recipient.field, recipient.address, recipient.name) @@ -90,22 +97,44 @@ func (app *application) StartMailNotifier() { m.SetBody("text/plain", mailText.String()) - if err = app.mailNotifier.dialer.DialAndSend(m); err != nil { - app.errorLog.Printf("sending mail failed: %v", err) + if err = mn.dialer.DialAndSend(m); err != nil { + mn.errorLog.Printf("sending mail failed: %v", err) } - case <-app.mailNotifier.quitChannel: - app.infoLog.Print("ending mail notifier") + case <-mn.quitChannel: + mn.infoLog.Print("ending mail notifier") return } } } -func (m *MailNotifier) Quit() { - m.quitChannel <- struct{}{} +func (mn *MailNotifier) Quit() { + mn.quitChannel <- struct{}{} +} + +func (mn *MailNotifier) Notify(w NotificationMail) { + mn.notifyChannel <- w +} + +func (mn *MailNotifier) Ping() error { + conn, err := net.DialTimeout( + "tcp", + fmt.Sprintf("%s:%d", mn.mailConfig.SMTPHost, mn.mailConfig.SMTPPort), + mn.mailConfig.SMTPTimeOut, + ) + if err != nil { + return fmt.Errorf("could not connect to SMTP server: %w", err) + } + + defer func(conn net.Conn) { + _ = conn.Close() + }(conn) + + return nil } func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) { + // TODO: implement a template cache for mail templates too b, err := internal.MailTemplates.ReadFile(path.Join("mailtemplates", n.template)) if err != nil { return nil, fmt.Errorf("could not read mail template %s: %w", n.template, err) @@ -130,27 +159,6 @@ func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) { return mailText, nil } -type RemindVoterNotification struct { - voter *models.User - decisions []*models.Motion -} - -func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent { - return &NotificationContent{ - template: "remind_voter_mail.txt", - data: struct { - Decisions []*models.Motion - Name string - }{Decisions: r.decisions, Name: r.voter.Name}, - subject: "Outstanding CAcert board votes", - recipients: []recipientData{{ - field: "To", - address: r.voter.Reminder, - name: r.voter.Name, - }}, - } -} - func defaultRecipient(mc *mailConfig) recipientData { return recipientData{ field: "To", @@ -174,6 +182,27 @@ func motionReplyHeaders(m *models.Motion) map[string][]string { } } +type RemindVoterNotification struct { + voter *models.User + decisions []*models.Motion +} + +func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent { + return &NotificationContent{ + template: "remind_voter_mail.txt", + data: struct { + Decisions []*models.Motion + Name string + }{Decisions: r.decisions, Name: r.voter.Name}, + subject: "Outstanding CAcert board votes", + recipients: []recipientData{{ + field: "To", + address: r.voter.Reminder, + name: r.voter.Name, + }}, + } +} + type ClosedDecisionNotification struct { Decision *models.Motion } diff --git a/forms.go b/forms.go deleted file mode 100644 index 2c698dc..0000000 --- a/forms.go +++ /dev/null @@ -1,171 +0,0 @@ -/* -Copyright 2017-2022 CAcert Inc. -SPDX-License-Identifier: Apache-2.0 - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "fmt" - "strconv" - "strings" - "time" -) - -const ( - minimumContentLen = 3 - minimumTitleLen = 3 - base10 = 10 - size8Bit = 8 - size64Bit = 64 -) - -const ( - hoursInADay = 24 - dueThreeDays = 3 - dueOneWeek = 7 - dueTwoWeeks = 14 - dueFourWeeks = 28 -) - -var validDueDurations = map[string]time.Duration{ - "+3 days": time.Hour * hoursInADay * dueThreeDays, - "+7 days": time.Hour * hoursInADay * dueOneWeek, - "+14 days": time.Hour * hoursInADay * dueTwoWeeks, - "+28 days": time.Hour * hoursInADay * dueFourWeeks, -} - -type NewDecisionForm struct { - Title string - Content string - VoteType string - Due string - Errors map[string]string -} - -func (f *NewDecisionForm) Validate() (bool, *Decision) { - f.Errors = make(map[string]string) - - data := &Decision{} - - data.Title = strings.TrimSpace(f.Title) - if len(data.Title) < minimumTitleLen { - f.Errors["Title"] = fmt.Sprintf("Please enter at least %d characters for Title.", minimumTitleLen) - } - - data.Content = strings.TrimSpace(f.Content) - if len(strings.Fields(data.Content)) < minimumContentLen { - f.Errors["Content"] = fmt.Sprintf("Please enter at least %d words as Text.", minimumContentLen) - } - - if voteType, err := strconv.ParseUint(f.VoteType, base10, size8Bit); 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) - } - - 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) < minimumTitleLen { - f.Errors["Title"] = fmt.Sprintf("Please enter at least %d characters for Title.", minimumTitleLen) - } - - data.Content = strings.TrimSpace(f.Content) - if len(strings.Fields(data.Content)) < minimumContentLen { - f.Errors["Content"] = fmt.Sprintf("Please enter at least %d words as Text.", minimumContentLen) - } - - if voteType, err := strconv.ParseUint(f.VoteType, base10, size8Bit); 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) - } - - 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) - - const minimumJustificationLen = 3 - - var ( - voter *Voter - err error - voterID, vote int64 - ) - - data := &Vote{} - - if voterID, err = strconv.ParseInt(f.Voter, base10, size64Bit); err != nil { - f.Errors["Voter"] = fmt.Sprintf("Please choose a valid voter: %v.", err) - } else if voter, err = GetVoterByID(voterID); err != nil { - f.Errors["Voter"] = fmt.Sprintf("Please choose a valid voter: %v.", err) - } else { - data.VoterID = voter.ID - } - - if vote, err = strconv.ParseInt(f.Vote, base10, size8Bit); err != nil { - f.Errors["Vote"] = fmt.Sprintf("Please choose a valid vote: %v.", err) - } else if voteChoice, ok := VoteChoices[vote]; !ok { - f.Errors["Vote"] = "Please choose a valid vote." - } else { - data.Vote = voteChoice - } - - justification := strings.TrimSpace(f.Justification) - if len(justification) < minimumJustificationLen { - f.Errors["Justification"] = "Please enter at least 3 characters for justification." - } - - return len(f.Errors) == 0, voter, data, justification -} diff --git a/go.mod b/go.mod index cf4ab1c..556503b 100644 --- a/go.mod +++ b/go.mod @@ -14,10 +14,8 @@ require ( github.com/johejo/golang-migrate-extra v0.0.0-20211005021153-c17dd75f8b4a github.com/mattn/go-sqlite3 v1.14.12 github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/sirupsen/logrus v1.8.1 github.com/vearutop/statigz v1.1.8 golang.org/x/crypto v0.0.0-20220507011949-2cf3adece122 // indirect - golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v2 v2.4.0 @@ -27,11 +25,10 @@ require ( github.com/alexedwards/scs/sqlite3store v0.0.0-20220216073957-c252878bcf5a github.com/alexedwards/scs/v2 v2.5.0 github.com/go-playground/form/v4 v4.2.0 - github.com/gorilla/csrf v1.7.1 - github.com/gorilla/sessions v1.2.1 github.com/julienschmidt/httprouter v1.3.0 github.com/justinas/alice v1.2.0 github.com/justinas/nosurf v1.1.1 + github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb github.com/stretchr/testify v1.7.0 ) @@ -40,9 +37,7 @@ require ( github.com/Masterminds/semver/v3 v3.1.1 // indirect github.com/andybalholm/brotli v1.0.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gorilla/securecookie v1.1.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.4.1 // indirect diff --git a/go.sum b/go.sum index 7df4665..0348e27 100644 --- a/go.sum +++ b/go.sum @@ -617,18 +617,12 @@ github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2c github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE= -github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA= github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -797,6 +791,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4= +github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb h1:sb9NxqWoS17VT3aZd4mlBm48bsaHB1Fvwro3H/uiuZM= +github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb/go.mod h1:bBamYL9/WjNn0b2CS4v4F8cHmWRpClSxrpEoAY+maJo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= @@ -959,7 +955,6 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzL github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -1034,7 +1029,6 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -1485,8 +1479,6 @@ golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220317061510-51cd9980dadf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60= -golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/internal/forms/forms.go b/internal/forms/forms.go index d728cab..7a70e8c 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -78,6 +78,7 @@ func (f *EditMotionForm) Validate() { "content", fmt.Sprintf("This field must be at most %d characters long", maximumContentLength), ) + f.CheckField(validator.NotNil(f.Type), "type", "You must choose a valid vote type") f.CheckField(validator.PermittedInt( f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice", @@ -94,7 +95,7 @@ type DirectVoteForm struct { } type ProxyVoteForm struct { - VoterID int64 `form:"voter"` + Voter *models.User `form:"voter"` Choice *models.VoteChoice `form:"choice"` Justification string `form:"justification"` Voters []*models.User `form:"-"` @@ -102,6 +103,7 @@ type ProxyVoteForm struct { } func (f *ProxyVoteForm) Validate() { + f.CheckField(validator.NotNil(f.Voter), "voter", "Please choose a valid voter") f.CheckField(validator.NotBlank(f.Justification), "justification", "This field cannot be blank") f.CheckField( validator.MinChars( diff --git a/internal/mailtemplates/closed_motion_mail.txt b/internal/mailtemplates/closed_motion_mail.txt index b1b6a24..eae987f 100644 --- a/internal/mailtemplates/closed_motion_mail.txt +++ b/internal/mailtemplates/closed_motion_mail.txt @@ -1,16 +1,16 @@ Dear Board, -{{ with .Decision }}The motion with the identifier {{.Tag}} has been {{.Status}}.{{ end }} +The motion with the identifier {{ .Data.Tag }} has been closed. -The reasoning for this result is: {{ .Reasoning }} +The reasoning for this result is: {{ .Data.Reasoning }} -{{ with .Decision }}Motion: - {{.Title}} - {{.Content}} +{{ with .Data }}Motion: + {{ .Title}} + {{ .Content}} -Vote type: {{.VoteType}}{{end}} +Vote type: {{ .Type}}{{end}} -{{ with .VoteSums }} Ayes: {{ .Ayes }} +{{ with .Data.Sums }} Ayes: {{ .Ayes }} Nayes: {{ .Nayes }} Abstentions: {{ .Abstains }} diff --git a/internal/models/motions.go b/internal/models/motions.go index cd8ab96..e1231de 100644 --- a/internal/models/motions.go +++ b/internal/models/motions.go @@ -23,7 +23,6 @@ import ( "database/sql/driver" "errors" "fmt" - "log" "strings" "time" @@ -108,11 +107,11 @@ var ( voteStatusDeclined = &VoteStatus{Label: "declined", ID: -1} voteStatusPending = &VoteStatus{Label: "pending", ID: 0} voteStatusApproved = &VoteStatus{Label: "approved", ID: 1} - VoteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2} + voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2} ) func VoteStatusFromInt(id int64) (*VoteStatus, error) { - for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, VoteStatusWithdrawn, voteStatusDeclined} { + for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, voteStatusWithdrawn, voteStatusDeclined} { if int64(vs.ID) == id { return vs, nil } @@ -216,6 +215,15 @@ func (v *VoteSums) TotalVotes() int { return v.Ayes + v.Nayes } +func (v *VoteSums) Percent() int { + totalVotes := v.TotalVotes() + if totalVotes == 0 { + return 0 + } + + return v.Ayes * 100 / totalVotes +} + func (v *VoteSums) CalculateResult(quorum int, majority float32) (*VoteStatus, string) { if v.VoteCount() < quorum { return voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum) @@ -229,25 +237,24 @@ func (v *VoteSums) CalculateResult(quorum int, majority float32) (*VoteStatus, s } type Motion struct { - ID int64 `db:"id"` - Proposed time.Time - Proponent int64 `db:"proponent"` - Proposer string `db:"proposer"` - Title string - Content string - Status *VoteStatus - Due time.Time - Modified time.Time - Tag string - Type *VoteType `db:"votetype"` - Sums *VoteSums `db:"-"` - Votes []*Vote `db:"-"` - Reasoning string `db:"-"` + ID int64 `db:"id"` + Proposed time.Time `db:"proposed"` + Proponent int64 `db:"proponent"` + Proposer string `db:"proposer"` + Title string `db:"title"` + Content string `db:"content"` + Status *VoteStatus `db:"status"` + Due time.Time `db:"due"` + Modified time.Time `db:"modified"` + Tag string `db:"tag"` + Type *VoteType `db:"votetype"` + Sums *VoteSums `db:"-"` + Votes []*Vote `db:"-"` + Reasoning string `db:"-"` } type MotionModel struct { - DB *sqlx.DB - InfoLog *log.Logger + DB *sqlx.DB } // Create a new decision. @@ -335,9 +342,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now var decisionResult *Motion for _, decision := range decisions { - m.InfoLog.Printf("found closable decision %s", decision.Tag) - - if decisionResult, err = m.CloseDecision(ctx, tx, decision); err != nil { + if decisionResult, err = closeDecision(ctx, tx, decision); err != nil { return nil, fmt.Errorf("closing decision %s failed: %w", decision.Tag, err) } @@ -351,7 +356,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now return results, nil } -func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) { +func closeDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) { quorum, majority := d.Type.QuorumAndMajority() var ( @@ -360,13 +365,14 @@ func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) reasoning string ) - if voteSums, err = m.SumsForDecision(ctx, tx, d); err != nil { + // TODO: implement prefetching in CloseDecisions + if voteSums, err = sumsForDecision(ctx, tx, d); err != nil { return nil, fmt.Errorf("getting vote sums failed: %w", err) } d.Status, reasoning = voteSums.CalculateResult(quorum, majority) - result, err := m.DB.NamedExecContext( + result, err := tx.NamedExecContext( ctx, `UPDATE decisions SET status=:status, modified=CURRENT_TIMESTAMP WHERE id=:id`, d, @@ -385,20 +391,21 @@ func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) return nil, fmt.Errorf("unexpected number of rows %d instead of 1", affectedRows) } - m.InfoLog.Printf("decision %s closed with result %s: reasoning '%s'", d.Tag, d.Status, reasoning) - d.Sums = voteSums d.Reasoning = reasoning return d, nil } -func (m *MotionModel) UnVotedDecisionsForVoter(ctx context.Context, voter *User) ([]*Motion, error) { +func (m *MotionModel) UnvotedForVoter(ctx context.Context, voter *User) ([]*Motion, error) { + // TODO: implement more efficient variant that fetches unvoted votes for a slice of voters rows, err := m.DB.QueryxContext( ctx, `SELECT decisions.* FROM decisions -WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)`, +WHERE due < ? AND status=? AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)`, + time.Now().UTC(), + voteStatusPending, voter.ID) if err != nil { return nil, fmt.Errorf(errCouldNotExecuteQuery, err) @@ -427,7 +434,7 @@ WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ? return result, nil } -func (m *MotionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*VoteSums, error) { +func sumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*VoteSums, error) { voteRows, err := tx.QueryxContext( ctx, `SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`, @@ -470,7 +477,7 @@ func (m *MotionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motio return sums, nil } -func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, error) { +func (m *MotionModel) NextPendingDue(ctx context.Context) (*time.Time, error) { row := m.DB.QueryRowContext( ctx, `SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`, @@ -489,8 +496,6 @@ func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, e if err := row.Scan(&due); err != nil { if errors.Is(err, sql.ErrNoRows) { - m.InfoLog.Print("no pending decisions") - return nil, nil } @@ -500,11 +505,6 @@ func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, e return &due, nil } -type VoteForDisplay struct { - Name string - Vote *VoteChoice -} - type MotionListOptions struct { Limit int UnvotedOnly bool @@ -561,7 +561,7 @@ WHERE due >= ? return firstTs, lastTs, nil } -func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*Motion, error) { +func (m *MotionModel) List(ctx context.Context, options *MotionListOptions) ([]*Motion, error) { var ( rows *sqlx.Rows err error @@ -569,11 +569,11 @@ func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions switch { case options.Before != nil: - rows, err = m.GetMotionRowsBefore(ctx, options) + rows, err = m.rowsBefore(ctx, options) case options.After != nil: - rows, err = m.GetMotionRowsAfter(ctx, options) + rows, err = m.rowsAfter(ctx, options) default: - rows, err = m.GetFirstMotionRows(ctx, options) + rows, err = m.rowsFirst(ctx, options) } if err != nil { @@ -621,7 +621,10 @@ func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*Motion) err } query, args, err := sqlx.In( - `SELECT v.decision, v.vote, COUNT(*) FROM votes v WHERE v.decision IN (?) GROUP BY v.decision, v.vote`, + `SELECT v.decision, v.vote, COUNT(*) +FROM votes v +WHERE v.decision IN (?) +GROUP BY v.decision, v.vote`, decisionIds, ) if err != nil { @@ -666,7 +669,8 @@ func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*Motion) err return nil } -func (m *MotionModel) GetMotionRowsBefore(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { +func (m *MotionModel) rowsBefore(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { + // TODO: implement variant for options.UnvotedOnly rows, err := m.DB.QueryxContext( ctx, `SELECT decisions.id, @@ -695,7 +699,8 @@ LIMIT $2`, return rows, nil } -func (m *MotionModel) GetMotionRowsAfter(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { +func (m *MotionModel) rowsAfter(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { + // TODO: implement variant for options.UnvotedOnly rows, err := m.DB.QueryxContext( ctx, `WITH display_decision AS (SELECT decisions.id, @@ -727,7 +732,7 @@ ORDER BY proposed DESC`, return rows, nil } -func (m *MotionModel) GetFirstMotionRows(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { +func (m *MotionModel) rowsFirst(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { var ( rows *sqlx.Rows err error @@ -749,12 +754,12 @@ func (m *MotionModel) GetFirstMotionRows(ctx context.Context, options *MotionLis decisions.modified FROM decisions JOIN voters ON decisions.proponent = voters.id -WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?) - AND due >= ? +WHERE status=? AND due >= ? AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?) ORDER BY decisions.proposed DESC LIMIT ?`, - options.VoterID, + voteStatusPending, time.Now().UTC(), + options.VoterID, options.Limit, ) } else { @@ -786,7 +791,7 @@ LIMIT ?`, return rows, nil } -func (m *MotionModel) GetMotionByTag(ctx context.Context, tag string, withVotes bool) (*Motion, error) { +func (m *MotionModel) ByTag(ctx context.Context, tag string, withVotes bool) (*Motion, error) { row := m.DB.QueryRowxContext( ctx, `SELECT decisions.id, @@ -865,7 +870,7 @@ ORDER BY voters.name`, return nil } -func (m *MotionModel) GetByID(ctx context.Context, id int64) (*Motion, error) { +func (m *MotionModel) ByID(ctx context.Context, id int64) (*Motion, error) { row := m.DB.QueryRowxContext(ctx, `SELECT * FROM decisions WHERE id=?`, id) if err := row.Err(); err != nil { @@ -986,3 +991,9 @@ WHERE decision = :decision return nil } + +func (m *MotionModel) Withdraw(ctx context.Context, id int64) error { + return m.Update(ctx, id, func(m *Motion) { + m.Status = voteStatusWithdrawn + }) +} diff --git a/internal/models/motions_test.go b/internal/models/motions_test.go index 6407a8e..e01b717 100644 --- a/internal/models/motions_test.go +++ b/internal/models/motions_test.go @@ -34,7 +34,7 @@ import ( "git.cacert.org/cacert-boardvoting/internal/models" ) -func prepareTestDb(t *testing.T) (*sqlx.DB, *log.Logger) { +func prepareTestDb(t *testing.T) *sqlx.DB { t.Helper() testDir := t.TempDir() @@ -49,13 +49,13 @@ func prepareTestDb(t *testing.T) (*sqlx.DB, *log.Logger) { err = internal.InitializeDb(dbx.DB, logger) require.NoError(t, err) - return dbx, logger + return dbx } func TestDecisionModel_Create(t *testing.T) { - dbx, logger := prepareTestDb(t) + dbx := prepareTestDb(t) - dm := models.MotionModel{DB: dbx, InfoLog: logger} + dm := models.MotionModel{DB: dbx} v := &models.User{ ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index. @@ -77,16 +77,16 @@ func TestDecisionModel_Create(t *testing.T) { } func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) { - dbx, logger := prepareTestDb(t) + dbx := prepareTestDb(t) - dm := models.MotionModel{DB: dbx, InfoLog: logger} + dm := models.MotionModel{DB: dbx} var ( nextDue *time.Time err error ) - nextDue, err = dm.NextPendingDecisionDue(context.Background()) + nextDue, err = dm.NextPendingDue(context.Background()) assert.NoError(t, err) assert.Empty(t, nextDue) @@ -103,7 +103,7 @@ func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) { _, err = dm.Create(ctx, v, models.VoteTypeMotion, "test motion", "I move that we should test more", time.Now(), due) require.NoError(t, err) - nextDue, err = dm.NextPendingDecisionDue(ctx) + nextDue, err = dm.NextPendingDue(ctx) assert.NoError(t, err) assert.NotEmpty(t, nextDue) diff --git a/internal/models/voters.go b/internal/models/users.go similarity index 84% rename from internal/models/voters.go rename to internal/models/users.go index e56c992..54eda22 100644 --- a/internal/models/voters.go +++ b/internal/models/users.go @@ -26,10 +26,16 @@ import ( "github.com/jmoiron/sqlx" ) +type Role struct { + Name string `db:"role"` +} + +type RoleName string + const ( - RoleAdmin string = "ADMIN" - RoleSecretary string = "SECRETARY" - RoleVoter string = "VOTER" + RoleAdmin RoleName = "ADMIN" + RoleSecretary RoleName = "SECRETARY" + RoleVoter RoleName = "VOTER" ) // The User type is used for mapping users from the voters table. The table @@ -47,16 +53,16 @@ type User struct { roles []*Role `db:"-"` } -func (v *User) Roles() ([]*Role, error) { - if v.roles != nil { - return v.roles, nil +func (u *User) Roles() ([]*Role, error) { + if u.roles != nil { + return u.roles, nil } - return nil, errors.New("call to GetRoles required") + return nil, errors.New("call to Roles required") } -func (v *User) HasRole(roles []string) (bool, error) { - userRoles, err := v.Roles() +func (u *User) HasRole(roles ...RoleName) (bool, error) { + userRoles, err := u.Roles() if err != nil { return false, err } @@ -66,7 +72,7 @@ func (v *User) HasRole(roles []string) (bool, error) { outer: for _, role := range userRoles { for _, checkRole := range roles { - if role.Name == checkRole { + if role.Name == string(checkRole) { roleMatched = true break outer @@ -81,7 +87,7 @@ type UserModel struct { DB *sqlx.DB } -func (m *UserModel) GetReminderVoters(ctx context.Context) ([]*User, error) { +func (m *UserModel) ReminderVoters(ctx context.Context) ([]*User, error) { rows, err := m.DB.QueryxContext( ctx, `SELECT v.id, v.name, e.address AS reminder @@ -119,7 +125,11 @@ WHERE ur.role = ? return result, nil } -func (m *UserModel) GetUser(ctx context.Context, emails []string) (*User, error) { +func (m *UserModel) ByEmails(ctx context.Context, emails []string) (*User, error) { + for i := range emails { + emails[i] = strings.ToLower(emails[i]) + } + query, args, err := sqlx.In( `WITH reminders AS (SELECT voter, address FROM emails @@ -166,18 +176,14 @@ WHERE e.address IN (?)`, emails) } } - if user.roles, err = m.GetRoles(ctx, &user); err != nil { + if user.roles, err = m.Roles(ctx, &user); err != nil { return nil, fmt.Errorf("could not retrieve roles for user %s: %w", user.Name, err) } return &user, nil } -type Role struct { - Name string `db:"role"` -} - -func (m *UserModel) GetRoles(ctx context.Context, user *User) ([]*Role, error) { +func (m *UserModel) Roles(ctx context.Context, user *User) ([]*Role, error) { rows, err := m.DB.QueryxContext(ctx, `SELECT role FROM user_roles WHERE voter_id=?`, user.ID) if err != nil { return nil, fmt.Errorf("could not query roles for %s: %w", user.Name, err) @@ -206,7 +212,7 @@ func (m *UserModel) GetRoles(ctx context.Context, user *User) ([]*Role, error) { return result, nil } -func (m *UserModel) CreateUser(ctx context.Context, name string, reminder string, emails []string) (int64, error) { +func (m *UserModel) Create(ctx context.Context, name string, reminder string, emails []string) (int64, error) { tx, err := m.DB.BeginTxx(ctx, nil) if err != nil { return 0, fmt.Errorf("could not start transaction: %w", err) @@ -250,14 +256,15 @@ VALUES (?, ?, ?)`, return userID, nil } -func (m *UserModel) PotentialVoters(ctx context.Context) ([]*User, error) { +func (m *UserModel) InRole(ctx context.Context, role RoleName) ([]*User, error) { rows, err := m.DB.QueryxContext( ctx, `SELECT voters.id, voters.name FROM voters JOIN user_roles ur ON voters.id = ur.voter_id -WHERE ur.role = 'VOTER' +WHERE ur.role = ? ORDER BY voters.name`, + role, ) if err != nil { @@ -287,7 +294,11 @@ ORDER BY voters.name`, return result, nil } -func (m *UserModel) LoadVoter(ctx context.Context, voterID int64) (*User, error) { +func (m *UserModel) Voters(ctx context.Context) ([]*User, error) { + return m.InRole(ctx, RoleVoter) +} + +func (m *UserModel) ByID(ctx context.Context, voterID int64) (*User, error) { row := m.DB.QueryRowxContext( ctx, `SELECT DISTINCT v.id, v.name diff --git a/jobs.go b/jobs.go deleted file mode 100644 index 210d4b6..0000000 --- a/jobs.go +++ /dev/null @@ -1,199 +0,0 @@ -/* -Copyright 2017-2022 CAcert Inc. -SPDX-License-Identifier: Apache-2.0 - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "time" - - log "github.com/sirupsen/logrus" -) - -type Job interface { - Schedule() - Stop() - Run() -} - -type jobIdentifier int - -const ( - JobIDCloseDecisions jobIdentifier = iota - JobIDRemindVotersJob - reminderDays = 3 -) - -var rescheduleChannel = make(chan jobIdentifier, 1) - -func JobScheduler(quitChannel chan int) { - var jobs = map[jobIdentifier]Job{ - JobIDCloseDecisions: NewCloseDecisionsJob(), - JobIDRemindVotersJob: NewRemindVotersJob(), - } - - log.Info("started job scheduler") - - for { - select { - case jobID := <-rescheduleChannel: - job := jobs[jobID] - - log.Infof("reschedule job %s", job) - job.Schedule() - case <-quitChannel: - for _, job := range jobs { - job.Stop() - } - - log.Info("stop job scheduler") - - return - } - } -} - -type CloseDecisionsJob struct { - timer *time.Timer -} - -func NewCloseDecisionsJob() *CloseDecisionsJob { - job := &CloseDecisionsJob{} - job.Schedule() - - return job -} - -func (j *CloseDecisionsJob) Schedule() { - var ( - nextDue *time.Time - err error - ) - - nextDue, err = GetNextPendingDecisionDue() - if err != nil { - log.Error("Could not get next pending due date") - - if j.timer != nil { - j.timer.Stop() - j.timer = nil - } - - return - } - - if nextDue == nil { - log.Info("no next planned execution of CloseDecisionsJob") - j.Stop() - } else { - nextDue := nextDue.Add(time.Second) - log.Infof("scheduling CloseDecisionsJob for %s", nextDue) - when := time.Until(nextDue) - if j.timer != nil { - j.timer.Reset(when) - } else { - j.timer = time.AfterFunc(when, j.Run) - } - } -} - -func (j *CloseDecisionsJob) Stop() { - if j.timer != nil { - j.timer.Stop() - j.timer = nil - } -} - -func (j *CloseDecisionsJob) Run() { - log.Debug("running CloseDecisionsJob") - - err := CloseDecisions() - if err != nil { - log.Errorf("closing decisions %v", err) - } - - rescheduleChannel <- JobIDCloseDecisions -} - -func (j *CloseDecisionsJob) String() string { - return "CloseDecisionsJob" -} - -type RemindVotersJob struct { - timer *time.Timer -} - -func NewRemindVotersJob() *RemindVotersJob { - job := &RemindVotersJob{} - job.Schedule() - - return job -} - -func (j *RemindVotersJob) Schedule() { - year, month, day := time.Now().UTC().Date() - nextExecution := time.Date(year, month, day, 0, 0, 0, 0, time.UTC).AddDate(0, 0, reminderDays) - - log.Infof("scheduling RemindVotersJob for %s", nextExecution) - - when := time.Until(nextExecution) - - if j.timer != nil { - j.timer.Reset(when) - } else { - j.timer = time.AfterFunc(when, j.Run) - } -} - -func (j *RemindVotersJob) Stop() { - if j.timer != nil { - j.timer.Stop() - j.timer = nil - } -} - -func (j *RemindVotersJob) Run() { - log.Info("running RemindVotersJob") - - defer func() { rescheduleChannel <- JobIDRemindVotersJob }() - - var ( - voters []Voter - err error - ) - - voters, err = GetReminderVoters() - if err != nil { - log.Errorf("problem getting voters %v", err) - - return - } - - var decisions []Decision - - for i := range voters { - decisions, err = FindUnVotedDecisionsForVoter(&voters[i]) - if err != nil { - log.Errorf("problem getting unvoted decisions: %v", err) - - return - } - - if len(decisions) > 0 { - NotifyMailChannel <- &RemindVoterNotification{voter: voters[i], decisions: decisions} - } - } -} diff --git a/models.go b/models.go deleted file mode 100644 index b60d096..0000000 --- a/models.go +++ /dev/null @@ -1,876 +0,0 @@ -/* -Copyright 2017-2022 CAcert Inc. -SPDX-License-Identifier: Apache-2.0 - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "database/sql" - "embed" - "errors" - "fmt" - "time" - - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/sqlite3" - "github.com/jmoiron/sqlx" - "github.com/johejo/golang-migrate-extra/source/iofs" - - log "github.com/sirupsen/logrus" -) - -type sqlKey int - -const ( - sqlLoadDecisions sqlKey = iota - sqlLoadUnVotedDecisions - sqlLoadDecisionByTag - sqlLoadDecisionByID - sqlLoadVoteCountsForDecision - sqlLoadVotesForDecision - sqlLoadEnabledVoterByEmail - sqlCountOlderThanDecision - sqlCountOlderThanUnVotedDecision - sqlCreateDecision - sqlUpdateDecision - sqlUpdateDecisionStatus - sqlSelectClosableDecisions - sqlGetNextPendingDecisionDue - sqlGetReminderVoters - sqlFindUnVotedDecisionsForVoter - sqlGetEnabledVoterByID - sqlCreateVote - sqlLoadVote - sqlGetVotersForProxy -) - -var sqlStatements = map[sqlKey]string{ - sqlLoadDecisions: ` -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`, - sqlLoadUnVotedDecisions: ` -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 decisions.id NOT IN (SELECT votes.decision FROM votes WHERE votes.voter = $1) -ORDER BY proposed DESC -LIMIT 10 OFFSET 10 * $2;`, - sqlLoadDecisionByTag: ` -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;`, - sqlLoadDecisionByID: ` -SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content, - decisions.votetype, decisions.status, decisions.due, decisions.modified -FROM decisions -WHERE decisions.id=$1;`, - sqlLoadVoteCountsForDecision: ` -SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`, - sqlLoadVotesForDecision: ` -SELECT votes.decision, votes.voter, voters.name, votes.vote, votes.voted, votes.notes -FROM votes -JOIN voters ON votes.voter=voters.id -WHERE decision=$1`, - sqlLoadEnabledVoterByEmail: ` -SELECT voters.id, voters.name, voters.reminder -FROM voters -JOIN emails ON voters.id=emails.voter -JOIN user_roles ON user_roles.voter_id=voters.id -WHERE emails.address=$1 AND user_roles.role='VOTER'`, - sqlGetEnabledVoterByID: ` -SELECT voters.id, voters.name, voters.reminder -FROM voters -JOIN user_roles ON user_roles.voter_id=voters.id -WHERE user_roles.role='VOTER' AND voters.id=$1`, - sqlCountOlderThanDecision: ` -SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1`, - sqlCountOlderThanUnVotedDecision: ` -SELECT COUNT(*) > 0 FROM decisions -WHERE proposed < $1 AND status=0 AND id NOT IN (SELECT decision FROM votes WHERE votes.voter=$2)`, - sqlCreateDecision: ` -INSERT INTO decisions (proposed, proponent, title, content, votetype, status, due, modified,tag) -VALUES ( - :proposed, :proponent, :title, :content, :votetype, 0, :due, :proposed, - 'm' || strftime('%Y%m%d', :proposed) || '.' || ( - SELECT COUNT(*)+1 AS num - FROM decisions - WHERE proposed 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`, - sqlUpdateDecisionStatus: ` -UPDATE decisions SET status=:status, modified=:modified WHERE id=:id`, - sqlSelectClosableDecisions: ` -SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content, - decisions.votetype, decisions.status, decisions.due, decisions.modified -FROM decisions -WHERE decisions.status=0 AND :now > due`, - sqlGetNextPendingDecisionDue: ` -SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`, - sqlGetVotersForProxy: ` -SELECT voters.id, voters.name, voters.reminder -FROM voters -JOIN user_roles ON user_roles.voter_id=voters.id -WHERE user_roles.role='VOTER' AND voters.id != $1`, - sqlGetReminderVoters: ` -SELECT voters.id, voters.name, voters.reminder -FROM voters -JOIN user_roles ON user_roles.voter_id=voters.id -WHERE user_roles.role='VOTER' 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`, - sqlCreateVote: ` -INSERT OR REPLACE 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`, -} - -type VoteType uint8 -type VoteStatus int8 - -type Decision struct { - ID int64 `db:"id"` - Proposed time.Time - ProponentID int64 `db:"proponent"` - Title string - Content string - Quorum int - Majority int - Status VoteStatus - Due time.Time - Modified time.Time - Tag string - VoteType VoteType -} - -type Voter struct { - ID int64 `db:"id"` - Name string - Reminder string // reminder email address -} - -type VoteChoice int - -const ( - voteAye = 1 - voteNaye = -1 - voteAbstain = 0 -) - -const ( - voteTypeMotion = 0 - voteTypeVeto = 1 -) - -const ( - voteTypeLabelMotion = "motion" - voteTypeLabelUnknown = "unknown" - voteTypeLabelVeto = "veto" -) - -func (v VoteType) String() string { - switch v { - case voteTypeMotion: - return voteTypeLabelMotion - case voteTypeVeto: - return voteTypeLabelVeto - default: - return voteTypeLabelUnknown - } -} - -func (v VoteType) QuorumAndMajority() (int, float32) { - const ( - majorityDefault = 0.99 - majorityMotion = 0.50 - quorumDefault = 1 - quorumMotion = 3 - ) - - switch v { - case voteTypeMotion: - return quorumMotion, majorityMotion - default: - return quorumDefault, majorityDefault - } -} - -func (v VoteChoice) String() string { - switch v { - case voteAye: - return "aye" - case voteNaye: - return "naye" - case voteAbstain: - return "abstain" - default: - return "unknown" - } -} - -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 - voteStatusApproved = 1 - voteStatusWithdrawn = -2 -) - -func (v VoteStatus) String() string { - switch v { - case voteStatusDeclined: - return "declined" - case voteStatusPending: - return "pending" - case voteStatusApproved: - return "approved" - case voteStatusWithdrawn: - return "withdrawn" - default: - return "unknown" - } -} - -type Vote struct { - DecisionID int64 `db:"decision"` - VoterID int64 `db:"voter"` - Vote VoteChoice - Voted time.Time - Notes string -} - -type DbHandler struct { - db *sqlx.DB -} - -var db *DbHandler - -// go:embed boardvoting/migrations/* -var migrations embed.FS - -func NewDB(database *sql.DB) *DbHandler { - handler := &DbHandler{db: sqlx.NewDb(database, "sqlite3")} - - source, err := iofs.New(migrations, "boardvoting/migrations") - if err != nil { - log.Panicf("could not create migration source: %v", err) - } - - driver, err := sqlite3.WithInstance(database, &sqlite3.Config{}) - if err != nil { - log.Panicf("could not create migration driver: %v", err) - } - - m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver) - if err != nil { - log.Panicf("could not create migration instance: %v", err) - } - - m.Log = NewLogger() - - err = m.Up() - if err != nil { - if !errors.Is(err, migrate.ErrNoChange) { - log.Panicf("running database migration failed: %v", err) - } - - log.Info("no database migrations required") - } else { - log.Info("applied database migrations") - } - - failedStatements := make([]string, 0) - - for _, sqlStatement := range sqlStatements { - var stmt *sqlx.Stmt - - stmt, err := handler.db.Preparex(sqlStatement) - if err != nil { - log.Errorf("error parsing statement %s: %s", sqlStatement, err) - failedStatements = append(failedStatements, sqlStatement) - } - - _ = stmt.Close() - } - - if len(failedStatements) > 0 { - log.Panicf("%d statements failed to prepare", len(failedStatements)) - } - - return handler -} - -type migrationLogger struct{} - -func (m migrationLogger) Printf(format string, v ...interface{}) { - log.Printf(format, v...) -} - -func (m migrationLogger) Verbose() bool { - return log.IsLevelEnabled(log.DebugLevel) -} - -func NewLogger() migrate.Logger { - return &migrationLogger{} -} - -func (d *DbHandler) Close() error { - if err := d.db.Close(); err != nil { - return fmt.Errorf("could not close database: %w", err) - } - - return nil -} - -func (d *DbHandler) getPreparedNamedStatement(statementKey sqlKey) *sqlx.NamedStmt { - statement, err := d.db.PrepareNamed(sqlStatements[statementKey]) - if err != nil { - log.Panicf("Preparing statement failed: %v", err) - } - - return statement -} - -func (d *DbHandler) getPreparedStatement(statementKey sqlKey) *sqlx.Stmt { - statement, err := d.db.Preparex(sqlStatements[statementKey]) - if err != nil { - log.Panicf("Preparing statement failed: %v", err) - } - - return statement -} - -func (v *Vote) Save() error { - insertVoteStmt := db.getPreparedNamedStatement(sqlCreateVote) - - defer func() { _ = insertVoteStmt.Close() }() - - var err error - - if _, err = insertVoteStmt.Exec(v); err != nil { - return fmt.Errorf("saving vote failed: %w", err) - } - - getVoteStmt := db.getPreparedStatement(sqlLoadVote) - - defer func() { _ = getVoteStmt.Close() }() - - if err = getVoteStmt.Get(v, v.DecisionID, v.VoterID); err != nil { - return fmt.Errorf("getting inserted vote failed: %w", err) - } - - return nil -} - -type VoteSums struct { - Ayes int - Nayes int - Abstains int -} - -func (v *VoteSums) VoteCount() int { - return v.Ayes + v.Nayes + v.Abstains -} - -func (v *VoteSums) TotalVotes() int { - return v.Ayes + v.Nayes -} - -func (v *VoteSums) Percent() int { - totalVotes := v.TotalVotes() - if totalVotes == 0 { - return 0 - } - - return v.Ayes * 100 / totalVotes -} - -func (v *VoteSums) CalculateResult(quorum int, majority float32) (VoteStatus, string) { - if v.VoteCount() < quorum { - return voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum) - } - - if (float32(v.Ayes) / float32(v.TotalVotes())) < majority { - return voteStatusDeclined, fmt.Sprintf("Needed majority of %0.2f%% has not been reached.", majority) - } - - return voteStatusApproved, "Quorum and majority have been reached" -} - -type VoteForDisplay struct { - Vote - Name string -} - -type DecisionForDisplay struct { - Decision - Proposer string `db:"proposer"` - *VoteSums - Votes []VoteForDisplay -} - -func FindDecisionForDisplayByTag(tag string) (*DecisionForDisplay, error) { - decisionStmt := db.getPreparedStatement(sqlLoadDecisionByTag) - - defer func() { _ = decisionStmt.Close() }() - - decision := &DecisionForDisplay{} - - var err error - - if err = decisionStmt.Get(decision, tag); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - - return nil, fmt.Errorf("getting motion %s failed: %w", tag, err) - } - - decision.VoteSums, err = decision.Decision.VoteSums() - - return decision, err -} - -// FindDecisionsForDisplayOnPage loads a set of decisions from the database. -// -// This function uses OFFSET for pagination which is not a good idea for larger data sets. -// -// TODO: migrate to timestamp base pagination -func FindDecisionsForDisplayOnPage(page int, unVoted bool, voter *Voter) ([]*DecisionForDisplay, error) { - var decisionsStmt *sqlx.Stmt - - if unVoted && voter != nil { - decisionsStmt = db.getPreparedStatement(sqlLoadUnVotedDecisions) - } else { - decisionsStmt = db.getPreparedStatement(sqlLoadDecisions) - } - - defer func() { _ = decisionsStmt.Close() }() - - var ( - rows *sqlx.Rows - err error - decisions []*DecisionForDisplay - ) - - if unVoted && voter != nil { - rows, err = decisionsStmt.Queryx(voter.ID, page-1) - } else { - rows, err = decisionsStmt.Queryx(page - 1) - } - - if err != nil { - return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err) - } - - defer func() { _ = rows.Close() }() - - for rows.Next() { - var d DecisionForDisplay - - if err = rows.StructScan(&d); err != nil { - return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err) - } - - d.VoteSums, err = d.Decision.VoteSums() - - if err != nil { - return nil, err - } - - decisions = append(decisions, &d) - } - - return decisions, nil -} - -func (d *Decision) VoteSums() (*VoteSums, error) { - votesStmt := db.getPreparedStatement(sqlLoadVoteCountsForDecision) - - defer func() { _ = votesStmt.Close() }() - - voteRows, err := votesStmt.Queryx(d.ID) - if err != nil { - return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err) - } - - defer func() { _ = voteRows.Close() }() - - sums := &VoteSums{} - - for voteRows.Next() { - var ( - vote VoteChoice - count int - ) - - if err = voteRows.Scan(&vote, &count); err != nil { - return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err) - } - - switch vote { - case voteAye: - sums.Ayes = count - case voteNaye: - sums.Nayes = count - case voteAbstain: - sums.Abstains = count - } - } - - return sums, nil -} - -func (d *DecisionForDisplay) LoadVotes() (err error) { - votesStmt := db.getPreparedStatement(sqlLoadVotesForDecision) - - defer func() { _ = votesStmt.Close() }() - - err = votesStmt.Select(&d.Votes, d.ID) - if err != nil { - log.Errorf("selecting votes for motion %s failed: %v", d.Tag, err) - - return - } - - return -} - -func (d *Decision) OlderExists(unvoted bool, voter *Voter) (bool, error) { - var result bool - - if unvoted && voter != nil { - olderStmt := db.getPreparedStatement(sqlCountOlderThanUnVotedDecision) - - defer func() { _ = olderStmt.Close() }() - - if err := olderStmt.Get(&result, d.Proposed, voter.ID); err != nil { - return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err) - } - } else { - olderStmt := db.getPreparedStatement(sqlCountOlderThanDecision) - - defer func() { _ = olderStmt.Close() }() - - if err := olderStmt.Get(&result, d.Proposed); err != nil { - return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err) - } - } - - return result, nil -} - -func (d *Decision) Create() error { - insertDecisionStmt := db.getPreparedNamedStatement(sqlCreateDecision) - - defer func() { _ = insertDecisionStmt.Close() }() - - result, err := insertDecisionStmt.Exec(d) - if err != nil { - return fmt.Errorf("creating motion failed: %w", err) - } - - decisionID, err := result.LastInsertId() - if err != nil { - return fmt.Errorf("getting id of inserted motion failed: %w", err) - } - rescheduleChannel <- JobIDCloseDecisions - - getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID) - - defer func() { _ = getDecisionStmt.Close() }() - - err = getDecisionStmt.Get(d, decisionID) - if err != nil { - return fmt.Errorf("getting inserted motion failed: %w", err) - } - - return nil -} - -func (d *Decision) LoadWithID() (err error) { - getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID) - - defer func() { _ = getDecisionStmt.Close() }() - - err = getDecisionStmt.Get(d, d.ID) - if err != nil { - log.Errorf("loading updated motion failed: %v", err) - - return - } - - return -} - -func (d *Decision) Update() (err error) { - updateDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecision) - - defer func() { _ = updateDecisionStmt.Close() }() - - result, err := updateDecisionStmt.Exec(d) - if err != nil { - log.Errorf("updating motion failed: %v", err) - - return - } - - affectedRows, err := result.RowsAffected() - if err != nil { - log.Error("Problem determining the affected rows") - - return - } else if affectedRows != 1 { - log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows) - } - - rescheduleChannel <- JobIDCloseDecisions - - err = d.LoadWithID() - - return -} - -func (d *Decision) UpdateStatus() error { - updateStatusStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus) - - defer func() { _ = updateStatusStmt.Close() }() - - result, err := updateStatusStmt.Exec(d) - if err != nil { - return fmt.Errorf("setting motion status failed: %w", err) - } - - affectedRows, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("determining the affected rows failed: %w", err) - } else if affectedRows != 1 { - log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows) - } - - rescheduleChannel <- JobIDCloseDecisions - - err = d.LoadWithID() - - return err -} - -func (d *Decision) String() string { - return fmt.Sprintf("%s %s (ID %d)", d.Tag, d.Title, d.ID) -} - -func FindVoterByAddress(emailAddress string) (*Voter, error) { - findVoterStmt := db.getPreparedStatement(sqlLoadEnabledVoterByEmail) - - defer func() { _ = findVoterStmt.Close() }() - - voter := &Voter{} - if err := findVoterStmt.Get(voter, emailAddress); err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("getting voter for address %s failed: %w", emailAddress, err) - } - - voter = nil - } - - return voter, nil -} - -func (d *Decision) Close() error { - quorum, majority := d.VoteType.QuorumAndMajority() - - var ( - voteSums *VoteSums - err error - ) - - if voteSums, err = d.VoteSums(); err != nil { - log.Errorf("getting vote sums failed: %v", err) - - return err - } - - var reasoning string - - d.Status, reasoning = voteSums.CalculateResult(quorum, majority) - - closeDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus) - - defer func() { _ = closeDecisionStmt.Close() }() - - result, err := closeDecisionStmt.Exec(d) - if err != nil { - return fmt.Errorf("closing vote failed: %w", err) - } - - affectedRows, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("getting affected rows failed: %w", err) - } - - if affectedRows != 1 { - log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows) - } - - NotifyMailChannel <- NewNotificationClosedDecision(d, voteSums, reasoning) - - log.Infof("decision %s closed with result %s: reasoning %s", d.Tag, d.Status, reasoning) - - return nil -} - -func CloseDecisions() error { - getClosableDecisionsStmt := db.getPreparedNamedStatement(sqlSelectClosableDecisions) - - defer func() { _ = getClosableDecisionsStmt.Close() }() - - decisions := make([]*Decision, 0) - - rows, err := getClosableDecisionsStmt.Queryx(struct{ Now time.Time }{time.Now().UTC()}) - if err != nil { - return fmt.Errorf("fetching closable decisions failed: %w", err) - } - - defer func() { _ = rows.Close() }() - - for rows.Next() { - decision := &Decision{} - if err = rows.StructScan(decision); err != nil { - return fmt.Errorf("scanning row failed: %w", err) - } - - decisions = append(decisions, decision) - } - - defer func() { _ = rows.Close() }() - - for _, decision := range decisions { - log.Infof("found closable decision %s", decision.Tag) - - if err = decision.Close(); err != nil { - return fmt.Errorf("closing decision %s failed: %w", decision.Tag, err) - } - } - - return nil -} - -func GetNextPendingDecisionDue() (*time.Time, error) { - getNextPendingDecisionDueStmt := db.getPreparedStatement(sqlGetNextPendingDecisionDue) - - defer func() { _ = getNextPendingDecisionDueStmt.Close() }() - - row := getNextPendingDecisionDueStmt.QueryRow() - - due := &time.Time{} - if err := row.Scan(due); err != nil { - if errors.Is(err, sql.ErrNoRows) { - log.Debug("No pending decisions") - - return nil, nil - } - - return nil, fmt.Errorf("parsing result failed: %w", err) - } - - return due, nil -} - -func GetReminderVoters() ([]Voter, error) { - getReminderVotersStmt := db.getPreparedStatement(sqlGetReminderVoters) - - defer func() { _ = getReminderVotersStmt.Close() }() - - var voters []Voter - - if err := getReminderVotersStmt.Select(&voters); err != nil { - return nil, fmt.Errorf("getting voters failed: %w", err) - } - - return voters, nil -} - -func FindUnVotedDecisionsForVoter(voter *Voter) ([]Decision, error) { - findUnVotedDecisionsForVoterStmt := db.getPreparedStatement(sqlFindUnVotedDecisionsForVoter) - - defer func() { _ = findUnVotedDecisionsForVoterStmt.Close() }() - - var decisions []Decision - - if err := findUnVotedDecisionsForVoterStmt.Select(&decisions, voter.ID); err != nil { - return nil, fmt.Errorf("getting unvoted decisions failed: %w", err) - } - - return decisions, nil -} - -func GetVoterByID(id int64) (*Voter, error) { - getVoterByIDStmt := db.getPreparedStatement(sqlGetEnabledVoterByID) - - defer func() { _ = getVoterByIDStmt.Close() }() - - voter := &Voter{} - if err := getVoterByIDStmt.Get(voter, id); err != nil { - return nil, fmt.Errorf("getting voter failed: %w", err) - } - - return voter, nil -} - -func GetVotersForProxy(proxy *Voter) (voters *[]Voter, err error) { - getVotersForProxyStmt := db.getPreparedStatement(sqlGetVotersForProxy) - - defer func() { _ = getVotersForProxyStmt.Close() }() - - votersSlice := make([]Voter, 0) - - if err = getVotersForProxyStmt.Select(&votersSlice, proxy.ID); err != nil { - log.Errorf("Error getting voters for proxy failed: %v", err) - - return - } - - voters = &votersSlice - - return -} diff --git a/notifications.go b/notifications.go deleted file mode 100644 index efb03c2..0000000 --- a/notifications.go +++ /dev/null @@ -1,347 +0,0 @@ -/* -Copyright 2017-2022 CAcert Inc. -SPDX-License-Identifier: Apache-2.0 - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "bytes" - "embed" - "fmt" - "text/template" - - "github.com/Masterminds/sprig/v3" - "gopkg.in/mail.v2" - - log "github.com/sirupsen/logrus" -) - -type headerData struct { - name string - value []string -} - -type headerList []headerData - -type recipientData struct { - field, address, name string -} - -type NotificationContent struct { - template string - data interface{} - subject string - headers headerList - recipients []recipientData -} - -type NotificationMail interface { - GetNotificationContent() *NotificationContent -} - -var NotifyMailChannel = make(chan NotificationMail, 1) - -func MailNotifier(quitMailNotifier chan int) { - log.Info("Launched mail notifier") - - for { - select { - case notification := <-NotifyMailChannel: - content := notification.GetNotificationContent() - - mailText, err := buildMail(content.template, content.data) - if err != nil { - log.Errorf("building mail failed: %v", err) - - continue - } - - m := mail.NewMessage() - m.SetAddressHeader("From", config.NotificationSenderAddress, "CAcert board voting system") - - for _, recipient := range content.recipients { - m.SetAddressHeader(recipient.field, recipient.address, recipient.name) - } - - m.SetHeader("Subject", content.subject) - - for _, header := range content.headers { - m.SetHeader(header.name, header.value...) - } - - m.SetBody("text/plain", mailText.String()) - - d := mail.NewDialer(config.MailServer.Host, config.MailServer.Port, "", "") - if err := d.DialAndSend(m); err != nil { - log.Errorf("sending mail failed: %v", err) - } - case <-quitMailNotifier: - log.Info("Ending mail notifier") - - return - } - } -} - -//go:embed boardvoting/templates -var mailTemplates embed.FS - -func buildMail(templateName string, context interface{}) (mailText *bytes.Buffer, err error) { - b, err := mailTemplates.ReadFile(fmt.Sprintf("templates/%s", templateName)) - if err != nil { - return - } - - t, err := template.New(templateName).Funcs(sprig.GenericFuncMap()).Parse(string(b)) - if err != nil { - return - } - - mailText = bytes.NewBufferString("") - if err := t.Execute(mailText, context); err != nil { - return nil, fmt.Errorf( - "failed to execute template %s with context %+v: %w", - templateName, context, err, - ) - } - - return -} - -type notificationBase struct{} - -func (n *notificationBase) getRecipient() recipientData { - return recipientData{field: "To", address: config.NoticeMailAddress, name: "CAcert board mailing list"} -} - -type decisionReplyBase struct { - decision Decision -} - -func (n *decisionReplyBase) getHeaders() headerList { - headers := make(headerList, 0) - headers = append(headers, headerData{ - name: "References", value: []string{fmt.Sprintf("<%s>", n.decision.Tag)}, - }) - headers = append(headers, headerData{ - name: "In-Reply-To", value: []string{fmt.Sprintf("<%s>", n.decision.Tag)}, - }) - - return headers -} - -func (n *decisionReplyBase) getSubject() string { - return fmt.Sprintf("Re: %s - %s", n.decision.Tag, n.decision.Title) -} - -type notificationClosedDecision struct { - notificationBase - decisionReplyBase - voteSums VoteSums - reasoning string -} - -func NewNotificationClosedDecision(decision *Decision, voteSums *VoteSums, reasoning string) NotificationMail { - notification := ¬ificationClosedDecision{voteSums: *voteSums, reasoning: reasoning} - notification.decision = *decision - - return notification -} - -func (n *notificationClosedDecision) GetNotificationContent() *NotificationContent { - return &NotificationContent{ - template: "closed_motion_mail.txt", - data: struct { - *Decision - *VoteSums - Reasoning string - }{&n.decision, &n.voteSums, n.reasoning}, - subject: fmt.Sprintf("Re: %s - %s - finalised", n.decision.Tag, n.decision.Title), - headers: n.decisionReplyBase.getHeaders(), - recipients: []recipientData{n.notificationBase.getRecipient()}, - } -} - -type NotificationCreateMotion struct { - notificationBase - decision Decision - voter Voter -} - -func (n *NotificationCreateMotion) GetNotificationContent() *NotificationContent { - voteURL := fmt.Sprintf("%s/vote/%s", config.BaseURL, n.decision.Tag) - unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL) - - return &NotificationContent{ - template: "create_motion_mail.txt", - data: struct { - *Decision - Name string - VoteURL string - UnvotedURL string - }{&n.decision, n.voter.Name, voteURL, unvotedURL}, - subject: fmt.Sprintf("%s - %s", n.decision.Tag, n.decision.Title), - headers: headerList{headerData{"Message-ID", []string{fmt.Sprintf("<%s>", n.decision.Tag)}}}, - recipients: []recipientData{n.notificationBase.getRecipient()}, - } -} - -type notificationUpdateMotion struct { - notificationBase - decisionReplyBase - voter Voter -} - -func NewNotificationUpdateMotion(decision Decision, voter Voter) NotificationMail { - notification := notificationUpdateMotion{voter: voter} - notification.decision = decision - - return ¬ification -} - -func (n *notificationUpdateMotion) GetNotificationContent() *NotificationContent { - voteURL := fmt.Sprintf("%s/vote/%s", config.BaseURL, n.decision.Tag) - unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL) - - return &NotificationContent{ - template: "update_motion_mail.txt", - data: struct { - *Decision - Name string - VoteURL string - UnvotedURL string - }{&n.decision, n.voter.Name, voteURL, unvotedURL}, - subject: n.decisionReplyBase.getSubject(), - headers: n.decisionReplyBase.getHeaders(), - recipients: []recipientData{n.notificationBase.getRecipient()}, - } -} - -type notificationWithDrawMotion struct { - notificationBase - decisionReplyBase - voter Voter -} - -func NewNotificationWithDrawMotion(decision *Decision, voter *Voter) NotificationMail { - notification := ¬ificationWithDrawMotion{voter: *voter} - notification.decision = *decision - - return notification -} - -func (n *notificationWithDrawMotion) GetNotificationContent() *NotificationContent { - return &NotificationContent{ - template: "withdraw_motion_mail.txt", - data: struct { - *Decision - Name string - }{&n.decision, n.voter.Name}, - subject: fmt.Sprintf("Re: %s - %s - withdrawn", n.decision.Tag, n.decision.Title), - headers: n.decisionReplyBase.getHeaders(), - recipients: []recipientData{n.notificationBase.getRecipient()}, - } -} - -type RemindVoterNotification struct { - voter Voter - decisions []Decision -} - -func (n *RemindVoterNotification) GetNotificationContent() *NotificationContent { - return &NotificationContent{ - template: "remind_voter_mail.txt", - data: struct { - Decisions []Decision - Name string - BaseURL string - }{n.decisions, n.voter.Name, config.BaseURL}, - subject: "Outstanding CAcert board votes", - recipients: []recipientData{{"To", n.voter.Reminder, n.voter.Name}}, - } -} - -type voteNotificationBase struct{} - -func (n *voteNotificationBase) getRecipient() recipientData { - return recipientData{"To", config.VoteNoticeMailAddress, "CAcert board votes mailing list"} -} - -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, -) NotificationMail { - notification := ¬ificationProxyVote{proxy: *proxy, voter: *voter, vote: *vote, justification: justification} - notification.decision = *decision - - return notification -} - -func (n *notificationProxyVote) GetNotificationContent() *NotificationContent { - return &NotificationContent{ - template: "proxy_vote_mail.txt", - data: struct { - Proxy string - Vote VoteChoice - Voter string - Decision *Decision - Justification string - }{n.proxy.Name, n.vote.Vote, n.voter.Name, &n.decision, n.justification}, - subject: n.decisionReplyBase.getSubject(), - headers: n.decisionReplyBase.getHeaders(), - recipients: []recipientData{n.voteNotificationBase.getRecipient()}, - } -} - -type notificationDirectVote struct { - voteNotificationBase - decisionReplyBase - voter Voter - vote Vote -} - -func NewNotificationDirectVote(decision *Decision, voter *Voter, vote *Vote) NotificationMail { - notification := ¬ificationDirectVote{voter: *voter, vote: *vote} - notification.decision = *decision - - return notification -} - -func (n *notificationDirectVote) GetNotificationContent() *NotificationContent { - return &NotificationContent{ - template: "direct_vote_mail.txt", - data: struct { - Vote VoteChoice - Voter string - Decision *Decision - }{n.vote.Vote, n.voter.Name, &n.decision}, - subject: n.decisionReplyBase.getSubject(), - headers: n.decisionReplyBase.getHeaders(), - recipients: []recipientData{n.voteNotificationBase.getRecipient()}, - } -} diff --git a/ui/html/base.html b/ui/html/base.html index ed3c287..553453d 100644 --- a/ui/html/base.html +++ b/ui/html/base.html @@ -1,4 +1,4 @@ -{{ define "base" }} +{{ define "base" -}}