Refactoring away from main package
cacert-boardvoting/pipeline/head There was a failure building this commit Details

This commit is a refactoring of code that has been located in the main
package. We introduce separate packages for the main application, jobs,
notifications, and request handlers.

Dependencies are injected from the main application, this will make
testing easier.
main
Jan Dittberner 2 years ago
parent 3dc3160945
commit c2eef9cf7c

@ -7,8 +7,13 @@ UIFILES = package.json package-lock.json semantic.json $(shell find ui/semantic
all: cacert-boardvoting
cacert-boardvoting: ${GOFILES}
go build -o $@ -buildmode=pie -trimpath -x -ldflags " -s -w -X 'main.version=${VERSION}' -X 'main.commit=${COMMIT}' -X 'main.date=${DATE}'" ./cmd/boardvoting
#go build -o $@ -buildmode=pie -trimpath -x -ldflags " -s -w -X 'main.version=${VERSION}' -X 'main.commit=${COMMIT}' -X 'main.date=${DATE}'"
go build -o $@ -buildmode=pie -trimpath -ldflags " -s -w -X 'main.version=${VERSION}' -X 'main.commit=${COMMIT}' -X 'main.date=${DATE}'" ./cmd/boardvoting
test:
go test -v ./...
lint:
golangci-lint run
clean:
rm -f cacert-boardvoting
@ -17,4 +22,4 @@ ui: ${UIFILES}
npm install
cd node_modules/fomantic-ui ; npx gulp build
.PHONY: clean distclean all ui
.PHONY: clean all ui test

@ -23,6 +23,8 @@ import (
"time"
"gopkg.in/yaml.v2"
"git.cacert.org/cacert-boardvoting/internal/notifications"
)
const (
@ -34,16 +36,6 @@ const (
smtpTimeout = 10 * time.Second
)
type mailConfig struct {
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
SMTPTimeOut time.Duration `yaml:"smtp_timeout,omitempty"`
NotificationSenderAddress string `yaml:"notification_sender_address"`
NoticeMailAddress string `yaml:"notice_mail_address"`
VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"`
BaseURL string `yaml:"base_url"`
}
type httpTimeoutConfig struct {
Idle time.Duration `yaml:"idle,omitempty"`
Read time.Duration `yaml:"read,omitempty"`
@ -52,16 +44,16 @@ type httpTimeoutConfig struct {
}
type Config struct {
DatabaseFile string `yaml:"database_file"`
ClientCACertificates string `yaml:"client_ca_certificates"`
ServerCert string `yaml:"server_certificate"`
ServerKey string `yaml:"server_key"`
CookieSecretStr string `yaml:"cookie_secret"`
CsrfKeyStr string `yaml:"csrf_key"`
HTTPAddress string `yaml:"http_address,omitempty"`
HTTPSAddress string `yaml:"https_address,omitempty"`
MailConfig *mailConfig `yaml:"mail_config"`
Timeouts *httpTimeoutConfig `yaml:"timeouts,omitempty"`
DatabaseFile string `yaml:"database_file"`
ClientCACertificates string `yaml:"client_ca_certificates"`
ServerCert string `yaml:"server_certificate"`
ServerKey string `yaml:"server_key"`
CookieSecretStr string `yaml:"cookie_secret"`
CsrfKeyStr string `yaml:"csrf_key"`
HTTPAddress string `yaml:"http_address,omitempty"`
HTTPSAddress string `yaml:"https_address,omitempty"`
MailConfig *notifications.MailConfig `yaml:"mail_config"`
Timeouts *httpTimeoutConfig `yaml:"timeouts,omitempty"`
}
func parseConfig(configFile string) (*Config, error) {
@ -79,7 +71,7 @@ func parseConfig(configFile string) (*Config, error) {
Read: httpReadTimeout,
Write: httpWriteTimeout,
},
MailConfig: &mailConfig{
MailConfig: &notifications.MailConfig{
SMTPHost: "localhost",
SMTPPort: smtpPort,
SMTPTimeOut: smtpTimeout,

File diff suppressed because it is too large Load Diff

@ -1,309 +0,0 @@
/*
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.
*/
package main
import (
"bytes"
"context"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"html/template"
"io/fs"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/Masterminds/sprig/v3"
"github.com/go-chi/chi/v5"
"github.com/go-playground/form/v4"
"github.com/justinas/nosurf"
"git.cacert.org/cacert-boardvoting/internal/forms"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/ui"
)
func (app *application) clientError(w http.ResponseWriter, status int) {
http.Error(w, http.StatusText(status), status)
}
func (app *application) notFound(w http.ResponseWriter) {
app.clientError(w, http.StatusNotFound)
}
func (app *application) decodePostForm(r *http.Request, dst forms.Form) error {
err := r.ParseForm()
if err != nil {
return fmt.Errorf("could not parse HTML form: %w", err)
}
err = app.formDecoder.Decode(dst, r.PostForm)
if err != nil {
var invalidDecoderError *form.InvalidDecoderError
if errors.As(err, &invalidDecoderError) {
panic(err)
}
return fmt.Errorf("could not decode form: %w", err)
}
return nil
}
func newTemplateCache() (map[string]*template.Template, error) {
cache := map[string]*template.Template{}
pages, err := fs.Glob(ui.Files, "html/pages/*.html")
if err != nil {
return nil, fmt.Errorf("could not find page templates: %w", err)
}
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["canManageUsers"] = func(v *models.User) (bool, error) {
return checkRole(v, models.RoleSecretary, models.RoleAdmin)
}
funcMaps["canVote"] = func(v *models.User) (bool, error) {
return checkRole(v, models.RoleVoter)
}
funcMaps["canStartVote"] = func(v *models.User) (bool, error) {
return checkRole(v, models.RoleVoter)
}
for _, page := range pages {
name := filepath.Base(page)
ts, err := template.New("").Funcs(funcMaps).ParseFS(
ui.Files,
"html/base.html",
"html/partials/*.html",
page,
)
if err != nil {
return nil, fmt.Errorf("could not parse base template: %w", err)
}
cache[name] = ts
}
return cache, nil
}
type templateData struct {
PrevPage string
NextPage string
Motion *models.Motion
Motions []*models.Motion
User *models.User
Users []*models.User
Request *http.Request
Flashes []FlashMessage
Form forms.Form
ActiveNav topLevelNavItem
ActiveSubNav subLevelNavItem
CSRFToken string
}
func (app *application) newTemplateData(
ctx context.Context,
r *http.Request,
nav topLevelNavItem,
subNav subLevelNavItem,
) *templateData {
user, _ := app.GetUser(r)
return &templateData{
Request: r,
User: user,
ActiveNav: nav,
ActiveSubNav: subNav,
Flashes: app.flashes(ctx),
CSRFToken: nosurf.Token(r),
}
}
func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) {
ts, ok := app.templateCache[page]
if !ok {
panic(fmt.Sprintf("the template %s does not exist", page))
}
buf := new(bytes.Buffer)
err := ts.ExecuteTemplate(buf, "base", data)
if err != nil {
panic(err)
}
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
func (app *application) motionFromRequestParam(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
) *models.Motion {
withVotes := r.URL.Query().Has("showvotes")
motion, err := app.motions.ByTag(ctx, chi.URLParam(r, "tag"), withVotes)
if err != nil {
panic(err)
}
if motion.ID == 0 {
app.notFound(w)
return nil
}
return motion
}
func (app *application) userFromRequestParam(
ctx context.Context,
w http.ResponseWriter, r *http.Request, options ...models.UserListOption,
) *models.User {
userID, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
app.clientError(w, http.StatusBadRequest)
return nil
}
user, err := app.users.ByID(ctx, int64(userID), options...)
if err != nil {
panic(err)
}
if user == nil {
app.notFound(w)
return nil
}
return user
}
func (app *application) emailFromRequestParam(r *http.Request, user *models.User) (string, error) {
emailAddresses, err := user.EmailAddresses()
if err != nil {
return "", fmt.Errorf("could not get email addresses: %w", err)
}
emailParam := chi.URLParam(r, "address")
for _, address := range emailAddresses {
if emailParam == address {
return emailParam, nil
}
}
return "", nil
}
func (app *application) deleteEmailParams(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
) (*models.User, string, error) {
userToEdit := app.userFromRequestParam(ctx, w, r, app.users.WithEmailAddresses())
if userToEdit == nil {
return nil, "", nil
}
emailAddress, err := app.emailFromRequestParam(r, userToEdit)
if err != nil {
return nil, "", err
}
return userToEdit, emailAddress, nil
}
func (app *application) choiceFromRequestParam(w http.ResponseWriter, r *http.Request) *models.VoteChoice {
choice, err := models.VoteChoiceFromString(chi.URLParam(r, "choice"))
if err != nil {
app.clientError(w, http.StatusBadRequest)
return nil
}
return choice
}
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
}
type FlashVariant string
const (
flashWarning FlashVariant = "warning"
flashInfo FlashVariant = "info"
flashSuccess FlashVariant = "success"
)
type FlashMessage struct {
Variant FlashVariant
Title string
Message string
}
func (app *application) addFlash(ctx context.Context, message *FlashMessage) {
flashes := app.flashes(ctx)
flashes = append(flashes, *message)
app.sessionManager.Put(ctx, "flashes", flashes)
}
func (app *application) flashes(ctx context.Context) []FlashMessage {
flashInstance := app.sessionManager.Pop(ctx, "flashes")
if flashInstance != nil {
flashes, ok := flashInstance.([]FlashMessage)
if ok {
return flashes
}
}
return make([]FlashMessage, 0)
}

@ -1,289 +0,0 @@
/*
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.
*/
package main
import (
"context"
"log"
"time"
"git.cacert.org/cacert-boardvoting/internal/models"
)
type Job interface {
Schedule()
Run()
Stop()
}
type RemindVotersJob struct {
infoLog, errorLog *log.Logger
timer *time.Timer
voters *models.UserModel
decisions *models.MotionModel
notifier *MailNotifier
}
func (r *RemindVotersJob) Schedule() {
const reminderDays = 3
now := time.Now().UTC()
year, month, day := now.Date()
nextPotentialRun := time.Date(year, month, day+1, 0, 0, 0, 0, time.UTC)
nextPotentialRun.Add(hoursInDay * time.Hour)
relevantDue := nextPotentialRun.Add(reminderDays * hoursInDay * time.Hour)
due, err := r.decisions.NextPendingDue(context.Background(), relevantDue)
if err != nil {
r.errorLog.Printf("could not fetch next due date: %v", err)
}
if due == nil {
r.infoLog.Printf("no due motions after relevant due date %s, not scheduling ReminderJob", relevantDue)
return
}
remindNext := due.Add(-reminderDays * hoursInDay * time.Hour).UTC()
year, month, day = remindNext.Date()
potentialRun := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
if potentialRun.Before(time.Now().UTC()) {
r.infoLog.Printf("potential reminder time %s is in the past, not scheduling ReminderJob", potentialRun)
return
}
r.infoLog.Printf("scheduling RemindVotersJob for %s", potentialRun)
when := time.Until(potentialRun)
if r.timer != nil {
r.timer.Reset(when)
return
}
r.timer = time.AfterFunc(when, r.Run)
}
func (r *RemindVotersJob) Run() {
r.infoLog.Print("running RemindVotersJob")
defer func(r *RemindVotersJob) { r.Schedule() }(r)
var (
voters []*models.User
decisions []*models.Motion
err error
)
ctx := context.Background()
voters, err = r.voters.ReminderVoters(ctx)
if err != nil {
r.errorLog.Printf("problem getting voters: %v", err)
return
}
for _, voter := range voters {
v := voter
decisions, err = r.decisions.UnvotedForVoter(ctx, v)
if err != nil {
r.errorLog.Printf("problem getting unvoted decisions: %v", err)
return
}
if len(decisions) > 0 {
r.notifier.Notify(&RemindVoterNotification{voter: voter, decisions: decisions})
}
}
}
func (r *RemindVotersJob) Stop() {
if r.timer != nil {
r.timer.Stop()
r.timer = nil
}
}
func (app *application) NewRemindVotersJob() Job {
return &RemindVotersJob{
infoLog: app.infoLog,
errorLog: app.errorLog,
voters: app.users,
decisions: app.motions,
notifier: app.mailNotifier,
}
}
type CloseDecisionsJob struct {
timer *time.Timer
infoLog *log.Logger
errorLog *log.Logger
decisions *models.MotionModel
notifier *MailNotifier
}
func (c *CloseDecisionsJob) Schedule() {
var (
nextDue *time.Time
err error
)
ctx := context.Background()
nextDue, err = c.decisions.NextPendingDue(ctx, time.Now().UTC())
if err != nil {
c.errorLog.Printf("could not get next pending due date")
c.Stop()
return
}
if nextDue == nil {
c.infoLog.Printf("no next planned execution of CloseDecisionsJob")
c.Stop()
return
}
c.infoLog.Printf("scheduling CloseDecisionsJob for %s", nextDue)
when := time.Until(nextDue.Add(time.Second))
if c.timer == nil {
c.timer = time.AfterFunc(when, c.Run)
return
}
c.timer.Reset(when)
}
func (c *CloseDecisionsJob) Run() {
c.infoLog.Printf("running CloseDecisionsJob")
defer func(c *CloseDecisionsJob) { c.Schedule() }(c)
results, err := c.decisions.CloseDecisions(context.Background())
if err != nil {
c.errorLog.Printf("closing decisions failed: %v", err)
}
for _, res := range results {
c.infoLog.Printf(
"decision %s closed with result %s: reasoning '%s'",
res.Tag,
res.Status,
res.Reasoning,
)
c.notifier.Notify(&ClosedDecisionNotification{Decision: res})
}
}
func (c *CloseDecisionsJob) Stop() {
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
}
func (app *application) NewCloseDecisionsJob() Job {
return &CloseDecisionsJob{
infoLog: app.infoLog,
errorLog: app.errorLog,
decisions: app.motions,
notifier: app.mailNotifier,
}
}
type JobIdentifier int
const (
JobIDCloseDecisions JobIdentifier = iota
JobIDRemindVoters
)
type JobScheduler struct {
infoLogger *log.Logger
errorLogger *log.Logger
jobs map[JobIdentifier]Job
rescheduleChannel chan JobIdentifier
quitChannel chan struct{}
}
func (app *application) NewJobScheduler() {
rescheduleChannel := make(chan JobIdentifier, 1)
app.jobScheduler = &JobScheduler{
infoLogger: app.infoLog,
errorLogger: app.errorLog,
jobs: make(map[JobIdentifier]Job, 2),
rescheduleChannel: rescheduleChannel,
quitChannel: make(chan struct{}),
}
app.jobScheduler.addJob(JobIDCloseDecisions, app.NewCloseDecisionsJob())
app.jobScheduler.addJob(JobIDRemindVoters, app.NewRemindVotersJob())
}
func (js *JobScheduler) Schedule() {
for _, job := range js.jobs {
job.Schedule()
}
for {
select {
case jobID := <-js.rescheduleChannel:
js.jobs[jobID].Schedule()
case <-js.quitChannel:
for _, job := range js.jobs {
job.Stop()
}
js.infoLogger.Print("stop job scheduler")
return
}
}
}
func (js *JobScheduler) addJob(jobID JobIdentifier, job Job) {
js.jobs[jobID] = job
}
func (js *JobScheduler) Quit() {
js.quitChannel <- struct{}{}
}
func (js *JobScheduler) Reschedule(jobIDs ...JobIdentifier) {
for i := range jobIDs {
js.rescheduleChannel <- jobIDs[i]
}
}

@ -19,30 +19,27 @@ limitations under the License.
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/gob"
"flag"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
"github.com/go-playground/form/v4"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
u "git.cacert.org/cacert-boardvoting/internal/app"
"git.cacert.org/cacert-boardvoting/internal"
"git.cacert.org/cacert-boardvoting/internal/models"
)
const sessionHours = 12
@ -53,18 +50,6 @@ var (
date = "undefined"
)
type application struct {
errorLog, infoLog *log.Logger
users *models.UserModel
motions *models.MotionModel
jobScheduler *JobScheduler
mailNotifier *MailNotifier
mailConfig *mailConfig
templateCache map[string]*template.Template
sessionManager *scs.SessionManager
formDecoder *form.Decoder
}
func main() {
configFile := flag.String("config", "config.yaml", "Configuration file name")
flag.Parse()
@ -84,7 +69,7 @@ func main() {
errorLog.Fatal(err)
}
defer func(db *sqlx.DB) {
defer func(db io.Closer) {
_ = db.Close()
}(db)
@ -92,27 +77,15 @@ func main() {
errorLog.Fatalf("could not setup decision model: %v", err)
}
templateCache, err := newTemplateCache()
if err != nil {
errorLog.Fatal(err)
}
sessionManager := scs.New()
sessionManager.Store = sqlite3store.New(db.DB)
sessionManager.Lifetime = sessionHours * time.Hour
sessionManager.Cookie.SameSite = http.SameSiteStrictMode
sessionManager.Cookie.Secure = true
gob.Register([]FlashMessage{})
app := &application{
errorLog: errorLog,
infoLog: infoLog,
motions: &models.MotionModel{DB: db},
users: &models.UserModel{DB: db},
mailConfig: config.MailConfig,
templateCache: templateCache,
sessionManager: sessionManager,
application, err := u.New(errorLog, infoLog, db, config.MailConfig, sessionManager)
if err != nil {
errorLog.Fatalf("could not setup application: %v", err)
}
err = internal.InitializeDb(db.DB, infoLog)
@ -120,17 +93,9 @@ func main() {
errorLog.Fatal(err)
}
app.setupFormDecoder()
app.NewMailNotifier()
defer app.mailNotifier.Quit()
go app.mailNotifier.Start()
app.NewJobScheduler()
defer app.jobScheduler.Quit()
go app.jobScheduler.Schedule()
defer func(application io.Closer) {
_ = application.Close()
}(application)
infoLog.Printf("Starting server on %s", config.HTTPAddress)
@ -140,7 +105,7 @@ func main() {
go setupHTTPRedirect(config, errChan)
err = app.startHTTPSServer(config)
err = startHTTPSServer(config, errorLog, application.Routes(), func() { _ = application.Close() })
if err != nil {
errorLog.Fatalf("ListenAndServeTLS (HTTPS) failed: %v", err)
}
@ -150,43 +115,7 @@ func main() {
}
}
func (app *application) setupFormDecoder() {
decoder := form.NewDecoder()
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
v, err := models.VoteTypeFromString(values[0])
if err != nil {
return nil, fmt.Errorf("could not convert value %s: %w", values[0], err)
}
return v, nil
}, new(models.VoteType))
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
v, err := models.VoteChoiceFromString(values[0])
if err != nil {
return nil, fmt.Errorf("could not convert value %s: %w", values[0], err)
}
return v, nil
}, new(models.VoteChoice))
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
userID, err := strconv.Atoi(values[0])
if err != nil {
return nil, fmt.Errorf("could not convert value %s to user ID: %w", values[0], err)
}
u, err := app.users.ByID(context.Background(), int64(userID))
if err != nil {
return nil, fmt.Errorf("could not convert value %s to user: %w", values[0], err)
}
return u, nil
}, new(models.User))
app.formDecoder = decoder
}
func (app *application) startHTTPSServer(config *Config) error {
func startHTTPSServer(config *Config, errorLog *log.Logger, routes http.Handler, shutdownFunc func()) error {
tlsConfig, err := setupTLSConfig(config)
if err != nil {
return fmt.Errorf("could not setup TLS configuration: %w", err)
@ -195,14 +124,16 @@ func (app *application) startHTTPSServer(config *Config) error {
srv := &http.Server{
Addr: config.HTTPSAddress,
TLSConfig: tlsConfig,
ErrorLog: app.errorLog,
Handler: app.routes(),
ErrorLog: errorLog,
Handler: routes,
IdleTimeout: config.Timeouts.Idle,
ReadHeaderTimeout: config.Timeouts.ReadHeader,
ReadTimeout: config.Timeouts.Read,
WriteTimeout: config.Timeouts.Write,
}
srv.RegisterOnShutdown(shutdownFunc)
err = srv.ListenAndServeTLS(config.ServerCert, config.ServerKey)
if err != nil {
return fmt.Errorf("")
@ -211,27 +142,6 @@ func (app *application) startHTTPSServer(config *Config) error {
return nil
}
func (app *application) getVoter(ctx context.Context, w http.ResponseWriter, voterID int64) *models.User {
voter, err := app.users.ByID(ctx, voterID, app.users.WithRoles())
if err != nil {
panic(err)
}
var isVoter bool
if isVoter, err = voter.HasRole(models.RoleVoter); err != nil {
panic(err)
}
if !isVoter {
app.clientError(w, http.StatusBadRequest)
return nil
}
return voter
}
func setupHTTPRedirect(config *Config, errChan chan error) {
redirect := &http.Server{
Addr: config.HTTPAddress,

@ -1,116 +0,0 @@
/*
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.
*/
package main
import (
"io/fs"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/vearutop/statigz"
"github.com/vearutop/statigz/brotli"
"git.cacert.org/cacert-boardvoting/ui"
)
func (app *application) routes() http.Handler {
staticDir, _ := fs.Sub(ui.Files, "static")
staticData, ok := staticDir.(fs.ReadDirFS)
if !ok {
app.errorLog.Fatal("could not use uiStaticDir as fs.ReadDirFS")
}
fileServer := statigz.FileServer(staticData, brotli.AddEncoding, statigz.EncodeOnInit)
router := chi.NewRouter()
router.Use(middleware.RealIP)
router.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: app.infoLog}))
router.Use(middleware.Recoverer)
router.Use(secureHeaders)
router.NotFound(func(w http.ResponseWriter, _ *http.Request) { app.notFound(w) })
router.Get(
"/",
http.RedirectHandler("/motions/", http.StatusMovedPermanently).ServeHTTP,
)
router.Get(
"/favicon.ico",
http.RedirectHandler("/static/images/favicon.ico", http.StatusMovedPermanently).ServeHTTP,
)
router.Get("/static/*", http.StripPrefix("/static", fileServer).ServeHTTP)
router.Group(func(r chi.Router) {
r.Use(app.sessionManager.LoadAndSave, app.tryAuthenticate)
r.Get("/motions/", app.motionList)
r.Get("/motions/{tag}", app.motionDetails)
r.Group(func(r chi.Router) {
r.Use(app.userCanEditVote, noSurf)
r.Get("/newmotion/", app.newMotionForm)
r.Post("/newmotion/", app.newMotionSubmit)
r.Route("/motions/{tag}", func(r chi.Router) {
r.Get("/edit", app.editMotionForm)
r.Post("/edit", app.editMotionSubmit)
r.Get("/withdraw", app.withdrawMotionForm)
r.Post("/withdraw", app.withdrawMotionSubmit)
})
})
r.Group(func(r chi.Router) {
r.Use(app.userCanVote, noSurf)
r.Get("/vote/{tag}/{choice}", app.voteForm)
r.Post("/vote/{tag}/{choice}", app.voteSubmit)
r.Get("/proxy/{tag}", app.proxyVoteForm)
r.Post("/proxy/{tag}", app.proxyVoteSubmit)
})
r.Group(func(r chi.Router) {
r.Use(app.canManageUsers, noSurf)
r.Get("/users/", app.userList)
r.Get("/new-user/", app.newUserForm)
r.Post("/new-user/", app.newUserSubmit)
r.Route("/users/{id}", func(r chi.Router) {
r.Get("/", app.editUserForm)
r.Post("/", app.editUserSubmit)
r.Get("/add-mail", app.userAddEmailForm)
r.Post("/add-mail", app.userAddEmailSubmit)
r.Get("/mail/{address}/delete", app.userDeleteEmailForm)
r.Post("/mail/{address}/delete", app.userDeleteEmailSubmit)
r.Get("/delete", app.deleteUserForm)
r.Post("/delete", app.deleteUserSubmit)
})
r.Get("/voters/", app.chooseVotersForm)
r.Post("/voters/", app.chooseVotersSubmit)
})
})
router.Get("/health", app.healthCheck)
return router
}

@ -0,0 +1,245 @@
/*
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.
*/
package u
import (
"context"
"fmt"
"io/fs"
"log"
"net/http"
"strconv"
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-playground/form/v4"
"github.com/jmoiron/sqlx"
"github.com/vearutop/statigz"
"github.com/vearutop/statigz/brotli"
"git.cacert.org/cacert-boardvoting/internal/handlers"
"git.cacert.org/cacert-boardvoting/internal/jobs"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/notifications"
"git.cacert.org/cacert-boardvoting/ui"
)
type Application struct {
errorLog, infoLog *log.Logger
users *models.UserModel
motions *models.MotionModel
jobScheduler *jobs.JobScheduler
mailNotifier *notifications.MailNotifier
mailConfig *notifications.MailConfig
templateCache *handlers.TemplateCache
sessionManager *scs.SessionManager
formDecoder *form.Decoder
}
func New(
errorLog, infoLog *log.Logger,
db *sqlx.DB,
mailConfig *notifications.MailConfig,
sessionManager *scs.SessionManager,
) (*Application, error) {
app := &Application{
errorLog: errorLog,
infoLog: infoLog,
mailConfig: mailConfig,
motions: &models.MotionModel{DB: db},
users: &models.UserModel{DB: db},
sessionManager: sessionManager,
}
var err error
app.templateCache, err = handlers.NewTemplateCache()
if err != nil {
return nil, fmt.Errorf("could not initialize template cache: %w", err)
}
app.setupFormDecoder()
app.mailNotifier = notifications.NewMailNotifier(
app.mailConfig,
notifications.NotifierLog(app.infoLog, app.errorLog),
)
go app.mailNotifier.Start()
app.jobScheduler = jobs.NewJobScheduler(jobs.SchedulerLog(app.infoLog, app.errorLog))
app.jobScheduler.AddJob(jobs.NewCloseDecisionsJob(
app.motions,
app.mailNotifier,
jobs.CloseDecisionsLog(app.infoLog, app.errorLog),
))
app.jobScheduler.AddJob(jobs.NewRemindVoters(
app.users, app.motions, app.mailNotifier,
jobs.RemindVotersLog(app.infoLog, app.errorLog),
))
go app.jobScheduler.Schedule()
return app, nil
}
func (app *Application) Close() error {
app.jobScheduler.Quit()
app.mailNotifier.Quit()
return nil
}
func (app *Application) setupFormDecoder() {
decoder := form.NewDecoder()
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
v, err := models.VoteTypeFromString(values[0])
if err != nil {
return nil, fmt.Errorf("could not convert value %s: %w", values[0], err)
}
return v, nil
}, new(models.VoteType))
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
v, err := models.VoteChoiceFromString(values[0])
if err != nil {
return nil, fmt.Errorf("could not convert value %s: %w", values[0], err)
}
return v, nil
}, new(models.VoteChoice))
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
userID, err := strconv.Atoi(values[0])
if err != nil {
return nil, fmt.Errorf("could not convert value %s to user ID: %w", values[0], err)
}
u, err := app.users.ByID(context.Background(), int64(userID))
if err != nil {
return nil, fmt.Errorf("could not convert value %s to user: %w", values[0], err)
}
return u, nil
}, new(models.User))
app.formDecoder = decoder
}
func (app *Application) Routes() http.Handler {
staticDir, _ := fs.Sub(ui.Files, "static")
staticData, ok := staticDir.(fs.ReadDirFS)
if !ok {
app.errorLog.Fatal("could not use uiStaticDir as fs.ReadDirFS")
}
fileServer := statigz.FileServer(staticData, brotli.AddEncoding, statigz.EncodeOnInit)
router := chi.NewRouter()
router.Use(middleware.RealIP)
router.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: app.infoLog}))
router.Use(middleware.Recoverer)
router.Use(handlers.SecureHeaders)
router.NotFound(func(w http.ResponseWriter, _ *http.Request) { handlers.NotFound(w) })
router.Get(
"/",
http.RedirectHandler("/motions/", http.StatusMovedPermanently).ServeHTTP,
)
router.Get(
"/favicon.ico",
http.RedirectHandler("/static/images/favicon.ico", http.StatusMovedPermanently).ServeHTTP,
)
router.Get("/static/*", http.StripPrefix("/static", fileServer).ServeHTTP)
userMiddleware := handlers.NewUserMiddleware(app.users, app.errorLog)
flashes := handlers.NewFlashes(app.sessionManager)
handlerParams := handlers.CommonParams{
Flashes: flashes,
TemplateCache: app.templateCache,
FormDecoder: app.formDecoder,
}
motionHandler := handlers.NewMotionHandler(app.motions, app.mailNotifier, app.jobScheduler, handlerParams)
voteHandler := handlers.NewVoteHandler(app.motions, app.users, app.mailNotifier, handlerParams)
userHandler := handlers.NewUserHandler(app.users, handlerParams)
router.Group(func(r chi.Router) {
r.Use(app.sessionManager.LoadAndSave, userMiddleware.TryAuthenticate)
r.Get("/motions/", motionHandler.List)
r.Get("/motions/{tag}", motionHandler.Details)
r.Group(func(r chi.Router) {
r.Use(userMiddleware.UserCanEditVote, handlers.NoSurf)
r.Get("/newmotion/", motionHandler.NewForm)
r.Post("/newmotion/", motionHandler.New)
r.Route("/motions/{tag}", func(r chi.Router) {
r.Get("/edit", motionHandler.EditForm)
r.Post("/edit", motionHandler.Edit)
r.Get("/withdraw", motionHandler.WithdrawForm)
r.Post("/withdraw", motionHandler.Withdraw)
})
})
r.Group(func(r chi.Router) {
r.Use(userMiddleware.UserCanVote, handlers.NoSurf)
r.Get("/vote/{tag}/{choice}", voteHandler.VoteForm)
r.Post("/vote/{tag}/{choice}", voteHandler.Vote)
r.Get("/proxy/{tag}", voteHandler.ProxyVoteForm)
r.Post("/proxy/{tag}", voteHandler.ProxyVote)
})
r.Group(func(r chi.Router) {
r.Use(userMiddleware.CanManageUsers, handlers.NoSurf)
r.Get("/users/", userHandler.List)
r.Get("/new-user/", userHandler.CreateForm)
r.Post("/new-user/", userHandler.Create)
r.Route("/users/{id}", func(r chi.Router) {
r.Get("/", userHandler.EditForm)
r.Post("/", userHandler.Edit)
r.Get("/add-mail", userHandler.AddEmailForm)
r.Post("/add-mail", userHandler.AddEmail)
r.Get("/mail/{address}/delete", userHandler.DeleteEmailForm)
r.Post("/mail/{address}/delete", userHandler.DeleteEmail)
r.Get("/delete", userHandler.DeleteForm)
r.Post("/delete", userHandler.Delete)
})
r.Get("/voters/", userHandler.ChangeVotersForm)
r.Post("/voters/", userHandler.ChangeVoters)
})
})
router.Method(http.MethodGet, "/health", handlers.NewHealthCheck(app.mailNotifier, app.motions))
return router
}

