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" ) var logger *log.Logger var config *Config var store *sessions.CookieStore 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 contextKey = iota ctxDecision contextKey = iota ) 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 params.Flags.Unvoted && templateContext.Voter != nil { if templateContext.Decisions, err = FindVotersUnvotedDecisionsForDisplayOnPage( params.Page, templateContext.Voter); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } } else { if templateContext.Decisions, err = FindDecisionsForDisplayOnPage(params.Page); 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() 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 } type withDrawMotionAction struct { authenticationRequiredHandler } 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 (withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request) { voter, voter_ok := getVoterFromRequest(r) decision, decision_ok := getDecisionFromRequest(r) if !voter_ok || !decision_ok || decision.Status != voteStatusPending { http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) return } switch r.Method { case http.MethodPost: if confirm, err := strconv.ParseBool(r.PostFormValue("confirm")); err != nil { log.Println("could not parse confirm parameter:", err) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) } else if confirm { WithdrawMotion(&decision.Decision, voter) } else { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) } http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect) return default: fmt.Fprintln(w, "Withdraw motion", decision.Tag) } } type editMotionAction struct { authenticationRequiredHandler } func (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 } fmt.Fprintln(w, "Edit motion", decision.Tag) // TODO: implement } 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 strings.Count(subURL, "/") == 1: parts := strings.Split(subURL, "/") logger.Printf("handle %v\n", parts) 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) }) logger.Printf("motion: %s, action: %s\n", motionTag, action) 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 newMotionHandler(w http.ResponseWriter, r *http.Request) { voter, ok := getVoterFromRequest(r) if !ok { http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) } templates := []string{"newmotion_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 { if err := CreateMotion(data, voter); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } session, err := store.Get(r, sessionCookieName) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } session.AddFlash("The motion has been proposed!") session.Save(r, w) http.Redirect(w, r, "/motions/", http.StatusTemporaryRedirect) } return default: templateContext.Voter = voter templateContext.Form = NewDecisionForm{ VoteType: strconv.FormatInt(voteTypeMotion, 10), } renderTemplate(w, templates, templateContext) } } 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"` CookieSecret string `yaml:"cookie_secret"` } 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") } func main() { var err error db, err = sqlx.Open("sqlite3", config.DatabaseFile) if err != nil { logger.Fatal(err) } defer db.Close() http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{})) http.HandleFunc("/newmotion/", func(w http.ResponseWriter, r *http.Request) { authenticateRequest(w, r.WithContext(context.WithValue(r.Context(), ctxNeedsAuth, true)), newMotionHandler) }) 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) if err = server.ListenAndServeTLS(config.ServerCert, config.ServerKey); err != nil { logger.Fatal("ListenAndServerTLS: ", err) } }