Add version and build number output
[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 contextKey = iota
51 ctxDecision contextKey = iota
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 params.Flags.Unvoted && templateContext.Voter != nil {
140 if templateContext.Decisions, err = FindVotersUnvotedDecisionsForDisplayOnPage(
141 params.Page, templateContext.Voter); err != nil {
142 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
143 return
144 }
145 } else {
146 if templateContext.Decisions, err = FindDecisionsForDisplayOnPage(params.Page); err != nil {
147 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
148 return
149 }
150 }
151
152 if len(templateContext.Decisions) > 0 {
153 olderExists, err := templateContext.Decisions[len(templateContext.Decisions)-1].OlderExists()
154 if err != nil {
155 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
156 return
157 }
158 if olderExists {
159 templateContext.NextPage = params.Page + 1
160 }
161 }
162
163 if params.Page > 1 {
164 templateContext.PrevPage = params.Page - 1
165 }
166
167 renderTemplate(w, []string{"motions.html", "motion_fragments.html", "header.html", "footer.html"}, templateContext)
168 }
169
170 func motionHandler(w http.ResponseWriter, r *http.Request) {
171 params := parseMotionParameters(r)
172
173 decision, ok := getDecisionFromRequest(r)
174 if !ok {
175 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
176 return
177 }
178
179 var templateContext struct {
180 Decision *DecisionForDisplay
181 Voter *Voter
182 Params *motionParameters
183 PrevPage, NextPage int64
184 PageTitle string
185 Flashes interface{}
186 }
187 voter, ok := getVoterFromRequest(r)
188 if ok {
189 templateContext.Voter = voter
190 }
191 templateContext.Params = &params
192 if params.ShowVotes {
193 if err := decision.LoadVotes(); err != nil {
194 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
195 return
196 }
197 }
198 templateContext.Decision = decision
199 templateContext.PageTitle = fmt.Sprintf("Motion %s: %s", decision.Tag, decision.Title)
200 renderTemplate(w, []string{"motion.html", "motion_fragments.html", "header.html", "footer.html"}, templateContext)
201 }
202
203 func singleDecisionHandler(w http.ResponseWriter, r *http.Request, tag string, handler func(http.ResponseWriter, *http.Request)) {
204 decision, err := FindDecisionForDisplayByTag(tag)
205 if err != nil {
206 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
207 return
208 }
209 if decision == nil {
210 http.NotFound(w, r)
211 return
212 }
213 handler(w, r.WithContext(context.WithValue(r.Context(), ctxDecision, decision)))
214 }
215
216 type motionActionHandler interface {
217 Handle(w http.ResponseWriter, r *http.Request)
218 NeedsAuth() bool
219 }
220
221 type authenticationRequiredHandler struct{}
222
223 func (authenticationRequiredHandler) NeedsAuth() bool {
224 return true
225 }
226
227 type withDrawMotionAction struct {
228 authenticationRequiredHandler
229 }
230
231 func getVoterFromRequest(r *http.Request) (voter *Voter, ok bool) {
232 voter, ok = r.Context().Value(ctxVoter).(*Voter)
233 return
234 }
235
236 func getDecisionFromRequest(r *http.Request) (decision *DecisionForDisplay, ok bool) {
237 decision, ok = r.Context().Value(ctxDecision).(*DecisionForDisplay)
238 return
239 }
240
241 func (withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
242 voter, voter_ok := getVoterFromRequest(r)
243 decision, decision_ok := getDecisionFromRequest(r)
244
245 if !voter_ok || !decision_ok || decision.Status != voteStatusPending {
246 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
247 return
248 }
249
250 switch r.Method {
251 case http.MethodPost:
252 if confirm, err := strconv.ParseBool(r.PostFormValue("confirm")); err != nil {
253 log.Println("could not parse confirm parameter:", err)
254 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
255 } else if confirm {
256 WithdrawMotion(&decision.Decision, voter)
257 } else {
258 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
259 }
260 http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect)
261 return
262 default:
263 fmt.Fprintln(w, "Withdraw motion", decision.Tag)
264 }
265 }
266
267 type editMotionAction struct {
268 authenticationRequiredHandler
269 }
270
271 func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
272 decision, ok := getDecisionFromRequest(r)
273 if !ok || decision.Status != voteStatusPending {
274 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
275 return
276 }
277 fmt.Fprintln(w, "Edit motion", decision.Tag)
278 // TODO: implement
279 }
280
281 type motionsHandler struct{}
282
283 func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
284 if err := db.Ping(); err != nil {
285 logger.Fatal(err)
286 }
287
288 subURL := r.URL.Path
289
290 var motionActionMap = map[string]motionActionHandler{
291 "withdraw": withDrawMotionAction{},
292 "edit": editMotionAction{},
293 }
294
295 switch {
296 case subURL == "":
297 authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler)
298 return
299 case strings.Count(subURL, "/") == 1:
300 parts := strings.Split(subURL, "/")
301 logger.Printf("handle %v\n", parts)
302 motionTag := parts[0]
303 action, ok := motionActionMap[parts[1]]
304 if !ok {
305 http.NotFound(w, r)
306 return
307 }
308 authenticateRequest(
309 w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, action.NeedsAuth())),
310 func(w http.ResponseWriter, r *http.Request) {
311 singleDecisionHandler(w, r, motionTag, action.Handle)
312 })
313 logger.Printf("motion: %s, action: %s\n", motionTag, action)
314 return
315 case strings.Count(subURL, "/") == 0:
316 authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)),
317 func(w http.ResponseWriter, r *http.Request) {
318 singleDecisionHandler(w, r, subURL, motionHandler)
319 })
320 return
321 default:
322 http.NotFound(w, r)
323 return
324 }
325 }
326
327 func newMotionHandler(w http.ResponseWriter, r *http.Request) {
328 voter, ok := getVoterFromRequest(r)
329 if !ok {
330 http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
331 }
332
333 templates := []string{"newmotion_form.html", "header.html", "footer.html"}
334 var templateContext struct {
335 Form NewDecisionForm
336 PageTitle string
337 Voter *Voter
338 Flashes interface{}
339 }
340 switch r.Method {
341 case http.MethodPost:
342 form := NewDecisionForm{
343 Title: r.FormValue("Title"),
344 Content: r.FormValue("Content"),
345 VoteType: r.FormValue("VoteType"),
346 Due: r.FormValue("Due"),
347 }
348
349 if valid, data := form.Validate(); !valid {
350 templateContext.Voter = voter
351 templateContext.Form = form
352 renderTemplate(w, templates, templateContext)
353 } else {
354 if err := CreateMotion(data, voter); err != nil {
355 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
356 return
357 }
358 session, err := store.Get(r, sessionCookieName)
359 if err != nil {
360 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
361 return
362 }
363 session.AddFlash("The motion has been proposed!")
364 session.Save(r, w)
365
366 http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect)
367 }
368
369 return
370 default:
371 templateContext.Voter = voter
372 templateContext.Form = NewDecisionForm{
373 VoteType: strconv.FormatInt(voteTypeMotion, 10),
374 }
375 renderTemplate(w, templates, templateContext)
376 }
377 }
378
379 type Config struct {
380 BoardMailAddress string `yaml:"board_mail_address"`
381 NoticeSenderAddress string `yaml:"notice_sender_address"`
382 DatabaseFile string `yaml:"database_file"`
383 ClientCACertificates string `yaml:"client_ca_certificates"`
384 ServerCert string `yaml:"server_certificate"`
385 ServerKey string `yaml:"server_key"`
386 CookieSecret string `yaml:"cookie_secret"`
387 }
388
389 func init() {
390 logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile)
391
392 source, err := ioutil.ReadFile("config.yaml")
393 if err != nil {
394 logger.Fatal(err)
395 }
396 if err := yaml.Unmarshal(source, &config); err != nil {
397 logger.Fatal(err)
398 }
399
400 cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret)
401 if err != nil {
402 logger.Fatal(err)
403 }
404 if len(cookieSecret) < 32 {
405 logger.Fatalln("Cookie secret is less than 32 bytes long")
406 }
407 store = sessions.NewCookieStore(cookieSecret)
408 logger.Println("read configuration")
409 }
410
411 func main() {
412 logger.Printf("CAcert Board Voting version %s, build %s\n", version, build)
413
414 var err error
415 db, err = sqlx.Open("sqlite3", config.DatabaseFile)
416 if err != nil {
417 logger.Fatal(err)
418 }
419 defer db.Close()
420
421 http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{}))
422 http.HandleFunc("/newmotion/", func(w http.ResponseWriter, r *http.Request) {
423 authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)), newMotionHandler)
424 })
425 http.Handle("/static/", http.FileServer(http.Dir(".")))
426 http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently))
427
428 // load CA certificates for client authentication
429 caCert, err := ioutil.ReadFile(config.ClientCACertificates)
430 if err != nil {
431 logger.Fatal(err)
432 }
433 caCertPool := x509.NewCertPool()
434 if !caCertPool.AppendCertsFromPEM(caCert) {
435 logger.Fatal("could not initialize client CA certificate pool")
436 }
437
438 // setup HTTPS server
439 tlsConfig := &tls.Config{
440 ClientCAs: caCertPool,
441 ClientAuth: tls.VerifyClientCertIfGiven,
442 }
443 tlsConfig.BuildNameToCertificate()
444
445 server := &http.Server{
446 Addr: ":8443",
447 TLSConfig: tlsConfig,
448 }
449
450 logger.Printf("Launching application on https://localhost%s/\n", server.Addr)
451
452 if err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil {
453 logger.Fatal("ListenAndServerTLS: ", err)
454 }
455 }