Define target build directory environment variable
[cacert-boardvoting.git] / boardvoting.go
index 68210d9..3b62116 100644 (file)
 package main
 
 import (
+       "bytes"
+       "context"
+       "crypto/tls"
+       "crypto/x509"
+       "encoding/base64"
+       "encoding/pem"
        "fmt"
-       "log"
-       "strings"
-       "net/http"
-       "io/ioutil"
-       "time"
+       "github.com/Masterminds/sprig"
+       "github.com/gorilla/sessions"
+       "github.com/jmoiron/sqlx"
        _ "github.com/mattn/go-sqlite3"
+       "github.com/op/go-logging"
        "gopkg.in/yaml.v2"
-       "github.com/jmoiron/sqlx"
-       "github.com/Masterminds/sprig"
-       "os"
-       "crypto/x509"
-       "crypto/tls"
-       "database/sql"
        "html/template"
+       "io/ioutil"
+       "net/http"
+       "os"
+       "strconv"
+       "strings"
+       "time"
 )
 
-const (
-       list_decisions_sql = `
-SELECT decisions.id, decisions.tag, decisions.proponent,
-       voters.name AS proposer, decisions.proposed, decisions.title,
-       decisions.content, decisions.votetype, decisions.status, decisions.due,
-       decisions.modified
-FROM decisions
-JOIN voters ON decisions.proponent=voters.id
-ORDER BY proposed DESC
-LIMIT 10 OFFSET 10 * ($1 - 1)`
-       get_decision_sql = `
-SELECT decisions.id, decisions.tag, decisions.proponent,
-       voters.name AS proposer, decisions.proposed, decisions.title,
-       decisions.content, decisions.votetype, decisions.status, decisions.due,
-       decisions.modified
-FROM decisions
-JOIN voters ON decisions.proponent=voters.id
-WHERE decisions.id=$1;`
-       get_voter = `
-SELECT voters.id, voters.name
-FROM voters
-JOIN emails ON voters.id=emails.voter
-WHERE emails.address=$1 AND voters.enabled=1`
-       vote_count_sql = `
-SELECT vote, COUNT(vote)
-FROM votes
-WHERE decision=$1`
-)
+var config *Config
+var store *sessions.CookieStore
+var version = "undefined"
+var build = "undefined"
+var log *logging.Logger
 
-var db *sqlx.DB
-var logger *log.Logger
+const sessionCookieName = "votesession"
 
-const (
-       voteAye = 1
-       voteNaye = -1
-       voteAbstain = 0
-)
+func getTemplateFilenames(templates []string) (result []string) {
+       result = make([]string, len(templates))
+       for i := range templates {
+               result[i] = fmt.Sprintf("templates/%s", templates[i])
+       }
+       return result
+}
+
+func renderTemplate(w http.ResponseWriter, templates []string, context interface{}) {
+       funcMaps := sprig.FuncMap()
+       funcMaps["nl2br"] = func(text string) template.HTML {
+               return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1))
+       }
+       t := template.Must(template.New(templates[0]).Funcs(funcMaps).ParseFiles(getTemplateFilenames(templates)...))
+       if err := t.Execute(w, context); err != nil {
+               http.Error(w, err.Error(), http.StatusInternalServerError)
+       }
+}
+
+type contextKey int
 
 const (
-       voteTypeMotion = 0
-       voteTypeVeto = 1
+       ctxNeedsAuth contextKey = iota
+       ctxVoter
+       ctxDecision
+       ctxVote
+       ctxAuthenticatedCert
 )
 
