package main import ( "bytes" "context" "crypto/tls" "crypto/x509" "database/sql" "encoding/base64" "encoding/pem" "flag" "fmt" "git.cacert.org/cacert-boardvoting/boardvoting" "github.com/Masterminds/sprig" "github.com/gorilla/sessions" _ "github.com/mattn/go-sqlite3" "github.com/op/go-logging" "gopkg.in/yaml.v2" "html/template" "io/ioutil" "net/http" "os" "sort" "strconv" "strings" "time" ) var configFile string var config *Config var store *sessions.CookieStore var version = "undefined" var build = "undefined" var log *logging.Logger const sessionCookieName = "votesession" 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", "
", -1)) } var baseTemplate *template.Template for count, t := range templates { if assetBytes, err := boardvoting.Asset(fmt.Sprintf("templates/%s", t)); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } else { if count == 0 { if baseTemplate, err = template.New(t).Funcs(funcMaps).Parse(string(assetBytes)); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } else { if _, err := baseTemplate.New(t).Funcs(funcMaps).Parse(string(assetBytes)); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } } if err := baseTemplate.Execute(w, context); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } type contextKey int const ( ctxNeedsAuth contextKey = iota ctxVoter ctxDecision ctxVote ctxAuthenticatedCert ) func authenticateRequest(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) { emailsTried := make(map[string]bool) for _, cert := range r.TLS.PeerCertificates { for _, extKeyUsage := range cert.ExtKeyUsage { if extKeyUsage == x509.ExtKeyUsageClientAuth { for _, emailAddress := range cert.EmailAddresses { emailLower := strings.ToLower(emailAddress) emailsTried[emailLower] = true voter, err := FindVoterByAddress(emailLower) 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 { var templateContext struct { PageTitle string Voter *Voter Flashes interface{} Emails []string } for k := range emailsTried { templateContext.Emails = append(templateContext.Emails, k) } sort.Strings(templateContext.Emails) w.WriteHeader(http.StatusForbidden) renderTemplate(w, []string{"denied.html", "header.html", "footer.html"}, templateContext) return } handler(w, r) } type motionParameters struct { ShowVotes bool } type motionListParameters struct { Page int64 Flags struct { Confirmed, Withdraw, Unvoted bool } } func parseMotionParameters(r *http.Request) motionParameters { var m = motionParameters{} m.ShowVotes, _ = strconv.ParseBool(r.URL.Query().Get("showvotes")) return m } 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")) if r.Method == http.MethodPost { m.Flags.Confirmed, _ = strconv.ParseBool(r.PostFormValue("confirm")) } return m } 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 } 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 = ¶ms if templateContext.Decisions, err = FindDecisionsForDisplayOnPage(params.Page, params.Flags.Unvoted, templateContext.Voter); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } 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 } } if params.Page > 1 { templateContext.PrevPage = params.Page - 1 } renderTemplate(w, []string{"motions.html", "motion_fragments.html", "header.html", "footer.html"}, templateContext) } 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 } var templateContext struct { Decision *DecisionForDisplay Voter *Voter Params *motionParameters PrevPage, NextPage int64 PageTitle string Flashes interface{} } voter, ok := getVoterFromRequest(r) if ok { templateContext.Voter = voter } templateContext.Params = ¶ms if params.ShowVotes { if err := decision.LoadVotes(); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } } 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 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))) } 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 { log.Errorf("getting session failed: %v", err) return } session.AddFlash(message, tags...) session.Save(r, w) if err != nil { log.Errorf("saving session failed: %v", err) return } return } type withDrawMotionAction struct { FlashMessageAction authenticationRequiredHandler } 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 } voter, ok := getVoterFromRequest(r) if !ok { http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) return } templates := []string{"withdraw_motion_form.html", "header.html", "footer.html", "motion_fragments.html"} var templateContext struct { PageTitle string Decision *DecisionForDisplay Flashes interface{} } 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 } 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) } } 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) } templates := []string{"create_motion_form.html", "header.html", "footer.html"} var templateContext struct { Form NewDecisionForm PageTitle string Voter *Voter Flashes interface{} } switch r.Method { case http.MethodPost: form := NewDecisionForm{ Title: r.FormValue("Title"), Content: r.FormValue("Content"), VoteType: r.FormValue("VoteType"), Due: r.FormValue("Due"), } 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) } return default: templateContext.Voter = voter templateContext.Form = NewDecisionForm{ VoteType: strconv.FormatInt(voteTypeMotion, 10), } renderTemplate(w, templates, templateContext) } } type editMotionAction struct { FlashMessageAction authenticationRequiredHandler } 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 } NotifyMailChannel <- NewNotificationUpdateMotion(*data, *voter) a.AddFlash(w, r, "The motion has been modified!") http.Redirect(w, r, "/motions/", http.StatusMovedPermanently) } 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 var motionActionMap = map[string]motionActionHandler{ "withdraw": &withDrawMotionAction{}, "edit": &editMotionAction{}, } 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 } } 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 } NotifyMailChannel <- NewNotificationDirectVote(&decision.Decision, voter, voteResult) h.AddFlash(w, r, "Your vote has been registered.") 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) } } type proxyVoteHandler struct { FlashMessageAction authenticationRequiredHandler } 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 } renderTemplate(w, templates, templateContext) } } 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 { 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"` HttpAddress string `yaml:"http_address"` HttpsAddress string `yaml:"https_address"` MailServer struct { Host string `yaml:"host"` Port int `yaml:"port"` } `yaml:"mail_server"` } 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) 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") } fileBackend := logging.NewLogBackend(logfile, "", 0) fileBackendLeveled := logging.AddModuleLevel(logging.NewBackendFormatter(fileBackend, fileLogFormat)) fileBackendLeveled.SetLevel(logging.INFO, "") logging.SetBackend(fileBackendLeveled, logging.NewBackendFormatter(consoleBackend, consoleLogFormat)) 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(configFile) if err != nil { log.Panicf("Opening configuration file failed: %v", err) } if err := yaml.Unmarshal(source, &config); err != nil { log.Panicf("Loading configuration failed: %v", err) } if config.HttpsAddress == "" { config.HttpsAddress = ":8443" } if config.HttpAddress == "" { config.HttpAddress = ":8080" } cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret) if err != nil { log.Panicf("Decoding cookie secret failed: %v", err) panic(err) } if len(cookieSecret) < 32 { log.Panic("Cookie secret is less than 32 bytes long") } store = sessions.NewCookieStore(cookieSecret) log.Info("Read configuration") } func setupDbConfig(ctx context.Context) { database, err := sql.Open("sqlite3", config.DatabaseFile) if err != nil { 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 } }() } 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(boardvoting.GetAssetFS())) 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 { log.Panicf("Error reading client certificate CAs %v", err) } caCertPool := x509.NewCertPool() if !caCertPool.AppendCertsFromPEM(caCert) { log.Panic("could not initialize client CA certificate pool") } // setup HTTPS server tlsConfig = &tls.Config{ ClientCAs: caCertPool, ClientAuth: tls.VerifyClientCertIfGiven, } tlsConfig.BuildNameToCertificate() return } func init() { flag.StringVar( &configFile, "config", "config.yaml", "Configuration file name") } func main() { flag.Parse() 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: config.HttpsAddress, TLSConfig: tlsConfig, } log.Infof("Launching application on https://%s/", server.Addr) errs := make(chan error, 1) go func() { if err := http.ListenAndServe(config.HttpsAddress, http.RedirectHandler(config.BaseURL, http.StatusMovedPermanently)); err != nil { errs <- err } close(errs) }() 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) } }