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 {
})
}