Initial Go code for reimplementation
[cacert-boardvoting.git] / boardvoting.go
1 package main
2
3 import (
4 "fmt"
5 "log"
6 "strings"
7 "net/http"
8 "io/ioutil"
9 "time"
10 _ "github.com/mattn/go-sqlite3"
11 "gopkg.in/yaml.v2"
12 "github.com/jmoiron/sqlx"
13 "github.com/Masterminds/sprig"
14 "os"
15 "crypto/x509"
16 "crypto/tls"
17 "database/sql"
18 "html/template"
19 )
20
21 const (
22 list_decisions_sql = `
23 SELECT decisions.id, decisions.tag, decisions.proponent,
24 voters.name AS proposer, decisions.proposed, decisions.title,
25 decisions.content, decisions.votetype, decisions.status, decisions.due,
26 decisions.modified
27 FROM decisions
28 JOIN voters ON decisions.proponent=voters.id
29 ORDER BY proposed DESC
30 LIMIT 10 OFFSET 10 * ($1 - 1)`
31 get_decision_sql = `
32 SELECT decisions.id, decisions.tag, decisions.proponent,
33 voters.name AS proposer, decisions.proposed, decisions.title,
34 decisions.content, decisions.votetype, decisions.status, decisions.due,
35 decisions.modified
36 FROM decisions
37 JOIN voters ON decisions.proponent=voters.id
38 WHERE decisions.id=$1;`
39 get_voter = `
40 SELECT voters.id, voters.name
41 FROM voters
42 JOIN emails ON voters.id=emails.voter
43 WHERE emails.address=$1 AND voters.enabled=1`
44 vote_count_sql = `
45 SELECT vote, COUNT(vote)
46 FROM votes
47 WHERE decision=$1`
48 )
49
50 var db *sqlx.DB
51 var logger *log.Logger
52
53 const (
54 voteAye = 1
55 voteNaye = -1
56 voteAbstain = 0
57 )
58
59 const (
60 voteTypeMotion = 0
61 voteTypeVeto = 1
62 )
63
64 type VoteType int
65
66 func (v VoteType) String() string {
67 switch v {
68 case voteTypeMotion: return "motion"
69 case voteTypeVeto: return "veto"
70 default: return "unknown"
71 }
72 }
73
74 func (v VoteType) QuorumAndMajority() (int, int) {
75 switch v {
76 case voteTypeMotion: return 3, 50
77 default: return 1, 99
78 }
79 }
80
81 type VoteSums struct {
82 Ayes int
83 Nayes int
84 Abstains int
85 }
86
87 func (v *VoteSums) voteCount() int {
88 return v.Ayes + v.Nayes + v.Abstains
89 }
90
91 type VoteStatus int
92
93 func (v VoteStatus) String() string {
94 switch v {
95 case -1: return "declined"
96 case 0: return "pending"
97 case 1: return "approved"
98 case -2: return "withdrawn"
99 default: return "unknown"
100 }
101 }
102
103 type VoteKind int
104
105 func (v VoteKind) String() string {
106 switch v {
107 case voteAye: return "Aye"
108 case voteNaye: return "Naye"
109 case voteAbstain: return "Abstain"
110 default: return "unknown"
111 }
112 }
113
114 type Vote struct {
115 Name string
116 Vote VoteKind
117 }
118
119 type Decision struct {
120 Id int
121 Tag string
122 Proponent int
123 Proposer string
124 Proposed time.Time
125 Title string
126 Content string
127 Majority int
128 Quorum int
129 VoteType VoteType
130 Status VoteStatus
131 Due time.Time
132 Modified time.Time
133 VoteSums
134 Votes []Vote
135 }
136
137 func (d *Decision) parseVote(vote int, count int) {
138 switch vote {
139 case voteAye:
140 d.Ayes = count
141 case voteAbstain:
142 d.Abstains = count
143 case voteNaye:
144 d.Nayes = count
145 }
146 }
147
148 type Voter struct {
149 Id int
150 Name string
151 }
152
153 func authenticateVoter(emailAddress string, voter *Voter) bool {
154 err := db.Ping()
155 if err != nil {
156 logger.Fatal(err)
157 }
158
159 auth_stmt, err := db.Preparex(get_voter)
160 if err != nil {
161 logger.Fatal(err)
162 }
163 defer auth_stmt.Close()
164 var found = false
165 err = auth_stmt.Get(voter, emailAddress)
166 if err == nil {
167 found = true
168 } else {
169 if err != sql.ErrNoRows {
170 logger.Fatal(err)
171 }
172 }
173 return found
174 }
175
176 func redirectToMotionsHandler(w http.ResponseWriter, _ *http.Request) {
177 w.Header().Add("Location", "/motions")
178 w.WriteHeader(http.StatusMovedPermanently)
179 }
180
181 func renderTemplate(w http.ResponseWriter, tmpl string, context interface{}) {
182 t := template.New("motions.html")
183 t.Funcs(sprig.FuncMap())
184 t, err := t.ParseFiles(fmt.Sprintf("templates/%s.html", tmpl))
185 if err != nil {
186 http.Error(w, err.Error(), http.StatusInternalServerError)
187 }
188 err = t.Execute(w, context)
189 if err != nil {
190 http.Error(w, err.Error(), http.StatusInternalServerError)
191 }
192 }
193
194 func authenticateRequest(
195 w http.ResponseWriter, r *http.Request,
196 handler func(http.ResponseWriter, *http.Request, *Voter)) {
197 var voter Voter
198 var found = false
199 authLoop: for _, cert := range r.TLS.PeerCertificates {
200 var isClientCert = false
201 for _, extKeyUsage := range cert.ExtKeyUsage {
202 if extKeyUsage == x509.ExtKeyUsageClientAuth {
203 isClientCert = true
204 break
205 }
206 }
207 if !isClientCert {
208 continue
209 }
210
211 for _, emailAddress := range cert.EmailAddresses {
212 if authenticateVoter(emailAddress, &voter) {
213 found = true
214 break authLoop
215 }
216 }
217 }
218 if !found {
219 w.WriteHeader(http.StatusForbidden)
220 renderTemplate(w, "denied", nil)
221 return
222 }
223 handler(w, r, &voter)
224 }
225
226 func motionsHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
227 err := db.Ping()
228 if err != nil {
229 logger.Fatal(err)
230 }
231
232 // $page = is_numeric($_REQUEST['page'])?$_REQUEST['page']:1;
233
234 motion_stmt, err := db.Preparex(list_decisions_sql)
235 votes_stmt, err := db.Preparex(vote_count_sql)
236 if err != nil {
237 logger.Fatal(err)
238 }
239 defer motion_stmt.Close()
240 defer votes_stmt.Close()
241
242 rows, err := motion_stmt.Queryx(1)
243 if err != nil {
244 logger.Fatal(err)
245 }
246 defer rows.Close()
247
248 var page struct {
249 Decisions []Decision
250 Voter *Voter
251 }
252 page.Voter = voter
253
254 for rows.Next() {
255 var d Decision
256 err := rows.StructScan(&d)
257 if err != nil {
258 logger.Fatal(err)
259 }
260
261 voteRows, err := votes_stmt.Queryx(d.Id)
262 if err != nil {
263 logger.Fatal(err)
264 }
265
266 for voteRows.Next() {
267 var vote, count int
268 err = voteRows.Scan(&vote, &count)
269 if err != nil {
270 voteRows.Close()
271 logger.Fatal(err)
272 }
273 d.parseVote(vote, count)
274 }
275 page.Decisions = append(page.Decisions, d)
276
277 voteRows.Close()
278 }
279
280 renderTemplate(w, "motions", page)
281 }
282
283 func votersHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
284 err := db.Ping()
285 if err != nil {
286 logger.Fatal(err)
287 }
288
289 fmt.Fprintln(w, "Hello", voter.Name)
290
291 sqlStmt := "SELECT name, reminder FROM voters WHERE enabled=1"
292
293 rows, err := db.Query(sqlStmt)
294 if err != nil {
295 logger.Fatal(err)
296 }
297 defer rows.Close()
298
299 fmt.Print("Enabled voters\n\n")
300 fmt.Printf("%-30s %-30s\n", "Name", "Reminder E-Mail address")
301 fmt.Printf("%s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 30))
302 for rows.Next() {
303 var name string
304 var reminder string
305
306 err = rows.Scan(&name, &reminder)
307 if err != nil {
308 logger.Fatal(err)
309 }
310 fmt.Printf("%-30s %s\n", name, reminder)
311 }
312 err = rows.Err()
313 if err != nil {
314 logger.Fatal(err)
315 }
316 }
317
318 type Config struct {
319 BoardMailAddress string `yaml:"board_mail_address"`
320 NoticeSenderAddress string `yaml:"notice_sender_address"`
321 DatabaseFile string `yaml:"database_file"`
322 ClientCACertificates string `yaml:"client_ca_certificates"`
323 ServerCert string `yaml:"server_certificate"`
324 ServerKey string `yaml:"server_key"`
325 }
326
327 func main() {
328 logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags | log.LUTC)
329
330 var filename = "config.yaml"
331 if len(os.Args) == 2 {
332 filename = os.Args[1]
333 }
334
335 var err error
336
337 var config Config
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
355 http.HandleFunc("/motions", func(w http.ResponseWriter, r *http.Request) {
356 authenticateRequest(w, r, motionsHandler)
357 })
358 http.HandleFunc("/voters", func(w http.ResponseWriter, r *http.Request) {
359 authenticateRequest(w, r, votersHandler)
360 })
361 http.HandleFunc("/static/", http.FileServer(http.Dir(".")).ServeHTTP)
362 http.HandleFunc("/", redirectToMotionsHandler)
363
364 // load CA certificates for client authentication
365 caCert, err := ioutil.ReadFile(config.ClientCACertificates)
366 if err != nil {
367 logger.Fatal(err)
368 }
369 caCertPool := x509.NewCertPool()
370 if !caCertPool.AppendCertsFromPEM(caCert) {
371 logger.Fatal("could not initialize client CA certificate pool")
372 }
373
374 // setup HTTPS server
375 tlsConfig := &tls.Config{
376 ClientCAs:caCertPool,
377 ClientAuth:tls.RequireAndVerifyClientCert,
378 }
379 tlsConfig.BuildNameToCertificate()
380
381 server := &http.Server{
382 Addr: ":8443",
383 TLSConfig:tlsConfig,
384 }
385
386 err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey)
387 if err != nil {
388 logger.Fatal("ListenAndServerTLS: ", err)
389 }
390
391 defer db.Close()
392 }