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