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 {