Remove old code

- remove the old code and its dependencies
- perform some refactoring and fix notifications
- add TODO tags for observed shortcomings
- rename voters.go to users.go
- implement health check for SMTP connection
main
Jan Dittberner 2 years ago
parent 28ddbd2ce6
commit 368bd8eefb

5
.gitignore vendored

@ -4,10 +4,11 @@
*.pem
*.req.conf
*.sqlite
*.sqlite-journal
.*.swp
.idea/
/dist/
/ui/semantic/dist/
cacert-boardvoting
config.yaml
node_modules/
/dist/
/ui/semantic/dist/

File diff suppressed because it is too large Load Diff

@ -1,66 +0,0 @@
{{ template "header.html" . }}
{{ template "return_header" . }}
<div class="ui raised segment">
<form action="/newmotion/" method="post">
{{ csrfField }}
<div class="ui form{{ if .Form.Errors }} error{{ end }}">
<div class="three fields">
<div class="field">
<label>ID:</label>
(generated on submit)
</div>
<div class="field">
<label>Proponent:</label>
{{ .Voter.Name }}
</div>
<div class="field">
<label>Proposed date/time:</label>
(auto filled to current date/time)
</div>
</div>
<div class="required field{{ if .Form.Errors.Title }} error{{ end }}">
<label for="Title">Title:</label>
<input id="Title" name="Title" type="text" value="{{ .Form.Title }}">
</div>
<div class="required field{{ if .Form.Errors.Content }} error{{ end }}">
<label for="Content">Text:</label>
<textarea id="Content" name="Content">{{ .Form.Content }}</textarea>
</div>
<div class="two fields">
<div class="required field{{ if .Form.Errors.VoteType }} error{{ end }}">
<label for="VoteType">Vote type:</label>
<select id="VoteType" name="VoteType">
<option value="0"
{{ if eq "0" .Form.VoteType }}selected{{ end }}>
Motion
</option>
<option value="1"
{{ if eq "1" .Form.VoteType }}selected{{ end }}>
Veto
</option>
</select>
</div>
<div class="required field{{ if .Form.Errors.Due }} error{{ end }}">
<label for="Due">Due: (autofilled from chosen
option)</label>
<select id="Due" name="Due">
<option value="+3 days">In 3 Days</option>
<option value="+7 days">In 1 Week</option>
<option value="+14 days">In 2 Weeks</option>
<option value="+28 days">In 4 Weeks</option>
</select>
</div>
</div>
{{ with .Form.Errors }}
<div class="ui error message">
{{ with .Title }}<p>{{ . }}</p>{{ end }}
{{ with .Content }}<p>{{ . }}</p>{{ end }}
{{ with .VoteType }}<p>{{ . }}</p>{{ end }}
{{ with .Due }}<p>{{ . }}</p>{{ end }}
</div>
{{ end }}
<button class="ui button" type="submit">Propose</button>
</div>
</form>
</div>
{{ template "footer.html" . }}

@ -1,23 +0,0 @@
{{ template "header.html" . }}
<div class="ui container">
<div class="ui negative icon message">
<i class="ban icon "></i>
<div class="content">
<div class="header">You are not authorized to act here!</div>
<p>If you think this is in error, please contact the administrator.</p>
<p>If you don't know who that is, it is definitely not an error ;)</p>
{{ if .Emails }}
<p>The following addresses were present in your certificate:<p>
<div class="ui list">
{{ range .Emails }}
<div class="item">
<i class="address card outline icon"></i>
<div class="content">{{ . }}</div>
</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
</div>
{{ template "footer.html" . }}

@ -1,23 +0,0 @@
{{ template "header.html" . }}
{{ template "return_header" . }}
{{ with .Decision }}
<div class="ui raised segment">
{{ template "motion_fragment" . }}
</div>
{{ end }}
<form action="/vote/{{ .Decision.Tag }}/{{ .VoteChoice }}" method="post">
{{ csrfField }}
<div class="ui form">
{{ if eq 1 .VoteChoice }}
<button class="ui right labeled green icon button" type="submit"><i class="check circle icon"></i>
Vote {{ .VoteChoice }}</button>
{{ else if eq -1 .VoteChoice }}
<button class="ui right labeled red icon button" type="submit"><i class="minus circle icon"></i>
Vote {{ .VoteChoice }}</button>
{{ else }}
<button class="ui right labeled grey icon button" type="submit"><i class="circle icon"></i>
Vote {{ .VoteChoice }}</button>
{{ end }}
</div>
</form>
{{ template "footer.html" . }}

