Implement new motion form

- add session handler
- add form decoder from go-playground
- implement custom decoder for VoteType
This commit is contained in:
Jan Dittberner 2022-05-22 21:15:54 +02:00
parent 47af34f1cd
commit e1af6876c1
14 changed files with 350 additions and 102 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

5
go.mod
View file

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

11
go.sum
View file

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

View file

@ -0,0 +1,3 @@
-- drop sessions table for server side session storage
DROP INDEX session_expiry_idx;
DROP TABLE sessions;

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,72 @@
{{ define "title" }}CAcert Board Decisions: New motion{{ end }}
{{ define "main" }}
<div class="ui raised segment">
<form action="/newmotion/" method="post">
{{ csrfField .Request }}
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
<div class="three fields">
<div class="field">
<label>ID:</label>
(generated on submit)
</div>
<div class="field">
<label>Proponent:</label>
{{ .Form.Voter.Name }}
</div>
<div class="field">
<label>Proposed date/time:</label>
(auto filled to current date/time)
</div>
</div>
<div class="required field{{ if .Form.FieldErrors.title }} error{{ end }}">
<label for="title">Title:</label>
<input id="title" name="title" type="text" value="{{ .Form.Title }}">
{{ if .Form.FieldErrors.title }}
<span class="ui small error text">{{ .Form.FieldErrors.title }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.content }} error{{ end }}">
<label for="content">Text:</label>
<textarea id="content" name="content">{{ .Form.Content }}</textarea>
{{ if .Form.FieldErrors.content }}
<span class="ui small error text">{{ .Form.FieldErrors.content }}</span>
{{ end }}
</div>
<div class="two fields">
<div class="required field{{ if .Form.FieldErrors.type }} error{{ end }}">
<label for="type">Vote type:</label>
{{ $voteType := toString .Form.Type }}
<select id="type" name="type">
<option value="motion"
{{ if eq "motion" $voteType }}selected{{ end }}>
Motion
</option>
<option value="veto"
{{ if eq "veto" $voteType }}selected{{ end }}>
Veto
</option>
</select>
{{ if .Form.FieldErrors.type }}
<span class="ui small error text">{{ .Form.FieldErrors.type}}</span>
{{ end}}
</div>
<div class="required field{{ if .Form.FieldErrors.due }} error{{ end }}">
<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>
</select>
{{ if .Form.FieldErrors.due }}
<span class="ui small error text">{{ .Form.FieldErrors.due }}</span>
{{ end }}
</div>
</div>
<button class="ui button" type="submit">Propose</button>
</div>
</form>
</div>
{{ end }}