@ -0,0 +1,71 @@
/*
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.
*/
package handlers
import (
"context"
"encoding/gob"
"github.com/alexedwards/scs/v2"
)
type FlashVariant string
const (
flashWarning FlashVariant = "warning"
flashInfo FlashVariant = "info"
flashSuccess FlashVariant = "success"
)
type FlashMessage struct {
Variant FlashVariant
Title string
Message string
}
type FlashHandler struct {
sessionManager *scs.SessionManager
}
func NewFlashes(manager *scs.SessionManager) *FlashHandler {
gob.Register([]FlashMessage{})
return &FlashHandler{sessionManager: manager}
}
func (f *FlashHandler) addFlash(ctx context.Context, message *FlashMessage) {
flashes := f.flashes(ctx)
flashes = append(flashes, *message)
f.sessionManager.Put(ctx, "flashes", flashes)
}
func (f *FlashHandler) flashes(ctx context.Context) []FlashMessage {
flashInstance := f.sessionManager.Pop(ctx, "flashes")
if flashInstance != nil {
flashes, ok := flashInstance.([]FlashMessage)
if ok {
return flashes
}
}
return make([]FlashMessage, 0)
}

File diff suppressed because it is too large Load Diff

@ -15,11 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package main
package handlers
import (
"database/sql"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
@ -32,22 +33,11 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.cacert.org/cacert-boardvoting/internal/notifications"
"git.cacert.org/cacert-boardvoting/internal/models"
)
func prepareTestDb(t *testing.T) *sqlx.DB {
t.Helper()
testDir := t.TempDir()
db, err := sql.Open("sqlite3", path.Join(testDir, "test.sqlite"))
require.NoError(t, err)
dbx := sqlx.NewDb(db, "sqlite3")
return dbx
}
func StartTestTCPServer(t *testing.T) int {
t.Helper()
@ -82,7 +72,7 @@ func StartTestTCPServer(t *testing.T) int {
return port
}
func TestApplication_healthCheck(t *testing.T) {
func TestHealthCheck_ServeHTTP(t *testing.T) {
port := StartTestTCPServer(t)
t.Run("check with valid DB", func(t *testing.T) {
@ -93,18 +83,18 @@ func TestApplication_healthCheck(t *testing.T) {
testDB := prepareTestDb(t)
app := &application{
motions: &models.MotionModel{DB: testDB},
mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second},
}
app.NewMailNotifier()
notifier := notifications.NewMailNotifier(&notifications.MailConfig{
SMTPHost: "localhost",
SMTPPort: port,
SMTPTimeOut: 1 * time.Second,
})
app.healthCheck(rr, r)
hc := NewHealthCheck(notifier, &models.MotionModel{DB: testDB})
hc.ServeHTTP(rr, r)
rs := rr.Result() //nolint:bodyclose // linters bug
rs := rr.Result()
defer func() { _ = rs.Body.Close() }()
defer func(Body io.Closer) { _ = Body.Close() }(rs.Body)
assert.Equal(t, http.StatusOK, rs.StatusCode)
})
@ -124,18 +114,18 @@ func TestApplication_healthCheck(t *testing.T) {
_ = db.Close()
app := &application{
motions: &models.MotionModel{DB: testDB},
mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second},
}
app.NewMailNotifier()
notifier := notifications.NewMailNotifier(&notifications.MailConfig{
SMTPHost: "localhost",
SMTPPort: port,
SMTPTimeOut: 1 * time.Second,
})
app.healthCheck(rr, r)
hc := NewHealthCheck(notifier, &models.MotionModel{DB: testDB})
hc.ServeHTTP(rr, r)
rs := rr.Result() //nolint:bodyclose // linters bug
rs := rr.Result()
defer func() { _ = rs.Body.Close() }()
defer func(Body io.Closer) { _ = Body.Close() }(rs.Body)
assert.Equal(t, http.StatusInternalServerError, rs.StatusCode)
})

@ -15,13 +15,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package main
package handlers
import (
"bytes"
"context"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net/http"
"strings"
@ -37,7 +40,7 @@ const (
ctxAuthenticatedCert
)
func secureHeaders(next http.Handler) http.Handler {
func SecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; font-src 'self' data:")
w.Header().Set("Referrer-Policy", "origin-when-cross-origin")
@ -50,7 +53,16 @@ func secureHeaders(next http.Handler) http.Handler {
})
}
func (app *application) authenticateRequest(
type UserMiddleware struct {
users *models.UserModel
errorLog *log.Logger
}
func NewUserMiddleware(users *models.UserModel, errorLog *log.Logger) *UserMiddleware {
return &UserMiddleware{users: users, errorLog: errorLog}
}
func (m *UserMiddleware) AuthenticateRequest(
ctx context.Context,
r *http.Request,
) (*models.User, *x509.Certificate, error) {
@ -81,7 +93,7 @@ func (app *application) authenticateRequest(
emails := clientCert.EmailAddresses
user, err := app.users.ByEmails(ctx, emails)
user, err := m.users.ByEmails(ctx, emails)
if err != nil {
return nil, nil, fmt.Errorf("could not get user information from database: %w", err)
}
@ -89,9 +101,9 @@ func (app *application) authenticateRequest(
return user, clientCert, nil
}
func (app *application) tryAuthenticate(next http.Handler) http.Handler {
func (m *UserMiddleware) TryAuthenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, cert, err := app.authenticateRequest(r.Context(), r)
user, cert, err := m.AuthenticateRequest(r.Context(), r)
if err != nil {
panic(err)
}
@ -111,22 +123,8 @@ func (app *application) tryAuthenticate(next http.Handler) http.Handler {
})
}
func (app *application) GetUser(r *http.Request) (*models.User, error) {
user := r.Context().Value(ctxUser)
if user == nil {
return nil, errors.New("no user in context")
}
result, ok := user.(*models.User)
if !ok {
return nil, fmt.Errorf("%v is not a user", user)
}
return result, nil
}
func (app *application) HasRole(r *http.Request, roles ...models.RoleName) (bool, bool, error) {
user, err := app.GetUser(r)
func (m *UserMiddleware) HasRole(r *http.Request, roles ...models.RoleName) (bool, bool, error) {
user, err := getUser(r)
if err != nil {
return false, false, err
}
@ -146,7 +144,7 @@ func (app *application) HasRole(r *http.Request, roles ...models.RoleName) (bool
roleNames[idx] = string(roles[idx])
}
app.errorLog.Printf(
m.errorLog.Printf(
"user %s does not have any of the required role(s) %s assigned",
user.Name,
strings.Join(roleNames, ", "),
@ -158,21 +156,21 @@ func (app *application) HasRole(r *http.Request, roles ...models.RoleName) (bool
return true, true, nil
}
func (app *application) requireRole(next http.Handler, roles ...models.RoleName) http.Handler {
func (m *UserMiddleware) requireRole(next http.Handler, roles ...models.RoleName) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hasRole, hasUser, err := app.HasRole(r, roles...)
hasRole, hasUser, err := m.HasRole(r, roles...)
if err != nil {
panic(err)
}
if !hasUser {
app.clientError(w, http.StatusUnauthorized)
ClientError(w, http.StatusUnauthorized)
return
}
if !hasRole {
app.clientError(w, http.StatusForbidden)
ClientError(w, http.StatusForbidden)
return
}
@ -181,19 +179,19 @@ func (app *application) requireRole(next http.Handler, roles ...models.RoleName)
})
}
func (app *application) userCanVote(next http.Handler) http.Handler {
return app.requireRole(next, models.RoleVoter)
func (m *UserMiddleware) UserCanVote(next http.Handler) http.Handler {
return m.requireRole(next, models.RoleVoter)
}
func (app *application) userCanEditVote(next http.Handler) http.Handler {
return app.requireRole(next, models.RoleVoter)
func (m *UserMiddleware) UserCanEditVote(next http.Handler) http.Handler {
return m.requireRole(next, models.RoleVoter)
}
func (app *application) canManageUsers(next http.Handler) http.Handler {
return app.requireRole(next, models.RoleSecretary, models.RoleAdmin)
func (m *UserMiddleware) CanManageUsers(next http.Handler) http.Handler {
return m.requireRole(next, models.RoleSecretary, models.RoleAdmin)
}
func noSurf(next http.Handler) http.Handler {
func NoSurf(next http.Handler) http.Handler {
csrfHandler := nosurf.New(next)
csrfHandler.SetBaseCookie(http.Cookie{
HttpOnly: true,
@ -204,3 +202,35 @@ func noSurf(next http.Handler) http.Handler {
return csrfHandler
}
func getUser(r *http.Request) (*models.User, error) {
user := r.Context().Value(ctxUser)
if user == nil {
return nil, errors.New("no user in context")
}
result, ok := user.(*models.User)
if !ok {
return nil, fmt.Errorf("%v is not a user", user)
}
return result, nil
}
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
}

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package main
package handlers
import (
"context"
@ -26,8 +26,10 @@ import (
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -35,6 +37,19 @@ import (
"git.cacert.org/cacert-boardvoting/internal/models"
)
func prepareTestDb(t *testing.T) *sqlx.DB {
t.Helper()
testDir := t.TempDir()
db, err := sql.Open("sqlite3", path.Join(testDir, "test.sqlite"))
require.NoError(t, err)
dbx := sqlx.NewDb(db, "sqlite3")
return dbx
}
func Test_secureHeaders(t *testing.T) {
rr := httptest.NewRecorder()
@ -45,7 +60,7 @@ func Test_secureHeaders(t *testing.T) {
_, _ = w.Write([]byte("OK"))
})
secureHeaders(next).ServeHTTP(rr, r)
SecureHeaders(next).ServeHTTP(rr, r)
rs := rr.Result() //nolint:bodyclose // linters bug
@ -92,7 +107,7 @@ func TestApplication_tryAuthenticate(t *testing.T) {
require.NoError(t, err)
app := application{
mw := UserMiddleware{
users: &models.UserModel{DB: db},
}
@ -102,7 +117,7 @@ func TestApplication_tryAuthenticate(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err)
app.tryAuthenticate(next).ServeHTTP(rr, r)
mw.TryAuthenticate(next).ServeHTTP(rr, r)
rs := rr.Result() //nolint:bodyclose // linters bug
@ -120,7 +135,7 @@ func TestApplication_tryAuthenticate(t *testing.T) {
r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{}}
app.tryAuthenticate(next).ServeHTTP(rr, r)
mw.TryAuthenticate(next).ServeHTTP(rr, r)
rs := rr.Result() //nolint:bodyclose // linters bug
@ -141,7 +156,7 @@ func TestApplication_tryAuthenticate(t *testing.T) {
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}}}
app.tryAuthenticate(next).ServeHTTP(rr, r)
mw.TryAuthenticate(next).ServeHTTP(rr, r)
rs := rr.Result() //nolint:bodyclose // linters bug

@ -0,0 +1,122 @@
/*
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.
*/
package handlers
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"net/http"
"path/filepath"
"strings"
"github.com/Masterminds/sprig/v3"
"git.cacert.org/cacert-boardvoting/internal/forms"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/ui"
)
func checkRole(v *models.User, roles ...models.RoleName) (bool, error) {
hasRole, err := v.HasRole(roles...)
if err != nil {
return false, fmt.Errorf("could not determine user roles: %w", err)
}
return hasRole, nil
}
type TemplateData struct {
PrevPage string
NextPage string
Motion *models.Motion
Motions []*models.Motion
User *models.User
Users []*models.User
Request *http.Request
Flashes []FlashMessage
Form forms.Form
ActiveNav topLevelNavItem
ActiveSubNav subLevelNavItem
CSRFToken string
}
type TemplateCache struct {
cache map[string]*template.Template
}
func NewTemplateCache() (*TemplateCache, error) {
cache := map[string]*template.Template{}
pages, err := fs.Glob(ui.Files, "html/pages/*.html")
if err != nil {
return nil, fmt.Errorf("could not find page templates: %w", err)
}
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["canManageUsers"] = func(v *models.User) (bool, error) {
return checkRole(v, models.RoleSecretary, models.RoleAdmin)
}
funcMaps["canVote"] = func(v *models.User) (bool, error) {
return checkRole(v, models.RoleVoter)
}
funcMaps["canStartVote"] = func(v *models.User) (bool, error) {
return checkRole(v, models.RoleVoter)
}
for _, page := range pages {
name := filepath.Base(page)
ts, err := template.New("").Funcs(funcMaps).ParseFS(
ui.Files,
"html/base.html",
"html/partials/*.html",
page,
)
if err != nil {
return nil, fmt.Errorf("could not parse base template: %w", err)
}
cache[name] = ts
}
return &TemplateCache{cache: cache}, nil
}
func (c *TemplateCache) render(w http.ResponseWriter, status int, page string, data *TemplateData) {
ts, ok := c.cache[page]
if !ok {
panic(fmt.Sprintf("the template %s does not exist", page))
}
buf := new(bytes.Buffer)
err := ts.ExecuteTemplate(buf, "base", data)
if err != nil {
panic(err)
}
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}

@ -0,0 +1,139 @@
/*
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.
*/
package jobs
import (
"context"
"log"
"os"
"time"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/notifications"
)
type CloseDecisionsJob struct {
timer *time.Timer
infoLog *log.Logger
errorLog *log.Logger
decisions *models.MotionModel
notifier *notifications.MailNotifier
}
func (c *CloseDecisionsJob) Identifier() JobIdentifier {
return JobIDCloseDecisions
}
func (c *CloseDecisionsJob) Schedule() {
var (
nextDue *time.Time
err error
)
ctx := context.Background()
nextDue, err = c.decisions.NextPendingDue(ctx, time.Now().UTC())
if err != nil {
c.errorLog.Printf("could not get next pending due date")
c.Stop()
return
}
if nextDue == nil {
c.infoLog.Printf("no next planned execution of CloseDecisionsJob")
c.Stop()
return
}
c.infoLog.Printf("scheduling CloseDecisionsJob for %s", nextDue)
when := time.Until(nextDue.Add(time.Second))
if c.timer == nil {
c.timer = time.AfterFunc(when, c.Run)
return
}
c.timer.Reset(when)
}
func (c *CloseDecisionsJob) Run() {
c.infoLog.Printf("running CloseDecisionsJob")
defer func(c *CloseDecisionsJob) { c.Schedule() }(c)
c.RunExpired()
}
func (c *CloseDecisionsJob) RunExpired() {
results, err := c.decisions.CloseDecisions(context.Background())
if err != nil {
c.errorLog.Printf("closing decisions failed: %v", err)
}
for _, res := range results {
c.infoLog.Printf(
"decision %s closed with result %s: reasoning '%s'",
res.Tag,
res.Status,
res.Reasoning,
)
c.notifier.Notify(&notifications.ClosedDecisionNotification{Decision: res})
}
}
func (c *CloseDecisionsJob) Stop() {
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
}
type CloseDecisionsOption func(job *CloseDecisionsJob)
func NewCloseDecisionsJob(
decisions *models.MotionModel,
mailNotifier *notifications.MailNotifier,
opts ...CloseDecisionsOption,
) Job {
j := &CloseDecisionsJob{
infoLog: log.New(os.Stdout, "", 0),
errorLog: log.New(os.Stderr, "", 0),
decisions: decisions,
notifier: mailNotifier,
}
for _, o := range opts {
o(j)
}
j.RunExpired()
return j
}
func CloseDecisionsLog(infoLog, errorLog *log.Logger) CloseDecisionsOption {
return func(j *CloseDecisionsJob) {
j.infoLog = infoLog
j.errorLog = errorLog
}
}

@ -0,0 +1,19 @@
/*
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.
*/
// Package jobs is providing implementations for background jobs.
package jobs

@ -0,0 +1,34 @@
/*
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.
*/
package jobs
const hoursInDay = 24
type JobIdentifier int
const (
JobIDCloseDecisions JobIdentifier = iota
JobIDRemindVoters
)
type Job interface {
Schedule()
Run()
Stop()
Identifier() JobIdentifier
}

@ -0,0 +1,162 @@
/*
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.
*/
package jobs
import (
"context"
"log"
"os"
"time"
"git.cacert.org/cacert-boardvoting/internal/models"
"git.cacert.org/cacert-boardvoting/internal/notifications"
)
type RemindVotersJob struct {
infoLog, errorLog *log.Logger
timer *time.Timer
voters *models.UserModel
decisions *models.MotionModel
notifier *notifications.MailNotifier
}
func (r *RemindVotersJob) Identifier() JobIdentifier {
return JobIDRemindVoters
}
func (r *RemindVotersJob) Schedule() {
const reminderDays = 3
now := time.Now().UTC()
year, month, day := now.Date()
nextPotentialRun := time.Date(year, month, day+1, 0, 0, 0, 0, time.UTC)
nextPotentialRun.Add(hoursInDay * time.Hour)
relevantDue := nextPotentialRun.Add(reminderDays * hoursInDay * time.Hour)
due, err := r.decisions.NextPendingDue(context.Background(), relevantDue)
if err != nil {
r.errorLog.Printf("could not fetch next due date: %v", err)
}
if due == nil {
r.infoLog.Printf("no due motions after relevant due date %s, not scheduling ReminderJob", relevantDue)
return
}
remindNext := due.Add(-reminderDays * hoursInDay * time.Hour).UTC()
year, month, day = remindNext.Date()
potentialRun := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
if potentialRun.Before(time.Now().UTC()) {
r.infoLog.Printf("potential reminder time %s is in the past, not scheduling ReminderJob", potentialRun)
return
}
r.infoLog.Printf("scheduling RemindVotersJob for %s", potentialRun)
when := time.Until(potentialRun)
if r.timer != nil {
r.timer.Reset(when)
return
}
r.timer = time.AfterFunc(when, r.Run)
}
func (r *RemindVotersJob) Run() {
r.infoLog.Print("running RemindVotersJob")
defer func(r *RemindVotersJob) { r.Schedule() }(r)
var (
voters []*models.User
decisions []*models.Motion
err error
)
ctx := context.Background()
voters, err = r.voters.ReminderVoters(ctx)
if err != nil {
r.errorLog.Printf("problem getting voters: %v", err)
return
}
for _, voter := range voters {
v := voter
decisions, err = r.decisions.UnvotedForVoter(ctx, v)
if err != nil {
r.errorLog.Printf("problem getting unvoted decisions: %v", err)
return
}
if len(decisions) > 0 {
r.notifier.Notify(&notifications.RemindVoterNotification{Voter: voter, Decisions: decisions})
}
}
}
func (r *RemindVotersJob) Stop() {
if r.timer != nil {
r.timer.Stop()
r.timer = nil
}
}
type RemindVotersOption func(job *RemindVotersJob)
func NewRemindVoters(
voters *models.UserModel,
decisions *models.MotionModel,
notifier *notifications.MailNotifier,
opts ...RemindVotersOption,
) Job {
j := &RemindVotersJob{
voters: voters,
decisions: decisions,
notifier: notifier,
infoLog: log.New(os.Stdout, "", 0),
errorLog: log.New(os.Stderr, "", 0),
}
for _, o := range opts {
o(j)
}
return j
}
func RemindVotersLog(infoLog, errorLog *log.Logger) RemindVotersOption {
return func(r *RemindVotersJob) {
r.infoLog = infoLog
r.errorLog = errorLog
}
}

@ -0,0 +1,93 @@
/*
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.
*/
package jobs
import (
"log"
"os"
)
type JobScheduler struct {
infoLogger *log.Logger
errorLogger *log.Logger
jobs map[JobIdentifier]Job
rescheduleChannel chan JobIdentifier
quitChannel chan struct{}
}
type SchedulerOption func(scheduler *JobScheduler)
func NewJobScheduler(opts ...SchedulerOption) *JobScheduler {
rescheduleChannel := make(chan JobIdentifier, 1)
jobScheduler := &JobScheduler{
infoLogger: log.New(os.Stdout, "", 0),
errorLogger: log.New(os.Stderr, "", 0),
jobs: make(map[JobIdentifier]Job, 2),
rescheduleChannel: rescheduleChannel,
quitChannel: make(chan struct{}),
}
for _, o := range opts {
o(jobScheduler)
}
return jobScheduler
}
func SchedulerLog(infoLog, errorLog *log.Logger) SchedulerOption {
return func(s *JobScheduler) {
s.infoLogger = infoLog
s.errorLogger = errorLog
}
}
func (js *JobScheduler) Schedule() {
for _, job := range js.jobs {
job.Schedule()
}
for {
select {
case jobID := <-js.rescheduleChannel:
js.jobs[jobID].Schedule()
case <-js.quitChannel:
for _, job := range js.jobs {
job.Stop()
}
js.infoLogger.Print("stop job scheduler")
return
}
}
}
func (js *JobScheduler) AddJob(job Job) {
js.jobs[job.Identifier()] = job
}
func (js *JobScheduler) Quit() {
js.quitChannel <- struct{}{}
}
func (js *JobScheduler) Reschedule(jobIDs ...JobIdentifier) {
for i := range jobIDs {
js.rescheduleChannel <- jobIDs[i]
}
}

@ -1,5 +1,5 @@
/*
Copyright 2022 CAcert Inc.
Copyright 2017-2022 CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
@ -15,15 +15,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package main
package notifications
import (
"bytes"
"fmt"
"log"
"net"
"os"
"path"
"text/template"
"time"
"github.com/Masterminds/sprig/v3"
"gopkg.in/mail.v2"
@ -32,6 +34,16 @@ import (
"git.cacert.org/cacert-boardvoting/internal/models"
)
type MailConfig struct {
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
SMTPTimeOut time.Duration `yaml:"smtp_timeout,omitempty"`
NotificationSenderAddress string `yaml:"notification_sender_address"`
NoticeMailAddress string `yaml:"notice_mail_address"`
VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"`
BaseURL string `yaml:"base_url"`
}
type recipientData struct {
field, address, name string
}
@ -45,7 +57,7 @@ type NotificationContent struct {
}
type NotificationMail interface {
GetNotificationContent(*mailConfig) *NotificationContent
GetNotificationContent(*MailConfig) *NotificationContent
}
type MailNotifier struct {
@ -54,18 +66,33 @@ type MailNotifier struct {
dialer *mail.Dialer
quitChannel chan struct{}
infoLog, errorLog *log.Logger
mailConfig *mailConfig
mailConfig *MailConfig
}
func (app *application) NewMailNotifier() {
app.mailNotifier = &MailNotifier{
type Option func(*MailNotifier)
func NewMailNotifier(config *MailConfig, opts ...Option) *MailNotifier {
n := &MailNotifier{
notifyChannel: make(chan NotificationMail, 1),
senderAddress: app.mailConfig.NotificationSenderAddress,
dialer: mail.NewDialer(app.mailConfig.SMTPHost, app.mailConfig.SMTPPort, "", ""),
senderAddress: config.NotificationSenderAddress,
dialer: mail.NewDialer(config.SMTPHost, config.SMTPPort, "", ""),
quitChannel: make(chan struct{}),
infoLog: app.infoLog,
errorLog: app.errorLog,
mailConfig: app.mailConfig,
infoLog: log.New(os.Stdout, "", 0),
errorLog: log.New(os.Stderr, "", 0),
mailConfig: config,
}
for _, o := range opts {
o(n)
}
return n
}
func NotifierLog(infoLog, errorLog *log.Logger) Option {
return func(n *MailNotifier) {
n.infoLog = infoLog
n.errorLog = errorLog
}
}
@ -159,7 +186,7 @@ func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) {
return mailText, nil
}
func defaultRecipient(mc *mailConfig) recipientData {
func defaultRecipient(mc *MailConfig) recipientData {
return recipientData{
field: "To",
address: mc.NoticeMailAddress,
@ -167,7 +194,7 @@ func defaultRecipient(mc *mailConfig) recipientData {
}
}
func voteNoticeRecipient(mc *mailConfig) recipientData {
func voteNoticeRecipient(mc *MailConfig) recipientData {
return recipientData{
field: "To",
address: mc.VoteNoticeMailAddress,
@ -183,18 +210,18 @@ func motionReplyHeaders(m *models.Motion) map[string][]string {
}
type RemindVoterNotification struct {
voter *models.User
decisions []*models.Motion
Voter *models.User
Decisions []*models.Motion
}
func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent {
func (r RemindVoterNotification) GetNotificationContent(*MailConfig) *NotificationContent {
recipientAddress := make([]recipientData, 0)
if r.voter.Reminder.Valid {
if r.Voter.Reminder.Valid {
recipientAddress = append(recipientAddress, recipientData{
field: "To",
address: r.voter.Reminder.String,
name: r.voter.Name,
address: r.Voter.Reminder.String,
name: r.Voter.Name,
})
}
@ -203,7 +230,7 @@ func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *Notificati
data: struct {
Decisions []*models.Motion
Name string
}{Decisions: r.decisions, Name: r.voter.Name},
}{Decisions: r.Decisions, Name: r.Voter.Name},
subject: "Outstanding CAcert board votes",
recipients: recipientAddress,
}
@ -213,7 +240,7 @@ type ClosedDecisionNotification struct {
Decision *models.Motion
}
func (c *ClosedDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
func (c *ClosedDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
return &NotificationContent{
template: "closed_motion_mail.txt",
data: struct {
@ -230,7 +257,7 @@ type NewDecisionNotification struct {
Proposer *models.User
}
func (n NewDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
func (n NewDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
voteURL := fmt.Sprintf("/vote/%s", n.Decision.Tag)
unvotedURL := "/motions/?unvoted=1"
@ -264,7 +291,7 @@ type UpdateDecisionNotification struct {
User *models.User
}
func (u UpdateDecisionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
func (u UpdateDecisionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
voteURL := fmt.Sprintf("/vote/%s", u.Decision.Tag)
unvotedURL := "/motions/?unvoted=1"
@ -293,7 +320,7 @@ type DirectVoteNotification struct {
Choice *models.VoteChoice
}
func (d DirectVoteNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
func (d DirectVoteNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
return &NotificationContent{
template: "direct_vote_mail.txt",
data: struct {
@ -319,7 +346,7 @@ type ProxyVoteNotification struct {
Justification string
}
func (p ProxyVoteNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
func (p ProxyVoteNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
return &NotificationContent{
template: "proxy_vote_mail.txt",
data: struct {
@ -346,7 +373,7 @@ type WithDrawMotionNotification struct {
Voter *models.User
}
func (w WithDrawMotionNotification) GetNotificationContent(mc *mailConfig) *NotificationContent {
func (w WithDrawMotionNotification) GetNotificationContent(mc *MailConfig) *NotificationContent {
return &NotificationContent{
template: "withdraw_motion_mail.txt",
data: struct {
Loading…
Cancel
Save