Implement reminder job
[cacert-boardvoting.git] / boardvoting.go
1 package main
2
3 import (
4 "context"
5 "crypto/tls"
6 "crypto/x509"
7 "encoding/base64"
8 "fmt"
9 "github.com/Masterminds/sprig"
10 "github.com/gorilla/sessions"
11 "github.com/jmoiron/sqlx"
12 _ "github.com/mattn/go-sqlite3"
13 "gopkg.in/yaml.v2"
14 "html/template"
15 "io/ioutil"
16 "log"
17 "net/http"
18 "os"
19 "strconv"
20 "strings"
21 "time"
22 )
23
24 var logger *log.Logger
25 var config *Config
26 var store *sessions.CookieStore
27 var version = "undefined"
28 var build = "undefined"
29
30 const sessionCookieName = "votesession"
31
32 func getTemplateFilenames(templates []string) (result []string) {
33 result = make([]string, len(templates))
34 for i := range templates {
35 result[i] = fmt.Sprintf("templates/%s", templates[i])
36 }
37 return result
38 }
39
40 func renderTemplate(w http.ResponseWriter, templates []string, context interface{}) {
41 t := template.Must(template.New(templates[0]).Funcs(sprig.FuncMap()).ParseFiles(getTemplateFilenames(templates)...))
42 if err := t.Execute(w, context); err != nil {
43 http.Error(w, err.Error(), http.StatusInternalServerError)
44 }
45 }
46
47 type contextKey int
48
49 const (
50 ctxNeedsAuth contextKey = iota
51 ctxVoter
52 ctxDecision
53 )
54
55 func authenticateRequest(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) {
56 for _, cert := range r.TLS.PeerCertificates {
57 for _, extKeyUsage := range cert.ExtKeyUsage {
58 if extKeyUsage == x509.ExtKeyUsageClientAuth {
59 for _, emailAddress := range cert.EmailAddresses {
60 voter, err := FindVoterByAddress(emailAddress)
61 if err != nil {
62 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
63 return
64 }
65 if voter != nil {
66 handler(w, r.WithContext(context.WithValue(r.Context(), ctxVoter, voter)))
67 return
68 }
69 }
70 }
71 }
72 }
73 needsAuth, ok := r.Context().Value(ctxNeedsAuth).(bool)
74 if ok && needsAuth {
75 w.WriteHeader(http.StatusForbidden)
76 renderTemplate(w, []string{"denied.html"}, nil)
77 return
78 }
79 handler(w, r)
80 }
81
82 type motionParameters struct {
83 ShowVotes bool
84 }
85
86 type motionListParameters struct {
87 Page int64
88 Flags struct {
89 Confirmed, Withdraw, Unvoted bool
90 }
91 }
92
93 func parseMotionParameters(r *http.Request) motionParameters {
94 var m = motionParameters{}
95 m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes"))
96 return m
97 }
98
99 func parseMotionListParameters(r *http.Request) motionListParameters {
100 var m = motionListParameters{}
101 if page, err := strconv.ParseInt(r.URL.Query().Get("page"), 10, 0); err != nil {
102 m.Page = 1
103 } else {
104 m.Page = page
105 }
106 m.Flags.Withdraw, _ = strconv.ParseBool(r.URL.Query().Get("withdraw"))
107 m.Flags.Unvoted, _ = strconv.ParseBool(r.URL.Query().Get("unvoted"))
108
109 if r.Method == http.MethodPost {
110 m.Flags.Confirmed, _ = strconv.ParseBool(r.PostFormValue("confirm"))
111 }
112 return m
113 }
114
115 func motionListHandler(w http.ResponseWriter, r *http.Request) {
116 params := parseMotionListParameters(r)
117 session, err := store.Get(r, sessionCookieName)
118 if err != nil {
119 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
120 return
121 }
122
123 var templateContext struct {
124 Decisions []*DecisionForDisplay
125 Voter *Voter
126 Params *motionListParameters
127 PrevPage, NextPage int64
128 PageTitle string
129 Flashes interface{}
130 }
131 if voter, ok := r.Context().Value(ctxVoter).(*Voter); ok {
132 templateContext.Voter = voter
133 }
134 if flashes := session.Flashes(); len(flashes) > 0 {
135 templateContext.Flashes = flashes
136 }
137 session.Save(r, w)
138 templateContext.Params = &params
139
140 if templateContext.Decisions, err = FindDecisionsForDisplayOnPage(params.Page, params.Flags.Unvoted, templateContext.Voter); err != nil {
141 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
142 return
143 }
144
145 if len(templateContext.Decisions) > 0 {
146 olderExists, err := templateContext.Decisions[len(templateContext.Decisions)-1].OlderExists(params.Flags.Unvoted, templateContext.Voter)
147 if err != nil {
148 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
149 return
150 }
151 if olderExists {
152 templateContext.NextPage = params.Page + 1
153 }
154 }
155
156 if params.Page > 1 {
157 templateContext.PrevPage = params.Page - 1
158 }
159
160 renderTemplate(w, []string{"motions.html", "motion_fragments.html", "header.html", "footer.html"}, templateContext)
161 }
162
163 func motionHandler(w http.ResponseWriter, r *http.Request) {
164 params := parseMotionParameters(r)
165
166 decision, ok := getDecisionFromRequest(r)
167 if !ok {
168 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
169 return
170 }
171
172 var templateContext struct {
173 Decision *DecisionForDisplay
174 Voter *Voter
175 Params *motionParameters
176 PrevPage, NextPage int64
177 PageTitle string
178 Flashes interface{}
179 }
180 voter, ok := getVoterFromRequest(r)
181 if ok {
182 templateContext.Voter = voter
183 }
184 templateContext.Params = &params
185 if params.ShowVotes {
186 if err := decision.LoadVotes(); err != nil {
187 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
188 return
189 }
190 }
191 templateContext.Decision = decision
192 templateContext.PageTitle = fmt.Sprintf("Motion %s: %s", decision.Tag, decision.Title)
193 renderTemplate(w, []string{"motion.html", "motion_fragments.html", "header.html", "footer.html"}, templateContext)
194 }
195
196 func singleDecisionHandler(w http.ResponseWriter, r *http.Request, tag string, handler func(http.ResponseWriter, *http.Request)) {
197 decision, err := FindDecisionForDisplayByTag(tag)
198 if err != nil {
199 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
200 return
201 }
202 if decision == nil {
203 http.NotFound(w, r)
204 return
205 }
206 handler(w, r.WithContext(context.WithValue(r.Context(), ctxDecision, decision)))
207 }
208
209 type motionActionHandler interface {
210 Handle(w http.ResponseWriter, r *http.Request)
211 NeedsAuth() bool
212 }
213
214 type authenticationRequiredHandler struct{}
215
216 func (authenticationRequiredHandler) NeedsAuth() bool {
217 return true
218 }
219
220 func getVoterFromRequest(r *http.Request) (voter *Voter, ok bool) {
221 voter, ok = r.Context().Value(ctxVoter).(*Voter)
222 return
223 }
224
225 func getDecisionFromRequest(r *http.Request) (decision *DecisionForDisplay, ok bool) {
226 decision, ok = r.Context().Value(ctxDecision).(*DecisionForDisplay)
227 return
228 }
229
230 type FlashMessageAction struct{}
231
232 func (a *FlashMessageAction) AddFlash(w http.ResponseWriter, r *http.Request, message interface{}, tags ...string) (err error) {
233 session, err := store.Get(r, sessionCookieName)
234 if err != nil {
235 logger.Println("ERROR getting session:", err)
236 return
237 }
238 session.AddFlash(message, tags...)
239 session.Save(r, w)
240 if err != nil {
241 logger.Println("ERROR saving session:", err)
242 return
243 }
244 return
245 }
246
247 type withDrawMotionAction struct {
248 FlashMessageAction
249 authenticationRequiredHandler
250 }
251
252 func (a *withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
253 decision, ok := getDecisionFromRequest(r)
254 if !ok || decision.Status != voteStatusPending {
255 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
256 return
257 }
258 voter, ok := getVoterFromRequest(r)
259 if !ok {
260 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
261 return
262 }
263 templates := []string{"withdraw_motion_form.html", "header.html", "footer.html", "motion_fragments.html"}
264 var templateContext struct {
265 PageTitle string
266 Decision *DecisionForDisplay
267 Flashes interface{}
268 }
269
270 switch r.Method {
271 case http.MethodPost:
272 decision.Status = voteStatusWithdrawn
273 decision.Modified = time.Now().UTC()
274 if err := decision.UpdateStatus(); err != nil {
275 logger.Println("Error withdrawing motion:", err)
276 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
277 return
278 }
279
280 notifyMail <- &NotificationWithDrawMotion{decision: decision.Decision, voter: *voter}
281
282 if err := a.AddFlash(w, r, fmt.Sprintf("Motion %s has been withdrawn!", decision.Tag)); err != nil {
283 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
284 return
285 }
286
287 http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect)
288 default:
289 templateContext.Decision = decision
290 renderTemplate(w, templates, templateContext)
291 }
292 }
293
294 type newMotionHandler struct {
295 FlashMessageAction
296 }
297
298 func (h *newMotionHandler) Handle(w http.ResponseWriter, r *http.Request) {
299 voter, ok := getVoterFromRequest(r)
300 if !ok {
301 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
302 }
303
304 templates := []string{"create_motion_form.html", "header.html", "footer.html"}
305 var templateContext struct {
306 Form NewDecisionForm
307 PageTitle string
308 Voter *Voter
309 Flashes interface{}
310 }
311 switch r.Method {
312 case http.MethodPost:
313 form := NewDecisionForm{
314 Title: r.FormValue("Title"),
315 Content: r.FormValue("Content"),
316 VoteType: r.FormValue("VoteType"),
317 Due: r.FormValue("Due"),
318 }
319
320 if valid, data := form.Validate(); !valid {
321 templateContext.Voter = voter
322 templateContext.Form = form
323 renderTemplate(w, templates, templateContext)
324 } else {
325 data.Proposed = time.Now().UTC()
326 data.ProponentId = voter.Id
327 if err := data.Create(); err != nil {
328 logger.Println("Error saving motion:", err)
329 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
330 return
331 }
332
333 notifyMail <- &NotificationCreateMotion{decision: *data, voter: *voter}
334
335 if err := h.AddFlash(w, r, "The motion has been proposed!"); err != nil {
336 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
337 return
338 }
339
340 http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
341 }
342
343 return
344 default:
345 templateContext.Voter = voter
346 templateContext.Form = NewDecisionForm{
347 VoteType: strconv.FormatInt(voteTypeMotion, 10),
348 }
349 renderTemplate(w, templates, templateContext)
350 }
351 }
352
353 type editMotionAction struct {
354 FlashMessageAction
355 authenticationRequiredHandler
356 }
357
358 func (a editMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
359 decision, ok := getDecisionFromRequest(r)
360 if !ok || decision.Status != voteStatusPending {
361 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
362 return
363 }
364 voter, ok := getVoterFromRequest(r)
365 if !ok {
366 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
367 return
368 }
369 templates := []string{"edit_motion_form.html", "header.html", "footer.html"}
370 var templateContext struct {
371 Form EditDecisionForm
372 PageTitle string
373 Voter *Voter
374 Flashes interface{}
375 }
376 switch r.Method {
377 case http.MethodPost:
378 form := EditDecisionForm{
379 Title: r.FormValue("Title"),
380 Content: r.FormValue("Content"),
381 VoteType: r.FormValue("VoteType"),
382 Due: r.FormValue("Due"),
383 Decision: &decision.Decision,
384 }
385
386 if valid, data := form.Validate(); !valid {
387 templateContext.Voter = voter
388 templateContext.Form = form
389 renderTemplate(w, templates, templateContext)
390 } else {
391 data.Modified = time.Now().UTC()
392 if err := data.Update(); err != nil {
393 logger.Println("Error updating motion:", err)
394 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
395 return
396 }
397
398 notifyMail <- &NotificationUpdateMotion{decision: *data, voter: *voter}
399
400 if err := a.AddFlash(w, r, "The motion has been modified!"); err != nil {
401 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
402 return
403 }
404
405 http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
406 }
407 return
408 default:
409 templateContext.Voter = voter
410 templateContext.Form = EditDecisionForm{
411 Title: decision.Title,
412 Content: decision.Content,
413 VoteType: fmt.Sprintf("%d", decision.VoteType),
414 Decision: &decision.Decision,
415 }
416 renderTemplate(w, templates, templateContext)
417 }
418 }
419
420 type motionsHandler struct{}
421
422 func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
423 if err := db.Ping(); err != nil {
424 logger.Fatal(err)
425 }
426
427 subURL := r.URL.Path
428
429 var motionActionMap = map[string]motionActionHandler{
430 "withdraw": &withDrawMotionAction{},
431 "edit": &editMotionAction{},
432 }
433
434 switch {
435 case subURL == "":
436 authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler)
437 return
438 case subURL == "/newmotion/":
439 handler := &newMotionHandler{}
440 authenticateRequest(
441 w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)),
442 handler.Handle)
443 return
444 case strings.Count(subURL, "/") == 1:
445 parts := strings.Split(subURL, "/")
446 motionTag := parts[0]
447 action, ok := motionActionMap[parts[1]]
448 if !ok {
449 http.NotFound(w, r)
450 return
451 }
452 authenticateRequest(
453 w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, action.NeedsAuth())),
454 func(w http.ResponseWriter, r *http.Request) {
455 singleDecisionHandler(w, r, motionTag, action.Handle)
456 })
457 return
458 case strings.Count(subURL, "/") == 0:
459 authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)),
460 func(w http.ResponseWriter, r *http.Request) {
461 singleDecisionHandler(w, r, subURL, motionHandler)
462 })
463 return
464 default:
465 http.NotFound(w, r)
466 return
467 }
468 }
469
470 type Config struct {
471 BoardMailAddress string `yaml:"board_mail_address"`
472 NoticeSenderAddress string `yaml:"notice_sender_address"`
473 ReminderSenderAddress string `yaml:"reminder_sender_address"`
474 DatabaseFile string `yaml:"database_file"`
475 ClientCACertificates string `yaml:"client_ca_certificates"`
476 ServerCert string `yaml:"server_certificate"`
477 ServerKey string `yaml:"server_key"`
478 CookieSecret string `yaml:"cookie_secret"`
479 BaseURL string `yaml:"base_url"`
480 MailServer struct {
481 Host string `yaml:"host"`
482 Port int `yaml:"port"`
483 } `yaml:"mail_server"`
484 }
485
486 func init() {
487 logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile)
488
489 source, err := ioutil.ReadFile("config.yaml")
490 if err != nil {
491 logger.Fatal(err)
492 }
493 if err := yaml.Unmarshal(source, &config); err != nil {
494 logger.Fatal(err)
495 }
496
497 cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret)
498 if err != nil {
499 logger.Fatal(err)
500 }
501 if len(cookieSecret) < 32 {
502 logger.Fatalln("Cookie secret is less than 32 bytes long")
503 }
504 store = sessions.NewCookieStore(cookieSecret)
505 logger.Println("read configuration")
506
507 db, err = sqlx.Open("sqlite3", config.DatabaseFile)
508 if err != nil {
509 logger.Fatal(err)
510 }
511 logger.Println("opened database connection")
512 }
513
514 func main() {
515 logger.Printf("CAcert Board Voting version %s, build %s\n", version, build)
516
517 defer db.Close()
518
519 go MailNotifier()
520 defer CloseMailNotifier()
521
522 quitChannel := make(chan int)
523 go JobScheduler(quitChannel)
524 defer func() { quitChannel <- 1 }()
525
526 http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{}))
527 http.Handle("/newmotion/", motionsHandler{})
528 http.Handle("/static/", http.FileServer(http.Dir(".")))
529 http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently))
530
531 // load CA certificates for client authentication
532 caCert, err := ioutil.ReadFile(config.ClientCACertificates)
533 if err != nil {
534 logger.Fatal(err)
535 }
536 caCertPool := x509.NewCertPool()
537 if !caCertPool.AppendCertsFromPEM(caCert) {
538 logger.Fatal("could not initialize client CA certificate pool")
539 }
540
541 // setup HTTPS server
542 tlsConfig := &tls.Config{
543 ClientCAs: caCertPool,
544 ClientAuth: tls.VerifyClientCertIfGiven,
545 }
546 tlsConfig.BuildNameToCertificate()
547
548 server := &http.Server{
549 Addr: ":8443",
550 TLSConfig: tlsConfig,
551 }
552
553 logger.Printf("Launching application on https://localhost%s/\n", server.Addr)
554
555 errs := make(chan error, 1)
556 go func() {
557 if err := http.ListenAndServe(":8080", http.RedirectHandler(config.BaseURL, http.StatusMovedPermanently)); err != nil {
558 errs <- err
559 }
560 close(errs)
561 }()
562
563 if err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil {
564 logger.Fatal("ListenAndServerTLS: ", err)
565 }
566 if err := <-errs; err != nil {
567 logger.Fatal("ListenAndServe: ", err)
568 }
569 }