package main import ( "context" "crypto/tls" "crypto/x509" "encoding/base64" "fmt" "github.com/Masterminds/sprig" "github.com/gorilla/sessions" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "gopkg.in/yaml.v2" "html/template" "io/ioutil" "log" "net/http" "os" "strconv" "strings" "time" ) var logger *log.Logger var config *Config var store *sessions.CookieStore var version = "undefined" var build = "undefined" const sessionCookieName = "votesession" 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{}) { t := template.Must(template.New(templates[0]).Funcs(sprig.FuncMap()).ParseFiles(getTemplateFilenames(templates)...)) if err := t.Execute(w, context); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } type contextKey int const ( ctxNeedsAuth contextKey = iota ctxVoter ctxDecision ) 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 { handler(w, r.WithContext(context.WithValue(r.Context(), ctxVoter, voter))) return } } } } } needsAuth, ok := r.Context().Value(ctxNeedsAuth).(bool) if ok && needsAuth { w.WriteHeader(http.StatusForbidden) renderTemplate(w, []string{"denied.html"}, nil) 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 } type FlashMessageAction struct{} func (a *FlashMessageAction) AddFlash(w http.ResponseWriter, r *http.Request, message interface{}, tags ...string) (err error) { session, err := store.Get(r, sessionCookieName) if err != nil { logger.Println("ERROR getting session:", err) return } session.AddFlash(message, tags...) session.Save(r, w) if err != nil { logger.Println("ERROR saving session:", 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 { logger.Println("Error withdrawing motion:", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } notifyMail <- &NotificationWithDrawMotion{decision: decision.Decision, voter: *voter} if err := a.AddFlash(w, r, fmt.Sprintf("Motion %s has been withdrawn!", decision.Tag)); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } 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 { logger.Println("Error saving motion:", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } notifyMail <- &NotificationCreateMotion{decision: *data, voter: *voter} if err := h.AddFlash(w, r, "The motion has been proposed!"); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } 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 { logger.Println("Error updating motion:", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } notifyMail <- &NotificationUpdateMotion{decision: *data, voter: *voter} if err := a.AddFlash(w, r, "The motion has been modified!"); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } 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) { if err := db.Ping(); err != nil { logger.Fatal(err) } 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 Config struct { BoardMailAddress string `yaml:"board_mail_address"` NoticeSenderAddress string `yaml:"notice_sender_address"` ReminderSenderAddress string `yaml:"reminder_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"` MailServer struct { Host string `yaml:"host"` Port int `yaml:"port"` } `yaml:"mail_server"` } func init() { logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile) source, err := ioutil.ReadFile("config.yaml") if err != nil { logger.Fatal(err) } if err := yaml.Unmarshal(source, &config); err != nil { logger.Fatal(err) } cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret) if err != nil { logger.Fatal(err) } if len(cookieSecret) < 32 { logger.Fatalln("Cookie secret is less than 32 bytes long") } store = sessions.NewCookieStore(cookieSecret) logger.Println("read configuration") db, err = sqlx.Open("sqlite3", config.DatabaseFile) if err != nil { logger.Fatal(err) } logger.Println("opened database connection") } func main() { logger.Printf("CAcert Board Voting version %s, build %s\n", version, build) defer db.Close() go MailNotifier() defer CloseMailNotifier() quitChannel := make(chan int) go JobScheduler(quitChannel) defer func() { quitChannel <- 1 }() http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{})) http.Handle("/newmotion/", motionsHandler{}) http.Handle("/static/", http.FileServer(http.Dir("."))) http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently)) // load CA certificates for client authentication caCert, err := ioutil.ReadFile(config.ClientCACertificates) if err != nil { logger.Fatal(err) } caCertPool := x509.NewCertPool() if !caCertPool.AppendCertsFromPEM(caCert) { logger.Fatal("could not initialize client CA certificate pool") } // setup HTTPS server tlsConfig := &tls.Config{ ClientCAs: caCertPool, ClientAuth: tls.VerifyClientCertIfGiven, } tlsConfig.BuildNameToCertificate() server := &http.Server{ Addr: ":8443", TLSConfig: tlsConfig, } logger.Printf("Launching application on https://localhost%s/\n", 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) }() if err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil { logger.Fatal("ListenAndServerTLS: ", err) } if err := <-errs; err != nil { logger.Fatal("ListenAndServe: ", err) } }