From e1af6876c11914b8c06741a467101a4644b737ad Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Sun, 22 May 2022 21:15:54 +0200 Subject: [PATCH] Implement new motion form - add session handler - add form decoder from go-playground - implement custom decoder for VoteType --- cmd/boardvoting/config.go | 55 ++-------- cmd/boardvoting/handlers.go | 102 +++++++++++++++--- cmd/boardvoting/jobs.go | 6 +- cmd/boardvoting/main.go | 46 +++++--- cmd/boardvoting/notifications.go | 6 +- cmd/boardvoting/routes.go | 29 +++-- go.mod | 5 + go.sum | 11 ++ .../2022052201_add_sessions_tables.down.sql | 3 + .../2022052201_add_sessions_tables.up.sql | 8 ++ internal/models/motions.go | 41 ++++--- internal/models/voters.go | 6 +- internal/validator/validator.go | 62 +++++++++++ ui/html/pages/create_motion.html | 72 +++++++++++++ 14 files changed, 350 insertions(+), 102 deletions(-) create mode 100644 internal/migrations/2022052201_add_sessions_tables.down.sql create mode 100644 internal/migrations/2022052201_add_sessions_tables.up.sql create mode 100644 internal/validator/validator.go create mode 100644 ui/html/pages/create_motion.html diff --git a/cmd/boardvoting/config.go b/cmd/boardvoting/config.go index 767e0bf..4c61330 100644 --- a/cmd/boardvoting/config.go +++ b/cmd/boardvoting/config.go @@ -18,14 +18,11 @@ limitations under the License. package main import ( - "context" "encoding/base64" - "errors" "fmt" "io/ioutil" "time" - "github.com/gorilla/sessions" "gopkg.in/yaml.v2" ) @@ -36,7 +33,6 @@ const ( httpReadHeaderTimeout = 10 * time.Second httpReadTimeout = 10 * time.Second httpWriteTimeout = 60 * time.Second - sessionCookieName = "votesession" ) type mailConfig struct { @@ -59,23 +55,18 @@ type Config struct { ClientCACertificates string `yaml:"client_ca_certificates"` ServerCert string `yaml:"server_certificate"` ServerKey string `yaml:"server_key"` - CookieSecret string `yaml:"cookie_secret"` - CsrfKey string `yaml:"csrf_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"` Timeouts *httpTimeoutConfig `yaml:"timeouts,omitempty"` + CookieSecret []byte `yaml:"-"` + CsrfKey []byte `yaml:"-"` } -type configKey int - -const ( - ctxConfig configKey = iota - ctxCookieStore -) - -func parseConfig(ctx context.Context, configFile string) (context.Context, error) { +func parseConfig(configFile string) (*Config, error) { source, err := ioutil.ReadFile(configFile) if err != nil { return nil, fmt.Errorf("could not read configuration file %s: %w", configFile, err) @@ -96,47 +87,21 @@ func parseConfig(ctx context.Context, configFile string) (context.Context, error return nil, fmt.Errorf("could not parse configuration: %w", err) } - cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret) - if err != nil { + if config.CookieSecret, err = base64.StdEncoding.DecodeString(config.CookieSecretStr); err != nil { return nil, fmt.Errorf("could not decode cookie secret: %w", err) } - if len(cookieSecret) < cookieSecretMinLen { + if len(config.CookieSecret) < cookieSecretMinLen { return nil, fmt.Errorf("cookie secret is less than the minimum require %d bytes long", cookieSecretMinLen) } - csrfKey, err := base64.StdEncoding.DecodeString(config.CsrfKey) - if err != nil { + if config.CsrfKey, err = base64.StdEncoding.DecodeString(config.CsrfKeyStr); err != nil { return nil, fmt.Errorf("could not decode CSRF key: %w", err) } - if len(csrfKey) != csrfKeyLength { - return nil, fmt.Errorf("CSRF key must be exactly %d bytes long but is %d bytes long", csrfKeyLength, len(csrfKey)) - } - - cookieStore := sessions.NewCookieStore(cookieSecret) - cookieStore.Options.Secure = true - - ctx = context.WithValue(ctx, ctxConfig, config) - ctx = context.WithValue(ctx, ctxCookieStore, cookieStore) - - return ctx, nil -} - -func GetConfig(ctx context.Context) (*Config, error) { - config, ok := ctx.Value(ctxConfig).(*Config) - if !ok { - return nil, errors.New("invalid value type for config in context") + if len(config.CsrfKey) != csrfKeyLength { + return nil, fmt.Errorf("CSRF key must be exactly %d bytes long but is %d bytes long", csrfKeyLength, len(config.CsrfKey)) } return config, nil } - -func GetCookieStore(ctx context.Context) (*sessions.CookieStore, error) { - cookieStore, ok := ctx.Value(ctxConfig).(*sessions.CookieStore) - if !ok { - return nil, errors.New("invalid value type for cookie store in context") - } - - return cookieStore, nil -} diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 0efdf30..74d5845 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -29,12 +29,12 @@ import ( "strings" "time" + "git.cacert.org/cacert-boardvoting/internal/models" + "git.cacert.org/cacert-boardvoting/internal/validator" + "git.cacert.org/cacert-boardvoting/ui" "github.com/Masterminds/sprig/v3" "github.com/gorilla/csrf" "github.com/julienschmidt/httprouter" - - "git.cacert.org/cacert-boardvoting/internal/models" - "git.cacert.org/cacert-boardvoting/ui" ) func newTemplateCache() (map[string]*template.Template, error) { @@ -75,20 +75,25 @@ func newTemplateCache() (map[string]*template.Template, error) { } type templateData struct { - Voter *models.Voter - Flashes []string - Params 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, NextPage string - Motions []*models.MotionForDisplay - Motion *models.MotionForDisplay } func (app *application) newTemplateData(r *http.Request) *templateData { - return &templateData{} + return &templateData{ + Request: r, + } } func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) { @@ -245,12 +250,81 @@ func (app *application) motionDetails(w http.ResponseWriter, r *http.Request) { app.render(w, http.StatusOK, "motion.html", data) } -func (app *application) newMotionForm(writer http.ResponseWriter, request *http.Request) { - panic("not implemented") +type NewMotionForm struct { + Title string `form:"title"` + Content string `form:"content"` + Type *models.VoteType `form:"type"` + Due string `form:"due"` + Voter *models.Voter `form:"-"` + validator.Validator `form:"-"` } -func (app *application) newMotionSubmit(writer http.ResponseWriter, request *http.Request) { - panic("not implemented") +func (app *application) newMotionForm(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r) + data.Form = &NewMotionForm{ + Voter: &models.Voter{}, + Type: models.VoteTypeMotion, + } + + app.render(w, http.StatusOK, "create_motion.html", data) +} + +func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) { + const ( + minimumTitleLength = 3 + maximumTitleLength = 200 + minimumContentLength = 3 + maximumContentLength = 8000 + ) + + err := r.ParseForm() + if err != nil { + app.clientError(w, http.StatusBadRequest) + + return + } + + var form NewMotionForm + + err = app.formDecoder.Decode(&form, r.PostForm) + if err != nil { + app.errorLog.Printf("form decoding failed: %v", err) + + app.clientError(w, http.StatusBadRequest) + + return + } + + form.CheckField(validator.NotBlank(form.Title), "title", "This field cannot be blank") + form.CheckField(validator.MinChars(form.Title, minimumTitleLength), "title", fmt.Sprintf("This field must be at least %d characters long", minimumTitleLength)) + form.CheckField(validator.MaxChars(form.Title, maximumTitleLength), "title", fmt.Sprintf("This field must be at most %d characters long", maximumTitleLength)) + form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank") + form.CheckField(validator.MinChars(form.Content, minimumContentLength), "content", fmt.Sprintf("This field must be at least %d characters long", minimumContentLength)) + form.CheckField(validator.MaxChars(form.Content, maximumContentLength), "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") + + if !form.Valid() { + form.Voter = &models.Voter{} + + data := app.newTemplateData(r) + data.Form = form + + app.render(w, http.StatusUnprocessableEntity, "create_motion.html", data) + + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(fmt.Sprintf("%+v", form))) + // TODO: insert motion + + // TODO: redirect to motion detail page } func (app *application) voteForm(writer http.ResponseWriter, request *http.Request) { diff --git a/cmd/boardvoting/jobs.go b/cmd/boardvoting/jobs.go index 90b1666..6a766ad 100644 --- a/cmd/boardvoting/jobs.go +++ b/cmd/boardvoting/jobs.go @@ -68,8 +68,8 @@ func (r *RemindVotersJob) Run() { defer func(r *RemindVotersJob) { r.reschedule <- r }(r) var ( - voters []models.Voter - decisions []models.Motion + voters []*models.Voter + decisions []*models.Motion err error ) @@ -85,7 +85,7 @@ func (r *RemindVotersJob) Run() { for _, voter := range voters { v := voter - decisions, err = r.decisions.UnVotedDecisionsForVoter(ctx, &v) + decisions, err = r.decisions.UnVotedDecisionsForVoter(ctx, v) if err != nil { r.errorLog.Printf("problem getting unvoted decisions: %v", err) diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go index ec0a71e..82d8279 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -19,7 +19,6 @@ limitations under the License. package main import ( - "context" "crypto/tls" "crypto/x509" "database/sql" @@ -30,7 +29,11 @@ import ( "log" "net/http" "os" + "time" + "github.com/alexedwards/scs/sqlite3store" + "github.com/alexedwards/scs/v2" + "github.com/go-playground/form/v4" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" @@ -53,6 +56,9 @@ type application struct { mailConfig *mailConfig baseURL string templateCache map[string]*template.Template + sessionManager *scs.SessionManager + formDecoder *form.Decoder + csrfKey []byte } func main() { @@ -64,12 +70,7 @@ func main() { infoLog.Printf("CAcert Board Voting version %s, commit %s built at %s", version, commit, date) - ctx, err := parseConfig(context.Background(), *configFile) - if err != nil { - errorLog.Fatal(err) - } - - config, err := GetConfig(ctx) + config, err := parseConfig(*configFile) if err != nil { errorLog.Fatal(err) } @@ -92,14 +93,21 @@ func main() { errorLog.Fatal(err) } + sessionManager := scs.New() + sessionManager.Store = sqlite3store.New(db.DB) + sessionManager.Lifetime = 12 * time.Hour + app := &application{ - errorLog: errorLog, - infoLog: infoLog, - motions: &models.MotionModel{DB: db, InfoLog: infoLog}, - voters: &models.VoterModel{DB: db}, - mailConfig: config.MailConfig, - baseURL: config.BaseURL, - templateCache: templateCache, + errorLog: errorLog, + infoLog: infoLog, + motions: &models.MotionModel{DB: db, InfoLog: infoLog}, + voters: &models.VoterModel{DB: db}, + mailConfig: config.MailConfig, + baseURL: config.BaseURL, + templateCache: templateCache, + sessionManager: sessionManager, + csrfKey: config.CsrfKey, + formDecoder: setupFormDecoder(), } app.NewMailNotifier() @@ -133,6 +141,16 @@ func main() { } } +func setupFormDecoder() *form.Decoder { + decoder := form.NewDecoder() + + decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) { + return models.VoteTypeFromString(values[0]) + }, new(models.VoteType)) + + return decoder +} + func (app *application) startHTTPSServer(config *Config) error { tlsConfig, err := setupTLSConfig(config) if err != nil { diff --git a/cmd/boardvoting/notifications.go b/cmd/boardvoting/notifications.go index f0295fd..a78ae8f 100644 --- a/cmd/boardvoting/notifications.go +++ b/cmd/boardvoting/notifications.go @@ -131,15 +131,15 @@ func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) { } type RemindVoterNotification struct { - voter models.Voter - decisions []models.Motion + voter *models.Voter + decisions []*models.Motion } func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent { return &NotificationContent{ template: "remind_voter_mail.txt", data: struct { - Decisions []models.Motion + Decisions []*models.Motion Name string }{Decisions: r.decisions, Name: r.voter.Name}, subject: "Outstanding CAcert board votes", diff --git a/cmd/boardvoting/routes.go b/cmd/boardvoting/routes.go index 0c0b26c..11c5dc1 100644 --- a/cmd/boardvoting/routes.go +++ b/cmd/boardvoting/routes.go @@ -22,6 +22,7 @@ import ( "io/fs" "net/http" + "github.com/gorilla/csrf" "github.com/julienschmidt/httprouter" "github.com/justinas/alice" "github.com/vearutop/statigz" @@ -48,21 +49,31 @@ func (app *application) routes() http.Handler { app.serverError(w, fmt.Errorf("%s", err)) } - router.Handler(http.MethodGet, "/", http.RedirectHandler("/motions/", http.StatusMovedPermanently)) + router.Handler( + http.MethodGet, + "/", + http.RedirectHandler("/motions/", http.StatusMovedPermanently), + ) router.Handler( http.MethodGet, "/favicon.ico", http.RedirectHandler("/static/images/favicon.ico", http.StatusMovedPermanently), ) router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer)) - router.HandlerFunc(http.MethodGet, "/motions/", app.motionList) - router.HandlerFunc(http.MethodGet, "/motions/:tag", app.motionDetails) - router.HandlerFunc(http.MethodGet, "/vote/:tag", app.voteForm) - router.HandlerFunc(http.MethodPost, "/vote/:tag", app.voteSubmit) - router.HandlerFunc(http.MethodGet, "/proxy/:tag", app.proxyVoteForm) - router.HandlerFunc(http.MethodPost, "/proxy/:tag", app.proxyVoteSubmit) - router.HandlerFunc(http.MethodGet, "/newmotion/", app.newMotionForm) - router.HandlerFunc(http.MethodPost, "/newmotion/", app.newMotionSubmit) + + dynamic := alice.New(app.sessionManager.LoadAndSave, csrf.Protect( + app.csrfKey, + csrf.SameSite(csrf.SameSiteStrictMode), + )) + + 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)) standard := alice.New(app.logRequest, secureHeaders) diff --git a/go.mod b/go.mod index febdd5b..e406ea3 100644 --- a/go.mod +++ b/go.mod @@ -33,8 +33,13 @@ require ( require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.1.1 // indirect + github.com/alexedwards/scs v1.4.1 // indirect + github.com/alexedwards/scs/sqlite3store v0.0.0-20220216073957-c252878bcf5a // indirect + github.com/alexedwards/scs/v2 v2.5.0 // indirect github.com/andybalholm/brotli v1.0.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-playground/form/v4 v4.2.0 // indirect + github.com/gorilla/schema v1.2.0 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect diff --git a/go.sum b/go.sum index 6afcfed..8b0558b 100644 --- a/go.sum +++ b/go.sum @@ -121,6 +121,12 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alexedwards/scs v1.4.1 h1:/5L5a07IlqApODcEfZyMsu8Smd1S7Q4nBjEyKxIRTp0= +github.com/alexedwards/scs v1.4.1/go.mod h1:JRIFiXthhMSivuGbxpzUa0/hT5rz2hpyw61Bmd+S1bg= +github.com/alexedwards/scs/sqlite3store v0.0.0-20220216073957-c252878bcf5a h1:5SCXvM8hruEAoNdKHVte0v3uVKqWLjDQeq4KIfFGqpM= +github.com/alexedwards/scs/sqlite3store v0.0.0-20220216073957-c252878bcf5a/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= +github.com/alexedwards/scs/v2 v2.5.0 h1:zgxOfNFmiJyXG7UPIuw1g2b9LWBeRLh3PjfB9BDmfL4= +github.com/alexedwards/scs/v2 v2.5.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk= github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= @@ -456,6 +462,9 @@ github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dp github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/form/v4 v4.2.0 h1:N1wh+Goz61e6w66vo8vJkQt+uwZSoLz50kZPJWR8eic= +github.com/go-playground/form/v4 v4.2.0/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= @@ -617,6 +626,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= +github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= diff --git a/internal/migrations/2022052201_add_sessions_tables.down.sql b/internal/migrations/2022052201_add_sessions_tables.down.sql new file mode 100644 index 0000000..03b28eb --- /dev/null +++ b/internal/migrations/2022052201_add_sessions_tables.down.sql @@ -0,0 +1,3 @@ +-- drop sessions table for server side session storage +DROP INDEX session_expiry_idx; +DROP TABLE sessions; \ No newline at end of file diff --git a/internal/migrations/2022052201_add_sessions_tables.up.sql b/internal/migrations/2022052201_add_sessions_tables.up.sql new file mode 100644 index 0000000..107aec8 --- /dev/null +++ b/internal/migrations/2022052201_add_sessions_tables.up.sql @@ -0,0 +1,8 @@ +-- add sessions table for server side session storage +CREATE TABLE sessions +( + token char(43) PRIMARY KEY, + data BLOB NOT NULL, + expiry TIMESTAMP NOT NULL +); +CREATE INDEX session_expiry_idx ON sessions (expiry); \ No newline at end of file diff --git a/internal/models/motions.go b/internal/models/motions.go index 019ea71..ae93f59 100644 --- a/internal/models/motions.go +++ b/internal/models/motions.go @@ -23,34 +23,49 @@ import ( "errors" "fmt" "log" + "strings" "time" "github.com/jmoiron/sqlx" ) -type VoteType uint8 +type VoteType struct { + label string + id uint8 +} const unknownVariant = "unknown" -const ( - VoteTypeMotion VoteType = 0 - VoteTypeVeto VoteType = 1 +var ( + VoteTypeMotion = &VoteType{label: "motion", id: 0} + VoteTypeVeto = &VoteType{label: "veto", id: 1} ) -var voteTypeLabels = map[VoteType]string{ - VoteTypeMotion: "motion", - VoteTypeVeto: "veto", +func (v *VoteType) String() string { + return v.label } -func (v VoteType) String() string { - if label, ok := voteTypeLabels[v]; ok { - return label +func VoteTypeFromString(label string) (*VoteType, error) { + for _, vt := range []*VoteType{VoteTypeMotion, VoteTypeVeto} { + if strings.EqualFold(vt.label, label) { + return vt, nil + } } - return unknownVariant + return nil, fmt.Errorf("unknown vote type %s", label) } -func (v VoteType) QuorumAndMajority() (int, float32) { +func VoteTypeFromUint8(id uint8) (*VoteType, error) { + for _, vt := range []*VoteType{VoteTypeMotion, VoteTypeVeto} { + if vt.id == id { + return vt, nil + } + } + + return nil, fmt.Errorf("unknown vote type id %d", id) +} + +func (v *VoteType) QuorumAndMajority() (int, float32) { const ( majorityDefault = 0.99 majorityMotion = 0.50 @@ -300,7 +315,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, _ *Voter) ([]*Motion, error) { panic("not implemented") } diff --git a/internal/models/voters.go b/internal/models/voters.go index 47ec118..a76994f 100644 --- a/internal/models/voters.go +++ b/internal/models/voters.go @@ -33,6 +33,10 @@ type VoterModel struct { DB *sqlx.DB } -func (m VoterModel) GetReminderVoters(_ context.Context) ([]Voter, error) { +func (m VoterModel) GetReminderVoters(_ context.Context) ([]*Voter, error) { + panic("not implemented") +} + +func (m VoterModel) GetVoter(ctx context.Context, emails []string) (*Voter, error) { panic("not implemented") } diff --git a/internal/validator/validator.go b/internal/validator/validator.go new file mode 100644 index 0000000..4f1fdcb --- /dev/null +++ b/internal/validator/validator.go @@ -0,0 +1,62 @@ +package validator + +import ( + "strings" + "unicode/utf8" +) + +type Validator struct { + FieldErrors map[string]string +} + +func (v *Validator) Valid() bool { + return len(v.FieldErrors) == 0 +} + +func (v *Validator) AddFieldError(key, message string) { + if v.FieldErrors == nil { + v.FieldErrors = make(map[string]string) + } + + if _, exists := v.FieldErrors[key]; !exists { + v.FieldErrors[key] = message + } +} + +func (v *Validator) CheckField(ok bool, key, message string) { + if !ok { + v.AddFieldError(key, message) + } +} + +func NotBlank(value string) bool { + return strings.TrimSpace(value) != "" +} + +func MaxChars(value string, n int) bool { + return utf8.RuneCountInString(value) <= n +} + +func MinChars(value string, n int) bool { + return utf8.RuneCountInString(value) >= n +} + +func PermittedInt(value int, permittedValues ...int) bool { + for i := range permittedValues { + if value == permittedValues[i] { + return true + } + } + + return false +} + +func PermittedStr(value string, permittedValues ...string) bool { + for i := range permittedValues { + if value == permittedValues[i] { + return true + } + } + + return false +} diff --git a/ui/html/pages/create_motion.html b/ui/html/pages/create_motion.html new file mode 100644 index 0000000..9037539 --- /dev/null +++ b/ui/html/pages/create_motion.html @@ -0,0 +1,72 @@ +{{ define "title" }}CAcert Board Decisions: New motion{{ end }} + +{{ define "main" }} +
+
+ {{ csrfField .Request }} +
+
+
+ + (generated on submit) +
+
+ + {{ .Form.Voter.Name }} +
+
+ + (auto filled to current date/time) +
+
+
+ + + {{ if .Form.FieldErrors.title }} + {{ .Form.FieldErrors.title }} + {{ end }} +
+
+ + + {{ if .Form.FieldErrors.content }} + {{ .Form.FieldErrors.content }} + {{ end }} +
+
+
+ + {{ $voteType := toString .Form.Type }} + + {{ if .Form.FieldErrors.type }} + {{ .Form.FieldErrors.type}} + {{ end}} +
+
+ + + {{ if .Form.FieldErrors.due }} + {{ .Form.FieldErrors.due }} + {{ end }} +
+
+ +
+
+
+{{ end }} \ No newline at end of file