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
This commit is contained in:
parent
aa3a1b0cc7
commit
a1a66b7245
23 changed files with 772 additions and 214 deletions
32
README.md
32
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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
@ -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:"-"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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 }} <{{ .Voter.Reminder }}>
|
||||
{{ if .User }}
|
||||
<span class="ui left pointing label">Authenticated as {{ .User.Name }} <{{ .User.Reminder }}>
|
||||
</span>
|
||||
{{ end }}
|
||||
</h1>
|
||||
|
@ -27,33 +27,19 @@
|
|||
</header>
|
||||
{{ template "nav" . }}
|
||||
<main class="ui container">
|
||||
{{ 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>
|
||||
</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" . }}
|
||||
{{ 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>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</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…
Reference in a new issue