-type VoteType int
-
-func (v VoteType) String() string {
-       switch v {
-       case voteTypeMotion: return "motion"
-       case voteTypeVeto: return "veto"
-       default: return "unknown"
+func authenticateRequest(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) {
+       for _, cert := range r.TLS.PeerCertificates {
+               for _, extKeyUsage := range cert.ExtKeyUsage {
+                       if extKeyUsage == x509.ExtKeyUsageClientAuth {
+                               for _, emailAddress := range cert.EmailAddresses {
+                                       voter, err := FindVoterByAddress(emailAddress)
+                                       if err != nil {
+                                               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                                               return
+                                       }
+                                       if voter != nil {
+                                               requestContext := context.WithValue(r.Context(), ctxVoter, voter)
+                                               requestContext = context.WithValue(requestContext, ctxAuthenticatedCert, cert)
+                                               handler(w, r.WithContext(requestContext))
+                                               return
+                                       }
+                               }
+                       }
+               }
+       }
+       needsAuth, ok := r.Context().Value(ctxNeedsAuth).(bool)
+       if ok && needsAuth {
+               w.WriteHeader(http.StatusForbidden)
+               renderTemplate(w, []string{"denied.html", "header.html", "footer.html"}, nil)
+               return
        }
+       handler(w, r)
 }
 
-func (v VoteType) QuorumAndMajority() (int, int) {
-       switch v {
-       case voteTypeMotion: return 3, 50
-       default: return 1, 99
-       }
+type motionParameters struct {
+       ShowVotes bool
 }
 
-type VoteSums struct {
-       Ayes     int
-       Nayes    int
-       Abstains int
+type motionListParameters struct {
+       Page  int64
+       Flags struct {
+               Confirmed, Withdraw, Unvoted bool
+       }
 }
 
-func (v *VoteSums) voteCount() int {
-       return v.Ayes + v.Nayes + v.Abstains
+func parseMotionParameters(r *http.Request) motionParameters {
+       var m = motionParameters{}
+       m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes"))
+       return m
 }
 
-type VoteStatus int
+func parseMotionListParameters(r *http.Request) motionListParameters {
+       var m = motionListParameters{}
+       if page, err := strconv.ParseInt(r.URL.Query().Get("page"), 10, 0); err != nil {
+               m.Page = 1
+       } else {
+               m.Page = page
+       }
+       m.Flags.Withdraw, _ = strconv.ParseBool(r.URL.Query().Get("withdraw"))
+       m.Flags.Unvoted, _ = strconv.ParseBool(r.URL.Query().Get("unvoted"))
 
-func (v VoteStatus) String() string {
-       switch v {
-       case -1: return "declined"
-       case 0: return "pending"
-       case 1: return "approved"
-       case -2: return "withdrawn"
-       default: return "unknown"
+       if r.Method == http.MethodPost {
+               m.Flags.Confirmed, _ = strconv.ParseBool(r.PostFormValue("confirm"))
        }
+       return m
 }
 
-type VoteKind int
+func motionListHandler(w http.ResponseWriter, r *http.Request) {
+       params := parseMotionListParameters(r)
+       session, err := store.Get(r, sessionCookieName)
+       if err != nil {
+               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+               return
+       }
 
-func (v VoteKind) String() string {
-       switch v {
-       case voteAye: return "Aye"
-       case voteNaye: return "Naye"
-       case voteAbstain: return "Abstain"
-       default: return "unknown"
+       var templateContext struct {
+               Decisions          []*DecisionForDisplay
+               Voter              *Voter
+               Params             *motionListParameters
+               PrevPage, NextPage int64
+               PageTitle          string
+               Flashes            interface{}
        }
-}
+       if voter, ok := r.Context().Value(ctxVoter).(*Voter); ok {
+               templateContext.Voter = voter
+       }
+       if flashes := session.Flashes(); len(flashes) > 0 {
+               templateContext.Flashes = flashes
+       }
+       session.Save(r, w)
+       templateContext.Params = &params
 
-type Vote struct {
-       Name string
-       Vote VoteKind
-}
+       if templateContext.Decisions, err = FindDecisionsForDisplayOnPage(params.Page, params.Flags.Unvoted, templateContext.Voter); err != nil {
+               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+               return
+       }
 
-type Decision struct {
-       Id        int
-       Tag       string
-       Proponent int
-       Proposer  string
-       Proposed  time.Time
-       Title     string
-       Content   string
-       Majority  int
-       Quorum    int
-       VoteType  VoteType
-       Status    VoteStatus
-       Due       time.Time
-       Modified  time.Time
-       VoteSums
-       Votes     []Vote
-}
+       if len(templateContext.Decisions) > 0 {
+               olderExists, err := templateContext.Decisions[len(templateContext.Decisions)-1].OlderExists(params.Flags.Unvoted, templateContext.Voter)
+               if err != nil {
+                       http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                       return
+               }
+               if olderExists {
+                       templateContext.NextPage = params.Page + 1
+               }
+       }
 
-func (d *Decision) parseVote(vote int, count int) {
-       switch vote {
-       case voteAye:
-               d.Ayes = count
-       case voteAbstain:
-               d.Abstains = count
-       case voteNaye:
-               d.Nayes = count
+       if params.Page > 1 {
+               templateContext.PrevPage = params.Page - 1
        }
-}
 
-type Voter struct {
-       Id   int
-       Name string
+       renderTemplate(w, []string{"motions.html", "motion_fragments.html", "header.html", "footer.html"}, templateContext)
 }
 
-func authenticateVoter(emailAddress string, voter *Voter) bool {
-       err := db.Ping()
-       if err != nil {
-               logger.Fatal(err)
+func motionHandler(w http.ResponseWriter, r *http.Request) {
+       params := parseMotionParameters(r)
+
+       decision, ok := getDecisionFromRequest(r)
+       if !ok {
+               http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
+               return
        }
 
-       auth_stmt, err := db.Preparex(get_voter)
-       if err != nil {
-               logger.Fatal(err)
+       var templateContext struct {
+               Decision           *DecisionForDisplay
+               Voter              *Voter
+               Params             *motionParameters
+               PrevPage, NextPage int64
+               PageTitle          string
+               Flashes            interface{}
        }
-       defer auth_stmt.Close()
-       var found = false
-       err = auth_stmt.Get(voter, emailAddress)
-       if err == nil {
-               found = true
-       } else {
-               if err != sql.ErrNoRows {
-                       logger.Fatal(err)
+       voter, ok := getVoterFromRequest(r)
+       if ok {
+               templateContext.Voter = voter
+       }
+       templateContext.Params = &params
+       if params.ShowVotes {
+               if err := decision.LoadVotes(); err != nil {
+                       http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                       return
                }
        }
-       return found
+       templateContext.Decision = decision
+       templateContext.PageTitle = fmt.Sprintf("Motion %s: %s", decision.Tag, decision.Title)
+       renderTemplate(w, []string{"motion.html", "motion_fragments.html", "header.html", "footer.html"}, templateContext)
 }
 
-func redirectToMotionsHandler(w http.ResponseWriter, _ *http.Request) {
-       w.Header().Add("Location", "/motions")
-       w.WriteHeader(http.StatusMovedPermanently)
+func singleDecisionHandler(w http.ResponseWriter, r *http.Request, tag string, handler func(http.ResponseWriter, *http.Request)) {
+       decision, err := FindDecisionForDisplayByTag(tag)
+       if err != nil {
+               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+               return
+       }
+       if decision == nil {
+               http.NotFound(w, r)
+               return
+       }
+       handler(w, r.WithContext(context.WithValue(r.Context(), ctxDecision, decision)))
 }
 
-func renderTemplate(w http.ResponseWriter, tmpl string, context interface{}) {
-       t := template.New("motions.html")
-       t.Funcs(sprig.FuncMap())
-       t, err := t.ParseFiles(fmt.Sprintf("templates/%s.html", tmpl))
+type motionActionHandler interface {
+       Handle(w http.ResponseWriter, r *http.Request)
+       NeedsAuth() bool
+}
+
+type authenticationRequiredHandler struct{}
+
+func (authenticationRequiredHandler) NeedsAuth() bool {
+       return true
+}
+
+func getVoterFromRequest(r *http.Request) (voter *Voter, ok bool) {
+       voter, ok = r.Context().Value(ctxVoter).(*Voter)
+       return
+}
+
+func getDecisionFromRequest(r *http.Request) (decision *DecisionForDisplay, ok bool) {
+       decision, ok = r.Context().Value(ctxDecision).(*DecisionForDisplay)
+       return
+}
+
+func getVoteFromRequest(r *http.Request) (vote VoteChoice, ok bool) {
+       vote, ok = r.Context().Value(ctxVote).(VoteChoice)
+       return
+}
+
+type FlashMessageAction struct{}
+
+func (a *FlashMessageAction) AddFlash(w http.ResponseWriter, r *http.Request, message interface{}, tags ...string) {
+       session, err := store.Get(r, sessionCookieName)
        if err != nil {
-               http.Error(w, err.Error(), http.StatusInternalServerError)
+               log.Errorf("getting session failed: %v", err)
+               return
        }
-       err = t.Execute(w, context)
+       session.AddFlash(message, tags...)
+       session.Save(r, w)
        if err != nil {
-               http.Error(w, err.Error(), http.StatusInternalServerError)
+               log.Errorf("saving session failed: %v", err)
+               return
        }
+       return
 }
 
-func authenticateRequest(
-w http.ResponseWriter, r *http.Request,
-handler func(http.ResponseWriter, *http.Request, *Voter)) {
-       var voter Voter
-       var found = false
-       authLoop: for _, cert := range r.TLS.PeerCertificates {
-               var isClientCert = false
-               for _, extKeyUsage := range cert.ExtKeyUsage {
-                       if extKeyUsage == x509.ExtKeyUsageClientAuth {
-                               isClientCert = true
-                               break
-                       }
-               }
-               if !isClientCert {
-                       continue
-               }
+type withDrawMotionAction struct {
+       FlashMessageAction
+       authenticationRequiredHandler
+}
 
-               for _, emailAddress := range cert.EmailAddresses {
-                       if authenticateVoter(emailAddress, &voter) {
-                               found = true
-                               break authLoop
-                       }
-               }
+func (a *withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
+       decision, ok := getDecisionFromRequest(r)
+       if !ok || decision.Status != voteStatusPending {
+               http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
+               return
        }
-       if !found {
-               w.WriteHeader(http.StatusForbidden)
-               renderTemplate(w, "denied", nil)
+       voter, ok := getVoterFromRequest(r)
+       if !ok {
+               http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
                return
        }
-       handler(w, r, &voter)
-}
-
-func motionsHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
-       err := db.Ping()
-       if err != nil {
-               logger.Fatal(err)
+       templates := []string{"withdraw_motion_form.html", "header.html", "footer.html", "motion_fragments.html"}
+       var templateContext struct {
+               PageTitle string
+               Decision  *DecisionForDisplay
+               Flashes   interface{}
        }
 
-       // $page = is_numeric($_REQUEST['page'])?$_REQUEST['page']:1;
+       switch r.Method {
+       case http.MethodPost:
+               decision.Status = voteStatusWithdrawn
+               decision.Modified = time.Now().UTC()
+               if err := decision.UpdateStatus(); err != nil {
+                       log.Errorf("withdrawing motion failed: %v", err)
+                       http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                       return
+               }
 
-       motion_stmt, err := db.Preparex(list_decisions_sql)
-       votes_stmt, err := db.Preparex(vote_count_sql)
-       if err != nil {
-               logger.Fatal(err)
+               NotifyMailChannel <- NewNotificationWithDrawMotion(&(decision.Decision), voter)
+
+               a.AddFlash(w, r, fmt.Sprintf("Motion %s has been withdrawn!", decision.Tag))
+
+               http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect)
+       default:
+               templateContext.Decision = decision
+               renderTemplate(w, templates, templateContext)
        }
-       defer motion_stmt.Close()
-       defer votes_stmt.Close()
+}
 
-       rows, err := motion_stmt.Queryx(1)
-       if err != nil {
-               logger.Fatal(err)
+type newMotionHandler struct {
+       FlashMessageAction
+}
+
+func (h *newMotionHandler) Handle(w http.ResponseWriter, r *http.Request) {
+       voter, ok := getVoterFromRequest(r)
+       if !ok {
+               http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
        }
-       defer rows.Close()
 
-       var page struct {
-               Decisions []Decision
+       templates := []string{"create_motion_form.html", "header.html", "footer.html"}
+       var templateContext struct {
+               Form      NewDecisionForm
+               PageTitle string
                Voter     *Voter
+               Flashes   interface{}
        }
-       page.Voter = voter
+       switch r.Method {
+       case http.MethodPost:
+               form := NewDecisionForm{
+                       Title:    r.FormValue("Title"),
+                       Content:  r.FormValue("Content"),
+                       VoteType: r.FormValue("VoteType"),
+                       Due:      r.FormValue("Due"),
+               }
 
-       for rows.Next() {
-               var d Decision
-               err := rows.StructScan(&d)
-               if err != nil {
-                       logger.Fatal(err)
+               if valid, data := form.Validate(); !valid {
+                       templateContext.Voter = voter
+                       templateContext.Form = form
+                       renderTemplate(w, templates, templateContext)
+               } else {
+                       data.Proposed = time.Now().UTC()
+                       data.ProponentId = voter.Id
+                       if err := data.Create(); err != nil {
+                               log.Errorf("saving motion failed: %v", err)
+                               http.Error(w, "Saving motion failed", http.StatusInternalServerError)
+                               return
+                       }
+
+                       NotifyMailChannel <- &NotificationCreateMotion{decision: *data, voter: *voter}
+
+                       h.AddFlash(w, r, "The motion has been proposed!")
+
+                       http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
                }
 
-               voteRows, err := votes_stmt.Queryx(d.Id)
-               if err != nil {
-                       logger.Fatal(err)
+               return
+       default:
+               templateContext.Voter = voter
+               templateContext.Form = NewDecisionForm{
+                       VoteType: strconv.FormatInt(voteTypeMotion, 10),
                }
+               renderTemplate(w, templates, templateContext)
+       }
+}
+
+type editMotionAction struct {
+       FlashMessageAction
+       authenticationRequiredHandler
+}
 
-               for voteRows.Next() {
-                       var vote, count int
-                       err = voteRows.Scan(&vote, &count)
-                       if err != nil {
-                               voteRows.Close()
-                               logger.Fatal(err)
+func (a editMotionAction) Handle(w http.ResponseWriter, r *http.Request) {
+       decision, ok := getDecisionFromRequest(r)
+       if !ok || decision.Status != voteStatusPending {
+               http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
+               return
+       }
+       voter, ok := getVoterFromRequest(r)
+       if !ok {
+               http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed)
+               return
+       }
+       templates := []string{"edit_motion_form.html", "header.html", "footer.html"}
+       var templateContext struct {
+               Form      EditDecisionForm
+               PageTitle string
+               Voter     *Voter
+               Flashes   interface{}
+       }
+       switch r.Method {
+       case http.MethodPost:
+               form := EditDecisionForm{
+                       Title:    r.FormValue("Title"),
+                       Content:  r.FormValue("Content"),
+                       VoteType: r.FormValue("VoteType"),
+                       Due:      r.FormValue("Due"),
+                       Decision: &decision.Decision,
+               }
+
+               if valid, data := form.Validate(); !valid {
+                       templateContext.Voter = voter
+                       templateContext.Form = form
+                       renderTemplate(w, templates, templateContext)
+               } else {
+                       data.Modified = time.Now().UTC()
+                       if err := data.Update(); err != nil {
+                               log.Errorf("updating motion failed: %v", err)
+                               http.Error(w, "Updating the motion failed.", http.StatusInternalServerError)
+                               return
                        }
-                       d.parseVote(vote, count)
+
+                       NotifyMailChannel <- NewNotificationUpdateMotion(*data, *voter)
+
+                       a.AddFlash(w, r, "The motion has been modified!")
+
+                       http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
                }
-               page.Decisions = append(page.Decisions, d)
+               return
+       default:
+               templateContext.Voter = voter
+               templateContext.Form = EditDecisionForm{
+                       Title:    decision.Title,
+                       Content:  decision.Content,
+                       VoteType: fmt.Sprintf("%d", decision.VoteType),
+                       Decision: &decision.Decision,
+               }
+               renderTemplate(w, templates, templateContext)
+       }
+}
+
+type motionsHandler struct{}
+
+func (h motionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+       subURL := r.URL.Path
 
-               voteRows.Close()
+       var motionActionMap = map[string]motionActionHandler{
+               "withdraw": &withDrawMotionAction{},
+               "edit":     &editMotionAction{},
        }
 
-       renderTemplate(w, "motions", page)
+       switch {
+       case subURL == "":
+               authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)), motionListHandler)
+               return
+       case subURL == "/newmotion/":
+               handler := &newMotionHandler{}
+               authenticateRequest(
+                       w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)),
+                       handler.Handle)
+               return
+       case strings.Count(subURL, "/") == 1:
+               parts := strings.Split(subURL, "/")
+               motionTag := parts[0]
+               action, ok := motionActionMap[parts[1]]
+               if !ok {
+                       http.NotFound(w, r)
+                       return
+               }
+               authenticateRequest(
+                       w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, action.NeedsAuth())),
+                       func(w http.ResponseWriter, r *http.Request) {
+                               singleDecisionHandler(w, r, motionTag, action.Handle)
+                       })
+               return
+       case strings.Count(subURL, "/") == 0:
+               authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, false)),
+                       func(w http.ResponseWriter, r *http.Request) {
+                               singleDecisionHandler(w, r, subURL, motionHandler)
+                       })
+               return
+       default:
+               http.NotFound(w, r)
+               return
+       }
 }
 
-func votersHandler(w http.ResponseWriter, _ *http.Request, voter *Voter) {
-       err := db.Ping()
-       if err != nil {
-               logger.Fatal(err)
+type directVoteHandler struct {
+       FlashMessageAction
+       authenticationRequiredHandler
+}
+
+func (h *directVoteHandler) Handle(w http.ResponseWriter, r *http.Request) {
+       decision, ok := getDecisionFromRequest(r)
+       if !ok {
+               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+               return
        }
+       voter, ok := getVoterFromRequest(r)
+       if !ok {
+               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+               return
+       }
+       vote, ok := getVoteFromRequest(r)
+       if !ok {
+               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+               return
+       }
+       switch r.Method {
+       case http.MethodPost:
+               voteResult := &Vote{
+                       VoterId: voter.Id, Vote: vote, DecisionId: decision.Id, Voted: time.Now().UTC(),
+                       Notes: fmt.Sprintf("Direct Vote\n\n%s", getPEMClientCert(r))}
+               if err := voteResult.Save(); err != nil {
+                       log.Errorf("Problem saving vote: %v", err)
+                       http.Error(w, "Problem saving vote", http.StatusInternalServerError)
+                       return
+               }
 
-       fmt.Fprintln(w, "Hello", voter.Name)
+               NotifyMailChannel <- NewNotificationDirectVote(&decision.Decision, voter, voteResult)
 
-       sqlStmt := "SELECT name, reminder FROM voters WHERE enabled=1"
+               h.AddFlash(w, r, "Your vote has been registered.")
 
-       rows, err := db.Query(sqlStmt)
-       if err != nil {
-               logger.Fatal(err)
+               http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
+       default:
+               templates := []string{"direct_vote_form.html", "header.html", "footer.html", "motion_fragments.html"}
+               var templateContext struct {
+                       Decision   *DecisionForDisplay
+                       VoteChoice VoteChoice
+                       PageTitle  string
+                       Flashes    interface{}
+               }
+               templateContext.Decision = decision
+               templateContext.VoteChoice = vote
+               renderTemplate(w, templates, templateContext)
        }
-       defer rows.Close()
+}
 
-       fmt.Print("Enabled voters\n\n")
-       fmt.Printf("%-30s %-30s\n", "Name", "Reminder E-Mail address")
-       fmt.Printf("%s %s\n", strings.Repeat("-", 30), strings.Repeat("-", 30))
-       for rows.Next() {
-               var name string
-               var reminder string
+type proxyVoteHandler struct {
+       FlashMessageAction
+       authenticationRequiredHandler
+}
 
-               err = rows.Scan(&name, &reminder)
-               if err != nil {
-                       logger.Fatal(err)
+func getPEMClientCert(r *http.Request) string {
+       clientCertPEM := bytes.NewBufferString("")
+       authenticatedCertificate := r.Context().Value(ctxAuthenticatedCert).(*x509.Certificate)
+       pem.Encode(clientCertPEM, &pem.Block{Type: "CERTIFICATE", Bytes: authenticatedCertificate.Raw})
+       return clientCertPEM.String()
+}
+
+func (h *proxyVoteHandler) Handle(w http.ResponseWriter, r *http.Request) {
+       decision, ok := getDecisionFromRequest(r)
+       if !ok {
+               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+               return
+       }
+       proxy, ok := getVoterFromRequest(r)
+       if !ok {
+               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+               return
+       }
+       templates := []string{"proxy_vote_form.html", "header.html", "footer.html", "motion_fragments.html"}
+       var templateContext struct {
+               Form      ProxyVoteForm
+               Decision  *DecisionForDisplay
+               Voters    *[]Voter
+               PageTitle string
+               Flashes   interface{}
+       }
+       switch r.Method {
+       case http.MethodPost:
+               form := ProxyVoteForm{
+                       Voter:         r.FormValue("Voter"),
+                       Vote:          r.FormValue("Vote"),
+                       Justification: r.FormValue("Justification"),
+               }
+
+               if valid, voter, data, justification := form.Validate(); !valid {
+                       templateContext.Form = form
+                       templateContext.Decision = decision
+                       if voters, err := GetVotersForProxy(proxy); err != nil {
+                               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                               return
+                       } else {
+                               templateContext.Voters = voters
+                       }
+                       renderTemplate(w, templates, templateContext)
+               } else {
+                       data.DecisionId = decision.Id
+                       data.Voted = time.Now().UTC()
+                       data.Notes = fmt.Sprintf(
+                               "Proxy-Vote by %s\n\n%s\n\n%s",
+                               proxy.Name, justification, getPEMClientCert(r))
+
+                       if err := data.Save(); err != nil {
+                               log.Errorf("Error saving vote: %s", err)
+                               http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                               return
+                       }
+
+                       NotifyMailChannel <- NewNotificationProxyVote(&decision.Decision, proxy, voter, data, justification)
+
+                       h.AddFlash(w, r, "The vote has been registered.")
+
+                       http.Redirect(w, r, "/motions/", http.StatusMovedPermanently)
+               }
+               return
+       default:
+               templateContext.Form = ProxyVoteForm{}
+               templateContext.Decision = decision
+               if voters, err := GetVotersForProxy(proxy); err != nil {
+                       http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+                       return
+               } else {
+                       templateContext.Voters = voters
                }
-               fmt.Printf("%-30s %s\n", name, reminder)
+               renderTemplate(w, templates, templateContext)
        }
-       err = rows.Err()
-       if err != nil {
-               logger.Fatal(err)
+}
+
+type decisionVoteHandler struct{}
+
+func (h *decisionVoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+       switch {
+       case strings.HasPrefix(r.URL.Path, "/proxy/"):
+               motionTag := r.URL.Path[len("/proxy/"):]
+               handler := &proxyVoteHandler{}
+               authenticateRequest(
+                       w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)),
+                       func(w http.ResponseWriter, r *http.Request) {
+                               singleDecisionHandler(w, r, motionTag, handler.Handle)
+                       })
+       case strings.HasPrefix(r.URL.Path, "/vote/"):
+               parts := strings.Split(r.URL.Path[len("/vote/"):], "/")
+               if len(parts) != 2 {
+                       http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+                       return
+               }
+               motionTag := parts[0]
+               voteValue, ok := VoteValues[parts[1]]
+               if !ok {
+                       http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+                       return
+               }
+               handler := &directVoteHandler{}
+               authenticateRequest(
+                       w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)),
+                       func(w http.ResponseWriter, r *http.Request) {
+                               singleDecisionHandler(
+                                       w, r.WithContext(context.WithValue(r.Context(), ctxVote, voteValue)),
+                                       motionTag, handler.Handle)
+                       })
+               return
        }
 }
 
 type Config struct {
-       BoardMailAddress     string `yaml:"board_mail_address"`
-       NoticeSenderAddress  string `yaml:"notice_sender_address"`
-       DatabaseFile         string `yaml:"database_file"`
-       ClientCACertificates string `yaml:"client_ca_certificates"`
-       ServerCert           string `yaml:"server_certificate"`
-       ServerKey            string `yaml:"server_key"`
+       NoticeMailAddress         string `yaml:"notice_mail_address"`
+       VoteNoticeMailAddress     string `yaml:"vote_notice_mail_address"`
+       NotificationSenderAddress string `yaml:"notification_sender_address"`
+       DatabaseFile              string `yaml:"database_file"`
+       ClientCACertificates      string `yaml:"client_ca_certificates"`
+       ServerCert                string `yaml:"server_certificate"`
+       ServerKey                 string `yaml:"server_key"`
+       CookieSecret              string `yaml:"cookie_secret"`
+       BaseURL                   string `yaml:"base_url"`
+       MigrationsPath            string `yaml:"migrations_path"`
+       MailServer                struct {
+               Host string `yaml:"host"`
+               Port int    `yaml:"port"`
+       } `yaml:"mail_server"`
 }
 
-func main() {
-       logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags | log.LUTC)
+func setupLogging(ctx context.Context) {
+       log = logging.MustGetLogger("boardvoting")
+       consoleLogFormat := logging.MustStringFormatter(`%{color}%{time:20060102 15:04:05.000-0700} %{longfile} ▶ %{level:s} %{id:05d}%{color:reset} %{message}`)
+       fileLogFormat := logging.MustStringFormatter(`%{time:20060102 15:04:05.000-0700} %{level:s} %{id:05d} %{message}`)
+
+       consoleBackend := logging.NewLogBackend(os.Stderr, "", 0)
 
-       var filename = "config.yaml"
-       if len(os.Args) == 2 {
-               filename = os.Args[1]
+       logfile, err := os.OpenFile("boardvoting.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, os.FileMode(0640))
+       if err != nil {
+               panic("Could not open logfile")
        }
 
-       var err error
+       fileBackend := logging.NewLogBackend(logfile, "", 0)
+       fileBackendLeveled := logging.AddModuleLevel(logging.NewBackendFormatter(fileBackend, fileLogFormat))
+       fileBackendLeveled.SetLevel(logging.INFO, "")
 
-       var config Config
-       var source []byte
+       logging.SetBackend(fileBackendLeveled,
+               logging.NewBackendFormatter(consoleBackend, consoleLogFormat))
 
-       source, err = ioutil.ReadFile(filename)
+       go func() {
+               for range ctx.Done() {
+                       if err = logfile.Close(); err != nil {
+                               fmt.Fprintf(os.Stderr, "Problem closing the log file: %v", err)
+                       }
+               }
+       }()
+
+       log.Info("Setup logging")
+}
+
+func readConfig() {
+       source, err := ioutil.ReadFile("config.yaml")
        if err != nil {
-               logger.Fatal(err)
+               log.Panicf("Opening configuration file failed: %v", err)
        }
-       err = yaml.Unmarshal(source, &config)
+       if err := yaml.Unmarshal(source, &config); err != nil {
+               log.Panicf("Loading configuration failed: %v", err)
+       }
+
+       cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret)
        if err != nil {
-               logger.Fatal(err)
+               log.Panicf("Decoding cookie secret failed: %v", err)
+               panic(err)
+       }
+       if len(cookieSecret) < 32 {
+               log.Panic("Cookie secret is less than 32 bytes long")
        }
-       logger.Printf("read configuration %v", config)
+       store = sessions.NewCookieStore(cookieSecret)
+       log.Info("Read configuration")
+}
 
-       db, err = sqlx.Open("sqlite3", config.DatabaseFile)
+func setupDbConfig(ctx context.Context) {
+       database, err := sqlx.Open("sqlite3", config.DatabaseFile)
        if err != nil {
-               logger.Fatal(err)
+               log.Panicf("Opening database failed: %v", err)
        }
+       db = NewDB(database)
+
+       go func() {
+               for range ctx.Done() {
+                       if err := db.Close(); err != nil {
+                               fmt.Fprintf(os.Stderr, "Problem closing the database: %v", err)
+                       }
+               }
+       }()
+
+       log.Infof("opened database connection")
+}
+
+func setupNotifications(ctx context.Context) {
+       quitMailChannel := make(chan int)
+       go MailNotifier(quitMailChannel)
+
+       go func() {
+               for range ctx.Done() {
+                       quitMailChannel <- 1
+               }
+       }()
+}
+
+func setupJobs(ctx context.Context) {
+       quitChannel := make(chan int)
+       go JobScheduler(quitChannel)
+
+       go func() {
+               for range ctx.Done() {
+                       quitChannel <- 1
+               }
+       }()
+}
 
-       http.HandleFunc("/motions", func(w http.ResponseWriter, r *http.Request) {
-               authenticateRequest(w, r, motionsHandler)
-       })
-       http.HandleFunc("/voters", func(w http.ResponseWriter, r *http.Request) {
-               authenticateRequest(w, r, votersHandler)
-       })
-       http.HandleFunc("/static/", http.FileServer(http.Dir(".")).ServeHTTP)
-       http.HandleFunc("/", redirectToMotionsHandler)
+func setupHandlers() {
+       http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{}))
+       http.Handle("/newmotion/", motionsHandler{})
+       http.Handle("/proxy/", &decisionVoteHandler{})
+       http.Handle("/vote/", &decisionVoteHandler{})
+       http.Handle("/static/", http.FileServer(http.Dir(".")))
+       http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently))
+}
 
+func setupTLSConfig() (tlsConfig *tls.Config) {
        // load CA certificates for client authentication
        caCert, err := ioutil.ReadFile(config.ClientCACertificates)
        if err != nil {
-               logger.Fatal(err)
+               log.Panicf("Error reading client certificate CAs %v", err)
        }
        caCertPool := x509.NewCertPool()
        if !caCertPool.AppendCertsFromPEM(caCert) {
-               logger.Fatal("could not initialize client CA certificate pool")
+               log.Panic("could not initialize client CA certificate pool")
        }
 
        // setup HTTPS server
-       tlsConfig := &tls.Config{
-               ClientCAs:caCertPool,
-               ClientAuth:tls.RequireAndVerifyClientCert,
+       tlsConfig = &tls.Config{
+               ClientCAs:  caCertPool,
+               ClientAuth: tls.VerifyClientCertIfGiven,
        }
        tlsConfig.BuildNameToCertificate()
+       return
+}
+
+func main() {
+       var stopAll func()
+       executionContext, stopAll := context.WithCancel(context.Background())
+       setupLogging(executionContext)
+       readConfig()
+       setupDbConfig(executionContext)
+       setupNotifications(executionContext)
+       setupJobs(executionContext)
+       setupHandlers()
+       tlsConfig := setupTLSConfig()
+
+       defer stopAll()
+
+       log.Infof("CAcert Board Voting version %s, build %s", version, build)
 
        server := &http.Server{
-               Addr: ":8443",
-               TLSConfig:tlsConfig,
+               Addr:      ":8443",
+               TLSConfig: tlsConfig,
        }
 
-       err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey)
-       if err != nil {
-               logger.Fatal("ListenAndServerTLS: ", err)
-       }
+       log.Infof("Launching application on https://localhost%s/", server.Addr)
+
+       errs := make(chan error, 1)
+       go func() {
+               if err := http.ListenAndServe(":8080", http.RedirectHandler(config.BaseURL, http.StatusMovedPermanently)); err != nil {
+                       errs <- err
+               }
+               close(errs)
+       }()
 
-       defer db.Close()
+       if err := server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil {
+               log.Panicf("ListenAndServerTLS failed: %v", err)
+       }
+       if err := <-errs; err != nil {
+               log.Panicf("ListenAndServe failed: %v", err)
+       }
 }