@ -1,66 +0,0 @@
{{ template "header.html" . }}
{{ template "return_header" . }}
<div class="ui raised segment">
<form action="/motions/{{ .Form.Decision.Tag }}/edit" method="post">
{{ csrfField }}
<div class="ui form{{ if .Form.Errors }} error{{ end }}">
<div class="three fields">
<div class="field">
<label>ID:</label>
<a href="/motions/{{ .Form.Decision.Tag }}">{{ .Form.Decision.Tag }}</a>
</div>
<div class="field">
<label>Proponent:</label>
{{ .Voter.Name }}
</div>
<div class="field">
<label>Proposed date/time:</label>
{{ .Form.Decision.Proposed|date "2006-01-02 15:04:05 UTC" }}
</div>
</div>
<div class="required field{{ if .Form.Errors.Title }} error{{ end }}">
<label for="Title">Title:</label>
<input name="Title" type="text" value="{{ .Form.Title }}">
</div>
<div class="required field{{ if .Form.Errors.Content }} error{{ end }}">
<label for="Content">Text:</label>
<textarea name="Content">{{ .Form.Content }}</textarea>
</div>
<div class="two fields">
<div class="required field{{ if .Form.Errors.VoteType }} error{{ end }}">
<label for="VoteType">Vote type:</label>
<select name="VoteType">
<option value="0"
{{ if eq "0" .Form.VoteType }}selected{{ end }}>
Motion
</option>
<option value="1"
{{ if eq "1" .Form.VoteType }}selected{{ end }}>
Veto
</option>
</select>
</div>
<div class="required field{{ if .Form.Errors.Due }} error{{ end }}">
<label for="Due">Due: (autofilled from chosen
option)</label>
<select name="Due">
<option value="+3 days">In 3 Days</option>
<option value="+7 days">In 1 Week</option>
<option value="+14 days">In 2 Weeks</option>
<option value="+28 days">In 4 Weeks</option>
</select>
</div>
</div>
{{ with .Form.Errors }}
<div class="ui error message">
{{ with .Title }}<p>{{ . }}</p>{{ end }}
{{ with .Content }}<p>{{ . }}</p>{{ end }}
{{ with .VoteType }}<p>{{ . }}</p>{{ end }}
{{ with .Due }}<p>{{ . }}</p>{{ end }}
</div>
{{ end }}
<button class="ui button" type="submit">Propose</button>
</div>
</form>
</div>
{{ template "footer.html" . }}

@ -1,12 +0,0 @@
{{ define "footer.html" }}
</div>
<script type="text/javascript">
$(document).ready(function () {
$('.message .close').on('click', function () {
$(this).closest('.message').transition('fade');
});
});
</script>
</body>
</html>
{{ end }}

@ -1,42 +0,0 @@
{{ define "header.html" -}}
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">
<head>
<title>{{ block "pagetitle" . }}CAcert Board Decisions{{ end }}{{ if .PageTitle }} - {{ .PageTitle }}{{ end }}</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<link rel="stylesheet" type="text/css" href="/static/lato-fonts.css">
<link rel="stylesheet" type="text/css" href="/static/semantic.min.css"/>
<script type="text/javascript" src="/static/jquery.min.js"></script>
<script type="text/javascript" src="/static/semantic.min.js"></script>
<link rel="icon" href="/static/images/favicon.ico">
</head>
<body id="cacert-boardvoting">
<div class="pusher">
<div class="ui vertical masthead center aligned segment">
<div class="ui left secondary container">
<img src="/static/images/CAcert-logo-colour.svg" alt="CAcert" height="40rem"/>
</div>
<div class="ui text container">
<h1 class="ui header">
{{ template "pagetitle" . }}
{{ if .Voter }}
<div class="ui left pointing label">Authenticated as {{ .Voter.Name }} &lt;{{ .Voter.Reminder }}&gt;
</div>{{ end }}
</h1>
</div>
</div>
</div>
<div class="ui container">
{{ with .Flashes }}
<div class="basic segment">
<div class="ui info message">
<i class="close icon"></i>
<div class="ui list">
{{ range . }}
<div class="ui item">{{ . }}</div>
{{ end }}
</div>
</div>
</div>
{{ end }}
{{ end }}

@ -1,20 +0,0 @@
{{ template "header.html" . }}
{{ $voter := .Voter }}
<div class="ui basic segment">
<div class="ui secondary pointing menu">
<a href="/motions/" class="item" title="Show all votes">All votes</a>
{{ if $voter }}
<a href="/motions/?unvoted=1" class="item" title="Show my outstanding votes">My outstanding votes</a>
<div class="right item">
<a class="ui primary button" href="/newmotion/">New motion</a>
</div>
{{ end }}
</div>
</div>
{{ with .Decision }}
<div class="ui raised segment">
{{ template "motion_fragment" . }}
{{ if $voter }}{{ template "motion_actions" . }}{{ end }}
</div>
{{ end}}
{{ template "footer.html" . }}

