package main import ( "crypto/tls" "crypto/x509" "fmt" "github.com/Masterminds/sprig" "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 func getTemplateFilenames(tmpl []string) (result []string) { result = make([]string, len(tmpl)) for i := range tmpl { result[i] = fmt.Sprintf("templates/%s", tmpl[i]) } return result } func renderTemplate(w http.ResponseWriter, tmpl []string, context interface{}) { t := template.Must(template.New(tmpl[0]).Funcs(sprig.FuncMap()).ParseFiles(getTemplateFilenames(tmpl)...)) if err := t.Execute(w, context); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func authenticateRequest(w http.ResponseWriter, r *http.Request, authRequired bool, handler func(http.ResponseWriter, *http.Request, *Voter)) { 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, voter) return } } } } } if authRequired { w.WriteHeader(http.StatusForbidden) renderTemplate(w, []string{"denied.html"}, nil) return } handler(w, r, nil) } 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")) logger.Printf("parsed parameters: %+v\n", m) 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")) } logger.Printf("parsed parameters: %+v\n", m) return m } func motionListHandler(w http.ResponseWriter, r *http.Request, voter *Voter) { params := parseMotionListParameters(r) var context struct { Decisions []*DecisionForDisplay Voter *Voter Params *motionListParameters PrevPage, NextPage int64 PageTitle string } context.Voter = voter context.Params = ¶ms var err error if params.Flags.Unvoted { if context.Decisions, err = FindVotersUnvotedDecisionsForDisplayOnPage(params.Page, voter); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } } else { if context.Decisions, err = FindDecisionsForDisplayOnPage(params.Page); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } } if len(context.Decisions) > 0 { olderExists, err := context.Decisions[len(context.Decisions)-1].OlderExists() if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } if olderExists { context.NextPage = params.Page + 1 } } if params.Page > 1 { context.PrevPage = params.Page - 1 } renderTemplate(w, []string{"motions.html", "motion_fragments.html", "header.html", "footer.html"}, context) } func motionHandler(w http.ResponseWriter, r *http.Request, voter *Voter, decision *DecisionForDisplay) { params := parseMotionParameters(r) var context struct { Decision *DecisionForDisplay Voter *Voter Params *motionParameters PrevPage, NextPage int64 PageTitle string } context.Voter = voter context.Params = ¶ms if params.ShowVotes { if err := decision.LoadVotes(); err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } } context.Decision = decision context.PageTitle = fmt.Sprintf("Motion %s: %s", decision.Tag, decision.Title) renderTemplate(w, []string{"motion.html", "motion_fragments.html", "header.html", "footer.html"}, context) } func singleDecisionHandler(w http.ResponseWriter, r *http.Request, v *Voter, tag string, handler func(http.ResponseWriter, *http.Request, *Voter, *DecisionForDisplay)) { 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, v, decision) } type motionsHandler struct{} type motionActionHandler interface { Handle(w http.ResponseWriter, r *http.Request, voter *Voter, decision *DecisionForDisplay) NeedsAuth() bool } type withDrawMotionAction struct{} func (withDrawMotionAction) Handle(w http.ResponseWriter, r *http.Request, voter *Voter, decision *DecisionForDisplay) { fmt.Fprintln(w, "Withdraw motion", decision.Tag) // TODO: implement if r.Method == 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) } } } func (withDrawMotionAction) NeedsAuth() bool { return true } type editMotionAction struct{} func (editMotionAction) Handle(w http.ResponseWriter, r *http.Request, voter *Voter, decision *DecisionForDisplay) { fmt.Fprintln(w, "Edit motion", decision.Tag) // TODO: implement } func (editMotionAction) NeedsAuth() bool { return true } 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, 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, action.NeedsAuth(), func(w http.ResponseWriter, r *http.Request, v *Voter) { singleDecisionHandler(w, r, v, motionTag, action.Handle) }) logger.Printf("motion: %s, action: %s\n", motionTag, action) return case strings.Count(subURL, "/") == 0: authenticateRequest(w, r, false, func(w http.ResponseWriter, r *http.Request, v *Voter) { singleDecisionHandler(w, r, v, subURL, motionHandler) }) return default: fmt.Fprintf(w, "No handler for '%s'", subURL) return } } func newMotionHandler(w http.ResponseWriter, _ *http.Request, _ *Voter) { fmt.Fprintln(w,"New motion") // TODO: implement } 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"` } func main() { logger = log.New(os.Stderr, "boardvoting: ", log.LstdFlags|log.LUTC|log.Lshortfile) var filename = "config.yaml" if len(os.Args) == 2 { filename = os.Args[1] } var err error var source []byte source, err = ioutil.ReadFile(filename) if err != nil { logger.Fatal(err) } err = yaml.Unmarshal(source, &config) if err != nil { logger.Fatal(err) } logger.Printf("read configuration %v", config) 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, 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.RequireAndVerifyClientCert, } 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) } }