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
main
Jan Dittberner 2 years ago
parent aa3a1b0cc7
commit a1a66b7245

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

@ -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:"-"`

@ -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", "<br>"))
}
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)
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) editMotionForm(_ http.ResponseWriter, _ *http.Request) {
panic("not implemented")
}
func (app *application) editMotionSubmit(_ http.ResponseWriter, _ *http.Request) {
panic("not implemented")
}
func (app *application) withdrawMotionForm(_ http.ResponseWriter, _ *http.Request) {
panic("not implemented")
}
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")
}
// TODO: redirect to motion detail page
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) voteForm(writer http.ResponseWriter, request *http.Request) {
func (app *application) userAddEmailForm(_ http.ResponseWriter, _ *http.Request) {
panic("not implemented")
}
func (app *application) voteSubmit(writer http.ResponseWriter, request *http.Request) {
func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Request) {
panic("not implemented")
}
func (app *application) proxyVoteForm(writer http.ResponseWriter, request *http.Request) {
func (app *application) deleteUserForm(_ http.ResponseWriter, _ *http.Request) {
panic("not implemented")
}
func (app *application) proxyVoteSubmit(writer http.ResponseWriter, request *http.Request) {
func (app *application) deletetUserSubmit(_ http.ResponseWriter, _ *http.Request) {
panic("not implemented")
}

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

@ -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(),
}
err = internal.InitializeDb(db.DB, infoLog)
if err != nil {
errorLog.Fatal(err)
}
app.NewMailNotifier()
defer app.mailNotifier.Quit()
go app.StartMailNotifier()
app.NewJobScheduler()
defer app.jobScheduler.Quit()
err = internal.InitializeDb(db.DB, infoLog)
if err != nil {
errorLog.Fatal(err)
}
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,

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

@ -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)},
}
}

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

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

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

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

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

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

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

@ -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:"-"`
}
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 VoterModel struct {
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
}

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

@ -17,8 +17,8 @@
<div class="ui text container">
<h1 class="ui header">
{{ template "title" . }}
{{ if .Voter }}
<span class="ui left pointing label">Authenticated as {{ .Voter.Name }} &lt;{{ .Voter.Reminder }}&gt;
{{ if .User }}
<span class="ui left pointing label">Authenticated as {{ .User.Name }} &lt;{{ .User.Reminder }}&gt;
</span>
{{ end }}
</h1>
@ -27,33 +27,19 @@
</header>
{{ template "nav" . }}
<main class="ui container">
{{ template "main" . }}
{{ with .Flashes }}
<div class="basic segment">
<div class="ui info message">
<i class="close icon"></i>
<div class="ui list">
{{ range . }}
<div class="ui item">{{ . }}</div>
{{ end }}
<div class="basic segment">
<div class="ui info message">
<i class="close icon"></i>
<div class="ui list">
{{ range . }}
<div class="ui item">{{ . }}</div>
{{ end }}
</div>
</div>
</div>
</div>
{{ end }}
{{ $voter := .Voter }}
<div class="ui basic segment">
<div class="ui secondary pointing menu">
<a href="/motions/" class="{{ if not .Params.Flags.Unvoted }}active {{ end }}item" title="Show all motions">All
motions</a>
{{ if $voter }}
<a href="/motions/?unvoted=1" class="{{ if .Params.Flags.Unvoted }}active {{ end}}item"
title="My unvoted motions">My unvoted motions</a>
<div class="right item">
<a class="ui primary button" href="/newmotion/">New motion</a>
</div>
{{ end }}
</div>
</div>
{{ template "main" . }}
</main>
<footer class="ui vertical footer segment">
<div class="ui container">

@ -12,7 +12,7 @@
</div>
<div class="field">
<label>Proponent:</label>
{{ .Form.Voter.Name }}
{{ .Form.User.Name }}
</div>
<div class="field">
<label>Proposed date/time:</label>
@ -55,10 +55,10 @@
<label for="due">Due: (autofilled from chosen
option)</label>
<select id="due" name="due">
<option value="+3 days"{{ if eq "+3 days" .Form.Due }} selected{{ end }}>In 3 Days</option>
<option value="+7 days"{{ if eq "+7 days" .Form.Due }} selected{{ end }}>In 1 Week</option>
<option value="+14 days"{{ if eq "+14 days" .Form.Due }} selected{{ end }}>In 2 Weeks</option>
<option value="+28 days"{{ if eq "+28 days" .Form.Due }} selected{{ end }}>In 4 Weeks</option>
<option value="3"{{ if eq 3 .Form.Due }} selected{{ end }}>In 3 Days</option>
<option value="7"{{ if eq 7 .Form.Due }} selected{{ end }}>In 1 Week</option>
<option value="14"{{ if eq 14 .Form.Due }} selected{{ end }}>In 2 Weeks</option>
<option value="28"{{ if eq 28 .Form.Due }} selected{{ end }}>In 4 Weeks</option>
</select>
{{ if .Form.FieldErrors.due }}
<span class="ui small error text">{{ .Form.FieldErrors.due }}</span>

@ -1,7 +1,7 @@
{{ define "title" }}CAcert Board Decisions: {{ .Motion.Tag }}{{ end }}
{{ define "main" }}
{{ $voter := .Voter }}
{{ $voter := .User }}
{{ with .Motion }}
<div class="ui raised segment">
{{ template "motion_display" . }}

