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 @@