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 {