Implement new motion form
- add session handler - add form decoder from go-playground - implement custom decoder for VoteType
This commit is contained in:
parent
47af34f1cd
commit
e1af6876c1
14 changed files with 350 additions and 102 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
5
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
|
||||
|
|
11
go.sum
11
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=
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
-- drop sessions table for server side session storage
|
||||
DROP INDEX session_expiry_idx;
|
||||
DROP TABLE sessions;
|
|
@ -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);
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
62
internal/validator/validator.go
Normal file
62
internal/validator/validator.go
Normal 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
|
||||
}
|
72
ui/html/pages/create_motion.html
Normal file
72
ui/html/pages/create_motion.html
Normal 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 }}
|
Loading…
Reference in a new issue