@ -1,68 +0,0 @@
{{ define "motion_fragment" }}
<span class="ui {{ template "status_class" .Status }} ribbon label">{{ .Status|toString|title }}</span>
<span class="header.html">{{ .Modified|date "2006-01-02 15:04:05 UTC" }}</span>
<h3 class="header.html"><a href="/motions/{{ .Tag }}">{{ .Tag }}: {{ .Title }}</a></h3>
<p>{{ wrap 76 .Content | nl2br }}</p>
<table class="ui small definition table">
<tbody>
<tr>
<td>Due</td>
<td>{{.Due|date "2006-01-02 15:04:05 UTC"}}</td>
</tr>
<tr>
<td>Proposed</td>
<td>{{.Proposer}} ({{.Proposed|date "2006-01-02 15:04:05 UTC"}})</td>
</tr>
<tr>
<td>Vote type:</td>
<td>{{ .VoteType|toString|title }}</td>
</tr>
<tr>
<td>Votes:</td>
<td>
<div class="ui labels">
<div class="ui basic label green"><i
class="check circle icon"></i>Aye
<div class="detail">{{.Ayes}}</div>
</div>
<div class="ui basic label red"><i
class="minus circle icon"></i>Naye
<div class="detail">{{.Nayes}}</div>
</div>
<div class="ui basic label grey"><i class="circle icon"></i>Abstain
<div class="detail">{{.Abstains}}</div>
</div>
</div>
{{ if .Votes }}
<div class="list">
{{ range .Votes }}
<div class="item">{{ .Name }}: {{ .Vote.Vote }}</div>
{{ end }}
</div>
<a href="/motions/{{ .Tag }}">Hide Votes</a>
{{ else if or (ne 0 .Ayes) (ne 0 .Nayes) (ne 0 .Abstains) }}
<a href="/motions/{{ .Tag }}?showvotes=1">Show Votes</a>
{{ end }}
</td>
</tr>
</tbody>
</table>
{{ end }}
{{ define "status_class" }}{{ if eq . 0 }}blue{{ else if eq . 1 }}green{{ else if eq . -1 }}red{{ else if eq . -2 }}grey{{ end }}{{ end }}
{{ define "motion_actions" }}
{{ if eq .Status 0 }}
<a class="ui compact right labeled green icon button" href="/vote/{{ .Tag }}/aye"><i
class="check circle icon"></i> Aye</a>
<a class="ui compact right labeled red icon button" href="/vote/{{ .Tag }}/naye"><i
class="minus circle icon"></i> Naye</a>
<a class="ui compact right labeled grey icon button" href="/vote/{{ .Tag }}/abstain"><i class="circle icon"></i>
Abstain</a>
<a class="ui compact left labeled icon button" href="/proxy/{{ .Tag }}"><i class="users icon"></i> Proxy
Vote</a>
<a class="ui compact left labeled icon button" href="/motions/{{ .Tag }}/edit"><i class="edit icon"></i> Modify</a>
<a class="ui compact left labeled icon button" href="/motions/{{ .Tag }}/withdraw"><i class="trash icon"></i>
Withdraw</a>
{{ end }}
{{ end }}

@ -1,45 +0,0 @@
{{ template "header.html" . }}
{{ $voter := .Voter }}
{{ $page := . }}
<div class="ui basic segment">
<div class="ui secondary pointing menu">
<a href="/motions/" class="{{ if not .Params.Flags.Unvoted }}active {{ end }}item" title="Show all votes">All
votes</a>
{{ if $voter }}
<a href="/motions/?unvoted=1" class="{{ if .Params.Flags.Unvoted }}active {{ end}}item"
title="Show my outstanding votes">My outstanding votes</a>
<div class="right item">
<a class="ui primary button" href="/newmotion/">New motion</a>
</div>
{{ end }}
</div>
</div>
{{ if .Decisions }}
<div class="ui labeled icon menu">
{{ template "pagination_fragment" $page }}
</div>
{{ range .Decisions }}
<div class="ui raised segment">
{{ template "motion_fragment" . }}
{{ if $voter }}{{ template "motion_actions" . }}{{ end }}
</div>
{{ end }}
<div class="ui labeled icon menu">
{{ template "pagination_fragment" $page }}
</div>
{{ else }}
<div class="ui basic segment">
<div class="ui icon message">
<i class="inbox icon"></i>
<div class="content">
<div class="header">No motions available</div>
{{ if .Params.Flags.Unvoted }}
<p>There are no motions requiring a vote from you.</p>
{{ else }}
<p>There are no motions in the system yet.</p>
{{ end }}
</div>
</div>
</div>
{{ end }}
{{ template "footer.html" . }}

@ -1,20 +0,0 @@
{{ define "pagination_fragment" }}
{{ if .PrevPage -}}
<a class="item" href="?page={{ .PrevPage }}{{ if .Params.Flags.Unvoted }}&unvoted=1{{ end }}">
<i class="left arrow icon"></i> newer
</a>
{{- end }}
{{ if .NextPage -}}
<a class="right item" href="?page={{ .NextPage }}{{ if .Params.Flags.Unvoted }}&unvoted=1{{ end }}">
<i class="right arrow icon"></i> older
</a>
{{- end }}
{{ end }}
{{ define "return_header" }}
<div class="ui basic segment">
<div class="ui secondary pointing menu">
<a href="/motions/" class="item" title="Show all votes">Back to motions</a>
</div>
</div>
{{ end }}

