187a867a4ad9e32c42838d00e80cff26e0a2cc4d
[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 := WithdrawMotion(&decision.Decision, voter); err != nil {
275 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
276 return
277 }
278 if err := a.AddFlash(w, r, fmt.Sprintf("Motion %s has been withdrawn!", decision.Tag)); err != nil {
279 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
280 return
281 }
282 http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect)
283 return
284 default:
285 templateContext.Decision = decision
286 renderTemplate(w, templates, templateContext)
287 }
288 }
289
290 type newMotionHandler struct {
291 FlashMessageAction
292 }
293
294 func (h *newMotionHandler) Handle(w http.ResponseWriter, r *http.Request) {
295 voter, ok := getVoterFromRequest(r)
296 if !ok {
297 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
298 }
299
300 templates := []string{"create_motion_form.html", "header.html", "footer.html"}
301 var templateContext struct {
302 Form NewDecisionForm
303 PageTitle string
304 Voter *Voter
305 Flashes interface{}
306 }
307 switch r.Method {
308 case http.MethodPost:
309 form := NewDecisionForm{
310 Title: r.FormValue("Title"),
311 Content: r.FormValue("Content"),
312 VoteType: r.FormValue("VoteType"),
313 Due: r.FormValue("Due"),
314 }
315
316 if valid, data := form.Validate(); !valid {
317 templateContext.Voter = voter
318 templateContext.Form = form
319 renderTemplate(w, templates, templateContext)
320 } else {
321 data.Proposed = time.Now().UTC()
322 if err := CreateMotion(data, voter); err != nil {
323 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
324 return
325 }
326 if err := h.AddFlash(w, r, "The motion has been proposed!"); err != nil {
327 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
328 return
329 }
330
331 http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
332 }
333
334 return
335 default:
336 templateContext.Voter = voter
337 templateContext.Form = NewDecisionForm{
338 VoteType: strconv.FormatInt(voteTypeMotion, 10),
339 }
340 renderTemplate(w, templates, templateContext)
341 }
342 }
343
344 type editMotionAction struct {
345 FlashMessageAction
346 authenticationRequiredHandler
347 }
348
349 func (a editMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
350 decision, ok := getDecisionFromRequest(r)
351 if !ok || decision.Status != voteStatusPending {
352 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
353 return
354 }
355 voter, ok := getVoterFromRequest(r)
356 if !ok {
357 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
358 return
359 }
360 templates := []string{"edit_motion_form.html", "header.html", "footer.html"}
361 var templateContext struct {
362 Form EditDecisionForm
363 PageTitle string
364 Voter *Voter
365 Flashes interface{}
366 }
367 switch r.Method {
368 case http.MethodPost:
369 form := EditDecisionForm{
370 Title: r.FormValue("Title"),
371 Content: r.FormValue("Content"),
372 VoteType: r.FormValue("VoteType"),
373 Due: r.FormValue("Due"),
374 Decision: &decision.Decision,
375 }
376
377 if valid, data := form.Validate(); !valid {
378 templateContext.Voter = voter
379 templateContext.Form = form
380 renderTemplate(w, templates, templateContext)
381 } else {
382 data.Modified = time.Now().UTC()
383 if err := UpdateMotion(data, voter); err != nil {
384 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
385 return
386 }
387 if err := a.AddFlash(w, r, "The motion has been modified!"); err != nil {
388 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
389 return
390 }
391
392 http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
393 }
394 return
395 default:
396 templateContext.Voter = voter
397 templateContext.Form = EditDecisionForm{
398 Title: decision.Title,
399 Content: decision.Content,
400 VoteType: fmt.Sprintf("%d", decision.VoteType),
401 Decision: &decision.Decision,
402 }
403 renderTemplate(w, templates, templateContext)
404 }
405 }
406
407 type motionsHandler struct{}
408
409 func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
410 if err := db.Ping(); err != nil {
411 logger.Fatal(err)
412 }
413
414 subURL := r.URL.Path
415
416 var motionActionMap = map[string]motionActionHandler{
417 "withdraw": &withDrawMotionAction{},
418 "edit": &editMotionAction{},
419 }
420
421 switch {
422 case subURL == "":
423 authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler)
424 return
425 case subURL == "/newmotion/":
426 handler := &newMotionHandler{}
427 authenticateRequest(
428 w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)),
429 handler.Handle)
430 return
431 case strings.Count(subURL, "/") == 1:
432 parts := strings.Split(subURL, "/")
433 motionTag := parts[0]
434 action, ok := motionActionMap[parts[1]]
435 if !ok {
436 http.NotFound(w, r)
437 return
438 }
439 authenticateRequest(
440 w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, action.NeedsAuth())),
441 func(w http.ResponseWriter, r *http.Request) {
442 singleDecisionHandler(w, r, motionTag, action.Handle)
443 })
444 return
445 case strings.Count(subURL, "/") == 0:
446 authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)),
447 func(w http.ResponseWriter, r *http.Request) {
448 singleDecisionHandler(w, r, subURL, motionHandler)
449 })
450 return
451 default:
452 http.NotFound(w, r)
453 return
454 }
455 }
456
457 type Config struct {
458 BoardMailAddress string `yaml:"board_mail_address"`
459 NoticeSenderAddress string `yaml:"notice_sender_address"`
460 DatabaseFile string `yaml:"database_file"`
461 ClientCACertificates string `yaml:"client_ca_certificates"`
462 ServerCert string `yaml:"server_certificate"`
463 ServerKey string `yaml:"server_key"`
464 CookieSecret string `yaml:"cookie_secret"`
465 BaseURL string `yaml:"base_url"`
466 MailServer struct {
467 Host string `yaml:"host"`
468 Port int `yaml:"port"`
469 } `yaml:"mail_server"`
470 }
471
472 func init() {
473 logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile)
474
475 source, err := ioutil.ReadFile("config.yaml")
476 if err != nil {
477 logger.Fatal(err)
478 }
479 if err := yaml.Unmarshal(source, &config); err != nil {
480 logger.Fatal(err)
481 }
482
483 cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret)
484 if err != nil {
485 logger.Fatal(err)
486 }
487 if len(cookieSecret) < 32 {
488 logger.Fatalln("Cookie secret is less than 32 bytes long")
489 }
490 store = sessions.NewCookieStore(cookieSecret)
491 logger.Println("read configuration")
492
493 db, err = sqlx.Open("sqlite3", config.DatabaseFile)
494 if err != nil {
495 logger.Fatal(err)
496 }
497 logger.Println("opened database connection")
498 }
499
500 func main() {
501 logger.Printf("CAcert Board Voting version %s, build %s\n", version, build)
502
503 defer db.Close()
504
505 http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{}))
506 http.Handle("/newmotion/", motionsHandler{})
507 http.Handle("/static/", http.FileServer(http.Dir(".")))
508 http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently))
509
510 // load CA certificates for client authentication
511 caCert, err := ioutil.ReadFile(config.ClientCACertificates)
512 if err != nil {
513 logger.Fatal(err)
514 }
515 caCertPool := x509.NewCertPool()
516 if !caCertPool.AppendCertsFromPEM(caCert) {
517 logger.Fatal("could not initialize client CA certificate pool")
518 }
519
520 // setup HTTPS server
521 tlsConfig := &tls.Config{
522 ClientCAs: caCertPool,
523 ClientAuth: tls.VerifyClientCertIfGiven,
524 }
525 tlsConfig.BuildNameToCertificate()
526
527 server := &http.Server{
528 Addr: ":8443",
529 TLSConfig: tlsConfig,
530 }
531
532 logger.Printf("Launching application on https://localhost%s/\n", server.Addr)
533
534 errs := make(chan error, 1)
535 go func() {
536 if err := http.ListenAndServe(":8080", http.RedirectHandler(config.BaseURL, http.StatusMovedPermanently)); err != nil {
537 errs <- err
538 }
539 close(errs)
540 }()
541
542 if err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil {
543 logger.Fatal("ListenAndServerTLS: ", err)
544 }
545 if err := <-errs; err != nil {
546 logger.Fatal("ListenAndServe: ", err)
547 }
548 }