From a1a66b72453c81022bbd417ce276421bc717257b Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Thu, 26 May 2022 15:27:25 +0200 Subject: [PATCH] Finish new motion create implementation - rename config block mail_server to mail_config - rename smtp server settings - move mail notification settings to mail_config - improve navigation templates - prepare routes for user management --- README.md | 32 ++- cmd/boardvoting/config.go | 8 +- cmd/boardvoting/handlers.go | 239 ++++++++++++++---- cmd/boardvoting/jobs.go | 6 +- cmd/boardvoting/main.go | 41 ++- cmd/boardvoting/middleware.go | 128 ++++++++++ cmd/boardvoting/notifications.go | 38 ++- cmd/boardvoting/routes.go | 38 ++- config.yaml.example | 12 +- internal/mailtemplates/create_motion_mail.txt | 18 +- ...2601_drop_unused_decisions_colums.down.sql | 3 + ...052601_drop_unused_decisions_colums.up.sql | 3 + internal/models/motions.go | 100 ++++++-- internal/models/motions_test.go | 4 +- internal/models/voters.go | 131 +++++++++- internal/validator/validator.go | 2 +- ui/html/base.html | 42 +-- ui/html/pages/create_motion.html | 10 +- ui/html/pages/motion.html | 2 +- ui/html/pages/motions.html | 45 ++-- ui/html/partials/motion_actions.html | 14 +- ui/html/partials/nav.html | 42 ++- ui/html/partials/pagination.html | 28 +- 23 files changed, 772 insertions(+), 214 deletions(-) create mode 100644 internal/migrations/2022052601_drop_unused_decisions_colums.down.sql create mode 100644 internal/migrations/2022052601_drop_unused_decisions_colums.up.sql diff --git a/README.md b/README.md index 4b50324..e4b6ff8 100644 --- a/README.md +++ b/README.md @@ -85,20 +85,24 @@ It is advisable to have a local mail setup that intercepts outgoing email or to You can use the following table to find useful values for the parameters in `config.yaml`. -| Parameter | Description | How to get a valid value | -|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| -| `notice_mail_address` | email address where notifications about votes are sent (production value is cacert-board@lists.cacert.org) | be creative but do not spam others (i.e. use user+board@your-domain.org) | -| `vote_notice_mail_address` | email address where notifications about individual votes are sent (production value is cacert-board-votes@lists.cacert.org) | be creative but do not spam others (i.e. use user+votes@your-domain.org) | -| `notification_sender_address` | sender address for all mails sent by the system (production value is returns@cacert.org) | be creative but do not spam others (i.e. use user+returns@your-domain.org) | -| `database_file` | a SQLite database file (production value is `database.sqlite`) | keep the default or use something like `local.sqlite` | -| `client_ca_certificates` | File containing allowed client certificate CA certificates (production value is `cacert_class3.pem`) | use the shell code above | -| `server_certificate` | X.509 certificate that is used to identify your server (i.e. `server.crt`) | use the filename used as `-out` parameter in the `openssl` invocation above | -| `server_key` | PEM encoded private key file (i.e. `server.key`) | use the filename used as `-keyout` parameter in the `openssl` invocation above | -| `cookie_secret` | A base64 encoded random byte value of at least 32 bytes used to encrypt cookies | see [Generating random byte values](#generating-random-byte-values) below | -| `csrf_key` | A base64 encoded random byte value of at least 32 bytes used to encrypt [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery#Prevention) tokens | see [Generating random byte values](#generating-random-byte-values) below | -| `base_url` | The base URL of your application instance (production value is https://motions.cacert.org) | use https://localhost:8443 | -| `mail_server.host` | Mail server host (production value is `localhost`) | `localhost` | -| `mail_server.port` | Mail server TCP port (production value is `25` | see [how to setup a debugging SMTP server](#debugging-smtp-server) below and choose the port of that (default `8025`) | +| Parameter | Description | How to get a valid value | +|-------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| `database_file` | a SQLite database file (production value is `database.sqlite`) | keep the default or use something like `local.sqlite` | +| `client_ca_certificates` | File containing allowed client certificate CA certificates (production value is `cacert_class3.pem`) | use the shell code above | +| `server_certificate` | X.509 certificate that is used to identify your server (i.e. `server.crt`) | use the filename used as `-out` parameter in the `openssl` invocation above | +| `server_key` | PEM encoded private key file (i.e. `server.key`) | use the filename used as `-keyout` parameter in the `openssl` invocation above | +| `cookie_secret` | A base64 encoded random byte value of at least 32 bytes used to encrypt cookies | see [Generating random byte values](#generating-random-byte-values) below | +| `csrf_key` | A base64 encoded random byte value of at least 32 bytes used to encrypt [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery#Prevention) tokens | see [Generating random byte values](#generating-random-byte-values) below | +| `mail_config.smtp_host` | Mail server host (production value is `localhost`) | `localhost` | +| `mail_config.smtp_port` | Mail server TCP port (production value is `25` | see [how to setup a debugging SMTP server](#debugging-smtp-server) below and choose the port of that (default `8025`) | +| `mail_config.base_url` | The base URL of your application instance (production value is https://motions.cacert.org) | use https://localhost:8443 | +| `mail_config.notice_mail_address` | email address where notifications about votes are sent (production value is cacert-board@lists.cacert.org) | be creative but do not spam others (i.e. use user+board@your-domain.org) | +| `mail_config.vote_notice_mail_address` | email address where notifications about individual votes are sent (production value is cacert-board-votes@lists.cacert.org) | be creative but do not spam others (i.e. use user+votes@your-domain.org) | +| `mail_config.notification_sender_address` | sender address for all mails sent by the system (production value is returns@cacert.org) | be creative but do not spam others (i.e. use user+returns@your-domain.org) | +| `timeouts.idle` | idle timeout setting for HTTP and HTTPS (default: 5 seconds) | specify a nano second value | +| `timeouts.read` | read timeout setting for HTTP and HTTPS (default: 10 seconds) | | +| `timeouts.read_header` | header read timeout setting for HTTP and HTTPS (default: 10 seconds) | | +| `timeouts.write` | write timeout setting for HTTP and HTTPS (default: 60 seconds) | | ### Generating random byte values diff --git a/cmd/boardvoting/config.go b/cmd/boardvoting/config.go index a619900..29303fa 100644 --- a/cmd/boardvoting/config.go +++ b/cmd/boardvoting/config.go @@ -36,11 +36,12 @@ const ( ) type mailConfig struct { - SMTPHost string `yaml:"host"` - SMTPPort int `yaml:"port"` + SMTPHost string `yaml:"smtp_host"` + SMTPPort int `yaml:"smtp_port"` NotificationSenderAddress string `yaml:"notification_sender_address"` NoticeMailAddress string `yaml:"notice_mail_address"` VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"` + BaseURL string `yaml:"base_url"` } type httpTimeoutConfig struct { @@ -57,10 +58,9 @@ type Config struct { ServerKey string `yaml:"server_key"` CookieSecretStr string `yaml:"cookie_secret"` CsrfKeyStr string `yaml:"csrf_key"` - BaseURL string `yaml:"base_url"` HTTPAddress string `yaml:"http_address,omitempty"` HTTPSAddress string `yaml:"https_address,omitempty"` - MailConfig *mailConfig `yaml:"mail_server"` + MailConfig *mailConfig `yaml:"mail_config"` Timeouts *httpTimeoutConfig `yaml:"timeouts,omitempty"` CookieSecret []byte `yaml:"-"` CsrfKey []byte `yaml:"-"` diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 7f608ce..6617f1c 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -38,6 +38,15 @@ import ( "git.cacert.org/cacert-boardvoting/ui" ) +func checkRole(v *models.User, roles []string) (bool, error) { + hasRole, err := v.HasRole(roles) + if err != nil { + return false, fmt.Errorf("could not determine user roles: %w", err) + } + + return hasRole, nil +} + func newTemplateCache() (map[string]*template.Template, error) { cache := map[string]*template.Template{} @@ -51,8 +60,14 @@ func newTemplateCache() (map[string]*template.Template, error) { // #nosec G203 input is sanitized return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "
")) } - funcMaps["canManageUsers"] = func(*models.Voter) bool { - return false + funcMaps["canManageUsers"] = func(v *models.User) (bool, error) { + return checkRole(v, []string{models.RoleSecretary, models.RoleAdmin}) + } + funcMaps["canVote"] = func(v *models.User) (bool, error) { + return checkRole(v, []string{models.RoleVoter}) + } + funcMaps["canStartVote"] = func(v *models.User) (bool, error) { + return checkRole(v, []string{models.RoleVoter}) } funcMaps[csrf.TemplateTag] = csrf.TemplateField @@ -75,25 +90,46 @@ func newTemplateCache() (map[string]*template.Template, error) { return cache, 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" +) + type templateData struct { - PrevPage string - NextPage string - Voter *models.Voter - Motion *models.MotionForDisplay - Motions []*models.MotionForDisplay - Request *http.Request - Flashes []string - Form any - Params struct { - Flags struct { - Unvoted bool - } - } + PrevPage string + NextPage string + User *models.User + Motion *models.MotionForDisplay + Motions []*models.MotionForDisplay + Request *http.Request + Flashes []string + Form any + ActiveNav topLevelNavItem + ActiveSubNav subLevelNavItem } -func (app *application) newTemplateData(r *http.Request) *templateData { +func (app *application) newTemplateData( + r *http.Request, + nav topLevelNavItem, + subNav subLevelNavItem, +) *templateData { + user, err := app.GetUser(r) + if err != nil { + app.errorLog.Printf("error getting user for template data: %v", err) + } + return &templateData{ - Request: r, + Request: r, + User: user, + ActiveNav: nav, + ActiveSubNav: subNav, } } @@ -119,10 +155,10 @@ func (app *application) render(w http.ResponseWriter, status int, page string, d _, _ = buf.WriteTo(w) } -func (m *templateData) setPaginationParameters(first, last *time.Time) error { +func (m *templateData) motionPaginationOptions(limit int, first, last *time.Time) error { motions := m.Motions - if len(motions) > 0 && first.Before(motions[len(motions)-1].Proposed) { + if len(motions) == limit && first.Before(motions[len(motions)-1].Proposed) { marshalled, err := motions[len(motions)-1].Proposed.MarshalText() if err != nil { return fmt.Errorf("could not serialize timestamp: %w", err) @@ -155,7 +191,7 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) { err error ) - listOptions, err = calculateMotionListOptions(r) + listOptions, err = app.calculateMotionListOptions(r) if err != nil { app.clientError(w, http.StatusBadRequest) @@ -171,18 +207,22 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) { return } - first, last, err := app.motions.TimestampRange(ctx) + first, last, err := app.motions.TimestampRange(ctx, listOptions) if err != nil { app.serverError(w, err) return } - templateData := app.newTemplateData(r) + templateData := app.newTemplateData(r, "motions", "all-motions") + + if listOptions.UnvotedOnly { + templateData.ActiveSubNav = subLevelNavMotionsUnvoted + } templateData.Motions = motions - err = templateData.setPaginationParameters(first, last) + err = templateData.motionPaginationOptions(listOptions.Limit, first, last) if err != nil { app.serverError(w, err) @@ -192,11 +232,12 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) { app.render(w, http.StatusOK, "motions.html", templateData) } -func calculateMotionListOptions(r *http.Request) (*models.MotionListOptions, error) { +func (app *application) calculateMotionListOptions(r *http.Request) (*models.MotionListOptions, error) { const ( - queryParamBefore = "before" - queryParamAfter = "after" - motionsPerPage = 10 + queryParamBefore = "before" + queryParamAfter = "after" + queryParamUnvoted = "unvoted" + motionsPerPage = 10 ) listOptions := &models.MotionListOptions{Limit: motionsPerPage} @@ -221,6 +262,19 @@ func calculateMotionListOptions(r *http.Request) (*models.MotionListOptions, 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 } @@ -244,7 +298,7 @@ func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) { return } - data := app.newTemplateData(r) + data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Motion = motion @@ -255,16 +309,16 @@ type NewMotionForm struct { Title string `form:"title"` Content string `form:"content"` Type *models.VoteType `form:"type"` - Due string `form:"due"` - Voter *models.Voter `form:"-"` + Due int `form:"due"` + User *models.User `form:"-"` validator.Validator `form:"-"` } func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) { - data := app.newTemplateData(r) + data := app.newTemplateData(r, "motions", "all-motions") data.Form = &NewMotionForm{ - Voter: &models.Voter{}, - Type: models.VoteTypeMotion, + User: data.User, + Type: models.VoteTypeMotion, } app.render(w, http.StatusOK, "create_motion.html", data) @@ -276,6 +330,13 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) maximumTitleLength = 200 minimumContentLength = 3 maximumContentLength = 8000 + + threeDays = 3 + oneWeek = 7 + twoWeeks = 14 + threeWeeks = 28 + + hoursInDay = 24 ) err := r.ParseForm() @@ -326,18 +387,15 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) "content", fmt.Sprintf("This field must be at most %d characters long", maximumContentLength), ) - form.CheckField(validator.PermittedStr( - form.Due, - "+3 days", - "+7 days", - "+14 days", - "+28 days", - ), "due", "invalid duration choice") + + form.CheckField(validator.PermittedInt( + form.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice", + ) if !form.Valid() { - form.Voter = &models.Voter{} + form.User = &models.User{} - data := app.newTemplateData(r) + data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) data.Form = form app.render(w, http.StatusUnprocessableEntity, "create_motion.html", data) @@ -345,25 +403,106 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) return } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(fmt.Sprintf("%+v", form))) - // TODO: insert motion + user, err := app.GetUser(r) + if err != nil { + app.clientError(w, http.StatusUnauthorized) - // TODO: redirect to motion detail page + 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 { + app.serverError(w, err) + + return + } + + decision, err := app.motions.GetByID(r.Context(), decisionID) + if err != nil { + app.serverError(w, err) + + return + } + + app.mailNotifier.notifyChannel <- &NewDecisionNotification{ + decision: &models.NewMotion{Decision: decision, Proposer: user}, + } + + // TODO: add flash message for new motion + + http.Redirect(w, r, fmt.Sprintf("/motions/%s", decision.Tag), http.StatusFound) } -func (app *application) voteForm(writer http.ResponseWriter, request *http.Request) { +func (app *application) editMotionForm(_ http.ResponseWriter, _ *http.Request) { panic("not implemented") } -func (app *application) voteSubmit(writer http.ResponseWriter, request *http.Request) { +func (app *application) editMotionSubmit(_ http.ResponseWriter, _ *http.Request) { panic("not implemented") } -func (app *application) proxyVoteForm(writer http.ResponseWriter, request *http.Request) { +func (app *application) withdrawMotionForm(_ http.ResponseWriter, _ *http.Request) { panic("not implemented") } -func (app *application) proxyVoteSubmit(writer http.ResponseWriter, request *http.Request) { +func (app *application) withdrawMotionSubmit(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) voteForm(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) voteSubmit(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) proxyVoteForm(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) proxyVoteSubmit(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) userList(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) submitUserRoles(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) editUserForm(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} +func (app *application) editUserSubmit(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) userAddEmailForm(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) deleteUserForm(_ http.ResponseWriter, _ *http.Request) { + panic("not implemented") +} + +func (app *application) deletetUserSubmit(_ http.ResponseWriter, _ *http.Request) { panic("not implemented") } diff --git a/cmd/boardvoting/jobs.go b/cmd/boardvoting/jobs.go index 6a766ad..b9a4870 100644 --- a/cmd/boardvoting/jobs.go +++ b/cmd/boardvoting/jobs.go @@ -34,7 +34,7 @@ type Job interface { type RemindVotersJob struct { infoLog, errorLog *log.Logger timer *time.Timer - voters *models.VoterModel + voters *models.UserModel decisions *models.MotionModel notify chan NotificationMail reschedule chan Job @@ -68,7 +68,7 @@ func (r *RemindVotersJob) Run() { defer func(r *RemindVotersJob) { r.reschedule <- r }(r) var ( - voters []*models.Voter + voters []*models.User decisions []*models.Motion err error ) @@ -112,7 +112,7 @@ func (app *application) NewRemindVotersJob( return &RemindVotersJob{ infoLog: app.infoLog, errorLog: app.errorLog, - voters: app.voters, + voters: app.users, decisions: app.motions, reschedule: rescheduleChannel, notify: app.mailNotifier.notifyChannel, diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go index a34617e..5eee7dd 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -28,7 +28,9 @@ import ( "io/ioutil" "log" "net/http" + "net/url" "os" + "strings" "time" "github.com/alexedwards/scs/sqlite3store" @@ -51,12 +53,11 @@ var ( type application struct { errorLog, infoLog *log.Logger - voters *models.VoterModel + users *models.UserModel motions *models.MotionModel jobScheduler *JobScheduler mailNotifier *MailNotifier mailConfig *mailConfig - baseURL string templateCache map[string]*template.Template sessionManager *scs.SessionManager formDecoder *form.Decoder @@ -103,28 +104,28 @@ func main() { errorLog: errorLog, infoLog: infoLog, motions: &models.MotionModel{DB: db, InfoLog: infoLog}, - voters: &models.VoterModel{DB: db}, + users: &models.UserModel{DB: db}, mailConfig: config.MailConfig, - baseURL: config.BaseURL, templateCache: templateCache, sessionManager: sessionManager, csrfKey: config.CsrfKey, formDecoder: setupFormDecoder(), } - app.NewMailNotifier() - defer app.mailNotifier.Quit() - - app.NewJobScheduler() - defer app.jobScheduler.Quit() - err = internal.InitializeDb(db.DB, infoLog) if err != nil { errorLog.Fatal(err) } - go app.jobScheduler.Schedule() + app.NewMailNotifier() + defer app.mailNotifier.Quit() + go app.StartMailNotifier() + + app.NewJobScheduler() + defer app.jobScheduler.Quit() + + go app.jobScheduler.Schedule() infoLog.Printf("Starting server on %s", config.HTTPAddress) errChan := make(chan error, 1) @@ -185,8 +186,22 @@ func (app *application) startHTTPSServer(config *Config) error { func setupHTTPRedirect(config *Config, errChan chan error) { redirect := &http.Server{ - Addr: config.HTTPAddress, - Handler: http.RedirectHandler(config.BaseURL, http.StatusMovedPermanently), + Addr: config.HTTPAddress, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirectUrl := url.URL{ + Scheme: "https://", + Host: strings.Join( + []string{ + strings.Split(r.URL.Host, ":")[0], + strings.Split(config.HTTPSAddress, ":")[1], + }, + ":", + ), + Path: r.URL.Path, + } + + http.Redirect(w, r, redirectUrl.String(), http.StatusMovedPermanently) + }), IdleTimeout: config.Timeouts.Idle, ReadHeaderTimeout: config.Timeouts.ReadHeader, ReadTimeout: config.Timeouts.Read, diff --git a/cmd/boardvoting/middleware.go b/cmd/boardvoting/middleware.go index 54b836b..df1de5e 100644 --- a/cmd/boardvoting/middleware.go +++ b/cmd/boardvoting/middleware.go @@ -18,7 +18,19 @@ limitations under the License. package main import ( + "context" + "errors" + "fmt" "net/http" + "strings" + + "git.cacert.org/cacert-boardvoting/internal/models" +) + +type contextKey int + +const ( + ctxUser contextKey = iota ) func secureHeaders(next http.Handler) http.Handler { @@ -41,3 +53,119 @@ func (app *application) logRequest(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +func (app *application) authenticateRequest(r *http.Request) (*models.User, error) { + if r.TLS == nil { + return nil, nil + } + + if len(r.TLS.PeerCertificates) < 1 { + return nil, nil + } + + emails := r.TLS.PeerCertificates[0].EmailAddresses + user, err := app.users.GetUser(r.Context(), emails) + if err != nil { + return nil, err + } + + return user, nil +} + +func (app *application) tryAuthenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, err := app.authenticateRequest(r) + if err != nil { + app.serverError(w, err) + + return + } + + if user == nil { + next.ServeHTTP(w, r) + + return + } + + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, user))) + }) +} + +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 []string) (bool, bool, error) { + user, err := app.GetUser(r) + if err != nil { + return false, false, err + } + + if user == nil { + return false, false, nil + } + + roleMatched, err := user.HasRole(roles) + if err != nil { + return false, true, fmt.Errorf("could not determin user role assignment: %w", err) + } + + if !roleMatched { + app.errorLog.Printf( + "user %s does not have any of the required role(s) %s assigned", + user.Name, + strings.Join(roles, ", "), + ) + + return false, true, nil + } + + return true, true, nil +} + +func (app *application) requireRole(next http.Handler, roles []string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hasRole, hasUser, err := app.HasRole(r, roles) + if err != nil { + app.serverError(w, err) + + return + } + + if !hasUser { + app.clientError(w, http.StatusUnauthorized) + + return + } + + if !hasRole { + app.clientError(w, http.StatusForbidden) + + return + } + + next.ServeHTTP(w, r) + }) +} + +func (app *application) userCanVote(next http.Handler) http.Handler { + return app.requireRole(next, []string{models.RoleVoter}) +} + +func (app *application) userCanEditVote(next http.Handler) http.Handler { + return app.requireRole(next, []string{models.RoleVoter}) +} + +func (app *application) userCanChangeVoters(next http.Handler) http.Handler { + return app.requireRole(next, []string{models.RoleSecretary, models.RoleAdmin}) +} diff --git a/cmd/boardvoting/notifications.go b/cmd/boardvoting/notifications.go index a78ae8f..781e1a3 100644 --- a/cmd/boardvoting/notifications.go +++ b/cmd/boardvoting/notifications.go @@ -70,7 +70,7 @@ func (app *application) StartMailNotifier() { case notification := <-app.mailNotifier.notifyChannel: content := notification.GetNotificationContent(app.mailConfig) - mailText, err := content.buildMail(app.baseURL) + mailText, err := content.buildMail(app.mailConfig.BaseURL) if err != nil { app.errorLog.Printf("building mail failed: %v", err) @@ -131,7 +131,7 @@ func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) { } type RemindVoterNotification struct { - voter *models.Voter + voter *models.User decisions []*models.Motion } @@ -147,6 +147,10 @@ func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *Notificati } } +func defaultRecipient(mc *mailConfig) recipientData { + return recipientData{field: "To", address: mc.NoticeMailAddress, name: "CAcert board mailing list"} +} + type ClosedDecisionNotification struct { decision *models.ClosedMotion } @@ -157,7 +161,7 @@ func (c *ClosedDecisionNotification) GetNotificationContent(mc *mailConfig) *Not data: c.decision, subject: fmt.Sprintf("Re: %s - %s - finalised", c.decision.Decision.Tag, c.decision.Decision.Title), headers: c.getHeaders(), - recipients: []recipientData{c.getRecipient(mc)}, + recipients: []recipientData{defaultRecipient(mc)}, } } @@ -168,6 +172,30 @@ func (c *ClosedDecisionNotification) getHeaders() map[string][]string { } } -func (c *ClosedDecisionNotification) getRecipient(mc *mailConfig) recipientData { - return recipientData{field: "To", address: mc.NoticeMailAddress, name: "CAcert board mailing list"} +type NewDecisionNotification struct { + decision *models.NewMotion +} + +func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent { + voteURL := fmt.Sprintf("/vote/%s", n.decision.Decision.Tag) + unvotedURL := "/motions/?unvoted=1" + + return &NotificationContent{ + template: "create_motion_mail.txt", + data: struct { + *models.Motion + Name string + VoteURL string + UnvotedURL string + }{n.decision.Decision, n.decision.Proposer.Name, voteURL, unvotedURL}, + subject: fmt.Sprintf("%s - %s", n.decision.Decision.Tag, n.decision.Decision.Title), + headers: n.getHeaders(), + recipients: []recipientData{defaultRecipient(mc)}, + } +} + +func (n NewDecisionNotification) getHeaders() map[string][]string { + return map[string][]string{ + "Message-ID": {fmt.Sprintf("<%s>", n.decision.Decision.Tag)}, + } } diff --git a/cmd/boardvoting/routes.go b/cmd/boardvoting/routes.go index 11c5dc1..8e0e49f 100644 --- a/cmd/boardvoting/routes.go +++ b/cmd/boardvoting/routes.go @@ -61,19 +61,37 @@ func (app *application) routes() http.Handler { ) router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer)) - dynamic := alice.New(app.sessionManager.LoadAndSave, csrf.Protect( - app.csrfKey, - csrf.SameSite(csrf.SameSiteStrictMode), - )) + dynamic := alice.New( + app.sessionManager.LoadAndSave, + csrf.Protect(app.csrfKey, csrf.SameSite(csrf.SameSiteStrictMode)), + app.tryAuthenticate, + ) + + canVote := dynamic.Append(app.userCanVote) + canEditVote := dynamic.Append(app.userCanEditVote) + canManageUsers := dynamic.Append(app.userCanChangeVoters) router.Handler(http.MethodGet, "/motions/", dynamic.ThenFunc(app.motionList)) router.Handler(http.MethodGet, "/motions/:tag", dynamic.ThenFunc(app.motionDetails)) - router.Handler(http.MethodGet, "/vote/:tag", dynamic.ThenFunc(app.voteForm)) - router.Handler(http.MethodPost, "/vote/:tag", dynamic.ThenFunc(app.voteSubmit)) - router.Handler(http.MethodGet, "/proxy/:tag", dynamic.ThenFunc(app.proxyVoteForm)) - router.Handler(http.MethodPost, "/proxy/:tag", dynamic.ThenFunc(app.proxyVoteSubmit)) - router.Handler(http.MethodGet, "/newmotion/", dynamic.ThenFunc(app.newMotionForm)) - router.Handler(http.MethodPost, "/newmotion/", dynamic.ThenFunc(app.newMotionSubmit)) + router.Handler(http.MethodGet, "/motions/:tag/edit", canEditVote.ThenFunc(app.editMotionForm)) + router.Handler(http.MethodPost, "/motions/:tag/edit", canEditVote.ThenFunc(app.editMotionSubmit)) + router.Handler(http.MethodGet, "/motions/:tag/withdraw", canEditVote.ThenFunc(app.withdrawMotionForm)) + router.Handler(http.MethodPost, "/motions/:tag/withdraw", canEditVote.ThenFunc(app.withdrawMotionSubmit)) + router.Handler(http.MethodGet, "/vote/:tag/:choice", canVote.ThenFunc(app.voteForm)) + router.Handler(http.MethodPost, "/vote/:tag/:choice", canVote.ThenFunc(app.voteSubmit)) + router.Handler(http.MethodGet, "/proxy/:tag", canVote.ThenFunc(app.proxyVoteForm)) + router.Handler(http.MethodPost, "/proxy/:tag", canVote.ThenFunc(app.proxyVoteSubmit)) + router.Handler(http.MethodGet, "/newmotion/", canEditVote.ThenFunc(app.newMotionForm)) + router.Handler(http.MethodPost, "/newmotion/", canEditVote.ThenFunc(app.newMotionSubmit)) + + router.Handler(http.MethodGet, "/users/", canManageUsers.ThenFunc(app.userList)) + router.Handler(http.MethodPost, "/users/", canManageUsers.ThenFunc(app.submitUserRoles)) + router.Handler(http.MethodGet, "/users/:id/", canManageUsers.ThenFunc(app.editUserForm)) + router.Handler(http.MethodPost, "/users/:id/", canManageUsers.ThenFunc(app.editUserSubmit)) + router.Handler(http.MethodGet, "/users/:id/add-mail", canManageUsers.ThenFunc(app.userAddEmailForm)) + router.Handler(http.MethodPost, "/users/:id/add-mail", canManageUsers.ThenFunc(app.userAddEmailSubmit)) + router.Handler(http.MethodGet, "/users/:id/delete", canManageUsers.ThenFunc(app.deleteUserForm)) + router.Handler(http.MethodPost, "/users/:id/delete", canManageUsers.ThenFunc(app.deletetUserSubmit)) standard := alice.New(app.logRequest, secureHeaders) diff --git a/config.yaml.example b/config.yaml.example index 234679b..86b51f7 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,14 +1,14 @@ --- -notice_mail_address: cacert-board@lists.cacert.org -vote_notice_mail_address: cacert-board-votes@lists.cacert.org -notification_sender_address: returns@cacert.org database_file: database.sqlite client_ca_certificates: cacert_class3.pem server_certificate: server.crt server_key: server.key cookie_secret: base64encoded_random_byte_value_of_at_least_32_bytes csrf_key: base64encoded_random_byte_value_of_at_least_32_bytes -base_url: https://motions.cacert.org mail_server: - host: localhost - port: 25 \ No newline at end of file + smtp_host: localhost + smtp_port: 25 + base_url: https://motions.cacert.org + notice_mail_address: cacert-board@lists.cacert.org + vote_notice_mail_address: cacert-board-votes@lists.cacert.org + notification_sender_address: returns@cacert.org diff --git a/internal/mailtemplates/create_motion_mail.txt b/internal/mailtemplates/create_motion_mail.txt index b526615..bfe4393 100644 --- a/internal/mailtemplates/create_motion_mail.txt +++ b/internal/mailtemplates/create_motion_mail.txt @@ -1,22 +1,22 @@ Dear Board, -{{ .Name }} has made the following motion: +{{ .Data.Name }} has made the following motion: -{{ .Title }} +{{ .Data.Title }} -{{ wrap 76 .Content }} +{{ wrap 76 .Data.Content }} -Vote type: {{ .VoteType }} +Vote type: {{ .Data.Type }} -Voting will close {{ .Due }} +Voting will close {{ .Data.Due }} To vote please choose: -Aye: {{ .VoteURL }}/aye -Naye: {{ .VoteURL }}/naye -Abstain: {{ .VoteURL }}/abstain +Aye: {{ .BaseURL }}{{ .Data.VoteURL }}/aye +Naye: {{ .BaseURL }}{{ .Data.VoteURL }}/naye +Abstain: {{ .BaseURL }}{{ .Data.VoteURL }}/abstain -To see all your pending votes: {{ .UnvotedURL }} +To see all your pending votes: {{ .BaseURL }}{{ .Data.UnvotedURL }} Kind regards, the voting system \ No newline at end of file diff --git a/internal/migrations/2022052601_drop_unused_decisions_colums.down.sql b/internal/migrations/2022052601_drop_unused_decisions_colums.down.sql new file mode 100644 index 0000000..22c626a --- /dev/null +++ b/internal/migrations/2022052601_drop_unused_decisions_colums.down.sql @@ -0,0 +1,3 @@ +-- drop unused columns majority and quorum from decisions table +ALTER TABLE decisions ADD COLUMN quorum INTEGER; +ALTER TABLE decisions ADD COLUMN majority INTEGER; diff --git a/internal/migrations/2022052601_drop_unused_decisions_colums.up.sql b/internal/migrations/2022052601_drop_unused_decisions_colums.up.sql new file mode 100644 index 0000000..51ca627 --- /dev/null +++ b/internal/migrations/2022052601_drop_unused_decisions_colums.up.sql @@ -0,0 +1,3 @@ +-- drop unused columns majority and quorum from decisions table +ALTER TABLE decisions DROP COLUMN quorum; +ALTER TABLE decisions DROP COLUMN majority; diff --git a/internal/models/motions.go b/internal/models/motions.go index bf1516d..04ce9e1 100644 --- a/internal/models/motions.go +++ b/internal/models/motions.go @@ -177,13 +177,11 @@ type Motion struct { Proponent int64 `db:"proponent"` Title string Content string - Quorum int - Majority int Status VoteStatus Due time.Time Modified time.Time Tag string - VoteType *VoteType + Type *VoteType `db:"votetype"` } type ClosedMotion struct { @@ -192,6 +190,11 @@ type ClosedMotion struct { Reasoning string } +type NewMotion struct { + Decision *Motion + Proposer *User +} + type MotionModel struct { DB *sqlx.DB InfoLog *log.Logger @@ -200,7 +203,7 @@ type MotionModel struct { // Create a new decision. func (m *MotionModel) Create( ctx context.Context, - proponent *Voter, + proponent *User, voteType *VoteType, title, content string, proposed, due time.Time, @@ -211,7 +214,7 @@ func (m *MotionModel) Create( Title: title, Content: content, Due: due.UTC(), - VoteType: voteType, + Type: voteType, } result, err := m.DB.NamedExecContext( @@ -298,7 +301,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now } func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*ClosedMotion, error) { - quorum, majority := d.VoteType.QuorumAndMajority() + quorum, majority := d.Type.QuorumAndMajority() var ( voteSums *VoteSums @@ -336,7 +339,7 @@ func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) return &ClosedMotion{d, voteSums, reasoning}, nil } -func (m *MotionModel) UnVotedDecisionsForVoter(_ context.Context, _ *Voter) ([]*Motion, error) { +func (m *MotionModel) UnVotedDecisionsForVoter(_ context.Context, _ *User) ([]*Motion, error) { panic("not implemented") } @@ -436,11 +439,30 @@ type VoteForDisplay struct { type MotionListOptions struct { Limit int + UnvotedOnly bool Before, After *time.Time + VoterId int64 } -func (m *MotionModel) TimestampRange(ctx context.Context) (*time.Time, *time.Time, error) { - row := m.DB.QueryRowxContext(ctx, `SELECT MIN(proposed), MAX(proposed) FROM decisions`) +func (m *MotionModel) TimestampRange(ctx context.Context, options *MotionListOptions) (*time.Time, *time.Time, error) { + var row *sqlx.Row + + if options.UnvotedOnly { + row = m.DB.QueryRowxContext( + ctx, + `SELECT MIN(proposed), MAX(proposed) +FROM decisions +WHERE due >= ? + AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)`, + time.Now().UTC(), + options.VoterId, + ) + } else { + row = m.DB.QueryRowxContext( + ctx, + `SELECT MIN(proposed), MAX(proposed) FROM decisions`, + ) + } if err := row.Err(); err != nil { return nil, nil, fmt.Errorf("could not query for motion timestamps: %w", err) @@ -637,9 +659,39 @@ ORDER BY proposed DESC`, } func (m *MotionModel) GetFirstMotionRows(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) { - rows, err := m.DB.QueryxContext( - ctx, - `SELECT decisions.id, + var ( + rows *sqlx.Rows + err error + ) + + if options.UnvotedOnly { + rows, err = m.DB.QueryxContext( + ctx, + `SELECT decisions.id, + decisions.tag, + decisions.proponent, + voters.name AS proposer, + decisions.proposed, + decisions.title, + decisions.content, + decisions.votetype, + decisions.status, + decisions.due, + decisions.modified +FROM decisions + JOIN voters ON decisions.proponent = voters.id +WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?) + AND due >= ? +ORDER BY decisions.proposed DESC +LIMIT ?`, + options.VoterId, + time.Now().UTC(), + options.Limit, + ) + } else { + rows, err = m.DB.QueryxContext( + ctx, + `SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, @@ -653,9 +705,10 @@ func (m *MotionModel) GetFirstMotionRows(ctx context.Context, options *MotionLis FROM decisions JOIN voters ON decisions.proponent = voters.id ORDER BY proposed DESC -LIMIT $1`, - options.Limit, - ) +LIMIT ?`, + options.Limit, + ) + } if err != nil { return nil, fmt.Errorf("could not query motions: %w", err) } @@ -741,3 +794,20 @@ ORDER BY voters.name`, return nil } + +func (m *MotionModel) GetByID(ctx context.Context, id int64) (*Motion, error) { + row := m.DB.QueryRowxContext(ctx, `SELECT * FROM decisions WHERE id=?`, id) + + if err := row.Err(); err != nil { + return nil, fmt.Errorf("could not fetch tag for id %d: %w", id, err) + } + + var motion Motion + + if err := row.StructScan(&motion); err != nil { + return nil, fmt.Errorf("could not get tag from row: %w", err) + } + + return &motion, nil + +} diff --git a/internal/models/motions_test.go b/internal/models/motions_test.go index 4be4cbd..6407a8e 100644 --- a/internal/models/motions_test.go +++ b/internal/models/motions_test.go @@ -57,7 +57,7 @@ func TestDecisionModel_Create(t *testing.T) { dm := models.MotionModel{DB: dbx, InfoLog: logger} - v := &models.Voter{ + v := &models.User{ ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index. Name: "test voter", Reminder: "test+voter@example.com", @@ -90,7 +90,7 @@ func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) { assert.NoError(t, err) assert.Empty(t, nextDue) - v := &models.Voter{ + v := &models.User{ ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index. Name: "test voter", Reminder: "test+voter@example.com", diff --git a/internal/models/voters.go b/internal/models/voters.go index a76994f..14bc82a 100644 --- a/internal/models/voters.go +++ b/internal/models/voters.go @@ -19,24 +19,143 @@ package models import ( "context" + "errors" + "fmt" + "strings" "github.com/jmoiron/sqlx" ) -type Voter struct { +const ( + RoleAdmin string = "ADMIN" + RoleSecretary string = "SECRETARY" + RoleVoter string = "VOTER" +) + +type User struct { ID int64 `db:"id"` Name string - Reminder string // reminder email address + Reminder string // reminder email address + roles []*Role `db:"-"` } -type VoterModel struct { +func (v *User) Roles() ([]*Role, error) { + if v.roles != nil { + return v.roles, nil + } + + return nil, errors.New("call to GetRoles required") +} + +func (v *User) HasRole(roles []string) (bool, error) { + userRoles, err := v.Roles() + if err != nil { + return false, err + } + + roleMatched := false + +outer: + for _, role := range userRoles { + for _, checkRole := range roles { + if role.Name == checkRole { + roleMatched = true + + break outer + } + } + } + + return roleMatched, nil +} + +type UserModel struct { DB *sqlx.DB } -func (m VoterModel) GetReminderVoters(_ context.Context) ([]*Voter, error) { +func (m *UserModel) GetReminderVoters(_ context.Context) ([]*User, error) { panic("not implemented") } -func (m VoterModel) GetVoter(ctx context.Context, emails []string) (*Voter, error) { - panic("not implemented") +func (m *UserModel) GetUser(ctx context.Context, emails []string) (*User, error) { + query, args, err := sqlx.In( + `SELECT DISTINCT v.id, v.name, v.reminder +FROM voters v + JOIN emails e ON e.voter = v.id +WHERE e.address IN (?)`, emails) + if err != nil { + return nil, fmt.Errorf("could not build query: %w", err) + } + + rows, err := m.DB.QueryxContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("could not run query: %w", err) + } + + defer func(rows *sqlx.Rows) { + _ = rows.Close() + }(rows) + + var ( + user User + count int + ) + + for rows.Next() { + count++ + + if count > 1 { + return nil, fmt.Errorf( + "multiple voters found for addresses in certificate %s", + strings.Join(emails, ", "), + ) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("could not fetch row: %w", err) + } + + if err := rows.StructScan(&user); err != nil { + return nil, fmt.Errorf("could not get user from row: %w", err) + } + } + + if user.roles, err = m.GetRoles(ctx, &user); err != nil { + return nil, fmt.Errorf("could not retrieve roles for user %s: %w", user.Name, err) + } + + return &user, nil +} + +type Role struct { + Name string `db:"role"` +} + +func (m *UserModel) GetRoles(ctx context.Context, user *User) ([]*Role, error) { + rows, err := m.DB.QueryxContext(ctx, `SELECT role FROM user_roles WHERE voter_id=?`, user.ID) + if err != nil { + return nil, fmt.Errorf("could not query roles for %s: %w", user.Name, err) + } + + defer func(rows *sqlx.Rows) { + _ = rows.Close() + }(rows) + + result := make([]*Role, 0) + + for rows.Next() { + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("could not retrieve row: %w", err) + } + + var role Role + + if err := rows.StructScan(&role); err != nil { + return nil, fmt.Errorf("could not get role from row: %w", err) + } + + result = append(result, &role) + } + + return result, nil } diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 5f3f4c8..331d4ce 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -58,7 +58,7 @@ func MinChars(value string, n int) bool { return utf8.RuneCountInString(value) >= n } -func PermittedStr(value string, permittedValues ...string) bool { +func PermittedInt(value int, permittedValues ...int) bool { for i := range permittedValues { if value == permittedValues[i] { return true diff --git a/ui/html/base.html b/ui/html/base.html index 7a6baa4..502a704 100644 --- a/ui/html/base.html +++ b/ui/html/base.html @@ -17,8 +17,8 @@

{{ template "title" . }} - {{ if .Voter }} - Authenticated as {{ .Voter.Name }} <{{ .Voter.Reminder }}> + {{ if .User }} + Authenticated as {{ .User.Name }} <{{ .User.Reminder }}> {{ end }}

@@ -27,33 +27,19 @@ {{ template "nav" . }}
- {{ with .Flashes }} -
-
- -
- {{ range . }} -
{{ . }}
- {{ end }} -
-
-
- {{ end }} - {{ $voter := .Voter }} -
- -
{{ template "main" . }} + {{ with .Flashes }} +
+
+ +
+ {{ range . }} +
{{ . }}
+ {{ end }} +
+
+
+ {{ end }}