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