summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJan Dittberner <jan@dittberner.info>2017-04-18 02:34:21 +0200
committerJan Dittberner <jan@dittberner.info>2017-04-22 00:12:32 +0200
commit471daf12ea98aa2a7fcb11deff9697403c5dd6a5 (patch)
tree995daa7635955bef5b973f38ecf88cf3b2a2d82f
parent57e3d5324559255e57655f71b333edc6c54fabdf (diff)
downloadcacert-boardvoting-471daf12ea98aa2a7fcb11deff9697403c5dd6a5.tar.gz
cacert-boardvoting-471daf12ea98aa2a7fcb11deff9697403c5dd6a5.tar.xz
cacert-boardvoting-471daf12ea98aa2a7fcb11deff9697403c5dd6a5.zip
Partialy add new motion creation
-rw-r--r--actions.go12
-rw-r--r--boardvoting.go117
-rw-r--r--config.yaml.example3
-rw-r--r--forms.go54
-rw-r--r--models.go37
-rw-r--r--templates/header.html7
-rw-r--r--templates/newmotion_form.html73
7 files changed, 272 insertions, 31 deletions
diff --git a/actions.go b/actions.go
index f41e1ff..190314c 100644
--- a/actions.go
+++ b/actions.go
@@ -6,6 +6,18 @@ import (
"text/template"
)
+func CreateMotion(decision *Decision, voter *Voter) (err error) {
+ decision.ProponentId = voter.Id
+ err = decision.Save()
+ if err != nil {
+ logger.Println("Error saving motion:", err)
+ return
+ }
+
+ // TODO: implement fetching new decision, implement mail
+ return
+}
+
func WithdrawMotion(decision *Decision, voter *Voter) (err error) {
// load template, fill name, tag, title, content
type mailContext struct {
diff --git a/boardvoting.go b/boardvoting.go
index 1fe031e..c467b40 100644
--- a/boardvoting.go
+++ b/boardvoting.go
@@ -4,8 +4,10 @@ import (
"context"
"crypto/tls"
"crypto/x509"
+ "encoding/base64"
"fmt"
"github.com/Masterminds/sprig"
+ "github.com/gorilla/sessions"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"gopkg.in/yaml.v2"
@@ -20,17 +22,20 @@ import (
var logger *log.Logger
var config *Config
+var store *sessions.CookieStore
-func getTemplateFilenames(tmpl []string) (result []string) {
- result = make([]string, len(tmpl))
- for i := range tmpl {
- result[i] = fmt.Sprintf("templates/%s", tmpl[i])
+const sessionCookieName = "votesession"
+
+func getTemplateFilenames(templates []string) (result []string) {
+ result = make([]string, len(templates))
+ for i := range templates {
+ result[i] = fmt.Sprintf("templates/%s", templates[i])
}
return result
}
-func renderTemplate(w http.ResponseWriter, tmpl []string, context interface{}) {
- t := template.Must(template.New(tmpl[0]).Funcs(sprig.FuncMap()).ParseFiles(getTemplateFilenames(tmpl)...))
+func renderTemplate(w http.ResponseWriter, templates []string, context interface{}) {
+ t := template.Must(template.New(templates[0]).Funcs(sprig.FuncMap()).ParseFiles(getTemplateFilenames(templates)...))
if err := t.Execute(w, context); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -76,7 +81,7 @@ type motionParameters struct {
}
type motionListParameters struct {
- Page int64
+ Page int64
Flags struct {
Confirmed, Withdraw, Unvoted bool
}
@@ -85,7 +90,6 @@ type motionListParameters struct {
func parseMotionParameters(r *http.Request) motionParameters {
var m = motionParameters{}
m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes"))
- logger.Printf("parsed parameters: %+v\n", m)
return m
}
@@ -102,12 +106,16 @@ func parseMotionListParameters(r *http.Request) motionListParameters {
if r.Method == http.MethodPost {
m.Flags.Confirmed, _ = strconv.ParseBool(r.PostFormValue("confirm"))
}
- logger.Printf("parsed parameters: %+v\n", m)
return m
}
func motionListHandler(w http.ResponseWriter, r *http.Request) {
params := parseMotionListParameters(r)
+ session, err := store.Get(r, sessionCookieName)
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
var templateContext struct {
Decisions []*DecisionForDisplay
@@ -115,12 +123,16 @@ func motionListHandler(w http.ResponseWriter, r *http.Request) {
Params *motionListParameters
PrevPage, NextPage int64
PageTitle string
+ Flashes interface{}
}
if voter, ok := r.Context().Value(ctxVoter).(*Voter); ok {
templateContext.Voter = voter
}
+ if flashes := session.Flashes(); len(flashes) > 0 {
+ templateContext.Flashes = flashes
+ }
+ session.Save(r, w)
templateContext.Params = &params
- var err error
if params.Flags.Unvoted && templateContext.Voter != nil {
if templateContext.Decisions, err = FindVotersUnvotedDecisionsForDisplayOnPage(
@@ -168,6 +180,7 @@ func motionHandler(w http.ResponseWriter, r *http.Request) {
Params *motionParameters
PrevPage, NextPage int64
PageTitle string
+ Flashes interface{}
}
voter, ok := getVoterFromRequest(r)
if ok {
@@ -304,16 +317,61 @@ func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
})
return
default:
- fmt.Fprintf(w, "No handler for '%s'", subURL)
+ http.NotFound(w, r)
return
}
}
func newMotionHandler(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintln(w, "New motion")
- voter, _ := getVoterFromRequest(r)
- fmt.Fprintf(w, "%+v\n", voter)
- // TODO: implement
+ voter, ok := getVoterFromRequest(r)
+ if !ok {
+ http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
+ }
+
+ templates := []string{"newmotion_form.html", "header.html", "footer.html"}
+ var templateContext struct {
+ Form NewDecisionForm
+ PageTitle string
+ Voter *Voter
+ Flashes interface{}
+ }
+ switch r.Method {
+ case http.MethodPost:
+ form := NewDecisionForm{
+ Title: r.FormValue("Title"),
+ Content: r.FormValue("Content"),
+ VoteType: r.FormValue("VoteType"),
+ Due: r.FormValue("Due"),
+ }
+
+ if valid, data := form.Validate(); !valid {
+ templateContext.Voter = voter
+ templateContext.Form = form
+ renderTemplate(w, templates, templateContext)
+ } else {
+ if err := CreateMotion(data, voter); err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ session, err := store.Get(r, sessionCookieName)
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ session.AddFlash("The motion has been proposed!")
+ session.Save(r, w)
+
+ http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect)
+ }
+
+ return
+ default:
+ templateContext.Voter = voter
+ templateContext.Form = NewDecisionForm{
+ VoteType: strconv.FormatInt(voteTypeMotion, 10),
+ }
+ renderTemplate(w, templates, templateContext)
+ }
}
type Config struct {
@@ -323,30 +381,33 @@ type Config struct {
ClientCACertificates string `yaml:"client_ca_certificates"`
ServerCert string `yaml:"server_certificate"`
ServerKey string `yaml:"server_key"`
+ CookieSecret string `yaml:"cookie_secret"`
}
-func main() {
+func init() {
logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile)
- var filename = "config.yaml"
- if len(os.Args) == 2 {
- filename = os.Args[1]
- }
-
- var err error
-
- var source []byte
-
- source, err = ioutil.ReadFile(filename)
+ source, err := ioutil.ReadFile("config.yaml")
if err != nil {
logger.Fatal(err)
}
- err = yaml.Unmarshal(source, &config)
+ if err := yaml.Unmarshal(source, &config); err != nil {
+ logger.Fatal(err)
+ }
+
+ cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret)
if err != nil {
logger.Fatal(err)
}
- logger.Printf("read configuration %v", config)
+ if len(cookieSecret) < 32 {
+ logger.Fatalln("Cookie secret is less than 32 bytes long")
+ }
+ store = sessions.NewCookieStore(cookieSecret)
+ logger.Println("read configuration")
+}
+func main() {
+ var err error
db, err = sqlx.Open("sqlite3", config.DatabaseFile)
if err != nil {
logger.Fatal(err)
diff --git a/config.yaml.example b/config.yaml.example
index d24977e..fac503d 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -4,4 +4,5 @@ notice_sender_address: cacert-board-votes@lists.cacert.org
database_file: database.sqlite
client_ca_certificates: cacert_class3.pem
server_certificate: server.crt
-server_key: server.key \ No newline at end of file
+server_key: server.key
+cookie_secret: base64encoded_random_byte_value_of_at_least_32_bytes \ No newline at end of file
diff --git a/forms.go b/forms.go
new file mode 100644
index 0000000..d5359ff
--- /dev/null
+++ b/forms.go
@@ -0,0 +1,54 @@
+package main
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var validDueDurations = map[string]time.Duration{
+ "+3 days": time.Hour * 24 * 3,
+ "+7 days": time.Hour * 24 * 7,
+ "+14 days": time.Hour * 24 * 14,
+ "+28 days": time.Hour * 24 * 28,
+}
+
+type NewDecisionForm struct {
+ Title string
+ Content string
+ VoteType string
+ Due string
+ Errors map[string]string
+}
+
+func (f *NewDecisionForm) Validate() (bool, *Decision) {
+ f.Errors = make(map[string]string)
+
+ data := &Decision{}
+
+ data.Title = strings.TrimSpace(f.Title)
+ if len(data.Title) < 3 {
+ f.Errors["Title"] = "Please enter at least 3 characters."
+ }
+
+ data.Content = strings.TrimSpace(f.Content)
+ if len(strings.Fields(data.Content)) < 3 {
+ f.Errors["Content"] = "Please enter at least 3 words."
+ }
+
+ if voteType, err := strconv.ParseUint(f.VoteType, 10, 8); err != nil || (voteType != 0 && voteType != 1) {
+ f.Errors["VoteType"] = fmt.Sprint("Please choose a valid vote type", err)
+ } else {
+ data.VoteType = VoteType(uint8(voteType))
+ }
+
+ if dueDuration, ok := validDueDurations[f.Due]; !ok {
+ f.Errors["Due"] = "Please choose a valid due date"
+ } else {
+ year, month, day := time.Now().UTC().Add(dueDuration).Date()
+ data.Due = time.Date(year, month, day, 23,59,59,0, time.UTC)
+ }
+
+ return len(f.Errors) == 0, data
+}
diff --git a/models.go b/models.go
index 46d669e..410ed3e 100644
--- a/models.go
+++ b/models.go
@@ -51,12 +51,26 @@ WHERE decisions.status=0 AND decisions.id NOT IN (
SELECT decision FROM votes WHERE votes.voter=$2)
ORDER BY proposed DESC
LIMIT 10 OFFSET 10 * $1`
+ sqlCreateDecision = `
+INSERT INTO decisions (
+ proposed, proponent, title, content, votetype, status, due, modified,tag
+) VALUES (
+ datetime('now','utc'), :proponent, :title, :content, :votetype, 0,
+ :due,
+ datetime('now','utc'),
+ 'm' || strftime('%Y%m%d','now') || '.' || (
+ SELECT COUNT(*)+1 AS num
+ FROM decisions
+ WHERE proposed BETWEEN date('now') AND date('now','1 day')
+ )
+)
+`
)
var db *sqlx.DB
-type VoteType int
-type VoteStatus int
+type VoteType uint8
+type VoteStatus int8
type Decision struct {
Id int
@@ -332,6 +346,25 @@ func (d *Decision) OlderExists() (result bool, err error) {
return
}
+func (d *Decision) Save() (err error) {
+ insertDecisionStmt, err := db.PrepareNamed(sqlCreateDecision)
+ if err != nil {
+ logger.Println("Error preparing statement:", err)
+ return
+ }
+ defer insertDecisionStmt.Close()
+
+ result, err := insertDecisionStmt.Exec(d)
+ if err != nil {
+ logger.Println("Error creating motion:", err)
+ return
+ }
+ logger.Println(result)
+ // TODO: implement fetch last id from result
+ // TODO: load decision from DB
+ return
+}
+
func FindVoterByAddress(emailAddress string) (voter *Voter, err error) {
findVoterStmt, err := db.Preparex(sqlGetVoter)
if err != nil {
diff --git a/templates/header.html b/templates/header.html
index 5f777bb..a6fc69e 100644
--- a/templates/header.html
+++ b/templates/header.html
@@ -12,4 +12,11 @@ CAcert Board Decisions{{ if .PageTitle }} - {{ .PageTitle }}{{ end}}
</head>
<body>
<h1>{{ template "pagetitle" . }}</h1>
+{{ with .Flashes }}
+<ul class="flash-messages">
+{{ range . }}
+ <li>{{ . }}</li>
+{{ end }}
+</ul>
+{{ end }}
{{ end }} \ No newline at end of file
diff --git a/templates/newmotion_form.html b/templates/newmotion_form.html
new file mode 100644
index 0000000..b7573d4
--- /dev/null
+++ b/templates/newmotion_form.html
@@ -0,0 +1,73 @@
+{{ template "header" . }}
+<form action="/newmotion/" method="post">
+ <table>
+ <tr>
+ <td>ID:</td>
+ <td>(generated on submit)</td>
+ </tr>
+ <tr>
+ <td>Proponent:</td>
+ <td>{{ .Voter.Name }}</td>
+ </tr>
+ <tr>
+ <td>Proposed date/time:</td>
+ <td>(auto filled to current date/time)</td>
+ </tr>
+ <tr>
+ <td>Title:</td>
+ <td><input name="Title" value="{{ .Form.Title }}"/>
+ {{ with .Form.Errors.Title }}
+ <span class="error">{{ . }}</span>
+ {{ end }}
+ </td>
+ </tr>
+ <tr>
+ <td>Text:</td>
+ <td><textarea name="Content">{{ .Form.Content }}</textarea>
+ {{ with .Form.Errors.Content }}
+ <span class="error">{{ . }}</span>
+ {{ end }}
+ </td>
+ </tr>
+ <tr>
+ <td>Vote type:</td>
+ <td>
+ <select name="VoteType">
+ <option value="0"
+ {{ if eq "0" .Form.VoteType }}selected{{ end }}>
+ Motion
+ </option>
+ <option value="1"
+ {{ if eq "1" .Form.VoteType }}selected{{ end }}>Veto
+ </option>
+ </select>
+ {{ with .Form.Errors.VoteType }}
+ <span class="error">{{ . }}</span>
+ {{ end }}
+ </td>
+ </tr>
+ <tr>
+ <td rowspan="2">Due:</td>
+ <td>(autofilled from option below)</td>
+ </tr>
+ <tr>
+ <td>
+ <select name="Due">
+ <option value="+3 days">In 3 Days</option>
+ <option value="+7 days">In 1 Week</option>
+ <option value="+14 days">In 2 Weeks</option>
+ <option value="+28 days">In 4 Weeks</option>
+ </select>
+ {{ with .Form.Errors.Due }}
+ <span class="error">{{ . }}</span>
+ {{ end }}
+ </td>
+ </tr>
+ <tr>
+ <td>&nbsp;</td>
+ <td><input type="submit" value="Propose"/></td>
+ </tr>
+ </table>
+</form>
+<a href="/motions/">Back to motions</a>
+{{ template "footer" . }} \ No newline at end of file