@ -1,30 +1,25 @@
{{ define "title" }}CAcert Board Decisions{{ end }}
{{ define "main" }}
{{ $page := . }}
{{ $voter := .Voter }}
{{ if .Motions }}
<div class="ui labeled icon menu">
{{ template "pagination" $page }}
</div>
{{ range .Motions }}
<div class="ui raised segment">
{{ template "motion_display" . }}
{{ if $voter }}{{ template "motion_actions" . }}{{ end }}
</div>
{{ end }}
<div class="ui labeled icon menu">
{{ template "pagination" $page }}
</div>
{{ else }}
<div class="ui basic segment">
<div class="ui icon message">
<i class="inbox icon"></i>
<div class="content">
<div class="header">No motions available</div>
{{ $page := . }}
{{ $user := .User }}
{{ if .Motions }}
{{ template "pagination" $page }}
{{ range .Motions }}
<div class="ui raised segment">
{{ template "motion_display" . }}
{{ if $user }}{{ template "motion_actions" . }}{{ end }}
</div>
{{ end }}
{{ template "pagination" $page }}
{{ else }}
<div class="ui basic segment">
<div class="ui icon message">
<i class="inbox icon"></i>
<div class="content">
<div class="header">{{ if eq .ActiveSubNav "unvoted-motions" }}No unvoted motions available.{{ else }}No motions available.{{ end }}</div>
</div>
</div>
</div>
</div>
</div>
<p>Nothing to see yet.</p>
{{ end }}
{{ end }}
{{ end }}

@ -1,3 +1,15 @@
{{ define "motion_actions" }}
<p>TODO: MOTION_ACTIONS PLACEHOLDER</p>
{{ if eq .Status 0 }}
<a class="ui compact right labeled green icon button" href="/vote/{{ .Tag }}/aye"><i
class="check circle icon"></i> Aye</a>
<a class="ui compact right labeled red icon button" href="/vote/{{ .Tag }}/naye"><i
class="minus circle icon"></i> Naye</a>
<a class="ui compact right labeled grey icon button" href="/vote/{{ .Tag }}/abstain"><i class="circle icon"></i>
Abstain</a>
<a class="ui compact left labeled icon button" href="/proxy/{{ .Tag }}"><i class="users icon"></i> Proxy
Vote</a>
<a class="ui compact left labeled icon button" href="/motions/{{ .Tag }}/edit"><i class="edit icon"></i> Modify</a>
<a class="ui compact left labeled icon button" href="/motions/{{ .Tag }}/withdraw"><i class="trash icon"></i>
Withdraw</a>
{{ end }}
{{ end }}

@ -1,8 +1,38 @@
{{ define "nav" }}
{{ if .Voter | canManageUsers }}
<nav class="ui top attached tabular menu">
<a class="active item" href="/motions/">Motions</a>
<a class="item" href="/users/">User management</a>
</nav>
{{ end }}
{{ $user := .User }}
{{ if $user }}
{{ if canManageUsers $user }}
<nav class="ui top attached tabular menu">
<a class="{{ if eq .ActiveNav "motions" }}active {{ end }}item" href="/motions/">Motions</a>
<a class="{{ if eq .ActiveNav "users" }}active {{ end }}item" href="/users/">User management</a>
</nav>
{{ end }}
{{ end }}
{{ if eq .ActiveNav "motions"}}
<nav class="ui secondary pointing menu">
<a href="/motions/" class="{{ if eq .ActiveSubNav "all-motions" }}active {{ end }}item"
title="Show all motions">All
motions</a>
{{ if $user }}
{{ if canVote $user }}
<a href="/motions/?unvoted=1" class="{{ if eq .ActiveSubNav "unvoted-motions" }}active {{ end}}item"
title="My unvoted motions">My unvoted motions</a>
{{ end }}
{{ if canStartVote $user }}
<div class="right item">
<a class="ui primary button" href="/newmotion/">New motion</a>
</div>
{{ end }}
{{ end }}
</nav>
{{ end }}
{{ if eq .ActiveNav "users"}}
{{ if $user }}
{{ if canManageUsers $user }}
<nav class="ui secondary pointing menu">
<a href="/users/" class="{{ if eq .ActiveSubNav "users" }}active {{ end }}item">Manage users</a>
</nav>
{{ end }}
{{ end }}
{{ end }}
{{ end }}

@ -1,12 +1,20 @@
{{ define "pagination" }}
{{ if .PrevPage -}}
<a class="item" href="?after={{ .PrevPage }}{{ if .Params.Flags.Unvoted }}&unvoted=1{{ end }}" title="newer motions">
<i class="left arrow icon"></i> newer
</a>
{{- end }}
{{ if .NextPage -}}
<a class="right item" href="?before={{ .NextPage }}{{ if .Params.Flags.Unvoted }}&unvoted=1{{ end }}" title="older motions">
<i class="right arrow icon"></i> older
</a>
{{- end }}
{{ if or .PrevPage .NextPage }}
<div class="ui labeled icon menu">
{{ if .PrevPage -}}
<a class="item"
href="?after={{ .PrevPage }}{{ if eq .ActiveSubNav "unvoted-motions" }}&unvoted=1{{ end }}"
title="newer motions">
<i class="left arrow icon"></i> newer
</a>
{{- end }}
{{ if .NextPage -}}
<a class="right item"
href="?before={{ .NextPage }}{{ if eq .ActiveSubNav "unvoted-motions" }}&unvoted=1{{ end }}"
title="older motions">
<i class="right arrow icon"></i> older
</a>
{{- end }}
</div>
{{ end }}
{{ end }}
Loading…
Cancel
Save