@ -1,47 +0,0 @@
{{ template "header.html" . }}
{{ template "return_header" . }}
{{ $form := .Form }}
<div class="ui raised segment">
{{ with .Decision }}
{{ template "motion_fragment" . }}
{{ end }}
<form action="/proxy/{{ .Decision.Tag }}" method="post">
{{ csrfField }}
<div class="ui form{{ if .Form.Errors }} error{{ end }}">
<div class="two fields">
<div class="required field{{ if .Form.Errors.Voter }} error{{ end }}">
<label for="Voter">Voter</label>
<select name="Voter">
{{ range .Voters }}
<option value="{{ .Id }}"
{{ if eq (.Id | print) $form.Voter }}
selected{{ end }}>{{ .Name }}</option>
{{ end }}
</select>
</div>
<div class="required field{{ if .Form.Errors.Vote }} error{{ end }}">
<label for="Vote">Vote</label>
<select name="Vote">
<option value="1"{{ if eq .Form.Vote "1" }} selected{{ end }}>Aye</option>
<option value="0"{{ if eq .Form.Vote "0" }} selected{{ end }}>Abstain</option>
<option value="-1"{{ if eq .Form.Vote "-1" }} selected{{ end }}>Naye</option>
</select>
</div>
</div>
<div class="required field{{ if .Form.Errors.Justification }} error{{ end }}">
<label for="Justification">Justification</label>
<textarea name="Justification" rows="2">{{ .Form.Justification }}</textarea>
</div>
{{ with .Form.Errors }}
<div class="ui error message">
{{ with .Voter }}<p>{{ . }}</p>{{ end }}
{{ with .Vote }}<p>{{ . }}</p>{{ end }}
{{ with .Justification }}<p>{{ . }}</p>{{ end }}
</div>
{{ end }}
<button class="ui primary left labeled icon button" type="submit"><i class="users icon"></i> Proxy Vote
</button>
</div>
</form>
</div>
{{ template "footer.html" . }}

@ -1,17 +0,0 @@
{{ template "header.html" . }}
{{ template "return_header" . }}
{{ with .Decision }}
<div class="ui raised segment">
{{ template "motion_fragment" . }}
</div>
{{ end }}
<div class="ui basic segment">
<form action="/motions/{{ .Decision.Tag }}/withdraw" method="post">
{{ csrfField }}
<div class="ui form">
<button class="ui primary left labeled icon button" type="submit"><i class="trash icon"></i> Withdraw
</button>
</div>
</form>
</div>
{{ template "footer.html" . }}

