Implement more RESTful URLs for motions
[cacert-boardvoting.git] / boardvoting.go
1 package main
2
3 import (
4 "crypto/tls"
5 "crypto/x509"
6 "database/sql"
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 "time"
20 )
21
22 const (
23 sqlGetDecisions = `
24 SELECT decisions.id, decisions.tag, decisions.proponent,
25 voters.name AS proposer, decisions.proposed, decisions.title,
26 decisions.content, decisions.votetype, decisions.status, decisions.due,
27 decisions.modified
28 FROM decisions
29 JOIN voters ON decisions.proponent=voters.id
30 ORDER BY proposed DESC
31 LIMIT 10 OFFSET 10 * $1`
32 sqlGetDecision = `
33 SELECT decisions.id, decisions.tag, decisions.proponent,
34 voters.name AS proposer, decisions.proposed, decisions.title,
35 decisions.content, decisions.votetype, decisions.status, decisions.due,
36 decisions.modified
37 FROM decisions
38 JOIN voters ON decisions.proponent=voters.id
39 WHERE decisions.tag=$1;`
40 sqlGetVoter = `
41 SELECT voters.id, voters.name
42 FROM voters
43 JOIN emails ON voters.id=emails.voter
44 WHERE emails.address=$1 AND voters.enabled=1`
45 sqlVoteCount = `
46 SELECT vote, COUNT(vote)
47 FROM votes
48 WHERE decision=$1 GROUP BY vote`
49 sqlCountNewerOlderThanMotion = `
50 SELECT "newer" AS label, COUNT(*) AS value FROM decisions WHERE proposed > $1
51 UNION
52 SELECT "older", COUNT(*) FROM decisions WHERE proposed < $2`
53 )
54
55 var db *sqlx.DB
56 var logger *log.Logger
57
58 const (
59 voteAye = 1
60 voteNaye = -1
61 voteAbstain = 0
62 )
63
64 const (
65 voteTypeMotion = 0
66 voteTypeVeto = 1
67 )
68
69 type VoteType int
70
71 func (v VoteType) String() string {
72 switch v {
73 case voteTypeMotion:
74 return "motion"
75 case voteTypeVeto:
76 return "veto"
77 default:
78 return "unknown"
79 }
80 }
81
82 func (v VoteType) QuorumAndMajority() (int, int) {
83 switch v {
84 case voteTypeMotion:
85 return 3, 50
86 default:
87 return 1, 99
88 }
89 }
90
91 type VoteSums struct {
92 Ayes int
93 Nayes int
94 Abstains int
95 }
96
97 func (v *VoteSums) voteCount() int {
98 return v.Ayes + v.Nayes + v.Abstains
99 }
100
101 type VoteStatus int
102
103 func (v VoteStatus) String() string {
104 switch v {
105 case -1:
106 return "declined"
107 case 0:
108 return "pending"
109 case 1:
110 return "approved"
111 case -2:
112 return "withdrawn"
113 default:
114 return "unknown"
115 }
116 }
117
118 type VoteKind int
119
120 func (v VoteKind) String() string {
121 switch v {
122 case voteAye:
123 return "Aye"
124 case voteNaye:
125 return "Naye"
126 case voteAbstain:
127 return "Abstain"
128 default:
129 return "unknown"
130 }
131 }
132
133 type Vote struct {
134 Name string
135 Vote VoteKind
136 }
137
138 type Decision struct {
139 Id int
140 Tag string
141 Proponent int
142 Proposer string
143 Proposed time.Time
144 Title string
145 Content string
146 Majority int
147 Quorum int
148 VoteType VoteType
149 Status VoteStatus
150 Due time.Time
151 Modified time.Time
152 VoteSums
153 Votes []Vote
154 }
155
156 func (d *Decision) parseVote(vote int, count int) {
157 switch vote {
158 case voteAye:
159 d.Ayes = count
160 case voteAbstain:
161 d.Abstains = count
162 case voteNaye:
163 d.Nayes = count
164 }
165 }
166
167 type Voter struct {
168 Id int
169 Name string
170 }
171
172 func withDrawMotion(tag string, voter *Voter, config *Config) {
173 err := db.Ping()
174 if err != nil {
175 logger.Fatal(err)
176 }
177
178 decision_stmt, err := db.Preparex(sqlGetDecision)
179 if err != nil {
180 logger.Fatal(err)
181 }
182 defer decision_stmt.Close()
183
184 var d Decision
185 err = decision_stmt.Get(&d, tag)
186 if err == nil {
187 logger.Println(d)
188 }
189
190 type MailContext struct {
191 Decision
192 Name string
193 Sender string
194 Recipient string
195 }
196
197 context := MailContext{d, voter.Name, config.NoticeSenderAddress, config.BoardMailAddress}
198
199 // TODO: implement
200 // fill withdraw_mail.txt
201 t, err := template.New("withdraw_mail.txt").Funcs(sprig.FuncMap()).ParseFiles("templates/withdraw_mail.txt")
202 if err != nil {
203 logger.Fatal(err)
204 }
205 t.Execute(os.Stdout, context)
206 }
207
208 func authenticateVoter(emailAddress string) *Voter {
209 if err := db.Ping(); err != nil {
210 logger.Fatal(err)
211 }
212
213 auth_stmt, err := db.Preparex(sqlGetVoter)
214 if err != nil {
215 logger.Println("Problem getting voter", err)
216 return nil
217 }
218 defer auth_stmt.Close()
219
220 var voter = &Voter{}
221 if err = auth_stmt.Get(voter, emailAddress); err != nil {
222 if err != sql.ErrNoRows {
223 logger.Println("Problem getting voter", err)
224 }
225 return nil
226 }
227 return voter
228 }
229
230 func redirectToMotionsHandler(w http.ResponseWriter, _ *http.Request) {
231 w.Header().Set("Location", "/motions/")
232 w.WriteHeader(http.StatusMovedPermanently)
233 }
234
235 func renderTemplate(w http.ResponseWriter, tmpl string, context interface{}) {
236 t, err := template.New(fmt.Sprintf("%s.html", tmpl)).Funcs(sprig.FuncMap()).ParseFiles(fmt.Sprintf("templates/%s.html", tmpl))
237 if err != nil {
238 http.Error(w, err.Error(), http.StatusInternalServerError)
239 }
240 if err := t.Execute(w, context); err != nil {
241 http.Error(w, err.Error(), http.StatusInternalServerError)
242 }
243 }
244
245 func authenticateRequest(w http.ResponseWriter, r *http.Request, authRequired bool, handler func(http.ResponseWriter, *http.Request, *Voter)) {
246 for _, cert := range r.TLS.PeerCertificates {
247 for _, extKeyUsage := range cert.ExtKeyUsage {
248 if extKeyUsage == x509.ExtKeyUsageClientAuth {
249 for _, emailAddress := range cert.EmailAddresses {
250 if voter := authenticateVoter(emailAddress); voter != nil {
251 handler(w, r, voter)
252 return
253 }
254 }
255 }
256 }
257 }
258 if authRequired {
259 w.WriteHeader(http.StatusForbidden)
260 renderTemplate(w, "denied", nil)
261 return
262 }
263 handler(w, r, nil)
264 }
265
266 type motionParameters struct {
267 ShowVotes bool
268 }
269
270 type motionListParameters struct {
271 Page int64
272 Flags struct {
273 Confirmed, Withdraw, Unvoted bool
274 }
275 }
276
277 func parseMotionParameters(r *http.Request) motionParameters {
278 var m = motionParameters{}
279 m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes"))
280 logger.Printf("parsed parameters: %+v\n", m)
281 return m
282 }
283
284 func parseMotionListParameters(r *http.Request) motionListParameters {
285 var m = motionListParameters{}
286 if page, err := strconv.ParseInt(r.URL.Query().Get("page"), 10, 0); err != nil {
287 m.Page = 1
288 } else {
289 m.Page = page
290 }
291 m.Flags.Withdraw, _ = strconv.ParseBool(r.URL.Query().Get("withdraw"))
292 m.Flags.Unvoted, _ = strconv.ParseBool(r.URL.Query().Get("unvoted"))
293
294 if r.Method == http.MethodPost {
295 m.Flags.Confirmed, _ = strconv.ParseBool(r.PostFormValue("confirm"))
296 }
297 logger.Printf("parsed parameters: %+v\n", m)
298 return m
299 }
300
301 func motionListHandler(w http.ResponseWriter, r *http.Request, voter *Voter) {
302 params := parseMotionListParameters(r)
303
304 votes_stmt, err := db.Preparex(sqlVoteCount)
305 if err != nil {
306 logger.Fatal(err)
307 }
308 defer votes_stmt.Close()
309 beforeAfterStmt, err := db.Preparex(sqlCountNewerOlderThanMotion)
310 if err != nil {
311 logger.Fatal(err)
312 }
313 defer beforeAfterStmt.Close()
314
315 var context struct {
316 Decisions []Decision
317 Voter *Voter
318 Params *motionListParameters
319 PrevPage, NextPage int64
320 }
321 context.Voter = voter
322 context.Params = &params
323
324 motion_stmt, err := db.Preparex(sqlGetDecisions)
325 if err != nil {
326 logger.Fatal(err)
327 }
328 defer motion_stmt.Close()
329 rows, err := motion_stmt.Queryx(params.Page - 1)
330 if err != nil {
331 logger.Fatal(err)
332 }
333 for rows.Next() {
334 var d Decision
335 err := rows.StructScan(&d)
336 if err != nil {
337 rows.Close()
338 logger.Fatal(err)
339 }
340
341 voteRows, err := votes_stmt.Queryx(d.Id)
342 if err != nil {
343 rows.Close()
344 logger.Fatal(err)
345 }
346
347 for voteRows.Next() {
348 var vote int
349 var count int
350 if err := voteRows.Scan(&vote, &count); err != nil {
351 voteRows.Close()
352 logger.Fatalf("Error fetching counts for motion %s: %s", d.Tag, err)
353 }
354 d.parseVote(vote, count)
355 }
356 context.Decisions = append(context.Decisions, d)
357
358 voteRows.Close()
359 }
360 rows.Close()
361 rows, err = beforeAfterStmt.Queryx(
362 context.Decisions[0].Proposed,
363 context.Decisions[len(context.Decisions)-1].Proposed)
364 if err != nil {
365 logger.Fatal(err)
366 }
367 defer rows.Close()
368 for rows.Next() {
369 var key string
370 var value int
371 if err := rows.Scan(&key, &value); err != nil {
372 rows.Close()
373 logger.Fatal(err)
374 }
375 if key == "older" && value > 0 {
376 context.NextPage = params.Page + 1
377 }
378 }
379 if params.Page > 1 {
380 context.PrevPage = params.Page - 1
381 }
382
383 renderTemplate(w, "motions", context)
384 }
385
386 func motionHandler(w http.ResponseWriter, r *http.Request, voter *Voter, decision *Decision) {
387 params := parseMotionParameters(r)
388
389 var context struct {
390 Decisions []Decision
391 Voter *Voter
392 Params *motionParameters
393 PrevPage, NextPage int64
394 }
395 context.Voter = voter
396 context.Params = &params
397 context.Decisions = append(context.Decisions, *decision)
398 renderTemplate(w, "motions", context)
399 }
400
401 func singleDecisionHandler(w http.ResponseWriter, r *http.Request, v *Voter, tag string, handler func(http.ResponseWriter, *http.Request, *Voter, *Decision)) {
402 votes_stmt, err := db.Preparex(sqlVoteCount)
403 if err != nil {
404 logger.Fatal(err)
405 }
406 defer votes_stmt.Close()
407 motion_stmt, err := db.Preparex(sqlGetDecision)
408 if err != nil {
409 logger.Fatal(err)
410 }
411 defer motion_stmt.Close()
412 var d *Decision = &Decision{}
413 err = motion_stmt.Get(d, tag)
414 if err != nil {
415 logger.Fatal(err)
416 }
417 voteRows, err := votes_stmt.Queryx(d.Id)
418 if err != nil {
419 logger.Fatal(err)
420 }
421
422 for voteRows.Next() {
423 var vote, count int
424 err = voteRows.Scan(&vote, &count)
425 if err != nil {
426 voteRows.Close()
427 logger.Fatal(err)
428 }
429 d.parseVote(vote, count)
430 }
431 voteRows.Close()
432
433 handler(w, r, v, d)
434 }
435
436 func motionsHandler(w http.ResponseWriter, r *http.Request) {
437 if err := db.Ping(); err != nil {
438 logger.Fatal(err)
439 }
440
441 subURL := r.URL.Path[len("/motions/"):]
442
443 switch {
444 case subURL == "":
445 authenticateRequest(w, r, false, motionListHandler)
446 return
447 case strings.Count(subURL, "/") == 1:
448 parts := strings.Split(subURL, "/")
449 logger.Printf("handle %v\n", parts)
450 fmt.Fprintf(w, "No handler for '%s'", subURL)
451 motionTag := parts[0]
452 action := parts[1]
453 logger.Printf("motion: %s, action: %s\n", motionTag, action)
454 return
455 case strings.Count(subURL, "/") == 0:
456 authenticateRequest(w, r, false, func(w http.ResponseWriter, r *http.Request, v *Voter) {
457 singleDecisionHandler(w, r, v, subURL, motionHandler)
458 })
459 return
460 default:
461 fmt.Fprintf(w, "No handler for '%s'", subURL)
462 return
463 }
464 }
465
466 func votersHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
467 err := db.Ping()
468 if err != nil {
469 logger.Fatal(err)
470 }
471
472 fmt.Fprintln(w, "Hello", voter.Name)
473
474 sqlStmt := "SELECT name, reminder FROM voters WHERE enabled=1"
475
476 rows, err := db.Query(sqlStmt)
477 if err != nil {
478 logger.Fatal(err)
479 }
480 defer rows.Close()
481
482 fmt.Print("Enabled voters\n\n")
483 fmt.Printf("%-30s %-30s\n", "Name", "Reminder E-Mail address")
484 fmt.Printf("%s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 30))
485 for rows.Next() {
486 var name string
487 var reminder string
488
489 err = rows.Scan(&name, &reminder)
490 if err != nil {
491 logger.Fatal(err)
492 }
493 fmt.Printf("%-30s %s\n", name, reminder)
494 }
495 err = rows.Err()
496 if err != nil {
497 logger.Fatal(err)
498 }
499 }
500
501 type Config struct {
502 BoardMailAddress string `yaml:"board_mail_address"`
503 NoticeSenderAddress string `yaml:"notice_sender_address"`
504 DatabaseFile string `yaml:"database_file"`
505 ClientCACertificates string `yaml:"client_ca_certificates"`
506 ServerCert string `yaml:"server_certificate"`
507 ServerKey string `yaml:"server_key"`
508 }
509
510 func main() {
511 logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile)
512
513 var filename = "config.yaml"
514 if len(os.Args) == 2 {
515 filename = os.Args[1]
516 }
517
518 var err error
519
520 var config Config
521 var source []byte
522
523 source, err = ioutil.ReadFile(filename)
524 if err != nil {
525 logger.Fatal(err)
526 }
527 err = yaml.Unmarshal(source, &config)
528 if err != nil {
529 logger.Fatal(err)
530 }
531 logger.Printf("read configuration %v", config)
532
533 db, err = sqlx.Open("sqlite3", config.DatabaseFile)
534 if err != nil {
535 logger.Fatal(err)
536 }
537
538 http.HandleFunc("/motions/", motionsHandler)
539 http.HandleFunc("/voters/", func(w http.ResponseWriter, r *http.Request) {
540 authenticateRequest(w, r, true, votersHandler)
541 })
542 http.Handle("/static/", http.FileServer(http.Dir(".")))
543 http.HandleFunc("/", redirectToMotionsHandler)
544
545 // load CA certificates for client authentication
546 caCert, err := ioutil.ReadFile(config.ClientCACertificates)
547 if err != nil {
548 logger.Fatal(err)
549 }
550 caCertPool := x509.NewCertPool()
551 if !caCertPool.AppendCertsFromPEM(caCert) {
552 logger.Fatal("could not initialize client CA certificate pool")
553 }
554
555 // setup HTTPS server
556 tlsConfig := &tls.Config{
557 ClientCAs: caCertPool,
558 ClientAuth: tls.RequireAndVerifyClientCert,
559 }
560 tlsConfig.BuildNameToCertificate()
561
562 server := &http.Server{
563 Addr: ":8443",
564 TLSConfig: tlsConfig,
565 }
566
567 err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey)
568 if err != nil {
569 logger.Fatal("ListenAndServerTLS: ", err)
570 }
571
572 defer db.Close()
573 }