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