From c2eef9cf7cf412ec071ec0f62f700b9cc0ddc64c Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Sat, 15 Oct 2022 19:58:58 +0200 Subject: [PATCH] Refactoring away from main package This commit is a refactoring of code that has been located in the main package. We introduce separate packages for the main application, jobs, notifications, and request handlers. Dependencies are injected from the main application, this will make testing easier. --- Makefile | 11 +- cmd/boardvoting/config.go | 34 +- cmd/boardvoting/handlers.go | 1144 ------------- cmd/boardvoting/helpers.go | 309 ---- cmd/boardvoting/jobs.go | 289 ---- cmd/boardvoting/main.go | 122 +- cmd/boardvoting/routes.go | 116 -- internal/app/app.go | 245 +++ internal/handlers/flashmessage.go | 71 + internal/handlers/handlers.go | 1415 +++++++++++++++++ .../handlers}/handlers_test.go | 56 +- .../handlers}/middleware.go | 98 +- .../handlers}/middleware_test.go | 27 +- internal/handlers/templatecache.go | 122 ++ internal/jobs/closedecisions.go | 139 ++ internal/jobs/doc.go | 19 + internal/jobs/jobs.go | 34 + internal/jobs/remindvoters.go | 162 ++ internal/jobs/scheduler.go | 93 ++ .../notifications/mailnotifier.go | 79 +- 20 files changed, 2498 insertions(+), 2087 deletions(-) delete mode 100644 cmd/boardvoting/handlers.go delete mode 100644 cmd/boardvoting/helpers.go delete mode 100644 cmd/boardvoting/jobs.go delete mode 100644 cmd/boardvoting/routes.go create mode 100644 internal/app/app.go create mode 100644 internal/handlers/flashmessage.go create mode 100644 internal/handlers/handlers.go rename {cmd/boardvoting => internal/handlers}/handlers_test.go (70%) rename {cmd/boardvoting => internal/handlers}/middleware.go (64%) rename {cmd/boardvoting => internal/handlers}/middleware_test.go (89%) create mode 100644 internal/handlers/templatecache.go create mode 100644 internal/jobs/closedecisions.go create mode 100644 internal/jobs/doc.go create mode 100644 internal/jobs/jobs.go create mode 100644 internal/jobs/remindvoters.go create mode 100644 internal/jobs/scheduler.go rename cmd/boardvoting/notifications.go => internal/notifications/mailnotifier.go (80%) diff --git a/Makefile b/Makefile index 4088cf5..81bf542 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,13 @@ UIFILES = package.json package-lock.json semantic.json $(shell find ui/semantic all: cacert-boardvoting cacert-boardvoting: ${GOFILES} - go build -o $@ -buildmode=pie -trimpath -x -ldflags " -s -w -X 'main.version=${VERSION}' -X 'main.commit=${COMMIT}' -X 'main.date=${DATE}'" ./cmd/boardvoting - #go build -o $@ -buildmode=pie -trimpath -x -ldflags " -s -w -X 'main.version=${VERSION}' -X 'main.commit=${COMMIT}' -X 'main.date=${DATE}'" + go build -o $@ -buildmode=pie -trimpath -ldflags " -s -w -X 'main.version=${VERSION}' -X 'main.commit=${COMMIT}' -X 'main.date=${DATE}'" ./cmd/boardvoting + +test: + go test -v ./... + +lint: + golangci-lint run clean: rm -f cacert-boardvoting @@ -17,4 +22,4 @@ ui: ${UIFILES} npm install cd node_modules/fomantic-ui ; npx gulp build -.PHONY: clean distclean all ui +.PHONY: clean all ui test \ No newline at end of file diff --git a/cmd/boardvoting/config.go b/cmd/boardvoting/config.go index 90c6f16..04b597f 100644 --- a/cmd/boardvoting/config.go +++ b/cmd/boardvoting/config.go @@ -23,6 +23,8 @@ import ( "time" "gopkg.in/yaml.v2" + + "git.cacert.org/cacert-boardvoting/internal/notifications" ) const ( @@ -34,16 +36,6 @@ const ( smtpTimeout = 10 * time.Second ) -type mailConfig struct { - 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 { Idle time.Duration `yaml:"idle,omitempty"` Read time.Duration `yaml:"read,omitempty"` @@ -52,16 +44,16 @@ type httpTimeoutConfig struct { } type Config struct { - DatabaseFile string `yaml:"database_file"` - ClientCACertificates string `yaml:"client_ca_certificates"` - ServerCert string `yaml:"server_certificate"` - ServerKey string `yaml:"server_key"` - CookieSecretStr string `yaml:"cookie_secret"` - CsrfKeyStr string `yaml:"csrf_key"` - HTTPAddress string `yaml:"http_address,omitempty"` - HTTPSAddress string `yaml:"https_address,omitempty"` - MailConfig *mailConfig `yaml:"mail_config"` - Timeouts *httpTimeoutConfig `yaml:"timeouts,omitempty"` + DatabaseFile string `yaml:"database_file"` + ClientCACertificates string `yaml:"client_ca_certificates"` + ServerCert string `yaml:"server_certificate"` + ServerKey string `yaml:"server_key"` + CookieSecretStr string `yaml:"cookie_secret"` + CsrfKeyStr string `yaml:"csrf_key"` + HTTPAddress string `yaml:"http_address,omitempty"` + HTTPSAddress string `yaml:"https_address,omitempty"` + MailConfig *notifications.MailConfig `yaml:"mail_config"` + Timeouts *httpTimeoutConfig `yaml:"timeouts,omitempty"` } func parseConfig(configFile string) (*Config, error) { @@ -79,7 +71,7 @@ func parseConfig(configFile string) (*Config, error) { Read: httpReadTimeout, Write: httpWriteTimeout, }, - MailConfig: &mailConfig{ + MailConfig: ¬ifications.MailConfig{ SMTPHost: "localhost", SMTPPort: smtpPort, SMTPTimeOut: smtpTimeout, diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go deleted file mode 100644 index 8e7331c..0000000 --- a/cmd/boardvoting/handlers.go +++ /dev/null @@ -1,1144 +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 ( - "encoding/json" - "fmt" - "net/http" - "strings" - "time" - - "git.cacert.org/cacert-boardvoting/internal/forms" - - "git.cacert.org/cacert-boardvoting/internal/models" -) - -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) - } - - return hasRole, nil -} - -type topLevelNavItem string -type subLevelNavItem string - -const ( - topLevelNavMotions topLevelNavItem = "motions" - topLevelNavUsers topLevelNavItem = "users" - - subLevelNavMotionsAll subLevelNavItem = "all-motions" - subLevelNavMotionsUnvoted subLevelNavItem = "unvoted-motions" - subLevelNavUsers subLevelNavItem = "users" - subLevelNavVoters subLevelNavItem = "manage-voters" -) - -func (m *templateData) motionPaginationOptions(limit int, first, last *time.Time) error { - motions := m.Motions - - if len(motions) == limit && first.Before(motions[len(motions)-1].Proposed) { - marshaled, err := motions[len(motions)-1].Proposed.MarshalText() - if err != nil { - return fmt.Errorf("could not serialize timestamp: %w", err) - } - - m.NextPage = string(marshaled) - } - - if len(motions) > 0 && last.After(motions[0].Proposed) { - marshaled, err := motions[0].Proposed.MarshalText() - if err != nil { - return fmt.Errorf("could not serialize timestamp: %w", err) - } - - m.PrevPage = string(marshaled) - } - - return nil -} - -func (app *application) motionList(w http.ResponseWriter, r *http.Request) { - var ( - listOptions *models.MotionListOptions - err error - ) - - listOptions, err = app.calculateMotionListOptions(r) - if err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - ctx := r.Context() - - motions, err := app.motions.List(ctx, listOptions) - if err != nil { - panic(err) - } - - first, last, err := app.motions.TimestampRange(ctx, listOptions) - if err != nil { - panic(err) - } - - templateData := app.newTemplateData(r.Context(), r, "motions", "all-motions") - - if listOptions.UnvotedOnly { - templateData.ActiveSubNav = subLevelNavMotionsUnvoted - } - - templateData.Motions = motions - - err = templateData.motionPaginationOptions(listOptions.Limit, first, last) - if err != nil { - panic(err) - } - - app.render(w, http.StatusOK, "motions.html", templateData) -} - -func (app *application) calculateMotionListOptions(r *http.Request) (*models.MotionListOptions, error) { - const ( - queryParamBefore = "before" - queryParamAfter = "after" - queryParamUnvoted = "unvoted" - motionsPerPage = 10 - ) - - listOptions := &models.MotionListOptions{Limit: motionsPerPage} - - if r.URL.Query().Has(queryParamAfter) { - var after time.Time - - err := after.UnmarshalText([]byte(r.URL.Query().Get(queryParamAfter))) - if err != nil { - return nil, fmt.Errorf("could not unmarshal timestamp: %w", err) - } - - listOptions.After = &after - } else if r.URL.Query().Has(queryParamBefore) { - var before time.Time - - err := before.UnmarshalText([]byte(r.URL.Query().Get(queryParamBefore))) - if err != nil { - return nil, fmt.Errorf("could not unmarshal timestamp: %w", err) - } - - listOptions.Before = &before - } - - if r.URL.Query().Has(queryParamUnvoted) { - listOptions.UnvotedOnly = true - - voter, err := app.GetUser(r) - if err != nil { - return nil, fmt.Errorf("could not get voter: %w", err) - } - - if voter != nil { - listOptions.VoterID = voter.ID - } - } - - return listOptions, nil -} - -func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) { - motion := app.motionFromRequestParam(r.Context(), w, r) - if motion == nil { - return - } - - data := app.newTemplateData(r.Context(), r, topLevelNavMotions, subLevelNavMotionsAll) - - data.Motion = motion - - app.render(w, http.StatusOK, "motion.html", data) -} - -func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) { - data := app.newTemplateData(r.Context(), r, topLevelNavMotions, subLevelNavMotionsAll) - data.Form = &forms.EditMotionForm{ - Type: models.VoteTypeMotion, - } - - app.render(w, http.StatusOK, "create_motion.html", data) -} - -const hoursInDay = 24 - -func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) { - var form forms.EditMotionForm - - err := app.decodePostForm(r, &form) - if err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - if err := form.Validate(); err != nil { - panic(err) - } - - if !form.Valid() { - data := app.newTemplateData(r.Context(), r, topLevelNavMotions, subLevelNavMotionsAll) - data.Form = &form - - app.render(w, http.StatusUnprocessableEntity, "create_motion.html", data) - - return - } - - form.Normalize() - - user, err := app.GetUser(r) - if err != nil { - app.clientError(w, http.StatusUnauthorized) - - return - } - - now := time.Now().UTC() - dueDuration := time.Duration(form.Due) * hoursInDay * time.Hour - - decisionID, err := app.motions.Create( - r.Context(), - user, - form.Type, - form.Title, - form.Content, - now, - now.Add(dueDuration), - ) - if err != nil { - panic(err) - } - - decision, err := app.motions.ByID(r.Context(), decisionID) - if err != nil { - panic(err) - } - - app.mailNotifier.Notify(&NewDecisionNotification{ - Decision: decision, - Proposer: user, - }) - - app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters) - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashSuccess, - Title: "New motion started", - Message: fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title), - }) - - http.Redirect(w, r, "/motions/", http.StatusSeeOther) -} - -func (app *application) editMotionForm(w http.ResponseWriter, r *http.Request) { - motion := app.motionFromRequestParam(r.Context(), w, r) - if motion == nil { - return - } - - data := app.newTemplateData(r.Context(), r, topLevelNavMotions, subLevelNavMotionsAll) - - data.Motion = motion - - data.Form = &forms.EditMotionForm{ - Title: motion.Title, - Content: motion.Content, - Type: motion.Type, - } - - app.render(w, http.StatusOK, "edit_motion.html", data) -} - -func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) { - motion := app.motionFromRequestParam(r.Context(), w, r) - if motion == nil { - return - } - - var form forms.EditMotionForm - - err := app.decodePostForm(r, &form) - if err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - if err := form.Validate(); err != nil { - panic(err) - } - - if !form.Valid() { - data := app.newTemplateData(r.Context(), r, topLevelNavMotions, subLevelNavMotionsAll) - data.Form = &form - - app.render(w, http.StatusUnprocessableEntity, "edit_motion.html", data) - - return - } - - form.Normalize() - - user, err := app.GetUser(r) - if err != nil { - app.clientError(w, http.StatusUnauthorized) - - return - } - - now := time.Now().UTC() - dueDuration := time.Duration(form.Due) * hoursInDay * time.Hour - - err = app.motions.Update( - r.Context(), - motion.ID, - func(m *models.Motion) { - m.Type = form.Type - m.Title = strings.TrimSpace(form.Title) - m.Content = strings.TrimSpace(form.Content) - m.Due = now.Add(dueDuration) - }, - ) - if err != nil { - panic(err) - } - - decision, err := app.motions.ByID(r.Context(), motion.ID) - if err != nil { - panic(err) - } - - app.mailNotifier.Notify(&UpdateDecisionNotification{ - Decision: decision, - User: user, - }) - - app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters) - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashInfo, - Title: "Motion modified", - Message: fmt.Sprintf("The motion %s has been modified!", decision.Tag), - }) - - http.Redirect(w, r, "/motions/", http.StatusSeeOther) -} - -func (app *application) withdrawMotionForm(w http.ResponseWriter, r *http.Request) { - motion := app.motionFromRequestParam(r.Context(), w, r) - if motion == nil { - app.notFound(w) - - return - } - - data := app.newTemplateData(r.Context(), r, topLevelNavMotions, subLevelNavMotionsAll) - - data.Motion = motion - - app.render(w, http.StatusOK, "withdraw_motion.html", data) -} - -func (app *application) withdrawMotionSubmit(w http.ResponseWriter, r *http.Request) { - motion := app.motionFromRequestParam(r.Context(), w, r) - if motion == nil { - app.notFound(w) - - return - } - - user, err := app.GetUser(r) - if err != nil { - app.clientError(w, http.StatusUnauthorized) - - return - } - - err = app.motions.Withdraw(r.Context(), motion.ID) - - if err != nil { - panic(err) - } - - app.mailNotifier.Notify(&WithDrawMotionNotification{motion, user}) - - app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters) - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashWarning, - Title: "Motion withdrawn", - Message: fmt.Sprintf("The motion %s has been withdrawn!", motion.Tag), - }) - - http.Redirect(w, r, "/motions/", http.StatusSeeOther) -} - -func (app *application) voteForm(w http.ResponseWriter, r *http.Request) { - motion := app.motionFromRequestParam(r.Context(), w, r) - if motion == nil { - app.notFound(w) - - return - } - - choice := app.choiceFromRequestParam(w, r) - if choice == nil { - app.notFound(w) - - return - } - - data := app.newTemplateData(r.Context(), r, topLevelNavMotions, subLevelNavMotionsAll) - - data.Motion = motion - - data.Form = &forms.DirectVoteForm{ - Choice: choice, - } - - app.render(w, http.StatusOK, "direct_vote.html", data) -} - -func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) { - motion := app.motionFromRequestParam(r.Context(), w, r) - if motion == nil { - return - } - - choice := app.choiceFromRequestParam(w, r) - if choice == nil { - return - } - - user, err := app.GetUser(r) - if err != nil { - app.clientError(w, http.StatusUnauthorized) - - return - } - - clientCert, err := getPEMClientCert(r) - if err != nil { - panic(err) - } - - if err := app.motions.UpdateVote(r.Context(), user.ID, motion.ID, func(v *models.Vote) { - v.Vote = choice - v.Voted = time.Now().UTC() - v.Notes = fmt.Sprintf("Direct Vote\n\n%s", clientCert) - }); err != nil { - panic(err) - } - - app.mailNotifier.Notify(&DirectVoteNotification{ - Decision: motion, User: user, Choice: choice, - }) - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashSuccess, - Title: "Vote registered", - Message: fmt.Sprintf("Your vote for motion %s has been registered.", motion.Tag), - }) - - http.Redirect(w, r, "/motions/", http.StatusSeeOther) -} - -func (app *application) proxyVoteForm(w http.ResponseWriter, r *http.Request) { - motion := app.motionFromRequestParam(r.Context(), w, r) - if motion == nil { - return - } - - data := app.newTemplateData(r.Context(), r, topLevelNavMotions, subLevelNavMotionsAll) - - data.Motion = motion - - potentialVoters, err := app.users.Voters(r.Context()) - if err != nil { - panic(err) - } - - data.Form = &forms.ProxyVoteForm{ - Voters: potentialVoters, - } - - app.render(w, http.StatusOK, "proxy_vote.html", data) -} - -func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) { - motion := app.motionFromRequestParam(r.Context(), w, r) - if motion == nil { - return - } - - var form forms.ProxyVoteForm - - err := app.decodePostForm(r, &form) - if err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - if err := form.Validate(); err != nil { - panic(err) - } - - if !form.Valid() { - data := app.newTemplateData(r.Context(), r, topLevelNavMotions, subLevelNavMotionsAll) - data.Motion = motion - - potentialVoters, err := app.users.Voters(r.Context()) - if err != nil { - panic(err) - } - - form.Voters = potentialVoters - data.Form = &form - - app.render(w, http.StatusUnprocessableEntity, "proxy_vote.html", data) - - return - } - - form.Normalize() - - user, err := app.GetUser(r) - if err != nil { - app.clientError(w, http.StatusUnauthorized) - - return - } - - clientCert, err := getPEMClientCert(r) - if err != nil { - panic(err) - } - - voter := app.getVoter(r.Context(), w, form.Voter.ID) - if voter == nil { - return - } - - 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) - }); err != nil { - panic(err) - } - - app.mailNotifier.Notify(&ProxyVoteNotification{ - Decision: motion, User: user, Voter: voter, Choice: form.Choice, Justification: form.Justification, - }) - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashSuccess, - Title: "Proxy vote registered", - Message: fmt.Sprintf( - "Your proxy vote for %s for motion %s has been registered.", - voter.Name, - motion.Tag, - ), - }) - - http.Redirect(w, r, "/motions/", http.StatusSeeOther) -} - -func (app *application) userList(w http.ResponseWriter, r *http.Request) { - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - users, err := app.users.List( - r.Context(), - app.users.WithRoles(), - app.users.CanDelete(), - ) - if err != nil { - panic(err) - } - - data.Users = users - - app.render(w, http.StatusOK, "users.html", data) -} - -func (app *application) editUserForm(w http.ResponseWriter, r *http.Request) { - userToEdit := app.userFromRequestParam(r.Context(), w, r, app.users.WithRoles(), app.users.WithEmailAddresses()) - if userToEdit == nil { - return - } - - roles, err := userToEdit.Roles() - if err != nil { - panic(err) - } - - emailAddresses, err := userToEdit.EmailAddresses() - if err != nil { - panic(err) - } - - roleNames := make([]string, len(roles)) - for i := range roles { - roleNames[i] = roles[i].Name - } - - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - data.Form = &forms.EditUserForm{ - User: userToEdit, - Name: userToEdit.Name, - MailAddresses: emailAddresses, - AllRoles: models.AllRoles, - ReminderMail: userToEdit.Reminder.String, - Roles: roleNames, - } - - app.render(w, http.StatusOK, "edit_user.html", data) -} - -func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) { - userToEdit := app.userFromRequestParam(r.Context(), w, r, app.users.WithRoles(), app.users.WithEmailAddresses()) - if userToEdit == nil { - app.notFound(w) - - return - } - - var form forms.EditUserForm - - if err := app.decodePostForm(r, &form); err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - form.User = userToEdit - form.AllRoles = models.AllRoles - - if err := form.Validate(); err != nil { - panic(err) - } - - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - if !form.Valid() { - roles, err := userToEdit.Roles() - if err != nil { - panic(err) - } - - emailAddresses, err := userToEdit.EmailAddresses() - if err != nil { - panic(err) - } - - roleNames := make([]string, len(roles)) - for i := range roles { - roleNames[i] = roles[i].Name - } - - form.MailAddresses = emailAddresses - form.Roles = roleNames - - data.Form = &form - - app.render(w, http.StatusUnprocessableEntity, "edit_user.html", data) - - return - } - - form.Normalize() - form.UpdateUser(userToEdit) - - if err := app.users.EditUser(r.Context(), userToEdit, data.User, form.Roles, form.Reasoning); err != nil { - panic(err) - } - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashInfo, - Title: "User modified", - Message: fmt.Sprintf("User %s has been modified.", userToEdit.Name), - }) - - http.Redirect(w, r, "/users/", http.StatusSeeOther) -} - -func (app *application) userAddEmailForm(w http.ResponseWriter, r *http.Request) { - userToEdit := app.userFromRequestParam(r.Context(), w, r, app.users.WithEmailAddresses()) - if userToEdit == nil { - app.notFound(w) - - return - } - - emailAddresses, err := userToEdit.EmailAddresses() - if err != nil { - panic(err) - } - - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - data.Form = &forms.AddEmailForm{ - User: userToEdit, - EmailAddresses: emailAddresses, - } - - app.render(w, http.StatusOK, "add_email.html", data) -} - -func (app *application) userAddEmailSubmit(w http.ResponseWriter, r *http.Request) { - userToEdit := app.userFromRequestParam(r.Context(), w, r, app.users.WithEmailAddresses()) - if userToEdit == nil { - app.notFound(w) - - return - } - - var form forms.AddEmailForm - - if err := app.decodePostForm(r, &form); err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - form.User = userToEdit - - if err := form.Validate(); err != nil { - panic(err) - } - - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - if form.Valid() { - emailExists, err := app.users.EmailExists(r.Context(), form.EmailAddress) - if err != nil { - panic(err) - } - - if emailExists { - form.FieldErrors = map[string]string{ - "email_address": "Email address must be unique", - } - } - } - - if !form.Valid() { - emailAddresses, err := userToEdit.EmailAddresses() - if err != nil { - panic(err) - } - - form.EmailAddresses = emailAddresses - - data.Form = &form - - app.render(w, http.StatusUnprocessableEntity, "add_email.html", data) - - return - } - - form.Normalize() - - if err := app.users.AddEmail(r.Context(), userToEdit, data.User, form.EmailAddress, form.Reasoning); err != nil { - panic(err) - } - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashSuccess, - Title: "Email address added", - Message: fmt.Sprintf( - "Added email address %s for user %s", - form.EmailAddress, - userToEdit.Name, - ), - }) - - http.Redirect(w, r, fmt.Sprintf("/users/%d/", userToEdit.ID), http.StatusSeeOther) -} - -func (app *application) userDeleteEmailForm(w http.ResponseWriter, r *http.Request) { - userToEdit, emailAddress, err := app.deleteEmailParams(r.Context(), w, r) - if err != nil { - panic(err) - } - - if userToEdit == nil || emailAddress == "" { - app.notFound(w) - - return - } - - if userToEdit.Reminder.String == emailAddress { - // delete of reminder address should not happen - app.clientError(w, http.StatusBadRequest) - - return - } - - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - data.Form = &forms.DeleteEmailForm{ - User: userToEdit, - EmailAddress: emailAddress, - } - - app.render(w, http.StatusOK, "delete_email.html", data) -} - -func (app *application) userDeleteEmailSubmit(w http.ResponseWriter, r *http.Request) { - userToEdit, emailAddress, err := app.deleteEmailParams(r.Context(), w, r) - if err != nil { - panic(err) - } - - if userToEdit == nil || emailAddress == "" { - app.notFound(w) - - return - } - - if userToEdit.Reminder.String == emailAddress { - // delete of reminder address should not happen - app.clientError(w, http.StatusBadRequest) - - return - } - - admin, err := app.GetUser(r) - if err != nil { - app.clientError(w, http.StatusUnauthorized) - - return - } - - var form forms.DeleteEmailForm - - if err := app.decodePostForm(r, &form); err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - if err := form.Validate(); err != nil { - panic(err) - } - - if !form.Valid() { - form.EmailAddress = emailAddress - form.User = userToEdit - - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - data.Form = &form - - app.render(w, http.StatusUnprocessableEntity, "delete_email.html", data) - - return - } - - form.Normalize() - - if err := app.users.DeleteEmail(r.Context(), userToEdit, admin, emailAddress, form.Reasoning); err != nil { - panic(err) - } - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashWarning, - Title: "Email address deleted", - Message: fmt.Sprintf( - "Deleted email address %s of user %s", - form.EmailAddress, - userToEdit.Name, - ), - }) - - http.Redirect(w, r, fmt.Sprintf("/users/%d/", userToEdit.ID), http.StatusSeeOther) -} - -func (app *application) newUserForm(w http.ResponseWriter, r *http.Request) { - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - data.Form = &forms.NewUserForm{} - - app.render(w, http.StatusOK, "create_user.html", data) -} - -func (app *application) newUserSubmit(w http.ResponseWriter, r *http.Request) { - admin, err := app.GetUser(r) - if err != nil { - app.clientError(w, http.StatusUnauthorized) - - return - } - - var form forms.NewUserForm - - if err := app.decodePostForm(r, &form); err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - if err := form.Validate(); err != nil { - panic(err) - } - - if !form.Valid() { - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - data.Form = &form - - app.render(w, http.StatusUnprocessableEntity, "create_user.html", data) - - return - } - - form.Normalize() - - createUserParams := &models.CreateUserParams{ - Admin: admin, - Name: form.Name, - Reminder: form.EmailAddress, - Emails: []string{form.EmailAddress}, - Reasoning: form.Reasoning, - } - - userID, err := app.users.Create(r.Context(), createUserParams) - if err != nil { - panic(err) - } - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashSuccess, - Title: "User created", - Message: fmt.Sprintf("Created new user %s", form.Name), - }) - - http.Redirect(w, r, fmt.Sprintf("/users/%d", userID), http.StatusSeeOther) -} - -func (app *application) deleteUserForm(w http.ResponseWriter, r *http.Request) { - userToDelete := app.userFromRequestParam(r.Context(), w, r, app.users.CanDelete()) - if userToDelete == nil { - return - } - - if !userToDelete.CanDelete() { - app.notFound(w) - - return - } - - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - data.Form = &forms.DeleteUserForm{ - User: userToDelete, - } - - app.render(w, http.StatusOK, "delete_user.html", data) -} - -func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) { - userToDelete := app.userFromRequestParam(r.Context(), w, r, app.users.CanDelete()) - if userToDelete == nil { - return - } - - if !userToDelete.CanDelete() { - app.notFound(w) - - return - } - - var form forms.DeleteUserForm - - err := app.decodePostForm(r, &form) - if err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - form.User = userToDelete - - if err := form.Validate(); err != nil { - panic(err) - } - - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavUsers) - - if !form.Valid() { - data.Form = &form - - app.render(w, http.StatusUnprocessableEntity, "delete_user.html", data) - - return - } - - form.Normalize() - - if err = app.users.DeleteUser(r.Context(), userToDelete, data.User, form.Reasoning); err != nil { - panic(err) - } - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashWarning, - Title: "User deleted", - Message: fmt.Sprintf("User %s has been deleted.", userToDelete.Name), - }) - - http.Redirect(w, r, "/users/", http.StatusSeeOther) -} - -func (app *application) chooseVotersForm(w http.ResponseWriter, r *http.Request) { - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavVoters) - - allUsers, err := app.users.List(r.Context(), app.users.WithRoles()) - if err != nil { - panic(err) - } - - voterIDs := make([]int64, 0) - - for _, user := range allUsers { - isVoter, err := user.HasRole(models.RoleVoter) - if err != nil { - panic(err) - } - - if isVoter { - voterIDs = append(voterIDs, user.ID) - - continue - } - } - - data.Form = &forms.ChooseVoterForm{ - Users: allUsers, - VoterIDs: voterIDs, - } - - app.render(w, http.StatusOK, "choose_voters.html", data) -} - -func (app *application) chooseVotersSubmit(w http.ResponseWriter, r *http.Request) { - admin, err := app.GetUser(r) - if err != nil { - app.clientError(w, http.StatusUnauthorized) - - return - } - - var form forms.ChooseVoterForm - - if err = app.decodePostForm(r, &form); err != nil { - app.clientError(w, http.StatusBadRequest) - - return - } - - if err = form.Validate(); err != nil { - panic(err) - } - - if !form.Valid() { - data := app.newTemplateData(r.Context(), r, topLevelNavUsers, subLevelNavVoters) - - allUsers, err := app.users.List(r.Context(), app.users.WithRoles()) - if err != nil { - panic(err) - } - - form.Users = allUsers - - data.Form = &form - - app.render(w, http.StatusUnprocessableEntity, "choose_voters.html", data) - - return - } - - form.Normalize() - - if err = app.users.ChooseVoters(r.Context(), admin, form.VoterIDs, form.Reasoning); err != nil { - panic(err) - } - - app.addFlash(r.Context(), &FlashMessage{ - Variant: flashSuccess, - Title: "Voters selected", - Message: "A new list of voters has been selected", - }) - - http.Redirect(w, r, "/users/", http.StatusSeeOther) -} - -func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) { - const ( - ok = "OK" - failed = "FAILED" - ) - - response := struct { - 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") - - var ( - err error - hasErrors = false - ) - - if err = app.mailNotifier.Ping(); err != nil { - hasErrors = true - - 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/helpers.go b/cmd/boardvoting/helpers.go deleted file mode 100644 index ccebe7f..0000000 --- a/cmd/boardvoting/helpers.go +++ /dev/null @@ -1,309 +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" - "context" - "crypto/x509" - "encoding/pem" - "errors" - "fmt" - "html/template" - "io/fs" - "net/http" - "path/filepath" - "strconv" - "strings" - - "github.com/Masterminds/sprig/v3" - "github.com/go-chi/chi/v5" - "github.com/go-playground/form/v4" - "github.com/justinas/nosurf" - - "git.cacert.org/cacert-boardvoting/internal/forms" - "git.cacert.org/cacert-boardvoting/internal/models" - "git.cacert.org/cacert-boardvoting/ui" -) - -func (app *application) clientError(w http.ResponseWriter, status int) { - http.Error(w, http.StatusText(status), status) -} - -func (app *application) notFound(w http.ResponseWriter) { - app.clientError(w, http.StatusNotFound) -} - -func (app *application) decodePostForm(r *http.Request, dst forms.Form) error { - err := r.ParseForm() - if err != nil { - return fmt.Errorf("could not parse HTML form: %w", err) - } - - err = app.formDecoder.Decode(dst, r.PostForm) - if err != nil { - var invalidDecoderError *form.InvalidDecoderError - - if errors.As(err, &invalidDecoderError) { - panic(err) - } - - return fmt.Errorf("could not decode form: %w", err) - } - - return nil -} - -func newTemplateCache() (map[string]*template.Template, error) { - cache := map[string]*template.Template{} - - pages, err := fs.Glob(ui.Files, "html/pages/*.html") - if err != nil { - return nil, fmt.Errorf("could not find page templates: %w", err) - } - - 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["canManageUsers"] = func(v *models.User) (bool, error) { - return checkRole(v, models.RoleSecretary, models.RoleAdmin) - } - funcMaps["canVote"] = func(v *models.User) (bool, error) { - return checkRole(v, models.RoleVoter) - } - funcMaps["canStartVote"] = func(v *models.User) (bool, error) { - return checkRole(v, models.RoleVoter) - } - - for _, page := range pages { - name := filepath.Base(page) - - ts, err := template.New("").Funcs(funcMaps).ParseFS( - ui.Files, - "html/base.html", - "html/partials/*.html", - page, - ) - if err != nil { - return nil, fmt.Errorf("could not parse base template: %w", err) - } - - cache[name] = ts - } - - return cache, nil -} - -type templateData struct { - PrevPage string - NextPage string - Motion *models.Motion - Motions []*models.Motion - User *models.User - Users []*models.User - Request *http.Request - Flashes []FlashMessage - Form forms.Form - ActiveNav topLevelNavItem - ActiveSubNav subLevelNavItem - CSRFToken string -} - -func (app *application) newTemplateData( - ctx context.Context, - r *http.Request, - nav topLevelNavItem, - subNav subLevelNavItem, -) *templateData { - user, _ := app.GetUser(r) - - return &templateData{ - Request: r, - User: user, - ActiveNav: nav, - ActiveSubNav: subNav, - Flashes: app.flashes(ctx), - CSRFToken: nosurf.Token(r), - } -} - -func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) { - ts, ok := app.templateCache[page] - if !ok { - panic(fmt.Sprintf("the template %s does not exist", page)) - } - - buf := new(bytes.Buffer) - - err := ts.ExecuteTemplate(buf, "base", data) - if err != nil { - panic(err) - } - - w.WriteHeader(status) - - _, _ = buf.WriteTo(w) -} - -func (app *application) motionFromRequestParam( - ctx context.Context, - w http.ResponseWriter, - r *http.Request, -) *models.Motion { - withVotes := r.URL.Query().Has("showvotes") - - motion, err := app.motions.ByTag(ctx, chi.URLParam(r, "tag"), withVotes) - if err != nil { - panic(err) - } - - if motion.ID == 0 { - app.notFound(w) - - return nil - } - - return motion -} - -func (app *application) userFromRequestParam( - ctx context.Context, - w http.ResponseWriter, r *http.Request, options ...models.UserListOption, -) *models.User { - userID, err := strconv.Atoi(chi.URLParam(r, "id")) - if err != nil { - app.clientError(w, http.StatusBadRequest) - - return nil - } - - user, err := app.users.ByID(ctx, int64(userID), options...) - if err != nil { - panic(err) - } - - if user == nil { - app.notFound(w) - - return nil - } - - return user -} - -func (app *application) emailFromRequestParam(r *http.Request, user *models.User) (string, error) { - emailAddresses, err := user.EmailAddresses() - if err != nil { - return "", fmt.Errorf("could not get email addresses: %w", err) - } - - emailParam := chi.URLParam(r, "address") - - for _, address := range emailAddresses { - if emailParam == address { - return emailParam, nil - } - } - - return "", nil -} - -func (app *application) deleteEmailParams( - ctx context.Context, - w http.ResponseWriter, - r *http.Request, -) (*models.User, string, error) { - userToEdit := app.userFromRequestParam(ctx, w, r, app.users.WithEmailAddresses()) - if userToEdit == nil { - return nil, "", nil - } - - emailAddress, err := app.emailFromRequestParam(r, userToEdit) - if err != nil { - return nil, "", err - } - - return userToEdit, emailAddress, nil -} - -func (app *application) choiceFromRequestParam(w http.ResponseWriter, r *http.Request) *models.VoteChoice { - choice, err := models.VoteChoiceFromString(chi.URLParam(r, "choice")) - if err != nil { - app.clientError(w, http.StatusBadRequest) - - return nil - } - - return choice -} - -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 -} - -type FlashVariant string - -const ( - flashWarning FlashVariant = "warning" - flashInfo FlashVariant = "info" - flashSuccess FlashVariant = "success" -) - -type FlashMessage struct { - Variant FlashVariant - Title string - Message string -} - -func (app *application) addFlash(ctx context.Context, message *FlashMessage) { - flashes := app.flashes(ctx) - - flashes = append(flashes, *message) - - app.sessionManager.Put(ctx, "flashes", flashes) -} - -func (app *application) flashes(ctx context.Context) []FlashMessage { - flashInstance := app.sessionManager.Pop(ctx, "flashes") - - if flashInstance != nil { - flashes, ok := flashInstance.([]FlashMessage) - - if ok { - return flashes - } - } - - return make([]FlashMessage, 0) -} diff --git a/cmd/boardvoting/jobs.go b/cmd/boardvoting/jobs.go deleted file mode 100644 index 52c3592..0000000 --- a/cmd/boardvoting/jobs.go +++ /dev/null @@ -1,289 +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 ( - "context" - "log" - "time" - - "git.cacert.org/cacert-boardvoting/internal/models" -) - -type Job interface { - Schedule() - Run() - Stop() -} - -type RemindVotersJob struct { - infoLog, errorLog *log.Logger - timer *time.Timer - voters *models.UserModel - decisions *models.MotionModel - notifier *MailNotifier -} - -func (r *RemindVotersJob) Schedule() { - const reminderDays = 3 - - now := time.Now().UTC() - - year, month, day := now.Date() - - nextPotentialRun := time.Date(year, month, day+1, 0, 0, 0, 0, time.UTC) - nextPotentialRun.Add(hoursInDay * time.Hour) - - relevantDue := nextPotentialRun.Add(reminderDays * hoursInDay * time.Hour) - - due, err := r.decisions.NextPendingDue(context.Background(), relevantDue) - if err != nil { - r.errorLog.Printf("could not fetch next due date: %v", err) - } - - if due == nil { - r.infoLog.Printf("no due motions after relevant due date %s, not scheduling ReminderJob", relevantDue) - - return - } - - remindNext := due.Add(-reminderDays * hoursInDay * time.Hour).UTC() - - year, month, day = remindNext.Date() - - potentialRun := time.Date(year, month, day, 0, 0, 0, 0, time.UTC) - - if potentialRun.Before(time.Now().UTC()) { - r.infoLog.Printf("potential reminder time %s is in the past, not scheduling ReminderJob", potentialRun) - - return - } - - r.infoLog.Printf("scheduling RemindVotersJob for %s", potentialRun) - - when := time.Until(potentialRun) - - if r.timer != nil { - r.timer.Reset(when) - - return - } - - r.timer = time.AfterFunc(when, r.Run) -} - -func (r *RemindVotersJob) Run() { - r.infoLog.Print("running RemindVotersJob") - - defer func(r *RemindVotersJob) { r.Schedule() }(r) - - var ( - voters []*models.User - decisions []*models.Motion - err error - ) - - ctx := context.Background() - - voters, err = r.voters.ReminderVoters(ctx) - if err != nil { - r.errorLog.Printf("problem getting voters: %v", err) - - return - } - - for _, voter := range voters { - v := voter - - decisions, err = r.decisions.UnvotedForVoter(ctx, v) - if err != nil { - r.errorLog.Printf("problem getting unvoted decisions: %v", err) - - return - } - - if len(decisions) > 0 { - r.notifier.Notify(&RemindVoterNotification{voter: voter, decisions: decisions}) - } - } -} - -func (r *RemindVotersJob) Stop() { - if r.timer != nil { - r.timer.Stop() - - r.timer = nil - } -} - -func (app *application) NewRemindVotersJob() Job { - return &RemindVotersJob{ - 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 - notifier *MailNotifier -} - -func (c *CloseDecisionsJob) Schedule() { - var ( - nextDue *time.Time - err error - ) - - ctx := context.Background() - - nextDue, err = c.decisions.NextPendingDue(ctx, time.Now().UTC()) - if err != nil { - c.errorLog.Printf("could not get next pending due date") - - c.Stop() - - return - } - - if nextDue == nil { - c.infoLog.Printf("no next planned execution of CloseDecisionsJob") - c.Stop() - - return - } - - c.infoLog.Printf("scheduling CloseDecisionsJob for %s", nextDue) - when := time.Until(nextDue.Add(time.Second)) - - if c.timer == nil { - c.timer = time.AfterFunc(when, c.Run) - - return - } - - c.timer.Reset(when) -} - -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.infoLog.Printf( - "decision %s closed with result %s: reasoning '%s'", - res.Tag, - res.Status, - res.Reasoning, - ) - - c.notifier.Notify(&ClosedDecisionNotification{Decision: res}) - } -} - -func (c *CloseDecisionsJob) Stop() { - if c.timer != nil { - c.timer.Stop() - c.timer = nil - } -} - -func (app *application) NewCloseDecisionsJob() Job { - return &CloseDecisionsJob{ - 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 map[JobIdentifier]Job - rescheduleChannel chan JobIdentifier - quitChannel chan struct{} -} - -func (app *application) NewJobScheduler() { - rescheduleChannel := make(chan JobIdentifier, 1) - - app.jobScheduler = &JobScheduler{ - infoLogger: app.infoLog, - errorLogger: app.errorLog, - jobs: make(map[JobIdentifier]Job, 2), - rescheduleChannel: rescheduleChannel, - quitChannel: make(chan struct{}), - } - - app.jobScheduler.addJob(JobIDCloseDecisions, app.NewCloseDecisionsJob()) - app.jobScheduler.addJob(JobIDRemindVoters, app.NewRemindVotersJob()) -} - -func (js *JobScheduler) Schedule() { - for _, job := range js.jobs { - job.Schedule() - } - - for { - select { - case jobID := <-js.rescheduleChannel: - js.jobs[jobID].Schedule() - case <-js.quitChannel: - for _, job := range js.jobs { - job.Stop() - } - - js.infoLogger.Print("stop job scheduler") - - return - } - } -} - -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 73ec633..122f3f6 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -19,30 +19,27 @@ limitations under the License. package main import ( - "context" "crypto/tls" "crypto/x509" "database/sql" - "encoding/gob" "flag" "fmt" - "html/template" + "io" "log" "net/http" "net/url" "os" - "strconv" "strings" "time" "github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/v2" - "github.com/go-playground/form/v4" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" + u "git.cacert.org/cacert-boardvoting/internal/app" + "git.cacert.org/cacert-boardvoting/internal" - "git.cacert.org/cacert-boardvoting/internal/models" ) const sessionHours = 12 @@ -53,18 +50,6 @@ var ( date = "undefined" ) -type application struct { - errorLog, infoLog *log.Logger - users *models.UserModel - motions *models.MotionModel - jobScheduler *JobScheduler - mailNotifier *MailNotifier - mailConfig *mailConfig - templateCache map[string]*template.Template - sessionManager *scs.SessionManager - formDecoder *form.Decoder -} - func main() { configFile := flag.String("config", "config.yaml", "Configuration file name") flag.Parse() @@ -84,7 +69,7 @@ func main() { errorLog.Fatal(err) } - defer func(db *sqlx.DB) { + defer func(db io.Closer) { _ = db.Close() }(db) @@ -92,27 +77,15 @@ func main() { errorLog.Fatalf("could not setup decision model: %v", err) } - templateCache, err := newTemplateCache() - if err != nil { - errorLog.Fatal(err) - } - sessionManager := scs.New() sessionManager.Store = sqlite3store.New(db.DB) sessionManager.Lifetime = sessionHours * time.Hour sessionManager.Cookie.SameSite = http.SameSiteStrictMode sessionManager.Cookie.Secure = true - gob.Register([]FlashMessage{}) - - app := &application{ - errorLog: errorLog, - infoLog: infoLog, - motions: &models.MotionModel{DB: db}, - users: &models.UserModel{DB: db}, - mailConfig: config.MailConfig, - templateCache: templateCache, - sessionManager: sessionManager, + application, err := u.New(errorLog, infoLog, db, config.MailConfig, sessionManager) + if err != nil { + errorLog.Fatalf("could not setup application: %v", err) } err = internal.InitializeDb(db.DB, infoLog) @@ -120,17 +93,9 @@ func main() { errorLog.Fatal(err) } - app.setupFormDecoder() - - app.NewMailNotifier() - defer app.mailNotifier.Quit() - - go app.mailNotifier.Start() - - app.NewJobScheduler() - defer app.jobScheduler.Quit() - - go app.jobScheduler.Schedule() + defer func(application io.Closer) { + _ = application.Close() + }(application) infoLog.Printf("Starting server on %s", config.HTTPAddress) @@ -140,7 +105,7 @@ func main() { go setupHTTPRedirect(config, errChan) - err = app.startHTTPSServer(config) + err = startHTTPSServer(config, errorLog, application.Routes(), func() { _ = application.Close() }) if err != nil { errorLog.Fatalf("ListenAndServeTLS (HTTPS) failed: %v", err) } @@ -150,43 +115,7 @@ func main() { } } -func (app *application) setupFormDecoder() { - decoder := form.NewDecoder() - - decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) { - v, err := models.VoteTypeFromString(values[0]) - if err != nil { - return nil, fmt.Errorf("could not convert value %s: %w", values[0], err) - } - - return v, nil - }, new(models.VoteType)) - decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) { - v, err := models.VoteChoiceFromString(values[0]) - if err != nil { - return nil, fmt.Errorf("could not convert value %s: %w", values[0], err) - } - - 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)) - - app.formDecoder = decoder -} - -func (app *application) startHTTPSServer(config *Config) error { +func startHTTPSServer(config *Config, errorLog *log.Logger, routes http.Handler, shutdownFunc func()) error { tlsConfig, err := setupTLSConfig(config) if err != nil { return fmt.Errorf("could not setup TLS configuration: %w", err) @@ -195,14 +124,16 @@ func (app *application) startHTTPSServer(config *Config) error { srv := &http.Server{ Addr: config.HTTPSAddress, TLSConfig: tlsConfig, - ErrorLog: app.errorLog, - Handler: app.routes(), + ErrorLog: errorLog, + Handler: routes, IdleTimeout: config.Timeouts.Idle, ReadHeaderTimeout: config.Timeouts.ReadHeader, ReadTimeout: config.Timeouts.Read, WriteTimeout: config.Timeouts.Write, } + srv.RegisterOnShutdown(shutdownFunc) + err = srv.ListenAndServeTLS(config.ServerCert, config.ServerKey) if err != nil { return fmt.Errorf("") @@ -211,27 +142,6 @@ func (app *application) startHTTPSServer(config *Config) error { return nil } -func (app *application) getVoter(ctx context.Context, w http.ResponseWriter, voterID int64) *models.User { - voter, err := app.users.ByID(ctx, voterID, app.users.WithRoles()) - if err != nil { - panic(err) - } - - var isVoter bool - - if isVoter, err = voter.HasRole(models.RoleVoter); err != nil { - panic(err) - } - - if !isVoter { - app.clientError(w, http.StatusBadRequest) - - return nil - } - - return voter -} - func setupHTTPRedirect(config *Config, errChan chan error) { redirect := &http.Server{ Addr: config.HTTPAddress, diff --git a/cmd/boardvoting/routes.go b/cmd/boardvoting/routes.go deleted file mode 100644 index 3af2325..0000000 --- a/cmd/boardvoting/routes.go +++ /dev/null @@ -1,116 +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 ( - "io/fs" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/vearutop/statigz" - "github.com/vearutop/statigz/brotli" - - "git.cacert.org/cacert-boardvoting/ui" -) - -func (app *application) routes() http.Handler { - staticDir, _ := fs.Sub(ui.Files, "static") - - staticData, ok := staticDir.(fs.ReadDirFS) - if !ok { - app.errorLog.Fatal("could not use uiStaticDir as fs.ReadDirFS") - } - - fileServer := statigz.FileServer(staticData, brotli.AddEncoding, statigz.EncodeOnInit) - - router := chi.NewRouter() - - router.Use(middleware.RealIP) - router.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: app.infoLog})) - router.Use(middleware.Recoverer) - router.Use(secureHeaders) - - router.NotFound(func(w http.ResponseWriter, _ *http.Request) { app.notFound(w) }) - - router.Get( - "/", - http.RedirectHandler("/motions/", http.StatusMovedPermanently).ServeHTTP, - ) - router.Get( - "/favicon.ico", - http.RedirectHandler("/static/images/favicon.ico", http.StatusMovedPermanently).ServeHTTP, - ) - router.Get("/static/*", http.StripPrefix("/static", fileServer).ServeHTTP) - - router.Group(func(r chi.Router) { - r.Use(app.sessionManager.LoadAndSave, app.tryAuthenticate) - - r.Get("/motions/", app.motionList) - r.Get("/motions/{tag}", app.motionDetails) - - r.Group(func(r chi.Router) { - r.Use(app.userCanEditVote, noSurf) - - r.Get("/newmotion/", app.newMotionForm) - r.Post("/newmotion/", app.newMotionSubmit) - - r.Route("/motions/{tag}", func(r chi.Router) { - r.Get("/edit", app.editMotionForm) - r.Post("/edit", app.editMotionSubmit) - r.Get("/withdraw", app.withdrawMotionForm) - r.Post("/withdraw", app.withdrawMotionSubmit) - }) - }) - - r.Group(func(r chi.Router) { - r.Use(app.userCanVote, noSurf) - - r.Get("/vote/{tag}/{choice}", app.voteForm) - r.Post("/vote/{tag}/{choice}", app.voteSubmit) - r.Get("/proxy/{tag}", app.proxyVoteForm) - r.Post("/proxy/{tag}", app.proxyVoteSubmit) - }) - - r.Group(func(r chi.Router) { - r.Use(app.canManageUsers, noSurf) - - r.Get("/users/", app.userList) - r.Get("/new-user/", app.newUserForm) - r.Post("/new-user/", app.newUserSubmit) - - r.Route("/users/{id}", func(r chi.Router) { - r.Get("/", app.editUserForm) - r.Post("/", app.editUserSubmit) - r.Get("/add-mail", app.userAddEmailForm) - r.Post("/add-mail", app.userAddEmailSubmit) - r.Get("/mail/{address}/delete", app.userDeleteEmailForm) - r.Post("/mail/{address}/delete", app.userDeleteEmailSubmit) - r.Get("/delete", app.deleteUserForm) - r.Post("/delete", app.deleteUserSubmit) - }) - - r.Get("/voters/", app.chooseVotersForm) - r.Post("/voters/", app.chooseVotersSubmit) - }) - }) - - router.Get("/health", app.healthCheck) - - return router -} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..c05715d --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,245 @@ +/* +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 u + +import ( + "context" + "fmt" + "io/fs" + "log" + "net/http" + "strconv" + + "github.com/alexedwards/scs/v2" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-playground/form/v4" + "github.com/jmoiron/sqlx" + "github.com/vearutop/statigz" + "github.com/vearutop/statigz/brotli" + + "git.cacert.org/cacert-boardvoting/internal/handlers" + "git.cacert.org/cacert-boardvoting/internal/jobs" + "git.cacert.org/cacert-boardvoting/internal/models" + "git.cacert.org/cacert-boardvoting/internal/notifications" + "git.cacert.org/cacert-boardvoting/ui" +) + +type Application struct { + errorLog, infoLog *log.Logger + users *models.UserModel + motions *models.MotionModel + jobScheduler *jobs.JobScheduler + mailNotifier *notifications.MailNotifier + mailConfig *notifications.MailConfig + templateCache *handlers.TemplateCache + sessionManager *scs.SessionManager + formDecoder *form.Decoder +} + +func New( + errorLog, infoLog *log.Logger, + db *sqlx.DB, + mailConfig *notifications.MailConfig, + sessionManager *scs.SessionManager, +) (*Application, error) { + app := &Application{ + errorLog: errorLog, + infoLog: infoLog, + mailConfig: mailConfig, + motions: &models.MotionModel{DB: db}, + users: &models.UserModel{DB: db}, + sessionManager: sessionManager, + } + + var err error + + app.templateCache, err = handlers.NewTemplateCache() + if err != nil { + return nil, fmt.Errorf("could not initialize template cache: %w", err) + } + + app.setupFormDecoder() + + app.mailNotifier = notifications.NewMailNotifier( + app.mailConfig, + notifications.NotifierLog(app.infoLog, app.errorLog), + ) + + go app.mailNotifier.Start() + + app.jobScheduler = jobs.NewJobScheduler(jobs.SchedulerLog(app.infoLog, app.errorLog)) + + app.jobScheduler.AddJob(jobs.NewCloseDecisionsJob( + app.motions, + app.mailNotifier, + jobs.CloseDecisionsLog(app.infoLog, app.errorLog), + )) + + app.jobScheduler.AddJob(jobs.NewRemindVoters( + app.users, app.motions, app.mailNotifier, + jobs.RemindVotersLog(app.infoLog, app.errorLog), + )) + + go app.jobScheduler.Schedule() + + return app, nil +} + +func (app *Application) Close() error { + app.jobScheduler.Quit() + app.mailNotifier.Quit() + + return nil +} + +func (app *Application) setupFormDecoder() { + decoder := form.NewDecoder() + + decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) { + v, err := models.VoteTypeFromString(values[0]) + if err != nil { + return nil, fmt.Errorf("could not convert value %s: %w", values[0], err) + } + + return v, nil + }, new(models.VoteType)) + decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) { + v, err := models.VoteChoiceFromString(values[0]) + if err != nil { + return nil, fmt.Errorf("could not convert value %s: %w", values[0], err) + } + + 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)) + + app.formDecoder = decoder +} + +func (app *Application) Routes() http.Handler { + staticDir, _ := fs.Sub(ui.Files, "static") + + staticData, ok := staticDir.(fs.ReadDirFS) + if !ok { + app.errorLog.Fatal("could not use uiStaticDir as fs.ReadDirFS") + } + + fileServer := statigz.FileServer(staticData, brotli.AddEncoding, statigz.EncodeOnInit) + + router := chi.NewRouter() + + router.Use(middleware.RealIP) + router.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: app.infoLog})) + router.Use(middleware.Recoverer) + router.Use(handlers.SecureHeaders) + + router.NotFound(func(w http.ResponseWriter, _ *http.Request) { handlers.NotFound(w) }) + + router.Get( + "/", + http.RedirectHandler("/motions/", http.StatusMovedPermanently).ServeHTTP, + ) + router.Get( + "/favicon.ico", + http.RedirectHandler("/static/images/favicon.ico", http.StatusMovedPermanently).ServeHTTP, + ) + router.Get("/static/*", http.StripPrefix("/static", fileServer).ServeHTTP) + + userMiddleware := handlers.NewUserMiddleware(app.users, app.errorLog) + + flashes := handlers.NewFlashes(app.sessionManager) + + handlerParams := handlers.CommonParams{ + Flashes: flashes, + TemplateCache: app.templateCache, + FormDecoder: app.formDecoder, + } + + motionHandler := handlers.NewMotionHandler(app.motions, app.mailNotifier, app.jobScheduler, handlerParams) + voteHandler := handlers.NewVoteHandler(app.motions, app.users, app.mailNotifier, handlerParams) + userHandler := handlers.NewUserHandler(app.users, handlerParams) + + router.Group(func(r chi.Router) { + r.Use(app.sessionManager.LoadAndSave, userMiddleware.TryAuthenticate) + + r.Get("/motions/", motionHandler.List) + r.Get("/motions/{tag}", motionHandler.Details) + + r.Group(func(r chi.Router) { + r.Use(userMiddleware.UserCanEditVote, handlers.NoSurf) + + r.Get("/newmotion/", motionHandler.NewForm) + r.Post("/newmotion/", motionHandler.New) + + r.Route("/motions/{tag}", func(r chi.Router) { + r.Get("/edit", motionHandler.EditForm) + r.Post("/edit", motionHandler.Edit) + r.Get("/withdraw", motionHandler.WithdrawForm) + r.Post("/withdraw", motionHandler.Withdraw) + }) + }) + + r.Group(func(r chi.Router) { + r.Use(userMiddleware.UserCanVote, handlers.NoSurf) + + r.Get("/vote/{tag}/{choice}", voteHandler.VoteForm) + r.Post("/vote/{tag}/{choice}", voteHandler.Vote) + r.Get("/proxy/{tag}", voteHandler.ProxyVoteForm) + r.Post("/proxy/{tag}", voteHandler.ProxyVote) + }) + + r.Group(func(r chi.Router) { + r.Use(userMiddleware.CanManageUsers, handlers.NoSurf) + + r.Get("/users/", userHandler.List) + r.Get("/new-user/", userHandler.CreateForm) + r.Post("/new-user/", userHandler.Create) + + r.Route("/users/{id}", func(r chi.Router) { + r.Get("/", userHandler.EditForm) + r.Post("/", userHandler.Edit) + r.Get("/add-mail", userHandler.AddEmailForm) + r.Post("/add-mail", userHandler.AddEmail) + r.Get("/mail/{address}/delete", userHandler.DeleteEmailForm) + r.Post("/mail/{address}/delete", userHandler.DeleteEmail) + r.Get("/delete", userHandler.DeleteForm) + r.Post("/delete", userHandler.Delete) + }) + + r.Get("/voters/", userHandler.ChangeVotersForm) + r.Post("/voters/", userHandler.ChangeVoters) + }) + }) + + router.Method(http.MethodGet, "/health", handlers.NewHealthCheck(app.mailNotifier, app.motions)) + + return router +} diff --git a/internal/handlers/flashmessage.go b/internal/handlers/flashmessage.go new file mode 100644 index 0000000..dea2971 --- /dev/null +++ b/internal/handlers/flashmessage.go @@ -0,0 +1,71 @@ +/* +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 handlers + +import ( + "context" + "encoding/gob" + + "github.com/alexedwards/scs/v2" +) + +type FlashVariant string + +const ( + flashWarning FlashVariant = "warning" + flashInfo FlashVariant = "info" + flashSuccess FlashVariant = "success" +) + +type FlashMessage struct { + Variant FlashVariant + Title string + Message string +} + +type FlashHandler struct { + sessionManager *scs.SessionManager +} + +func NewFlashes(manager *scs.SessionManager) *FlashHandler { + gob.Register([]FlashMessage{}) + + return &FlashHandler{sessionManager: manager} +} + +func (f *FlashHandler) addFlash(ctx context.Context, message *FlashMessage) { + flashes := f.flashes(ctx) + + flashes = append(flashes, *message) + + f.sessionManager.Put(ctx, "flashes", flashes) +} + +func (f *FlashHandler) flashes(ctx context.Context) []FlashMessage { + flashInstance := f.sessionManager.Pop(ctx, "flashes") + + if flashInstance != nil { + flashes, ok := flashInstance.([]FlashMessage) + + if ok { + return flashes + } + } + + return make([]FlashMessage, 0) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..4160fed --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,1415 @@ +/* +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 handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-playground/form/v4" + "github.com/justinas/nosurf" + + "git.cacert.org/cacert-boardvoting/internal/forms" + "git.cacert.org/cacert-boardvoting/internal/jobs" + "git.cacert.org/cacert-boardvoting/internal/models" + "git.cacert.org/cacert-boardvoting/internal/notifications" +) + +func ClientError(w http.ResponseWriter, status int) { + http.Error(w, http.StatusText(status), status) +} + +func NotFound(w http.ResponseWriter) { + ClientError(w, http.StatusNotFound) +} + +type topLevelNavItem string +type subLevelNavItem string + +const ( + topLevelNavMotions topLevelNavItem = "motions" + topLevelNavUsers topLevelNavItem = "users" + + subLevelNavMotionsAll subLevelNavItem = "all-motions" + subLevelNavMotionsUnvoted subLevelNavItem = "unvoted-motions" + subLevelNavUsers subLevelNavItem = "users" + subLevelNavVoters subLevelNavItem = "manage-voters" +) + +const hoursInDay = 24 + +func choiceFromRequestParam(w http.ResponseWriter, r *http.Request) *models.VoteChoice { + choice, err := models.VoteChoiceFromString(chi.URLParam(r, "choice")) + if err != nil { + ClientError(w, http.StatusBadRequest) + + return nil + } + + return choice +} + +type UserAwareHandler interface { + UserModel() *models.UserModel +} + +func getVoter(ctx context.Context, w http.ResponseWriter, u UserAwareHandler, voterID int64) *models.User { + users := u.UserModel() + + voter, err := users.ByID(ctx, voterID, users.WithRoles()) + if err != nil { + panic(err) + } + + var isVoter bool + + if isVoter, err = voter.HasRole(models.RoleVoter); err != nil { + panic(err) + } + + if !isVoter { + ClientError(w, http.StatusBadRequest) + + return nil + } + + return voter +} + +func userFromRequestParam( + ctx context.Context, + w http.ResponseWriter, r *http.Request, u UserAwareHandler, options ...models.UserListOption, +) *models.User { + userID, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + ClientError(w, http.StatusBadRequest) + + return nil + } + + user, err := u.UserModel().ByID(ctx, int64(userID), options...) + if err != nil { + panic(err) + } + + if user == nil { + NotFound(w) + + return nil + } + + return user +} + +type MotionAwareHandler interface { + MotionModel() *models.MotionModel +} + +func motionFromRequestParam( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, + m MotionAwareHandler, +) *models.Motion { + withVotes := r.URL.Query().Has("showvotes") + + motion, err := m.MotionModel().ByTag(ctx, chi.URLParam(r, "tag"), withVotes) + if err != nil { + panic(err) + } + + if motion.ID == 0 { + NotFound(w) + + return nil + } + + return motion +} + +func newTemplateData( + ctx context.Context, + r *http.Request, + flash *FlashHandler, + nav topLevelNavItem, + subNav subLevelNavItem, +) *TemplateData { + user, _ := getUser(r) + + return &TemplateData{ + Request: r, + User: user, + ActiveNav: nav, + ActiveSubNav: subNav, + Flashes: flash.flashes(ctx), + CSRFToken: nosurf.Token(r), + } +} + +func (m *TemplateData) motionPaginationOptions(limit int, first, last *time.Time) error { + motions := m.Motions + + if len(motions) == limit && first.Before(motions[len(motions)-1].Proposed) { + marshaled, err := motions[len(motions)-1].Proposed.MarshalText() + if err != nil { + return fmt.Errorf("could not serialize timestamp: %w", err) + } + + m.NextPage = string(marshaled) + } + + if len(motions) > 0 && last.After(motions[0].Proposed) { + marshaled, err := motions[0].Proposed.MarshalText() + if err != nil { + return fmt.Errorf("could not serialize timestamp: %w", err) + } + + m.PrevPage = string(marshaled) + } + + return nil +} + +func calculateMotionListOptions(r *http.Request) (*models.MotionListOptions, error) { + const ( + queryParamBefore = "before" + queryParamAfter = "after" + queryParamUnvoted = "unvoted" + motionsPerPage = 10 + ) + + listOptions := &models.MotionListOptions{Limit: motionsPerPage} + + if r.URL.Query().Has(queryParamAfter) { + var after time.Time + + err := after.UnmarshalText([]byte(r.URL.Query().Get(queryParamAfter))) + if err != nil { + return nil, fmt.Errorf("could not unmarshal timestamp: %w", err) + } + + listOptions.After = &after + } else if r.URL.Query().Has(queryParamBefore) { + var before time.Time + + err := before.UnmarshalText([]byte(r.URL.Query().Get(queryParamBefore))) + if err != nil { + return nil, fmt.Errorf("could not unmarshal timestamp: %w", err) + } + + listOptions.Before = &before + } + + if r.URL.Query().Has(queryParamUnvoted) { + listOptions.UnvotedOnly = true + + voter, err := getUser(r) + if err != nil { + return nil, fmt.Errorf("could not get voter: %w", err) + } + + if voter != nil { + listOptions.VoterID = voter.ID + } + } + + return listOptions, nil +} + +func emailFromRequestParam(r *http.Request, user *models.User) (string, error) { + emailAddresses, err := user.EmailAddresses() + if err != nil { + return "", fmt.Errorf("could not get email addresses: %w", err) + } + + emailParam := chi.URLParam(r, "address") + + for _, address := range emailAddresses { + if emailParam == address { + return emailParam, nil + } + } + + return "", nil +} + +type CommonParams struct { + Flashes *FlashHandler + TemplateCache *TemplateCache + FormDecoder *form.Decoder +} + +func (c *CommonParams) decodePostForm(r *http.Request, dst forms.Form) error { + err := r.ParseForm() + if err != nil { + return fmt.Errorf("could not parse HTML form: %w", err) + } + + err = c.FormDecoder.Decode(dst, r.PostForm) + if err != nil { + var invalidDecoderError *form.InvalidDecoderError + + if errors.As(err, &invalidDecoderError) { + panic(err) + } + + return fmt.Errorf("could not decode form: %w", err) + } + + return nil +} + +type MotionHandler struct { + motions *models.MotionModel + mailNotifier *notifications.MailNotifier + jobScheduler *jobs.JobScheduler + CommonParams +} + +func (m *MotionHandler) newTemplateData(ctx context.Context, r *http.Request, subNav subLevelNavItem) *TemplateData { + return newTemplateData(ctx, r, m.Flashes, topLevelNavMotions, subNav) +} + +func (m *MotionHandler) MotionModel() *models.MotionModel { + return m.motions +} + +func (m *MotionHandler) List(w http.ResponseWriter, r *http.Request) { + var ( + listOptions *models.MotionListOptions + err error + ) + + listOptions, err = calculateMotionListOptions(r) + if err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + ctx := r.Context() + + motions, err := m.motions.List(ctx, listOptions) + if err != nil { + panic(err) + } + + first, last, err := m.motions.TimestampRange(ctx, listOptions) + if err != nil { + panic(err) + } + + templateData := m.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + + if listOptions.UnvotedOnly { + templateData.ActiveSubNav = subLevelNavMotionsUnvoted + } + + templateData.Motions = motions + + err = templateData.motionPaginationOptions(listOptions.Limit, first, last) + if err != nil { + panic(err) + } + + m.TemplateCache.render(w, http.StatusOK, "motions.html", templateData) +} + +func (m *MotionHandler) Details(w http.ResponseWriter, r *http.Request) { + motion := motionFromRequestParam(r.Context(), w, r, m) + if motion == nil { + return + } + + data := m.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + + data.Motion = motion + + m.TemplateCache.render(w, http.StatusOK, "motion.html", data) +} + +func (m *MotionHandler) NewForm(w http.ResponseWriter, r *http.Request) { + data := m.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + data.Form = &forms.EditMotionForm{ + Type: models.VoteTypeMotion, + } + + m.TemplateCache.render(w, http.StatusOK, "create_motion.html", data) +} + +func (m *MotionHandler) New(w http.ResponseWriter, r *http.Request) { + var motionForm forms.EditMotionForm + + err := m.decodePostForm(r, &motionForm) + if err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + if err := motionForm.Validate(); err != nil { + panic(err) + } + + if !motionForm.Valid() { + data := m.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + data.Form = &motionForm + + m.TemplateCache.render(w, http.StatusUnprocessableEntity, "create_motion.html", data) + + return + } + + motionForm.Normalize() + + user, err := getUser(r) + if err != nil { + ClientError(w, http.StatusUnauthorized) + + return + } + + now := time.Now().UTC() + dueDuration := time.Duration(motionForm.Due) * hoursInDay * time.Hour + + decisionID, err := m.motions.Create( + r.Context(), + user, + motionForm.Type, + motionForm.Title, + motionForm.Content, + now, + now.Add(dueDuration), + ) + if err != nil { + panic(err) + } + + decision, err := m.motions.ByID(r.Context(), decisionID) + if err != nil { + panic(err) + } + + m.mailNotifier.Notify(¬ifications.NewDecisionNotification{ + Decision: decision, + Proposer: user, + }) + + m.jobScheduler.Reschedule(jobs.JobIDCloseDecisions, jobs.JobIDRemindVoters) + + m.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashSuccess, + Title: "New motion started", + Message: fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title), + }) + + http.Redirect(w, r, "/motions/", http.StatusSeeOther) +} + +func (m *MotionHandler) EditForm(w http.ResponseWriter, r *http.Request) { + motion := motionFromRequestParam(r.Context(), w, r, m) + if motion == nil { + return + } + + data := m.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + + data.Motion = motion + + data.Form = &forms.EditMotionForm{ + Title: motion.Title, + Content: motion.Content, + Type: motion.Type, + } + + m.TemplateCache.render(w, http.StatusOK, "edit_motion.html", data) +} + +func (m *MotionHandler) Edit(w http.ResponseWriter, r *http.Request) { + motion := motionFromRequestParam(r.Context(), w, r, m) + if motion == nil { + return + } + + var motionForm forms.EditMotionForm + + err := m.decodePostForm(r, &motionForm) + if err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + if err := motionForm.Validate(); err != nil { + panic(err) + } + + if !motionForm.Valid() { + data := m.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + data.Form = &motionForm + + m.TemplateCache.render(w, http.StatusUnprocessableEntity, "edit_motion.html", data) + + return + } + + motionForm.Normalize() + + user, err := getUser(r) + if err != nil { + ClientError(w, http.StatusUnauthorized) + + return + } + + now := time.Now().UTC() + dueDuration := time.Duration(motionForm.Due) * hoursInDay * time.Hour + + err = m.motions.Update( + r.Context(), + motion.ID, + func(m *models.Motion) { + m.Type = motionForm.Type + m.Title = strings.TrimSpace(motionForm.Title) + m.Content = strings.TrimSpace(motionForm.Content) + m.Due = now.Add(dueDuration) + }, + ) + if err != nil { + panic(err) + } + + decision, err := m.motions.ByID(r.Context(), motion.ID) + if err != nil { + panic(err) + } + + m.mailNotifier.Notify(¬ifications.UpdateDecisionNotification{ + Decision: decision, + User: user, + }) + + m.jobScheduler.Reschedule(jobs.JobIDCloseDecisions, jobs.JobIDRemindVoters) + + m.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashInfo, + Title: "Motion modified", + Message: fmt.Sprintf("The motion %s has been modified!", decision.Tag), + }) + + http.Redirect(w, r, "/motions/", http.StatusSeeOther) +} + +func (m *MotionHandler) WithdrawForm(w http.ResponseWriter, r *http.Request) { + motion := motionFromRequestParam(r.Context(), w, r, m) + if motion == nil { + NotFound(w) + + return + } + + data := m.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + + data.Motion = motion + + m.TemplateCache.render(w, http.StatusOK, "withdraw_motion.html", data) +} + +func (m *MotionHandler) Withdraw(w http.ResponseWriter, r *http.Request) { + motion := motionFromRequestParam(r.Context(), w, r, m) + if motion == nil { + NotFound(w) + + return + } + + user, err := getUser(r) + if err != nil { + ClientError(w, http.StatusUnauthorized) + + return + } + + err = m.motions.Withdraw(r.Context(), motion.ID) + + if err != nil { + panic(err) + } + + m.mailNotifier.Notify(¬ifications.WithDrawMotionNotification{Motion: motion, Voter: user}) + + m.jobScheduler.Reschedule(jobs.JobIDCloseDecisions, jobs.JobIDRemindVoters) + + m.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashWarning, + Title: "Motion withdrawn", + Message: fmt.Sprintf("The motion %s has been withdrawn!", motion.Tag), + }) + + http.Redirect(w, r, "/motions/", http.StatusSeeOther) +} + +func NewMotionHandler( + motions *models.MotionModel, + mailNotifier *notifications.MailNotifier, + jobScheduler *jobs.JobScheduler, + params CommonParams, +) *MotionHandler { + return &MotionHandler{ + motions: motions, + mailNotifier: mailNotifier, + jobScheduler: jobScheduler, + CommonParams: params, + } +} + +type VoteHandler struct { + motions *models.MotionModel + users *models.UserModel + mailNotifier *notifications.MailNotifier + CommonParams +} + +func (v *VoteHandler) newTemplateData(ctx context.Context, r *http.Request, subNav subLevelNavItem) *TemplateData { + return newTemplateData(ctx, r, v.Flashes, topLevelNavMotions, subNav) +} + +func (v *VoteHandler) UserModel() *models.UserModel { + return v.users +} + +func (v *VoteHandler) MotionModel() *models.MotionModel { + return v.motions +} + +func (v *VoteHandler) VoteForm(w http.ResponseWriter, r *http.Request) { + motion := motionFromRequestParam(r.Context(), w, r, v) + if motion == nil { + NotFound(w) + + return + } + + choice := choiceFromRequestParam(w, r) + if choice == nil { + NotFound(w) + + return + } + + data := v.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + + data.Motion = motion + + data.Form = &forms.DirectVoteForm{ + Choice: choice, + } + + v.TemplateCache.render(w, http.StatusOK, "direct_vote.html", data) +} + +func (v *VoteHandler) Vote(w http.ResponseWriter, r *http.Request) { + motion := motionFromRequestParam(r.Context(), w, r, v) + if motion == nil { + return + } + + choice := choiceFromRequestParam(w, r) + if choice == nil { + return + } + + user, err := getUser(r) + if err != nil { + ClientError(w, http.StatusUnauthorized) + + return + } + + clientCert, err := getPEMClientCert(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, http.StatusText(http.StatusInternalServerError)) + + return + } + + if err := v.motions.UpdateVote(r.Context(), user.ID, motion.ID, func(v *models.Vote) { + v.Vote = choice + v.Voted = time.Now().UTC() + v.Notes = fmt.Sprintf("Direct Vote\n\n%s", clientCert) + }); err != nil { + panic(err) + } + + v.mailNotifier.Notify(¬ifications.DirectVoteNotification{ + Decision: motion, User: user, Choice: choice, + }) + + v.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashSuccess, + Title: "Vote registered", + Message: fmt.Sprintf("Your vote for motion %s has been registered.", motion.Tag), + }) + + http.Redirect(w, r, "/motions/", http.StatusSeeOther) +} + +func (v *VoteHandler) ProxyVoteForm(w http.ResponseWriter, r *http.Request) { + motion := motionFromRequestParam(r.Context(), w, r, v) + if motion == nil { + return + } + + data := v.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + + data.Motion = motion + + potentialVoters, err := v.users.Voters(r.Context()) + if err != nil { + panic(err) + } + + data.Form = &forms.ProxyVoteForm{ + Voters: potentialVoters, + } + + v.TemplateCache.render(w, http.StatusOK, "proxy_vote.html", data) +} + +func (v *VoteHandler) ProxyVote(w http.ResponseWriter, r *http.Request) { + motion := motionFromRequestParam(r.Context(), w, r, v) + if motion == nil { + return + } + + var voteForm forms.ProxyVoteForm + + err := v.decodePostForm(r, &voteForm) + if err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + if err := voteForm.Validate(); err != nil { + panic(err) + } + + if !voteForm.Valid() { + data := v.newTemplateData(r.Context(), r, subLevelNavMotionsAll) + data.Motion = motion + + potentialVoters, err := v.users.Voters(r.Context()) + if err != nil { + panic(err) + } + + voteForm.Voters = potentialVoters + data.Form = &voteForm + + v.TemplateCache.render(w, http.StatusUnprocessableEntity, "proxy_vote.html", data) + + return + } + + voteForm.Normalize() + + user, err := getUser(r) + if err != nil { + ClientError(w, http.StatusUnauthorized) + + return + } + + clientCert, err := getPEMClientCert(r) + if err != nil { + panic(err) + } + + voter := getVoter(r.Context(), w, v, voteForm.Voter.ID) + if voter == nil { + return + } + + if err := v.motions.UpdateVote(r.Context(), voteForm.Voter.ID, motion.ID, func(v *models.Vote) { + v.Vote = voteForm.Choice + v.Voted = time.Now().UTC() + v.Notes = fmt.Sprintf("Proxy-Vote by %s\n\n%s\n\n%s", user.Name, voteForm.Justification, clientCert) + }); err != nil { + panic(err) + } + + v.mailNotifier.Notify(¬ifications.ProxyVoteNotification{ + Decision: motion, User: user, Voter: voter, Choice: voteForm.Choice, Justification: voteForm.Justification, + }) + + v.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashSuccess, + Title: "Proxy vote registered", + Message: fmt.Sprintf( + "Your proxy vote for %s for motion %s has been registered.", + voter.Name, + motion.Tag, + ), + }) + + http.Redirect(w, r, "/motions/", http.StatusSeeOther) +} + +func NewVoteHandler( + motions *models.MotionModel, + users *models.UserModel, + mailNotifier *notifications.MailNotifier, + params CommonParams, +) *VoteHandler { + return &VoteHandler{motions: motions, users: users, mailNotifier: mailNotifier, CommonParams: params} +} + +type UserHandler struct { + users *models.UserModel + CommonParams +} + +func (u *UserHandler) UserModel() *models.UserModel { + return u.users +} + +func (u *UserHandler) newTemplateData(ctx context.Context, r *http.Request, subNav subLevelNavItem) *TemplateData { + return newTemplateData(ctx, r, u.Flashes, topLevelNavUsers, subNav) +} + +func (u *UserHandler) deleteEmailParams( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, +) (*models.User, string, error) { + userToEdit := userFromRequestParam(ctx, w, r, u, u.users.WithEmailAddresses()) + if userToEdit == nil { + return nil, "", nil + } + + emailAddress, err := emailFromRequestParam(r, userToEdit) + if err != nil { + return nil, "", err + } + + return userToEdit, emailAddress, nil +} + +func (u *UserHandler) List(w http.ResponseWriter, r *http.Request) { + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + users, err := u.users.List( + r.Context(), + u.users.WithRoles(), + u.users.CanDelete(), + ) + if err != nil { + panic(err) + } + + data.Users = users + + u.TemplateCache.render(w, http.StatusOK, "users.html", data) +} + +func (u *UserHandler) CreateForm(w http.ResponseWriter, r *http.Request) { + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + data.Form = &forms.NewUserForm{} + + u.TemplateCache.render(w, http.StatusOK, "create_user.html", data) +} + +func (u *UserHandler) Create(w http.ResponseWriter, r *http.Request) { + admin, err := getUser(r) + if err != nil { + ClientError(w, http.StatusUnauthorized) + + return + } + + var userForm forms.NewUserForm + + if err := u.decodePostForm(r, &userForm); err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + if err := userForm.Validate(); err != nil { + panic(err) + } + + if !userForm.Valid() { + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + data.Form = &userForm + + u.TemplateCache.render(w, http.StatusUnprocessableEntity, "create_user.html", data) + + return + } + + userForm.Normalize() + + createUserParams := &models.CreateUserParams{ + Admin: admin, + Name: userForm.Name, + Reminder: userForm.EmailAddress, + Emails: []string{userForm.EmailAddress}, + Reasoning: userForm.Reasoning, + } + + userID, err := u.users.Create(r.Context(), createUserParams) + if err != nil { + panic(err) + } + + u.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashSuccess, + Title: "User created", + Message: fmt.Sprintf("Created new user %s", userForm.Name), + }) + + http.Redirect(w, r, fmt.Sprintf("/users/%d", userID), http.StatusSeeOther) +} + +func (u *UserHandler) EditForm(w http.ResponseWriter, r *http.Request) { + userToEdit := userFromRequestParam(r.Context(), w, r, u, u.users.WithRoles(), u.users.WithEmailAddresses()) + if userToEdit == nil { + return + } + + roles, err := userToEdit.Roles() + if err != nil { + panic(err) + } + + emailAddresses, err := userToEdit.EmailAddresses() + if err != nil { + panic(err) + } + + roleNames := make([]string, len(roles)) + for i := range roles { + roleNames[i] = roles[i].Name + } + + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + data.Form = &forms.EditUserForm{ + User: userToEdit, + Name: userToEdit.Name, + MailAddresses: emailAddresses, + AllRoles: models.AllRoles, + ReminderMail: userToEdit.Reminder.String, + Roles: roleNames, + } + + u.TemplateCache.render(w, http.StatusOK, "edit_user.html", data) +} + +func (u *UserHandler) Edit(w http.ResponseWriter, r *http.Request) { + userToEdit := userFromRequestParam(r.Context(), w, r, u, u.users.WithRoles(), u.users.WithEmailAddresses()) + if userToEdit == nil { + NotFound(w) + + return + } + + var userForm forms.EditUserForm + + if err := u.decodePostForm(r, &userForm); err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + userForm.User = userToEdit + userForm.AllRoles = models.AllRoles + + if err := userForm.Validate(); err != nil { + panic(err) + } + + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + if !userForm.Valid() { + roles, err := userToEdit.Roles() + if err != nil { + panic(err) + } + + emailAddresses, err := userToEdit.EmailAddresses() + if err != nil { + panic(err) + } + + roleNames := make([]string, len(roles)) + for i := range roles { + roleNames[i] = roles[i].Name + } + + userForm.MailAddresses = emailAddresses + userForm.Roles = roleNames + + data.Form = &userForm + + u.TemplateCache.render(w, http.StatusUnprocessableEntity, "edit_user.html", data) + + return + } + + userForm.Normalize() + userForm.UpdateUser(userToEdit) + + if err := u.users.EditUser(r.Context(), userToEdit, data.User, userForm.Roles, userForm.Reasoning); err != nil { + panic(err) + } + + u.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashInfo, + Title: "User modified", + Message: fmt.Sprintf("User %s has been modified.", userToEdit.Name), + }) + + http.Redirect(w, r, "/users/", http.StatusSeeOther) +} + +func (u *UserHandler) AddEmailForm(w http.ResponseWriter, r *http.Request) { + userToEdit := userFromRequestParam(r.Context(), w, r, u, u.users.WithEmailAddresses()) + if userToEdit == nil { + NotFound(w) + + return + } + + emailAddresses, err := userToEdit.EmailAddresses() + if err != nil { + panic(err) + } + + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + data.Form = &forms.AddEmailForm{ + User: userToEdit, + EmailAddresses: emailAddresses, + } + + u.TemplateCache.render(w, http.StatusOK, "add_email.html", data) +} + +func (u *UserHandler) AddEmail(w http.ResponseWriter, r *http.Request) { + userToEdit := userFromRequestParam(r.Context(), w, r, u, u.users.WithEmailAddresses()) + if userToEdit == nil { + NotFound(w) + + return + } + + var emailForm forms.AddEmailForm + + if err := u.decodePostForm(r, &emailForm); err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + emailForm.User = userToEdit + + if err := emailForm.Validate(); err != nil { + panic(err) + } + + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + if emailForm.Valid() { + emailExists, err := u.users.EmailExists(r.Context(), emailForm.EmailAddress) + if err != nil { + panic(err) + } + + if emailExists { + emailForm.FieldErrors = map[string]string{ + "email_address": "Email address must be unique", + } + } + } + + if !emailForm.Valid() { + emailAddresses, err := userToEdit.EmailAddresses() + if err != nil { + panic(err) + } + + emailForm.EmailAddresses = emailAddresses + + data.Form = &emailForm + + u.TemplateCache.render(w, http.StatusUnprocessableEntity, "add_email.html", data) + + return + } + + emailForm.Normalize() + + if err := u.users.AddEmail( + r.Context(), + userToEdit, + data.User, + emailForm.EmailAddress, + emailForm.Reasoning, + ); err != nil { + panic(err) + } + + u.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashSuccess, + Title: "Email address added", + Message: fmt.Sprintf( + "Added email address %s for user %s", + emailForm.EmailAddress, + userToEdit.Name, + ), + }) + + http.Redirect(w, r, fmt.Sprintf("/users/%d/", userToEdit.ID), http.StatusSeeOther) +} + +func (u *UserHandler) DeleteEmailForm(w http.ResponseWriter, r *http.Request) { + userToEdit, emailAddress, err := u.deleteEmailParams(r.Context(), w, r) + if err != nil { + panic(err) + } + + if userToEdit == nil || emailAddress == "" { + NotFound(w) + + return + } + + if userToEdit.Reminder.String == emailAddress { + // delete of reminder address should not happen + ClientError(w, http.StatusBadRequest) + + return + } + + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + data.Form = &forms.DeleteEmailForm{ + User: userToEdit, + EmailAddress: emailAddress, + } + + u.TemplateCache.render(w, http.StatusOK, "delete_email.html", data) +} + +func (u *UserHandler) DeleteEmail(w http.ResponseWriter, r *http.Request) { + userToEdit, emailAddress, err := u.deleteEmailParams(r.Context(), w, r) + if err != nil { + panic(err) + } + + if userToEdit == nil || emailAddress == "" { + NotFound(w) + + return + } + + if userToEdit.Reminder.String == emailAddress { + // delete of reminder address should not happen + ClientError(w, http.StatusBadRequest) + + return + } + + admin, err := getUser(r) + if err != nil { + ClientError(w, http.StatusUnauthorized) + + return + } + + var deleteEmailForm forms.DeleteEmailForm + + if err := u.decodePostForm(r, &deleteEmailForm); err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + if err := deleteEmailForm.Validate(); err != nil { + panic(err) + } + + if !deleteEmailForm.Valid() { + deleteEmailForm.EmailAddress = emailAddress + deleteEmailForm.User = userToEdit + + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + data.Form = &deleteEmailForm + + u.TemplateCache.render(w, http.StatusUnprocessableEntity, "delete_email.html", data) + + return + } + + deleteEmailForm.Normalize() + + if err := u.users.DeleteEmail(r.Context(), userToEdit, admin, emailAddress, deleteEmailForm.Reasoning); err != nil { + panic(err) + } + + u.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashWarning, + Title: "Email address deleted", + Message: fmt.Sprintf( + "Deleted email address %s of user %s", + deleteEmailForm.EmailAddress, + userToEdit.Name, + ), + }) + + http.Redirect(w, r, fmt.Sprintf("/users/%d/", userToEdit.ID), http.StatusSeeOther) +} + +func (u *UserHandler) DeleteForm(w http.ResponseWriter, r *http.Request) { + userToDelete := userFromRequestParam(r.Context(), w, r, u, u.users.CanDelete()) + if userToDelete == nil { + return + } + + if !userToDelete.CanDelete() { + NotFound(w) + + return + } + + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + data.Form = &forms.DeleteUserForm{ + User: userToDelete, + } + + u.TemplateCache.render(w, http.StatusOK, "delete_user.html", data) +} + +func (u *UserHandler) Delete(w http.ResponseWriter, r *http.Request) { + userToDelete := userFromRequestParam(r.Context(), w, r, u, u.users.CanDelete()) + if userToDelete == nil { + return + } + + if !userToDelete.CanDelete() { + NotFound(w) + + return + } + + var deleteUserForm forms.DeleteUserForm + + err := u.decodePostForm(r, &deleteUserForm) + if err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + deleteUserForm.User = userToDelete + + if err := deleteUserForm.Validate(); err != nil { + panic(err) + } + + data := u.newTemplateData(r.Context(), r, subLevelNavUsers) + + if !deleteUserForm.Valid() { + data.Form = &deleteUserForm + + u.TemplateCache.render(w, http.StatusUnprocessableEntity, "delete_user.html", data) + + return + } + + deleteUserForm.Normalize() + + if err = u.users.DeleteUser(r.Context(), userToDelete, data.User, deleteUserForm.Reasoning); err != nil { + panic(err) + } + + u.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashWarning, + Title: "User deleted", + Message: fmt.Sprintf("User %s has been deleted.", userToDelete.Name), + }) + + http.Redirect(w, r, "/users/", http.StatusSeeOther) +} + +func (u *UserHandler) ChangeVotersForm(w http.ResponseWriter, r *http.Request) { + data := u.newTemplateData(r.Context(), r, subLevelNavVoters) + + allUsers, err := u.users.List(r.Context(), u.users.WithRoles()) + if err != nil { + panic(err) + } + + voterIDs := make([]int64, 0) + + for _, user := range allUsers { + isVoter, err := user.HasRole(models.RoleVoter) + if err != nil { + panic(err) + } + + if isVoter { + voterIDs = append(voterIDs, user.ID) + + continue + } + } + + data.Form = &forms.ChooseVoterForm{ + Users: allUsers, + VoterIDs: voterIDs, + } + + u.TemplateCache.render(w, http.StatusOK, "choose_voters.html", data) +} + +func (u *UserHandler) ChangeVoters(w http.ResponseWriter, r *http.Request) { + admin, err := getUser(r) + if err != nil { + ClientError(w, http.StatusUnauthorized) + + return + } + + var voterForm forms.ChooseVoterForm + + if err = u.decodePostForm(r, &voterForm); err != nil { + ClientError(w, http.StatusBadRequest) + + return + } + + if err = voterForm.Validate(); err != nil { + panic(err) + } + + if !voterForm.Valid() { + data := u.newTemplateData(r.Context(), r, subLevelNavVoters) + + allUsers, err := u.users.List(r.Context(), u.users.WithRoles()) + if err != nil { + panic(err) + } + + voterForm.Users = allUsers + + data.Form = &voterForm + + u.TemplateCache.render(w, http.StatusUnprocessableEntity, "choose_voters.html", data) + + return + } + + voterForm.Normalize() + + if err = u.users.ChooseVoters(r.Context(), admin, voterForm.VoterIDs, voterForm.Reasoning); err != nil { + panic(err) + } + + u.Flashes.addFlash(r.Context(), &FlashMessage{ + Variant: flashSuccess, + Title: "Voters selected", + Message: "A new list of voters has been selected", + }) + + http.Redirect(w, r, "/users/", http.StatusSeeOther) +} + +func NewUserHandler( + users *models.UserModel, + params CommonParams, +) *UserHandler { + return &UserHandler{users: users, CommonParams: params} +} + +type HealthCheck struct { + mailNotifier *notifications.MailNotifier + motions *models.MotionModel +} + +func NewHealthCheck(notifier *notifications.MailNotifier, motions *models.MotionModel) *HealthCheck { + return &HealthCheck{mailNotifier: notifier, motions: motions} +} + +func (h *HealthCheck) ServeHTTP(w http.ResponseWriter, _ *http.Request) { + const ( + ok = "OK" + failed = "FAILED" + ) + + response := struct { + 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") + + var ( + err error + hasErrors = false + ) + + if err = h.mailNotifier.Ping(); err != nil { + hasErrors = true + + response.Mail = failed + } + + if err = h.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/internal/handlers/handlers_test.go similarity index 70% rename from cmd/boardvoting/handlers_test.go rename to internal/handlers/handlers_test.go index 6259b98..682ae01 100644 --- a/cmd/boardvoting/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -15,11 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -package main +package handlers import ( "database/sql" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -32,22 +33,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "git.cacert.org/cacert-boardvoting/internal/notifications" + "git.cacert.org/cacert-boardvoting/internal/models" ) -func prepareTestDb(t *testing.T) *sqlx.DB { - t.Helper() - - testDir := t.TempDir() - - db, err := sql.Open("sqlite3", path.Join(testDir, "test.sqlite")) - require.NoError(t, err) - - dbx := sqlx.NewDb(db, "sqlite3") - - return dbx -} - func StartTestTCPServer(t *testing.T) int { t.Helper() @@ -82,7 +72,7 @@ func StartTestTCPServer(t *testing.T) int { return port } -func TestApplication_healthCheck(t *testing.T) { +func TestHealthCheck_ServeHTTP(t *testing.T) { port := StartTestTCPServer(t) t.Run("check with valid DB", func(t *testing.T) { @@ -93,18 +83,18 @@ func TestApplication_healthCheck(t *testing.T) { testDB := prepareTestDb(t) - app := &application{ - motions: &models.MotionModel{DB: testDB}, - mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second}, - } - - app.NewMailNotifier() + notifier := notifications.NewMailNotifier(¬ifications.MailConfig{ + SMTPHost: "localhost", + SMTPPort: port, + SMTPTimeOut: 1 * time.Second, + }) - app.healthCheck(rr, r) + hc := NewHealthCheck(notifier, &models.MotionModel{DB: testDB}) + hc.ServeHTTP(rr, r) - rs := rr.Result() //nolint:bodyclose // linters bug + rs := rr.Result() - defer func() { _ = rs.Body.Close() }() + defer func(Body io.Closer) { _ = Body.Close() }(rs.Body) assert.Equal(t, http.StatusOK, rs.StatusCode) }) @@ -124,18 +114,18 @@ func TestApplication_healthCheck(t *testing.T) { _ = db.Close() - app := &application{ - motions: &models.MotionModel{DB: testDB}, - mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second}, - } - - app.NewMailNotifier() + notifier := notifications.NewMailNotifier(¬ifications.MailConfig{ + SMTPHost: "localhost", + SMTPPort: port, + SMTPTimeOut: 1 * time.Second, + }) - app.healthCheck(rr, r) + hc := NewHealthCheck(notifier, &models.MotionModel{DB: testDB}) + hc.ServeHTTP(rr, r) - rs := rr.Result() //nolint:bodyclose // linters bug + rs := rr.Result() - defer func() { _ = rs.Body.Close() }() + defer func(Body io.Closer) { _ = Body.Close() }(rs.Body) assert.Equal(t, http.StatusInternalServerError, rs.StatusCode) }) diff --git a/cmd/boardvoting/middleware.go b/internal/handlers/middleware.go similarity index 64% rename from cmd/boardvoting/middleware.go rename to internal/handlers/middleware.go index 0ecd204..3477bb1 100644 --- a/cmd/boardvoting/middleware.go +++ b/internal/handlers/middleware.go @@ -15,13 +15,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -package main +package handlers import ( + "bytes" "context" "crypto/x509" + "encoding/pem" "errors" "fmt" + "log" "net/http" "strings" @@ -37,7 +40,7 @@ const ( ctxAuthenticatedCert ) -func secureHeaders(next http.Handler) http.Handler { +func SecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'self'; font-src 'self' data:") w.Header().Set("Referrer-Policy", "origin-when-cross-origin") @@ -50,7 +53,16 @@ func secureHeaders(next http.Handler) http.Handler { }) } -func (app *application) authenticateRequest( +type UserMiddleware struct { + users *models.UserModel + errorLog *log.Logger +} + +func NewUserMiddleware(users *models.UserModel, errorLog *log.Logger) *UserMiddleware { + return &UserMiddleware{users: users, errorLog: errorLog} +} + +func (m *UserMiddleware) AuthenticateRequest( ctx context.Context, r *http.Request, ) (*models.User, *x509.Certificate, error) { @@ -81,7 +93,7 @@ func (app *application) authenticateRequest( emails := clientCert.EmailAddresses - user, err := app.users.ByEmails(ctx, emails) + user, err := m.users.ByEmails(ctx, emails) if err != nil { return nil, nil, fmt.Errorf("could not get user information from database: %w", err) } @@ -89,9 +101,9 @@ func (app *application) authenticateRequest( return user, clientCert, nil } -func (app *application) tryAuthenticate(next http.Handler) http.Handler { +func (m *UserMiddleware) TryAuthenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, cert, err := app.authenticateRequest(r.Context(), r) + user, cert, err := m.AuthenticateRequest(r.Context(), r) if err != nil { panic(err) } @@ -111,22 +123,8 @@ func (app *application) tryAuthenticate(next http.Handler) http.Handler { }) } -func (app *application) GetUser(r *http.Request) (*models.User, error) { - user := r.Context().Value(ctxUser) - if user == nil { - return nil, errors.New("no user in context") - } - - result, ok := user.(*models.User) - if !ok { - return nil, fmt.Errorf("%v is not a user", user) - } - - return result, nil -} - -func (app *application) HasRole(r *http.Request, roles ...models.RoleName) (bool, bool, error) { - user, err := app.GetUser(r) +func (m *UserMiddleware) HasRole(r *http.Request, roles ...models.RoleName) (bool, bool, error) { + user, err := getUser(r) if err != nil { return false, false, err } @@ -146,7 +144,7 @@ func (app *application) HasRole(r *http.Request, roles ...models.RoleName) (bool roleNames[idx] = string(roles[idx]) } - app.errorLog.Printf( + m.errorLog.Printf( "user %s does not have any of the required role(s) %s assigned", user.Name, strings.Join(roleNames, ", "), @@ -158,21 +156,21 @@ func (app *application) HasRole(r *http.Request, roles ...models.RoleName) (bool return true, true, nil } -func (app *application) requireRole(next http.Handler, roles ...models.RoleName) http.Handler { +func (m *UserMiddleware) 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 := m.HasRole(r, roles...) if err != nil { panic(err) } if !hasUser { - app.clientError(w, http.StatusUnauthorized) + ClientError(w, http.StatusUnauthorized) return } if !hasRole { - app.clientError(w, http.StatusForbidden) + ClientError(w, http.StatusForbidden) return } @@ -181,19 +179,19 @@ func (app *application) requireRole(next http.Handler, roles ...models.RoleName) }) } -func (app *application) userCanVote(next http.Handler) http.Handler { - return app.requireRole(next, models.RoleVoter) +func (m *UserMiddleware) UserCanVote(next http.Handler) http.Handler { + return m.requireRole(next, models.RoleVoter) } -func (app *application) userCanEditVote(next http.Handler) http.Handler { - return app.requireRole(next, models.RoleVoter) +func (m *UserMiddleware) UserCanEditVote(next http.Handler) http.Handler { + return m.requireRole(next, models.RoleVoter) } -func (app *application) canManageUsers(next http.Handler) http.Handler { - return app.requireRole(next, models.RoleSecretary, models.RoleAdmin) +func (m *UserMiddleware) CanManageUsers(next http.Handler) http.Handler { + return m.requireRole(next, models.RoleSecretary, models.RoleAdmin) } -func noSurf(next http.Handler) http.Handler { +func NoSurf(next http.Handler) http.Handler { csrfHandler := nosurf.New(next) csrfHandler.SetBaseCookie(http.Cookie{ HttpOnly: true, @@ -204,3 +202,35 @@ func noSurf(next http.Handler) http.Handler { return csrfHandler } + +func getUser(r *http.Request) (*models.User, error) { + user := r.Context().Value(ctxUser) + if user == nil { + return nil, errors.New("no user in context") + } + + result, ok := user.(*models.User) + if !ok { + return nil, fmt.Errorf("%v is not a user", user) + } + + return result, nil +} + +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 +} diff --git a/cmd/boardvoting/middleware_test.go b/internal/handlers/middleware_test.go similarity index 89% rename from cmd/boardvoting/middleware_test.go rename to internal/handlers/middleware_test.go index f9d77c6..90e9fb9 100644 --- a/cmd/boardvoting/middleware_test.go +++ b/internal/handlers/middleware_test.go @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package main +package handlers import ( "context" @@ -26,8 +26,10 @@ import ( "net/http" "net/http/httptest" "os" + "path" "testing" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,6 +37,19 @@ import ( "git.cacert.org/cacert-boardvoting/internal/models" ) +func prepareTestDb(t *testing.T) *sqlx.DB { + t.Helper() + + testDir := t.TempDir() + + db, err := sql.Open("sqlite3", path.Join(testDir, "test.sqlite")) + require.NoError(t, err) + + dbx := sqlx.NewDb(db, "sqlite3") + + return dbx +} + func Test_secureHeaders(t *testing.T) { rr := httptest.NewRecorder() @@ -45,7 +60,7 @@ func Test_secureHeaders(t *testing.T) { _, _ = w.Write([]byte("OK")) }) - secureHeaders(next).ServeHTTP(rr, r) + SecureHeaders(next).ServeHTTP(rr, r) rs := rr.Result() //nolint:bodyclose // linters bug @@ -92,7 +107,7 @@ func TestApplication_tryAuthenticate(t *testing.T) { require.NoError(t, err) - app := application{ + mw := UserMiddleware{ users: &models.UserModel{DB: db}, } @@ -102,7 +117,7 @@ func TestApplication_tryAuthenticate(t *testing.T) { r, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) - app.tryAuthenticate(next).ServeHTTP(rr, r) + mw.TryAuthenticate(next).ServeHTTP(rr, r) rs := rr.Result() //nolint:bodyclose // linters bug @@ -120,7 +135,7 @@ func TestApplication_tryAuthenticate(t *testing.T) { r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{}} - app.tryAuthenticate(next).ServeHTTP(rr, r) + mw.TryAuthenticate(next).ServeHTTP(rr, r) rs := rr.Result() //nolint:bodyclose // linters bug @@ -141,7 +156,7 @@ func TestApplication_tryAuthenticate(t *testing.T) { ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, }}} - app.tryAuthenticate(next).ServeHTTP(rr, r) + mw.TryAuthenticate(next).ServeHTTP(rr, r) rs := rr.Result() //nolint:bodyclose // linters bug diff --git a/internal/handlers/templatecache.go b/internal/handlers/templatecache.go new file mode 100644 index 0000000..629df77 --- /dev/null +++ b/internal/handlers/templatecache.go @@ -0,0 +1,122 @@ +/* +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 handlers + +import ( + "bytes" + "fmt" + "html/template" + "io/fs" + "net/http" + "path/filepath" + "strings" + + "github.com/Masterminds/sprig/v3" + + "git.cacert.org/cacert-boardvoting/internal/forms" + "git.cacert.org/cacert-boardvoting/internal/models" + "git.cacert.org/cacert-boardvoting/ui" +) + +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) + } + + return hasRole, nil +} + +type TemplateData struct { + PrevPage string + NextPage string + Motion *models.Motion + Motions []*models.Motion + User *models.User + Users []*models.User + Request *http.Request + Flashes []FlashMessage + Form forms.Form + ActiveNav topLevelNavItem + ActiveSubNav subLevelNavItem + CSRFToken string +} + +type TemplateCache struct { + cache map[string]*template.Template +} + +func NewTemplateCache() (*TemplateCache, error) { + cache := map[string]*template.Template{} + + pages, err := fs.Glob(ui.Files, "html/pages/*.html") + if err != nil { + return nil, fmt.Errorf("could not find page templates: %w", err) + } + + 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["canManageUsers"] = func(v *models.User) (bool, error) { + return checkRole(v, models.RoleSecretary, models.RoleAdmin) + } + funcMaps["canVote"] = func(v *models.User) (bool, error) { + return checkRole(v, models.RoleVoter) + } + funcMaps["canStartVote"] = func(v *models.User) (bool, error) { + return checkRole(v, models.RoleVoter) + } + + for _, page := range pages { + name := filepath.Base(page) + + ts, err := template.New("").Funcs(funcMaps).ParseFS( + ui.Files, + "html/base.html", + "html/partials/*.html", + page, + ) + if err != nil { + return nil, fmt.Errorf("could not parse base template: %w", err) + } + + cache[name] = ts + } + + return &TemplateCache{cache: cache}, nil +} + +func (c *TemplateCache) render(w http.ResponseWriter, status int, page string, data *TemplateData) { + ts, ok := c.cache[page] + if !ok { + panic(fmt.Sprintf("the template %s does not exist", page)) + } + + buf := new(bytes.Buffer) + + err := ts.ExecuteTemplate(buf, "base", data) + if err != nil { + panic(err) + } + + w.WriteHeader(status) + + _, _ = buf.WriteTo(w) +} diff --git a/internal/jobs/closedecisions.go b/internal/jobs/closedecisions.go new file mode 100644 index 0000000..209fa85 --- /dev/null +++ b/internal/jobs/closedecisions.go @@ -0,0 +1,139 @@ +/* +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 jobs + +import ( + "context" + "log" + "os" + "time" + + "git.cacert.org/cacert-boardvoting/internal/models" + "git.cacert.org/cacert-boardvoting/internal/notifications" +) + +type CloseDecisionsJob struct { + timer *time.Timer + infoLog *log.Logger + errorLog *log.Logger + decisions *models.MotionModel + notifier *notifications.MailNotifier +} + +func (c *CloseDecisionsJob) Identifier() JobIdentifier { + return JobIDCloseDecisions +} + +func (c *CloseDecisionsJob) Schedule() { + var ( + nextDue *time.Time + err error + ) + + ctx := context.Background() + + nextDue, err = c.decisions.NextPendingDue(ctx, time.Now().UTC()) + if err != nil { + c.errorLog.Printf("could not get next pending due date") + + c.Stop() + + return + } + + if nextDue == nil { + c.infoLog.Printf("no next planned execution of CloseDecisionsJob") + c.Stop() + + return + } + + c.infoLog.Printf("scheduling CloseDecisionsJob for %s", nextDue) + when := time.Until(nextDue.Add(time.Second)) + + if c.timer == nil { + c.timer = time.AfterFunc(when, c.Run) + + return + } + + c.timer.Reset(when) +} + +func (c *CloseDecisionsJob) Run() { + c.infoLog.Printf("running CloseDecisionsJob") + + defer func(c *CloseDecisionsJob) { c.Schedule() }(c) + + c.RunExpired() +} + +func (c *CloseDecisionsJob) RunExpired() { + results, err := c.decisions.CloseDecisions(context.Background()) + if err != nil { + c.errorLog.Printf("closing decisions failed: %v", err) + } + + for _, res := range results { + c.infoLog.Printf( + "decision %s closed with result %s: reasoning '%s'", + res.Tag, + res.Status, + res.Reasoning, + ) + + c.notifier.Notify(¬ifications.ClosedDecisionNotification{Decision: res}) + } +} + +func (c *CloseDecisionsJob) Stop() { + if c.timer != nil { + c.timer.Stop() + c.timer = nil + } +} + +type CloseDecisionsOption func(job *CloseDecisionsJob) + +func NewCloseDecisionsJob( + decisions *models.MotionModel, + mailNotifier *notifications.MailNotifier, + opts ...CloseDecisionsOption, +) Job { + j := &CloseDecisionsJob{ + infoLog: log.New(os.Stdout, "", 0), + errorLog: log.New(os.Stderr, "", 0), + decisions: decisions, + notifier: mailNotifier, + } + + for _, o := range opts { + o(j) + } + + j.RunExpired() + + return j +} + +func CloseDecisionsLog(infoLog, errorLog *log.Logger) CloseDecisionsOption { + return func(j *CloseDecisionsJob) { + j.infoLog = infoLog + j.errorLog = errorLog + } +} diff --git a/internal/jobs/doc.go b/internal/jobs/doc.go new file mode 100644 index 0000000..2868937 --- /dev/null +++ b/internal/jobs/doc.go @@ -0,0 +1,19 @@ +/* +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 jobs is providing implementations for background jobs. +package jobs diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go new file mode 100644 index 0000000..55acd87 --- /dev/null +++ b/internal/jobs/jobs.go @@ -0,0 +1,34 @@ +/* +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 jobs + +const hoursInDay = 24 + +type JobIdentifier int + +const ( + JobIDCloseDecisions JobIdentifier = iota + JobIDRemindVoters +) + +type Job interface { + Schedule() + Run() + Stop() + Identifier() JobIdentifier +} diff --git a/internal/jobs/remindvoters.go b/internal/jobs/remindvoters.go new file mode 100644 index 0000000..f893157 --- /dev/null +++ b/internal/jobs/remindvoters.go @@ -0,0 +1,162 @@ +/* +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 jobs + +import ( + "context" + "log" + "os" + "time" + + "git.cacert.org/cacert-boardvoting/internal/models" + "git.cacert.org/cacert-boardvoting/internal/notifications" +) + +type RemindVotersJob struct { + infoLog, errorLog *log.Logger + timer *time.Timer + voters *models.UserModel + decisions *models.MotionModel + notifier *notifications.MailNotifier +} + +func (r *RemindVotersJob) Identifier() JobIdentifier { + return JobIDRemindVoters +} + +func (r *RemindVotersJob) Schedule() { + const reminderDays = 3 + + now := time.Now().UTC() + + year, month, day := now.Date() + + nextPotentialRun := time.Date(year, month, day+1, 0, 0, 0, 0, time.UTC) + nextPotentialRun.Add(hoursInDay * time.Hour) + + relevantDue := nextPotentialRun.Add(reminderDays * hoursInDay * time.Hour) + + due, err := r.decisions.NextPendingDue(context.Background(), relevantDue) + if err != nil { + r.errorLog.Printf("could not fetch next due date: %v", err) + } + + if due == nil { + r.infoLog.Printf("no due motions after relevant due date %s, not scheduling ReminderJob", relevantDue) + + return + } + + remindNext := due.Add(-reminderDays * hoursInDay * time.Hour).UTC() + + year, month, day = remindNext.Date() + + potentialRun := time.Date(year, month, day, 0, 0, 0, 0, time.UTC) + + if potentialRun.Before(time.Now().UTC()) { + r.infoLog.Printf("potential reminder time %s is in the past, not scheduling ReminderJob", potentialRun) + + return + } + + r.infoLog.Printf("scheduling RemindVotersJob for %s", potentialRun) + + when := time.Until(potentialRun) + + if r.timer != nil { + r.timer.Reset(when) + + return + } + + r.timer = time.AfterFunc(when, r.Run) +} + +func (r *RemindVotersJob) Run() { + r.infoLog.Print("running RemindVotersJob") + + defer func(r *RemindVotersJob) { r.Schedule() }(r) + + var ( + voters []*models.User + decisions []*models.Motion + err error + ) + + ctx := context.Background() + + voters, err = r.voters.ReminderVoters(ctx) + if err != nil { + r.errorLog.Printf("problem getting voters: %v", err) + + return + } + + for _, voter := range voters { + v := voter + + decisions, err = r.decisions.UnvotedForVoter(ctx, v) + if err != nil { + r.errorLog.Printf("problem getting unvoted decisions: %v", err) + + return + } + + if len(decisions) > 0 { + r.notifier.Notify(¬ifications.RemindVoterNotification{Voter: voter, Decisions: decisions}) + } + } +} + +func (r *RemindVotersJob) Stop() { + if r.timer != nil { + r.timer.Stop() + + r.timer = nil + } +} + +type RemindVotersOption func(job *RemindVotersJob) + +func NewRemindVoters( + voters *models.UserModel, + decisions *models.MotionModel, + notifier *notifications.MailNotifier, + opts ...RemindVotersOption, +) Job { + j := &RemindVotersJob{ + voters: voters, + decisions: decisions, + notifier: notifier, + infoLog: log.New(os.Stdout, "", 0), + errorLog: log.New(os.Stderr, "", 0), + } + + for _, o := range opts { + o(j) + } + + return j +} + +func RemindVotersLog(infoLog, errorLog *log.Logger) RemindVotersOption { + return func(r *RemindVotersJob) { + r.infoLog = infoLog + r.errorLog = errorLog + } +} diff --git a/internal/jobs/scheduler.go b/internal/jobs/scheduler.go new file mode 100644 index 0000000..6f487b5 --- /dev/null +++ b/internal/jobs/scheduler.go @@ -0,0 +1,93 @@ +/* +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 jobs + +import ( + "log" + "os" +) + +type JobScheduler struct { + infoLogger *log.Logger + errorLogger *log.Logger + jobs map[JobIdentifier]Job + rescheduleChannel chan JobIdentifier + quitChannel chan struct{} +} + +type SchedulerOption func(scheduler *JobScheduler) + +func NewJobScheduler(opts ...SchedulerOption) *JobScheduler { + rescheduleChannel := make(chan JobIdentifier, 1) + + jobScheduler := &JobScheduler{ + infoLogger: log.New(os.Stdout, "", 0), + errorLogger: log.New(os.Stderr, "", 0), + jobs: make(map[JobIdentifier]Job, 2), + rescheduleChannel: rescheduleChannel, + quitChannel: make(chan struct{}), + } + + for _, o := range opts { + o(jobScheduler) + } + + return jobScheduler +} + +func SchedulerLog(infoLog, errorLog *log.Logger) SchedulerOption { + return func(s *JobScheduler) { + s.infoLogger = infoLog + s.errorLogger = errorLog + } +} + +func (js *JobScheduler) Schedule() { + for _, job := range js.jobs { + job.Schedule() + } + + for { + select { + case jobID := <-js.rescheduleChannel: + js.jobs[jobID].Schedule() + case <-js.quitChannel: + for _, job := range js.jobs { + job.Stop() + } + + js.infoLogger.Print("stop job scheduler") + + return + } + } +} + +func (js *JobScheduler) AddJob(job Job) { + js.jobs[job.Identifier()] = 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/notifications.go b/internal/notifications/mailnotifier.go similarity index 80% rename from cmd/boardvoting/notifications.go rename to internal/notifications/mailnotifier.go index 29866ae..082b578 100644 --- a/cmd/boardvoting/notifications.go +++ b/internal/notifications/mailnotifier.go @@ -1,5 +1,5 @@ /* -Copyright 2022 CAcert Inc. +Copyright 2017-2022 CAcert Inc. SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); @@ -15,15 +15,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -package main +package notifications import ( "bytes" "fmt" "log" "net" + "os" "path" "text/template" + "time" "github.com/Masterminds/sprig/v3" "gopkg.in/mail.v2" @@ -32,6 +34,16 @@ import ( "git.cacert.org/cacert-boardvoting/internal/models" ) +type MailConfig struct { + 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 recipientData struct { field, address, name string } @@ -45,7 +57,7 @@ type NotificationContent struct { } type NotificationMail interface { - GetNotificationContent(*mailConfig) *NotificationContent + GetNotificationContent(*MailConfig) *NotificationContent } type MailNotifier struct { @@ -54,18 +66,33 @@ type MailNotifier struct { dialer *mail.Dialer quitChannel chan struct{} infoLog, errorLog *log.Logger - mailConfig *mailConfig + mailConfig *MailConfig } -func (app *application) NewMailNotifier() { - app.mailNotifier = &MailNotifier{ +type Option func(*MailNotifier) + +func NewMailNotifier(config *MailConfig, opts ...Option) *MailNotifier { + n := &MailNotifier{ notifyChannel: make(chan NotificationMail, 1), - senderAddress: app.mailConfig.NotificationSenderAddress, - dialer: mail.NewDialer(app.mailConfig.SMTPHost, app.mailConfig.SMTPPort, "", ""), + senderAddress: config.NotificationSenderAddress, + dialer: mail.NewDialer(config.SMTPHost, config.SMTPPort, "", ""), quitChannel: make(chan struct{}), - infoLog: app.infoLog, - errorLog: app.errorLog, - mailConfig: app.mailConfig, + infoLog: log.New(os.Stdout, "", 0), + errorLog: log.New(os.Stderr, "", 0), + mailConfig: config, + } + + for _, o := range opts { + o(n) + } + + return n +} + +func NotifierLog(infoLog, errorLog *log.Logger) Option { + return func(n *MailNotifier) { + n.infoLog = infoLog + n.errorLog = errorLog } } @@ -159,7 +186,7 @@ func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) { return mailText, nil } -func defaultRecipient(mc *mailConfig) recipientData { +func defaultRecipient(mc *MailConfig) recipientData { return recipientData{ field: "To", address: mc.NoticeMailAddress, @@ -167,7 +194,7 @@ func defaultRecipient(mc *mailConfig) recipientData { } } -func voteNoticeRecipient(mc *mailConfig) recipientData { +func voteNoticeRecipient(mc *MailConfig) recipientData { return recipientData{ field: "To", address: mc.VoteNoticeMailAddress, @@ -183,18 +210,18 @@ func motionReplyHeaders(m *models.Motion) map[string][]string { } type RemindVoterNotification struct { - voter *models.User - decisions []*models.Motion + Voter *models.User + Decisions []*models.Motion } -func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent { +func (r RemindVoterNotification) GetNotificationContent(*MailConfig) *NotificationContent { recipientAddress := make([]recipientData, 0) - if r.voter.Reminder.Valid { + if r.Voter.Reminder.Valid { recipientAddress = append(recipientAddress, recipientData{ field: "To", - address: r.voter.Reminder.String, - name: r.voter.Name, + address: r.Voter.Reminder.String, + name: r.Voter.Name, }) } @@ -203,7 +230,7 @@ func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *Notificati data: struct { Decisions []*models.Motion Name string - }{Decisions: r.decisions, Name: r.voter.Name}, + }{Decisions: r.Decisions, Name: r.Voter.Name}, subject: "Outstanding CAcert board votes", recipients: recipientAddress, } @@ -213,7 +240,7 @@ type ClosedDecisionNotification struct { Decision *models.Motion } -func (c *ClosedDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { +func (c *ClosedDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent { return &NotificationContent{ template: "closed_motion_mail.txt", data: struct { @@ -230,7 +257,7 @@ type NewDecisionNotification struct { Proposer *models.User } -func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { +func (n NewDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent { voteURL := fmt.Sprintf("/vote/%s", n.Decision.Tag) unvotedURL := "/motions/?unvoted=1" @@ -264,7 +291,7 @@ type UpdateDecisionNotification struct { User *models.User } -func (u UpdateDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { +func (u UpdateDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent { voteURL := fmt.Sprintf("/vote/%s", u.Decision.Tag) unvotedURL := "/motions/?unvoted=1" @@ -293,7 +320,7 @@ type DirectVoteNotification struct { Choice *models.VoteChoice } -func (d DirectVoteNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { +func (d DirectVoteNotification) GetNotificationContent(mc *MailConfig) *NotificationContent { return &NotificationContent{ template: "direct_vote_mail.txt", data: struct { @@ -319,7 +346,7 @@ type ProxyVoteNotification struct { Justification string } -func (p ProxyVoteNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { +func (p ProxyVoteNotification) GetNotificationContent(mc *MailConfig) *NotificationContent { return &NotificationContent{ template: "proxy_vote_mail.txt", data: struct { @@ -346,7 +373,7 @@ type WithDrawMotionNotification struct { Voter *models.User } -func (w WithDrawMotionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { +func (w WithDrawMotionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent { return &NotificationContent{ template: "withdraw_motion_mail.txt", data: struct {