Refactoring away from main package
cacert-boardvoting/pipeline/head There was a failure building this commit
Details
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
parent
3dc3160945
commit
c2eef9cf7c
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]
|
||||
}
|
||||
}
|
@ -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