Jan Dittberner
d7a742d97d
- add package.json for npm/npx - update to fomantic-ui - move ui files to ui directory - add UI build documentation to README.md - add ui target to Makefile - add addPrefix handler in boardvoting.go to allow the same /static/ prefix for static resources
1120 lines
28 KiB
Go
1120 lines
28 KiB
Go
/*
|
|
Copyright 2017-2022 CAcert Inc.
|
|
SPDX-License-Identifier: Apache-2.0
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
// The CAcert board voting software.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Masterminds/sprig/v3"
|
|
"github.com/gorilla/csrf"
|
|
"github.com/gorilla/sessions"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/vearutop/statigz"
|
|
"github.com/vearutop/statigz/brotli"
|
|
"gopkg.in/yaml.v2"
|
|
)
|
|
|
|
var configFile string
|
|
var config *Config
|
|
var store *sessions.CookieStore
|
|
var csrfKey []byte
|
|
var version = "undefined"
|
|
var commit = "undefined"
|
|
var date = "undefined"
|
|
|
|
const (
|
|
cookieSecretMinLen = 32
|
|
csrfKeyLength = 32
|
|
httpIdleTimeout = 5
|
|
httpReadHeaderTimeout = 10
|
|
httpReadTimeout = 10
|
|
httpWriteTimeout = 60
|
|
sessionCookieName = "votesession"
|
|
)
|
|
|
|
//go:embed boardvoting/templates
|
|
var fsTemplates embed.FS
|
|
|
|
func renderTemplate(w http.ResponseWriter, r *http.Request, templates []string, context interface{}) {
|
|
funcMaps := sprig.FuncMap()
|
|
funcMaps["nl2br"] = func(text string) template.HTML {
|
|
// #nosec G203 input is sanitized
|
|
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
|
|
}
|
|
funcMaps[csrf.TemplateTag] = func() template.HTML {
|
|
return csrf.TemplateField(r)
|
|
}
|
|
|
|
var baseTemplate *template.Template
|
|
|
|
for count, t := range templates {
|
|
var (
|
|
err error
|
|
assetBytes []byte
|
|
)
|
|
|
|
if assetBytes, err = fsTemplates.ReadFile(fmt.Sprintf("boardvoting/templates/%s", t)); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
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 {
|
|
continue
|
|
}
|
|
|
|
log.Infof(
|
|
"got a client certificate for the following email addresses: %s",
|
|
strings.Join(cert.EmailAddresses, ", "),
|
|
)
|
|
|
|
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)
|
|
|
|
log.Infof("authenticated as %s", voter.Name)
|
|
|
|
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, r, []string{"denied.html", "header.html", "footer.html"}, templateContext)
|
|
|
|
return
|
|
}
|
|
|
|
handler(w, r)
|
|
}
|
|
|
|
type motionParameters struct {
|
|
ShowVotes bool
|
|
}
|
|
|
|
type motionListParameters struct {
|
|
Page int
|
|
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.Atoi(r.URL.Query().Get("page")); 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 int
|
|
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
|
|
}
|
|
|
|
err = session.Save(r, w)
|
|
if err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
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, r, []string{
|
|
"motions.html", "motion_fragments.html", "page_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, r, []string{
|
|
"motion.html",
|
|
"motion_fragments.html",
|
|
"page_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.Warnf("could not get session cookie: %v", err)
|
|
|
|
return
|
|
}
|
|
|
|
session.AddFlash(message, tags...)
|
|
|
|
err = session.Save(r, w)
|
|
if err != nil {
|
|
log.Warnf("could not save flash message: %v", err)
|
|
|
|
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",
|
|
"page_fragments.html",
|
|
}
|
|
|
|
var templateContext struct {
|
|
PageTitle string
|
|
Decision *DecisionForDisplay
|
|
Flashes interface{}
|
|
Voter *Voter
|
|
}
|
|
|
|
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
|
|
templateContext.Voter = voter
|
|
renderTemplate(w, r, 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", "page_fragments.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, r, 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.Itoa(voteTypeMotion),
|
|
}
|
|
renderTemplate(w, r, 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", "page_fragments.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, r, 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, r, 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:
|
|
clientCert, err := getPEMClientCert(r)
|
|
if err != nil {
|
|
log.Errorf("could not get client certificate from request: %v", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
voteResult := &Vote{
|
|
VoterID: voter.ID, Vote: vote, DecisionID: decision.ID, Voted: time.Now().UTC(),
|
|
Notes: fmt.Sprintf("Direct Vote\n\n%s", clientCert)}
|
|
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",
|
|
"page_fragments.html",
|
|
}
|
|
|
|
var templateContext struct {
|
|
Decision *DecisionForDisplay
|
|
VoteChoice VoteChoice
|
|
PageTitle string
|
|
Flashes interface{}
|
|
Voter *Voter
|
|
}
|
|
|
|
templateContext.Decision = decision
|
|
templateContext.VoteChoice = vote
|
|
templateContext.Voter = voter
|
|
|
|
renderTemplate(w, r, templates, templateContext)
|
|
}
|
|
}
|
|
|
|
type proxyVoteHandler struct {
|
|
FlashMessageAction
|
|
authenticationRequiredHandler
|
|
}
|
|
|
|
func getPEMClientCert(r *http.Request) (string, error) {
|
|
cert := r.Context().Value(ctxAuthenticatedCert)
|
|
|
|
authenticatedCertificate, ok := cert.(*x509.Certificate)
|
|
if !ok {
|
|
return "", errors.New("could not handle certificate as x509.Certificate")
|
|
}
|
|
|
|
clientCertPEM := bytes.NewBuffer(make([]byte, 0))
|
|
|
|
err := pem.Encode(clientCertPEM, &pem.Block{Type: "CERTIFICATE", Bytes: authenticatedCertificate.Raw})
|
|
if err != nil {
|
|
return "", fmt.Errorf("error encoding client certificate: %w", err)
|
|
}
|
|
|
|
return clientCertPEM.String(), nil
|
|
}
|
|
|
|
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",
|
|
"page_fragments.html",
|
|
}
|
|
|
|
var templateContext struct {
|
|
Form ProxyVoteForm
|
|
Decision *DecisionForDisplay
|
|
Voters *[]Voter
|
|
PageTitle string
|
|
Flashes interface{}
|
|
Voter *Voter
|
|
}
|
|
|
|
templateContext.Voter = proxy
|
|
|
|
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
|
|
|
|
voters, err := GetVotersForProxy(proxy)
|
|
if err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
templateContext.Voters = voters
|
|
|
|
renderTemplate(w, r, templates, templateContext)
|
|
} else {
|
|
clientCert, err := getPEMClientCert(r)
|
|
if err != nil {
|
|
log.Errorf("could not get client certificate information: %v", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
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, clientCert)
|
|
|
|
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
|
|
|
|
voters, err := GetVotersForProxy(proxy)
|
|
if err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
templateContext.Voters = voters
|
|
|
|
renderTemplate(w, r, 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/"):
|
|
const expectedParts = 2
|
|
|
|
parts := strings.Split(r.URL.Path[len("/vote/"):], "/")
|
|
if len(parts) != expectedParts {
|
|
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"`
|
|
CsrfKey string `yaml:"csrf_key"`
|
|
BaseURL string `yaml:"base_url"`
|
|
HTTPAddress string `yaml:"http_address"`
|
|
HTTPSAddress string `yaml:"https_address"`
|
|
MailServer struct {
|
|
Host string `yaml:"host"`
|
|
Port int `yaml:"port"`
|
|
} `yaml:"mail_server"`
|
|
}
|
|
|
|
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 = "127.0.0.1:8443"
|
|
}
|
|
|
|
if config.HTTPAddress == "" {
|
|
config.HTTPAddress = "127.0.0.1:8080"
|
|
}
|
|
|
|
cookieSecret, err := base64.StdEncoding.DecodeString(config.CookieSecret)
|
|
if err != nil {
|
|
log.Panicf("Decoding cookie secret failed: %v", err)
|
|
panic(err)
|
|
}
|
|
|
|
if len(cookieSecret) < cookieSecretMinLen {
|
|
log.Panicf("Cookie secret is less than %d bytes long", cookieSecretMinLen)
|
|
}
|
|
|
|
csrfKey, err = base64.StdEncoding.DecodeString(config.CsrfKey)
|
|
if err != nil {
|
|
log.Panicf("Decoding csrf key failed: %v", err)
|
|
}
|
|
|
|
if len(csrfKey) != csrfKeyLength {
|
|
log.Panicf(
|
|
"CSRF key must be exactly %d bytes long but is %d bytes long",
|
|
csrfKeyLength,
|
|
len(csrfKey),
|
|
)
|
|
}
|
|
|
|
store = sessions.NewCookieStore(cookieSecret)
|
|
store.Options.Secure = true
|
|
|
|
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
|
|
}
|
|
}()
|
|
}
|
|
|
|
//go:embed ui/static
|
|
var uiStatic embed.FS
|
|
|
|
func setupHandlers() {
|
|
http.Handle("/motions/", http.StripPrefix("/motions/", motionsHandler{}))
|
|
http.Handle("/newmotion/", motionsHandler{})
|
|
http.Handle("/proxy/", &decisionVoteHandler{})
|
|
http.Handle("/vote/", &decisionVoteHandler{})
|
|
http.Handle("/static/", addPrefix("/ui", statigz.FileServer(uiStatic, brotli.AddEncoding, statigz.EncodeOnInit)))
|
|
http.Handle("/", http.RedirectHandler("/motions/", http.StatusMovedPermanently))
|
|
}
|
|
|
|
func addPrefix(prefix string, h http.Handler) http.Handler {
|
|
if prefix == "" {
|
|
return h
|
|
}
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
newPath := prefix + r.URL.Path
|
|
newRawPath := prefix + r.URL.RawPath
|
|
|
|
r2 := new(http.Request)
|
|
*r2 = *r
|
|
r2.URL = new(url.URL)
|
|
*r2.URL = *r.URL
|
|
r2.URL.Path = newPath
|
|
r2.URL.RawPath = newRawPath
|
|
|
|
h.ServeHTTP(w, r2)
|
|
})
|
|
}
|
|
|
|
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{
|
|
MinVersion: tls.VersionTLS12,
|
|
ClientCAs: caCertPool,
|
|
ClientAuth: tls.VerifyClientCertIfGiven,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func main() {
|
|
log.SetFormatter(&log.TextFormatter{FullTimestamp: true})
|
|
log.Infof("CAcert Board Voting version %s, commit %s built at %s", version, commit, date)
|
|
|
|
flag.StringVar(
|
|
&configFile, "config", "config.yaml", "Configuration file name")
|
|
|
|
flag.Parse()
|
|
|
|
var stopAll func()
|
|
|
|
executionContext, stopAll := context.WithCancel(context.Background())
|
|
|
|
readConfig()
|
|
setupDbConfig(executionContext)
|
|
setupNotifications(executionContext)
|
|
setupJobs(executionContext)
|
|
setupHandlers()
|
|
|
|
tlsConfig := setupTLSConfig()
|
|
|
|
defer stopAll()
|
|
|
|
server := &http.Server{
|
|
Addr: config.HTTPSAddress,
|
|
TLSConfig: tlsConfig,
|
|
IdleTimeout: time.Second * httpIdleTimeout,
|
|
ReadHeaderTimeout: time.Second * httpReadHeaderTimeout,
|
|
ReadTimeout: time.Second * httpReadTimeout,
|
|
WriteTimeout: time.Second * httpWriteTimeout,
|
|
}
|
|
|
|
server.Handler = csrf.Protect(csrfKey)(http.DefaultServeMux)
|
|
|
|
log.Infof("Launching application on https://%s/", server.Addr)
|
|
|
|
errs := make(chan error, 1)
|
|
|
|
go func() {
|
|
httpRedirector := &http.Server{
|
|
Addr: config.HTTPAddress,
|
|
Handler: http.RedirectHandler(config.BaseURL, http.StatusMovedPermanently),
|
|
IdleTimeout: time.Second * httpIdleTimeout,
|
|
ReadHeaderTimeout: time.Second * httpReadHeaderTimeout,
|
|
ReadTimeout: time.Second * httpReadTimeout,
|
|
WriteTimeout: time.Second * httpWriteTimeout,
|
|
}
|
|
if err := httpRedirector.ListenAndServe(); 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)
|
|
}
|
|
}
|