@ -30,15 +30,17 @@ const (
httpReadHeaderTimeout = 5 * time.Second
httpReadTimeout = 5 * time.Second
httpWriteTimeout = 10 * time.Second
smtpTimeout = 10 * time.Second
)
type mailConfig struct {
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
NotificationSenderAddress string `yaml:"notification_sender_address"`
NoticeMailAddress string `yaml:"notice_mail_address"`
VoteNoticeMailAddress string `yaml:"vote_notice_mail_address"`
BaseURL string `yaml:"base_url"`
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 {
@ -76,6 +78,11 @@ func parseConfig(configFile string) (*Config, error) {
Read: httpReadTimeout,
Write: httpWriteTimeout,
},
MailConfig: &mailConfig{
SMTPHost: "localhost",
SMTPPort: 25,
SMTPTimeOut: smtpTimeout,
},
}
if err := yaml.Unmarshal(source, config); err != nil {

@ -31,8 +31,8 @@ import (
"git.cacert.org/cacert-boardvoting/internal/models"
)
func checkRole(v *models.User, roles []string) (bool, error) {
hasRole, err := v.HasRole(roles)
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)
}
@ -97,7 +97,7 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
motions, err := app.motions.GetMotions(ctx, listOptions)
motions, err := app.motions.List(ctx, listOptions)
if err != nil {
app.serverError(w, err)
@ -247,17 +247,19 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request)
return
}
decision, err := app.motions.GetByID(r.Context(), decisionID)
decision, err := app.motions.ByID(r.Context(), decisionID)
if err != nil {
app.serverError(w, err)
return
}
app.mailNotifier.notifyChannel <- &NewDecisionNotification{
app.mailNotifier.Notify(&NewDecisionNotification{
Decision: decision,
Proposer: user,
}
})
app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters)
app.sessionManager.Put(r.Context(), "flash", fmt.Sprintf("Started new motion %s: %s", decision.Tag, decision.Title))
@ -341,17 +343,19 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request)
return
}
decision, err := app.motions.GetByID(r.Context(), motion.ID)
decision, err := app.motions.ByID(r.Context(), motion.ID)
if err != nil {
app.serverError(w, err)
return
}
app.mailNotifier.notifyChannel <- &UpdateDecisionNotification{
app.mailNotifier.Notify(&UpdateDecisionNotification{
Decision: decision,
User: user,
}
})
app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters)
app.sessionManager.Put(
r.Context(),
@ -396,16 +400,17 @@ func (app *application) withdrawMotionSubmit(w http.ResponseWriter, r *http.Requ
return
}
err = app.motions.Update(r.Context(), motion.ID, func(m *models.Motion) {
m.Status = models.VoteStatusWithdrawn
})
err = app.motions.Withdraw(r.Context(), motion.ID)
if err != nil {
app.serverError(w, err)
return
}
app.mailNotifier.notifyChannel <- &WithDrawMotionNotification{motion, user}
app.mailNotifier.Notify(&WithDrawMotionNotification{motion, user})
app.jobScheduler.Reschedule(JobIDCloseDecisions, JobIDRemindVoters)
app.sessionManager.Put(
r.Context(),
@ -481,9 +486,9 @@ func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) {
return
}
app.mailNotifier.notifyChannel <- &DirectVoteNotification{
app.mailNotifier.Notify(&DirectVoteNotification{
Decision: motion, User: user, Choice: choice,
}
})
app.sessionManager.Put(
r.Context(),
@ -506,7 +511,7 @@ func (app *application) proxyVoteForm(w http.ResponseWriter, r *http.Request) {
data.Motion = motion
potentialVoters, err := app.users.PotentialVoters(r.Context())
potentialVoters, err := app.users.Voters(r.Context())
if err != nil {
app.serverError(w, err)
@ -543,7 +548,7 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request)
data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll)
data.Motion = motion
potentialVoters, err := app.users.PotentialVoters(r.Context())
potentialVoters, err := app.users.Voters(r.Context())
if err != nil {
app.serverError(w, err)
@ -574,14 +579,14 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request)
return
}
voter, err := app.users.LoadVoter(r.Context(), form.VoterID)
voter, err := app.users.ByID(r.Context(), form.Voter.ID)
if err != nil {
app.serverError(w, err)
return
}
if err := app.motions.UpdateVote(r.Context(), form.VoterID, motion.ID, func(v *models.Vote) {
if err := app.motions.UpdateVote(r.Context(), form.Voter.ID, motion.ID, func(v *models.Vote) {
v.Vote = form.Choice
v.Voted = time.Now().UTC()
v.Notes = fmt.Sprintf("Proxy-Vote by %s\n\n%s\n\n%s", user.Name, form.Justification, clientCert)
@ -591,9 +596,9 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request)
return
}
app.mailNotifier.notifyChannel <- &ProxyVoteNotification{
app.mailNotifier.Notify(&ProxyVoteNotification{
Decision: motion, User: user, Voter: voter, Choice: form.Choice, Justification: form.Justification,
}
})
app.sessionManager.Put(
r.Context(),
@ -638,19 +643,41 @@ func (app *application) deleteUserSubmit(_ http.ResponseWriter, _ *http.Request)
}
func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) {
const (
ok = "OK"
failed = "FAILED"
)
response := struct {
DB string `json:"database"`
}{DB: "ok"}
DB string `json:"database"`
Mail string `json:"mail"`
}{DB: ok, Mail: ok}
enc := json.NewEncoder(w)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Refresh", "10")
w.Header().Add("Cache-Control", "no-store")
err := app.motions.DB.Ping()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
var err error
var hasErrors = false
if err = app.mailNotifier.Ping(); err != nil {
hasErrors = true
response.DB = "FAILED"
response.Mail = failed
}
if err = app.motions.DB.Ping(); err != nil {
hasErrors = true
response.DB = failed
}
if hasErrors {
w.WriteHeader(http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
_ = enc.Encode(response)

@ -19,12 +19,16 @@ package main
import (
"database/sql"
"fmt"
"net"
"net/http"
"net/http/httptest"
"path"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/lestrrat-go/tcputil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -44,7 +48,41 @@ func prepareTestDb(t *testing.T) *sqlx.DB {
return dbx
}
func StartTestTcpServer(t *testing.T) int {
t.Helper()
port, err := tcputil.EmptyPort()
require.NoError(t, err)
go func(port int) {
l, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
t.Errorf("could not run test TCP listener: %v", err)
}
defer func(l net.Listener) {
_ = l.Close()
}(l)
for {
conn, err := l.Accept()
if err != nil {
t.Errorf("could not accept connection: %v", err)
return
}
if err = conn.Close(); err != nil {
t.Errorf("could not close connection: %v", err)
}
}
}(port)
return port
}
func TestApplication_healthCheck(t *testing.T) {
port := StartTestTcpServer(t)
t.Run("check with valid DB", func(t *testing.T) {
rr := httptest.NewRecorder()
@ -54,9 +92,12 @@ func TestApplication_healthCheck(t *testing.T) {
testDB := prepareTestDb(t)
app := &application{
motions: &models.MotionModel{DB: testDB},
motions: &models.MotionModel{DB: testDB},
mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second},
}
app.NewMailNotifier()
app.healthCheck(rr, r)
rs := rr.Result()
@ -80,9 +121,12 @@ func TestApplication_healthCheck(t *testing.T) {
_ = db.Close()
app := &application{
motions: &models.MotionModel{DB: testDB},
motions: &models.MotionModel{DB: testDB},
mailConfig: &mailConfig{SMTPHost: "localhost", SMTPPort: port, SMTPTimeOut: 1 * time.Second},
}
app.NewMailNotifier()
app.healthCheck(rr, r)
rs := rr.Result()

@ -89,13 +89,13 @@ func newTemplateCache() (map[string]*template.Template, error) {
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
}
funcMaps["canManageUsers"] = func(v *models.User) (bool, error) {
return checkRole(v, []string{models.RoleSecretary, models.RoleAdmin})
return checkRole(v, models.RoleSecretary, models.RoleAdmin)
}
funcMaps["canVote"] = func(v *models.User) (bool, error) {
return checkRole(v, []string{models.RoleVoter})
return checkRole(v, models.RoleVoter)
}
funcMaps["canStartVote"] = func(v *models.User) (bool, error) {
return checkRole(v, []string{models.RoleVoter})
return checkRole(v, models.RoleVoter)
}
for _, page := range pages {
@ -180,7 +180,7 @@ func (app *application) motionFromRequestParam(
withVotes := r.URL.Query().Has("showvotes")
motion, err := app.motions.GetMotionByTag(r.Context(), tag, withVotes)
motion, err := app.motions.ByTag(r.Context(), tag, withVotes)
if err != nil {
app.serverError(w, err)

@ -36,11 +36,11 @@ type RemindVotersJob struct {
timer *time.Timer
voters *models.UserModel
decisions *models.MotionModel
notify chan NotificationMail
reschedule chan Job
notifier *MailNotifier
}
func (r *RemindVotersJob) Schedule() {
// TODO: check logic. It would make more sense to remind at a specific interval before the next pending decision is closed
const reminderDays = 3
year, month, day := time.Now().UTC().Date()
@ -65,7 +65,7 @@ func (r *RemindVotersJob) Schedule() {
func (r *RemindVotersJob) Run() {
r.infoLog.Print("running RemindVotersJob")
defer func(r *RemindVotersJob) { r.reschedule <- r }(r)
defer func(r *RemindVotersJob) { r.Schedule() }(r)
var (
voters []*models.User
@ -75,7 +75,7 @@ func (r *RemindVotersJob) Run() {
ctx := context.Background()
voters, err = r.voters.GetReminderVoters(ctx)
voters, err = r.voters.ReminderVoters(ctx)
if err != nil {
r.errorLog.Printf("problem getting voters: %v", err)
@ -85,7 +85,7 @@ func (r *RemindVotersJob) Run() {
for _, voter := range voters {
v := voter
decisions, err = r.decisions.UnVotedDecisionsForVoter(ctx, v)
decisions, err = r.decisions.UnvotedForVoter(ctx, v)
if err != nil {
r.errorLog.Printf("problem getting unvoted decisions: %v", err)
@ -93,7 +93,7 @@ func (r *RemindVotersJob) Run() {
}
if len(decisions) > 0 {
r.notify <- &RemindVoterNotification{voter: voter, decisions: decisions}
r.notifier.Notify(&RemindVoterNotification{voter: voter, decisions: decisions})
}
}
}
@ -106,26 +106,22 @@ func (r *RemindVotersJob) Stop() {
}
}
func (app *application) NewRemindVotersJob(
rescheduleChannel chan Job,
) Job {
func (app *application) NewRemindVotersJob() Job {
return &RemindVotersJob{
infoLog: app.infoLog,
errorLog: app.errorLog,
voters: app.users,
decisions: app.motions,
reschedule: rescheduleChannel,
notify: app.mailNotifier.notifyChannel,
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
reschedule chan Job
notify chan NotificationMail
timer *time.Timer
infoLog *log.Logger
errorLog *log.Logger
decisions *models.MotionModel
notifier *MailNotifier
}
func (c *CloseDecisionsJob) Schedule() {
@ -136,7 +132,7 @@ func (c *CloseDecisionsJob) Schedule() {
ctx := context.Background()
nextDue, err = c.decisions.NextPendingDecisionDue(ctx)
nextDue, err = c.decisions.NextPendingDue(ctx)
if err != nil {
c.errorLog.Printf("could not get next pending due date")
@ -167,16 +163,23 @@ func (c *CloseDecisionsJob) Schedule() {
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.notify <- &ClosedDecisionNotification{Decision: res}
c.infoLog.Printf(
"decision %s closed with result %s: reasoning '%s'",
res.Tag,
res.Status,
res.Reasoning,
)
c.notifier.Notify(&ClosedDecisionNotification{Decision: res})
}
c.reschedule <- c
}
func (c *CloseDecisionsJob) Stop() {
@ -186,39 +189,43 @@ func (c *CloseDecisionsJob) Stop() {
}
}
func (app *application) NewCloseDecisionsJob(
rescheduleChannel chan Job,
) Job {
func (app *application) NewCloseDecisionsJob() Job {
return &CloseDecisionsJob{
infoLog: app.infoLog,
errorLog: app.errorLog,
decisions: app.motions,
reschedule: rescheduleChannel,
notify: app.mailNotifier.notifyChannel,
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 []Job
rescheduleChannel chan Job
jobs map[JobIdentifier]Job
rescheduleChannel chan JobIdentifier
quitChannel chan struct{}
}
func (app *application) NewJobScheduler() {
rescheduleChannel := make(chan Job, 1)
rescheduleChannel := make(chan JobIdentifier, 1)
app.jobScheduler = &JobScheduler{
infoLogger: app.infoLog,
errorLogger: app.errorLog,
jobs: make([]Job, 0, 2),
jobs: make(map[JobIdentifier]Job, 2),
rescheduleChannel: rescheduleChannel,
quitChannel: make(chan struct{}),
}
app.jobScheduler.addJob(app.NewCloseDecisionsJob(rescheduleChannel))
app.jobScheduler.addJob(app.NewRemindVotersJob(rescheduleChannel))
app.jobScheduler.addJob(JobIDCloseDecisions, app.NewCloseDecisionsJob())
app.jobScheduler.addJob(JobIDRemindVoters, app.NewRemindVotersJob())
}
func (js *JobScheduler) Schedule() {
@ -228,8 +235,8 @@ func (js *JobScheduler) Schedule() {
for {
select {
case job := <-js.rescheduleChannel:
job.Schedule()
case jobId := <-js.rescheduleChannel:
js.jobs[jobId].Schedule()
case <-js.quitChannel:
for _, job := range js.jobs {
job.Stop()
@ -242,10 +249,16 @@ func (js *JobScheduler) Schedule() {
}
}
func (js *JobScheduler) addJob(job Job) {
js.jobs = append(js.jobs, job)
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,6 +19,7 @@ limitations under the License.
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"database/sql"
@ -30,6 +31,7 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@ -104,12 +106,11 @@ func main() {
app := &application{
errorLog: errorLog,
infoLog: infoLog,
motions: &models.MotionModel{DB: db, InfoLog: infoLog},
motions: &models.MotionModel{DB: db},
users: &models.UserModel{DB: db},
mailConfig: config.MailConfig,
templateCache: templateCache,
sessionManager: sessionManager,
formDecoder: setupFormDecoder(),
}
err = internal.InitializeDb(db.DB, infoLog)
@ -117,15 +118,18 @@ func main() {
errorLog.Fatal(err)
}
app.setupFormDecoder()
app.NewMailNotifier()
defer app.mailNotifier.Quit()
go app.StartMailNotifier()
go app.mailNotifier.Start()
app.NewJobScheduler()
defer app.jobScheduler.Quit()
go app.jobScheduler.Schedule()
infoLog.Printf("Starting server on %s", config.HTTPAddress)
errChan := make(chan error, 1)
@ -144,7 +148,7 @@ func main() {
}
}
func setupFormDecoder() *form.Decoder {
func (app *application) setupFormDecoder() {
decoder := form.NewDecoder()
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
@ -163,8 +167,21 @@ func setupFormDecoder() *form.Decoder {
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))
return decoder
app.formDecoder = decoder
}
func (app *application) startHTTPSServer(config *Config) error {

@ -68,9 +68,24 @@ func (app *application) authenticateRequest(r *http.Request) (*models.User, *x50
}
clientCert := r.TLS.PeerCertificates[0]
allowClientAuth := false
for _, eku := range clientCert.ExtKeyUsage {
if eku == x509.ExtKeyUsageClientAuth {
allowClientAuth = true
break
}
}
if !allowClientAuth {
// presented certificate is not valid for client authentication
return nil, nil, nil
}
emails := clientCert.EmailAddresses
user, err := app.users.GetUser(r.Context(), emails)
user, err := app.users.ByEmails(r.Context(), emails)
if err != nil {
return nil, nil, fmt.Errorf("could not get user information from database: %w", err)
}
@ -116,7 +131,7 @@ func (app *application) GetUser(r *http.Request) (*models.User, error) {
return result, nil
}
func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, error) {
func (app *application) HasRole(r *http.Request, roles ...models.RoleName) (bool, bool, error) {
user, err := app.GetUser(r)
if err != nil {
return false, false, err
@ -126,16 +141,21 @@ func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, er
return false, false, nil
}
roleMatched, err := user.HasRole(roles)
roleMatched, err := user.HasRole(roles...)
if err != nil {
return false, true, fmt.Errorf("could not determin user role assignment: %w", err)
}
if !roleMatched {
roleNames := make([]string, len(roles))
for idx := range roles {
roleNames[idx] = string(roles[idx])
}
app.errorLog.Printf(
"user %s does not have any of the required role(s) %s assigned",
user.Name,
strings.Join(roles, ", "),
strings.Join(roleNames, ", "),
)
return false, true, nil
@ -144,9 +164,9 @@ func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, er
return true, true, nil
}
func (app *application) requireRole(next http.Handler, roles []string) http.Handler {
func (app *application) 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 := app.HasRole(r, roles...)
if err != nil {
app.serverError(w, err)
@ -170,15 +190,15 @@ func (app *application) requireRole(next http.Handler, roles []string) http.Hand
}
func (app *application) userCanVote(next http.Handler) http.Handler {
return app.requireRole(next, []string{models.RoleVoter})
return app.requireRole(next, models.RoleVoter)
}
func (app *application) userCanEditVote(next http.Handler) http.Handler {
return app.requireRole(next, []string{models.RoleVoter})
return app.requireRole(next, models.RoleVoter)
}
func (app *application) userCanChangeVoters(next http.Handler) http.Handler {
return app.requireRole(next, []string{models.RoleSecretary, models.RoleAdmin})
return app.requireRole(next, models.RoleSecretary, models.RoleAdmin)
}
func noSurf(next http.Handler) http.Handler {

@ -96,7 +96,7 @@ func TestApplication_tryAuthenticate(t *testing.T) {
users := &models.UserModel{DB: db}
_, err = users.CreateUser(
_, err = users.Create(
context.Background(),
"Test User",
"test@example.org",
@ -151,7 +151,10 @@ func TestApplication_tryAuthenticate(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err)
r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{EmailAddresses: []string{"test@example.org"}}}}
r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{{
EmailAddresses: []string{"test@example.org"},
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}}}
app.tryAuthenticate(next).ServeHTTP(rr, r)

@ -20,6 +20,8 @@ package main
import (
"bytes"
"fmt"
"log"
"net"
"path"
"text/template"
@ -47,10 +49,12 @@ type NotificationMail interface {
}
type MailNotifier struct {
notifyChannel chan NotificationMail
senderAddress string
dialer *mail.Dialer
quitChannel chan struct{}
notifyChannel chan NotificationMail
senderAddress string
dialer *mail.Dialer
quitChannel chan struct{}
infoLog, errorLog *log.Logger
mailConfig *mailConfig
}
func (app *application) NewMailNotifier() {
@ -59,20 +63,23 @@ func (app *application) NewMailNotifier() {
senderAddress: app.mailConfig.NotificationSenderAddress,
dialer: mail.NewDialer(app.mailConfig.SMTPHost, app.mailConfig.SMTPPort, "", ""),
quitChannel: make(chan struct{}),
infoLog: app.infoLog,
errorLog: app.errorLog,
mailConfig: app.mailConfig,
}
}
func (app *application) StartMailNotifier() {
app.infoLog.Print("Launching mail notifier")
func (mn *MailNotifier) Start() {
mn.infoLog.Print("Launching mail notifier")
for {
select {
case notification := <-app.mailNotifier.notifyChannel:
content := notification.GetNotificationContent(app.mailConfig)
case notification := <-mn.notifyChannel:
content := notification.GetNotificationContent(mn.mailConfig)
mailText, err := content.buildMail(app.mailConfig.BaseURL)
mailText, err := content.buildMail(mn.mailConfig.BaseURL)
if err != nil {
app.errorLog.Printf("building mail failed: %v", err)
mn.errorLog.Printf("building mail failed: %v", err)
continue
}
@ -80,7 +87,7 @@ func (app *application) StartMailNotifier() {
m := mail.NewMessage()
m.SetHeaders(content.headers)
m.SetAddressHeader("From", app.mailNotifier.senderAddress, "CAcert board voting system")
m.SetAddressHeader("From", mn.senderAddress, "CAcert board voting system")
for _, recipient := range content.recipients {
m.SetAddressHeader(recipient.field, recipient.address, recipient.name)
@ -90,22 +97,44 @@ func (app *application) StartMailNotifier() {
m.SetBody("text/plain", mailText.String())
if err = app.mailNotifier.dialer.DialAndSend(m); err != nil {
app.errorLog.Printf("sending mail failed: %v", err)
if err = mn.dialer.DialAndSend(m); err != nil {
mn.errorLog.Printf("sending mail failed: %v", err)
}
case <-app.mailNotifier.quitChannel:
app.infoLog.Print("ending mail notifier")
case <-mn.quitChannel:
mn.infoLog.Print("ending mail notifier")
return
}
}
}