You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cacert-boardvoting/boardvoting.go

391 lines
11 KiB
Go

package main
import (
"context"
"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)
}
}
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"))
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) {
params := parseMotionListParameters(r)
var templateContext struct {
Decisions []*DecisionForDisplay
Voter *Voter
Params *motionListParameters
PrevPage, NextPage int64
PageTitle string
}
if voter, ok := r.Context().Value(ctxVoter).(*Voter); ok {
templateContext.Voter = voter
}
templateContext.Params = &params
var err error
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
}
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
}
}
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:
fmt.Fprintf(w, "No handler for '%s'", subURL)
return
}
}
func newMotionHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "New motion")
voter, _ := getVoterFromRequest(r)
fmt.Fprintf(w, "%+v\n", voter)
// 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.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)
}
}