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
}
}
}
func (m *MailNotifier) Quit() {
m.quitChannel <- struct{}{}
func (mn *MailNotifier) Quit() {
mn.quitChannel <- struct{}{}
}
func (mn *MailNotifier) Notify(w NotificationMail) {
mn.notifyChannel <- w
}
func (mn *MailNotifier) Ping() error {
conn, err := net.DialTimeout(
"tcp",
fmt.Sprintf("%s:%d", mn.mailConfig.SMTPHost, mn.mailConfig.SMTPPort),
mn.mailConfig.SMTPTimeOut,
)
if err != nil {
return fmt.Errorf("could not connect to SMTP server: %w", err)
}
defer func(conn net.Conn) {
_ = conn.Close()
}(conn)
return nil
}
func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) {
// TODO: implement a template cache for mail templates too
b, err := internal.MailTemplates.ReadFile(path.Join("mailtemplates", n.template))
if err != nil {
return nil, fmt.Errorf("could not read mail template %s: %w", n.template, err)
@ -130,27 +159,6 @@ func (n *NotificationContent) buildMail(baseURL string) (fmt.Stringer, error) {
return mailText, nil
}
type RemindVoterNotification struct {
voter *models.User
decisions []*models.Motion
}
func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent {
return &NotificationContent{
template: "remind_voter_mail.txt",
data: struct {
Decisions []*models.Motion
Name string
}{Decisions: r.decisions, Name: r.voter.Name},
subject: "Outstanding CAcert board votes",
recipients: []recipientData{{
field: "To",
address: r.voter.Reminder,
name: r.voter.Name,
}},
}
}
func defaultRecipient(mc *mailConfig) recipientData {
return recipientData{
field: "To",
@ -174,6 +182,27 @@ func motionReplyHeaders(m *models.Motion) map[string][]string {
}
}
type RemindVoterNotification struct {
voter *models.User
decisions []*models.Motion
}
func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent {
return &NotificationContent{
template: "remind_voter_mail.txt",
data: struct {
Decisions []*models.Motion
Name string
}{Decisions: r.decisions, Name: r.voter.Name},
subject: "Outstanding CAcert board votes",
recipients: []recipientData{{
field: "To",
address: r.voter.Reminder,
name: r.voter.Name,
}},
}
}
type ClosedDecisionNotification struct {
Decision *models.Motion
}

@ -1,171 +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 (
"fmt"
"strconv"
"strings"
"time"
)
const (
minimumContentLen = 3
minimumTitleLen = 3
base10 = 10
size8Bit = 8
size64Bit = 64
)
const (
hoursInADay = 24
dueThreeDays = 3
dueOneWeek = 7
dueTwoWeeks = 14
dueFourWeeks = 28
)
var validDueDurations = map[string]time.Duration{
"+3 days": time.Hour * hoursInADay * dueThreeDays,
"+7 days": time.Hour * hoursInADay * dueOneWeek,
"+14 days": time.Hour * hoursInADay * dueTwoWeeks,
"+28 days": time.Hour * hoursInADay * dueFourWeeks,
}
type NewDecisionForm struct {
Title string
Content string
VoteType string
Due string
Errors map[string]string
}
func (f *NewDecisionForm) Validate() (bool, *Decision) {
f.Errors = make(map[string]string)
data := &Decision{}
data.Title = strings.TrimSpace(f.Title)
if len(data.Title) < minimumTitleLen {
f.Errors["Title"] = fmt.Sprintf("Please enter at least %d characters for Title.", minimumTitleLen)
}
data.Content = strings.TrimSpace(f.Content)
if len(strings.Fields(data.Content)) < minimumContentLen {
f.Errors["Content"] = fmt.Sprintf("Please enter at least %d words as Text.", minimumContentLen)
}
if voteType, err := strconv.ParseUint(f.VoteType, base10, size8Bit); err != nil || (voteType != 0 && voteType != 1) {
f.Errors["VoteType"] = fmt.Sprint("Please choose a valid vote type.", err)
} else {
data.VoteType = VoteType(uint8(voteType))
}
if dueDuration, ok := validDueDurations[f.Due]; !ok {
f.Errors["Due"] = "Please choose a valid due date."
} else {
year, month, day := time.Now().UTC().Add(dueDuration).Date()
data.Due = time.Date(year, month, day, 23, 59, 59, 0, time.UTC)
}
return len(f.Errors) == 0, data
}
type EditDecisionForm struct {
Title string
Content string
VoteType string
Due string
Decision *Decision
Errors map[string]string
}
func (f *EditDecisionForm) Validate() (bool, *Decision) {
f.Errors = make(map[string]string)
data := f.Decision
data.Title = strings.TrimSpace(f.Title)
if len(data.Title) < minimumTitleLen {
f.Errors["Title"] = fmt.Sprintf("Please enter at least %d characters for Title.", minimumTitleLen)
}
data.Content = strings.TrimSpace(f.Content)
if len(strings.Fields(data.Content)) < minimumContentLen {
f.Errors["Content"] = fmt.Sprintf("Please enter at least %d words as Text.", minimumContentLen)
}
if voteType, err := strconv.ParseUint(f.VoteType, base10, size8Bit); err != nil || (voteType != 0 && voteType != 1) {
f.Errors["VoteType"] = fmt.Sprint("Please choose a valid vote type.", err)
} else {
data.VoteType = VoteType(uint8(voteType))
}
if dueDuration, ok := validDueDurations[f.Due]; !ok {
f.Errors["Due"] = "Please choose a valid due date."
} else {
year, month, day := time.Now().UTC().Add(dueDuration).Date()
data.Due = time.Date(year, month, day, 23, 59, 59, 0, time.UTC)
}
return len(f.Errors) == 0, data
}
type ProxyVoteForm struct {
Voter string
Vote string
Justification string
Errors map[string]string
}
func (f *ProxyVoteForm) Validate() (bool, *Voter, *Vote, string) {
f.Errors = make(map[string]string)
const minimumJustificationLen = 3
var (
voter *Voter
err error
voterID, vote int64
)
data := &Vote{}
if voterID, err = strconv.ParseInt(f.Voter, base10, size64Bit); err != nil {
f.Errors["Voter"] = fmt.Sprintf("Please choose a valid voter: %v.", err)
} else if voter, err = GetVoterByID(voterID); err != nil {
f.Errors["Voter"] = fmt.Sprintf("Please choose a valid voter: %v.", err)
} else {
data.VoterID = voter.ID
}
if vote, err = strconv.ParseInt(f.Vote, base10, size8Bit); err != nil {
f.Errors["Vote"] = fmt.Sprintf("Please choose a valid vote: %v.", err)
} else if voteChoice, ok := VoteChoices[vote]; !ok {
f.Errors["Vote"] = "Please choose a valid vote."
} else {
data.Vote = voteChoice
}
justification := strings.TrimSpace(f.Justification)
if len(justification) < minimumJustificationLen {
f.Errors["Justification"] = "Please enter at least 3 characters for justification."
}
return len(f.Errors) == 0, voter, data, justification
}

@ -14,10 +14,8 @@ require (
github.com/johejo/golang-migrate-extra v0.0.0-20211005021153-c17dd75f8b4a
github.com/mattn/go-sqlite3 v1.14.12
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/sirupsen/logrus v1.8.1
github.com/vearutop/statigz v1.1.8
golang.org/x/crypto v0.0.0-20220507011949-2cf3adece122 // indirect
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v2 v2.4.0
@ -27,11 +25,10 @@ require (
github.com/alexedwards/scs/sqlite3store v0.0.0-20220216073957-c252878bcf5a
github.com/alexedwards/scs/v2 v2.5.0
github.com/go-playground/form/v4 v4.2.0
github.com/gorilla/csrf v1.7.1
github.com/gorilla/sessions v1.2.1
github.com/julienschmidt/httprouter v1.3.0
github.com/justinas/alice v1.2.0
github.com/justinas/nosurf v1.1.1
github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb
github.com/stretchr/testify v1.7.0
)
@ -40,9 +37,7 @@ require (
github.com/Masterminds/semver/v3 v3.1.1 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/spf13/cast v1.4.1 // indirect

@ -617,18 +617,12 @@ github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2c
github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE=
github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA=
github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@ -797,6 +791,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ktrysmt/go-bitbucket v0.6.4/go.mod h1:9u0v3hsd2rqCHRIpbir1oP7F58uo5dq19sBYvuMoyQ4=
github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb h1:sb9NxqWoS17VT3aZd4mlBm48bsaHB1Fvwro3H/uiuZM=
github.com/lestrrat-go/tcputil v0.0.0-20180223003554-d3c7f98154fb/go.mod h1:bBamYL9/WjNn0b2CS4v4F8cHmWRpClSxrpEoAY+maJo=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -959,7 +955,6 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzL
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -1034,7 +1029,6 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
@ -1485,8 +1479,6 @@ golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220317061510-51cd9980dadf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

@ -78,6 +78,7 @@ func (f *EditMotionForm) Validate() {
"content",
fmt.Sprintf("This field must be at most %d characters long", maximumContentLength),
)
f.CheckField(validator.NotNil(f.Type), "type", "You must choose a valid vote type")
f.CheckField(validator.PermittedInt(
f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice",
@ -94,7 +95,7 @@ type DirectVoteForm struct {
}
type ProxyVoteForm struct {
VoterID int64 `form:"voter"`
Voter *models.User `form:"voter"`
Choice *models.VoteChoice `form:"choice"`
Justification string `form:"justification"`
Voters []*models.User `form:"-"`
@ -102,6 +103,7 @@ type ProxyVoteForm struct {
}
func (f *ProxyVoteForm) Validate() {
f.CheckField(validator.NotNil(f.Voter), "voter", "Please choose a valid voter")
f.CheckField(validator.NotBlank(f.Justification), "justification", "This field cannot be blank")
f.CheckField(
validator.MinChars(

@ -1,16 +1,16 @@
Dear Board,
{{ with .Decision }}The motion with the identifier {{.Tag}} has been {{.Status}}.{{ end }}
The motion with the identifier {{ .Data.Tag }} has been closed.
The reasoning for this result is: {{ .Reasoning }}
The reasoning for this result is: {{ .Data.Reasoning }}
{{ with .Decision }}Motion:
{{.Title}}
{{.Content}}
{{ with .Data }}Motion:
{{ .Title}}
{{ .Content}}
Vote type: {{.VoteType}}{{end}}
Vote type: {{ .Type}}{{end}}
{{ with .VoteSums }} Ayes: {{ .Ayes }}
{{ with .Data.Sums }} Ayes: {{ .Ayes }}
Nayes: {{ .Nayes }}
Abstentions: {{ .Abstains }}

@ -23,7 +23,6 @@ import (
"database/sql/driver"
"errors"
"fmt"
"log"
"strings"
"time"
@ -108,11 +107,11 @@ var (
voteStatusDeclined = &VoteStatus{Label: "declined", ID: -1}
voteStatusPending = &VoteStatus{Label: "pending", ID: 0}
voteStatusApproved = &VoteStatus{Label: "approved", ID: 1}
VoteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2}
voteStatusWithdrawn = &VoteStatus{Label: "withdrawn", ID: -2}
)
func VoteStatusFromInt(id int64) (*VoteStatus, error) {
for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, VoteStatusWithdrawn, voteStatusDeclined} {
for _, vs := range []*VoteStatus{voteStatusPending, voteStatusApproved, voteStatusWithdrawn, voteStatusDeclined} {
if int64(vs.ID) == id {
return vs, nil
}
@ -216,6 +215,15 @@ func (v *VoteSums) TotalVotes() int {
return v.Ayes + v.Nayes
}
func (v *VoteSums) Percent() int {
totalVotes := v.TotalVotes()
if totalVotes == 0 {
return 0
}
return v.Ayes * 100 / totalVotes
}
func (v *VoteSums) CalculateResult(quorum int, majority float32) (*VoteStatus, string) {
if v.VoteCount() < quorum {
return voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum)
@ -229,25 +237,24 @@ func (v *VoteSums) CalculateResult(quorum int, majority float32) (*VoteStatus, s
}
type Motion struct {
ID int64 `db:"id"`
Proposed time.Time
Proponent int64 `db:"proponent"`
Proposer string `db:"proposer"`
Title string
Content string
Status *VoteStatus
Due time.Time
Modified time.Time
Tag string
Type *VoteType `db:"votetype"`
Sums *VoteSums `db:"-"`
Votes []*Vote `db:"-"`
Reasoning string `db:"-"`
ID int64 `db:"id"`
Proposed time.Time `db:"proposed"`
Proponent int64 `db:"proponent"`
Proposer string `db:"proposer"`
Title string `db:"title"`
Content string `db:"content"`
Status *VoteStatus `db:"status"`
Due time.Time `db:"due"`
Modified time.Time `db:"modified"`
Tag string `db:"tag"`
Type *VoteType `db:"votetype"`
Sums *VoteSums `db:"-"`
Votes []*Vote `db:"-"`
Reasoning string `db:"-"`
}
type MotionModel struct {
DB *sqlx.DB
InfoLog *log.Logger
DB *sqlx.DB
}
// Create a new decision.
@ -335,9 +342,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
var decisionResult *Motion
for _, decision := range decisions {
m.InfoLog.Printf("found closable decision %s", decision.Tag)
if decisionResult, err = m.CloseDecision(ctx, tx, decision); err != nil {
if decisionResult, err = closeDecision(ctx, tx, decision); err != nil {
return nil, fmt.Errorf("closing decision %s failed: %w", decision.Tag, err)
}
@ -351,7 +356,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now
return results, nil
}
func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) {
func closeDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) {
quorum, majority := d.Type.QuorumAndMajority()
var (
@ -360,13 +365,14 @@ func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion)
reasoning string
)
if voteSums, err = m.SumsForDecision(ctx, tx, d); err != nil {
// TODO: implement prefetching in CloseDecisions
if voteSums, err = sumsForDecision(ctx, tx, d); err != nil {
return nil, fmt.Errorf("getting vote sums failed: %w", err)
}
d.Status, reasoning = voteSums.CalculateResult(quorum, majority)
result, err := m.DB.NamedExecContext(
result, err := tx.NamedExecContext(
ctx,
`UPDATE decisions SET status=:status, modified=CURRENT_TIMESTAMP WHERE id=:id`,
d,
@ -385,20 +391,21 @@ func (m *MotionModel) CloseDecision(ctx context.Context, tx *sqlx.Tx, d *Motion)
return nil, fmt.Errorf("unexpected number of rows %d instead of 1", affectedRows)
}
m.InfoLog.Printf("decision %s closed with result %s: reasoning '%s'", d.Tag, d.Status, reasoning)
d.Sums = voteSums
d.Reasoning = reasoning
return d, nil
}
func (m *MotionModel) UnVotedDecisionsForVoter(ctx context.Context, voter *User) ([]*Motion, error) {
func (m *MotionModel) UnvotedForVoter(ctx context.Context, voter *User) ([]*Motion, error) {
// TODO: implement more efficient variant that fetches unvoted votes for a slice of voters
rows, err := m.DB.QueryxContext(
ctx,
`SELECT decisions.*
FROM decisions
WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)`,
WHERE due < ? AND status=? AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)`,
time.Now().UTC(),
voteStatusPending,
voter.ID)
if err != nil {
return nil, fmt.Errorf(errCouldNotExecuteQuery, err)
@ -427,7 +434,7 @@ WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?
return result, nil
}
func (m *MotionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*VoteSums, error) {
func sumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*VoteSums, error) {
voteRows, err := tx.QueryxContext(
ctx,
`SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`,
@ -470,7 +477,7 @@ func (m *MotionModel) SumsForDecision(ctx context.Context, tx *sqlx.Tx, d *Motio
return sums, nil
}
func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, error) {
func (m *MotionModel) NextPendingDue(ctx context.Context) (*time.Time, error) {
row := m.DB.QueryRowContext(
ctx,
`SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`,
@ -489,8 +496,6 @@ func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, e
if err := row.Scan(&due); err != nil {
if errors.Is(err, sql.ErrNoRows) {
m.InfoLog.Print("no pending decisions")
return nil, nil
}
@ -500,11 +505,6 @@ func (m *MotionModel) NextPendingDecisionDue(ctx context.Context) (*time.Time, e
return &due, nil
}
type VoteForDisplay struct {
Name string
Vote *VoteChoice
}
type MotionListOptions struct {
Limit int
UnvotedOnly bool
@ -561,7 +561,7 @@ WHERE due >= ?
return firstTs, lastTs, nil
}
func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions) ([]*Motion, error) {
func (m *MotionModel) List(ctx context.Context, options *MotionListOptions) ([]*Motion, error) {
var (
rows *sqlx.Rows
err error
@ -569,11 +569,11 @@ func (m *MotionModel) GetMotions(ctx context.Context, options *MotionListOptions
switch {
case options.Before != nil:
rows, err = m.GetMotionRowsBefore(ctx, options)
rows, err = m.rowsBefore(ctx, options)
case options.After != nil:
rows, err = m.GetMotionRowsAfter(ctx, options)
rows, err = m.rowsAfter(ctx, options)
default:
rows, err = m.GetFirstMotionRows(ctx, options)
rows, err = m.rowsFirst(ctx, options)
}
if err != nil {
@ -621,7 +621,10 @@ func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*Motion) err
}
query, args, err := sqlx.In(
`SELECT v.decision, v.vote, COUNT(*) FROM votes v WHERE v.decision IN (?) GROUP BY v.decision, v.vote`,
`SELECT v.decision, v.vote, COUNT(*)
FROM votes v
WHERE v.decision IN (?)
GROUP BY v.decision, v.vote`,
decisionIds,
)
if err != nil {
@ -666,7 +669,8 @@ func (m *MotionModel) FillVoteSums(ctx context.Context, decisions []*Motion) err
return nil
}
func (m *MotionModel) GetMotionRowsBefore(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
func (m *MotionModel) rowsBefore(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
// TODO: implement variant for options.UnvotedOnly
rows, err := m.DB.QueryxContext(
ctx,
`SELECT decisions.id,
@ -695,7 +699,8 @@ LIMIT $2`,
return rows, nil
}
func (m *MotionModel) GetMotionRowsAfter(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
func (m *MotionModel) rowsAfter(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
// TODO: implement variant for options.UnvotedOnly
rows, err := m.DB.QueryxContext(
ctx,
`WITH display_decision AS (SELECT decisions.id,
@ -727,7 +732,7 @@ ORDER BY proposed DESC`,
return rows, nil
}
func (m *MotionModel) GetFirstMotionRows(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
func (m *MotionModel) rowsFirst(ctx context.Context, options *MotionListOptions) (*sqlx.Rows, error) {
var (
rows *sqlx.Rows
err error
@ -749,12 +754,12 @@ func (m *MotionModel) GetFirstMotionRows(ctx context.Context, options *MotionLis
decisions.modified
FROM decisions
JOIN voters ON decisions.proponent = voters.id
WHERE NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)
AND due >= ?
WHERE status=? AND due >= ? AND NOT EXISTS(SELECT * FROM votes WHERE decision = decisions.id AND voter = ?)
ORDER BY decisions.proposed DESC
LIMIT ?`,
options.VoterID,
voteStatusPending,
time.Now().UTC(),
options.VoterID,
options.Limit,
)
} else {
@ -786,7 +791,7 @@ LIMIT ?`,
return rows, nil
}
func (m *MotionModel) GetMotionByTag(ctx context.Context, tag string, withVotes bool) (*Motion, error) {
func (m *MotionModel) ByTag(ctx context.Context, tag string, withVotes bool) (*Motion, error) {
row := m.DB.QueryRowxContext(
ctx,
`SELECT decisions.id,
@ -865,7 +870,7 @@ ORDER BY voters.name`,
return nil
}
func (m *MotionModel) GetByID(ctx context.Context, id int64) (*Motion, error) {
func (m *MotionModel) ByID(ctx context.Context, id int64) (*Motion, error) {
row := m.DB.QueryRowxContext(ctx, `SELECT * FROM decisions WHERE id=?`, id)
if err := row.Err(); err != nil {
@ -986,3 +991,9 @@ WHERE decision = :decision
return nil
}
func (m *MotionModel) Withdraw(ctx context.Context, id int64) error {
return m.Update(ctx, id, func(m *Motion) {
m.Status = voteStatusWithdrawn
})
}

@ -34,7 +34,7 @@ import (
"git.cacert.org/cacert-boardvoting/internal/models"
)
func prepareTestDb(t *testing.T) (*sqlx.DB, *log.Logger) {
func prepareTestDb(t *testing.T) *sqlx.DB {
t.Helper()
testDir := t.TempDir()
@ -49,13 +49,13 @@ func prepareTestDb(t *testing.T) (*sqlx.DB, *log.Logger) {
err = internal.InitializeDb(dbx.DB, logger)
require.NoError(t, err)
return dbx, logger
return dbx
}
func TestDecisionModel_Create(t *testing.T) {
dbx, logger := prepareTestDb(t)
dbx := prepareTestDb(t)
dm := models.MotionModel{DB: dbx, InfoLog: logger}
dm := models.MotionModel{DB: dbx}
v := &models.User{
ID: 1, // sqlite does not check referential integrity. Might fail with a foreign key index.
@ -77,16 +77,16 @@ func TestDecisionModel_Create(t *testing.T) {
}
func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) {
dbx, logger := prepareTestDb(t)
dbx := prepareTestDb(t)
dm := models.MotionModel{DB: dbx, InfoLog: logger}
dm := models.MotionModel{DB: dbx}
var (
nextDue *time.Time
err error
)
nextDue, err = dm.NextPendingDecisionDue(context.Background())
nextDue, err = dm.NextPendingDue(context.Background())
assert.NoError(t, err)
assert.Empty(t, nextDue)
@ -103,7 +103,7 @@ func TestDecisionModel_GetNextPendingDecisionDue(t *testing.T) {
_, err = dm.Create(ctx, v, models.VoteTypeMotion, "test motion", "I move that we should test more", time.Now(), due)
require.NoError(t, err)
nextDue, err = dm.NextPendingDecisionDue(ctx)
nextDue, err = dm.NextPendingDue(ctx)
assert.NoError(t, err)
assert.NotEmpty(t, nextDue)

@ -26,10 +26,16 @@ import (
"github.com/jmoiron/sqlx"
)
type Role struct {
Name string `db:"role"`
}
type RoleName string
const (
RoleAdmin string = "ADMIN"
RoleSecretary string = "SECRETARY"
RoleVoter string = "VOTER"
RoleAdmin RoleName = "ADMIN"
RoleSecretary RoleName = "SECRETARY"
RoleVoter RoleName = "VOTER"
)
// The User type is used for mapping users from the voters table. The table
@ -47,16 +53,16 @@ type User struct {
roles []*Role `db:"-"`
}
func (v *User) Roles() ([]*Role, error) {
if v.roles != nil {
return v.roles, nil
func (u *User) Roles() ([]*Role, error) {
if u.roles != nil {
return u.roles, nil
}
return nil, errors.New("call to GetRoles required")
return nil, errors.New("call to Roles required")
}
func (v *User) HasRole(roles []string) (bool, error) {
userRoles, err := v.Roles()
func (u *User) HasRole(roles ...RoleName) (bool, error) {
userRoles, err := u.Roles()
if err != nil {
return false, err
}
@ -66,7 +72,7 @@ func (v *User) HasRole(roles []string) (bool, error) {
outer:
for _, role := range userRoles {
for _, checkRole := range roles {
if role.Name == checkRole {
if role.Name == string(checkRole) {
roleMatched = true
break outer
@ -81,7 +87,7 @@ type UserModel struct {
DB *sqlx.DB
}
func (m *UserModel) GetReminderVoters(ctx context.Context) ([]*User, error) {
func (m *UserModel) ReminderVoters(ctx context.Context) ([]*User, error) {
rows, err := m.DB.QueryxContext(
ctx,
`SELECT v.id, v.name, e.address AS reminder
@ -119,7 +125,11 @@ WHERE ur.role = ?
return result, nil
}
func (m *UserModel) GetUser(ctx context.Context, emails []string) (*User, error) {
func (m *UserModel) ByEmails(ctx context.Context, emails []string) (*User, error) {
for i := range emails {
emails[i] = strings.ToLower(emails[i])
}
query, args, err := sqlx.In(
`WITH reminders AS (SELECT voter, address
FROM emails
@ -166,18 +176,14 @@ WHERE e.address IN (?)`, emails)
}
}
if user.roles, err = m.GetRoles(ctx, &user); err != nil {
if user.roles, err = m.Roles(ctx, &user); err != nil {
return nil, fmt.Errorf("could not retrieve roles for user %s: %w", user.Name, err)
}
return &user, nil
}
type Role struct {
Name string `db:"role"`
}
func (m *UserModel) GetRoles(ctx context.Context, user *User) ([]*Role, error) {
func (m *UserModel) Roles(ctx context.Context, user *User) ([]*Role, error) {
rows, err := m.DB.QueryxContext(ctx, `SELECT role FROM user_roles WHERE voter_id=?`, user.ID)
if err != nil {
return nil, fmt.Errorf("could not query roles for %s: %w", user.Name, err)
@ -206,7 +212,7 @@ func (m *UserModel) GetRoles(ctx context.Context, user *User) ([]*Role, error) {
return result, nil
}
func (m *UserModel) CreateUser(ctx context.Context, name string, reminder string, emails []string) (int64, error) {
func (m *UserModel) Create(ctx context.Context, name string, reminder string, emails []string) (int64, error) {
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("could not start transaction: %w", err)
@ -250,14 +256,15 @@ VALUES (?, ?, ?)`,
return userID, nil
}
func (m *UserModel) PotentialVoters(ctx context.Context) ([]*User, error) {
func (m *UserModel) InRole(ctx context.Context, role RoleName) ([]*User, error) {
rows, err := m.DB.QueryxContext(
ctx,
`SELECT voters.id, voters.name
FROM voters
JOIN user_roles ur ON voters.id = ur.voter_id
WHERE ur.role = 'VOTER'
WHERE ur.role = ?
ORDER BY voters.name`,
role,
)
if err != nil {
@ -287,7 +294,11 @@ ORDER BY voters.name`,
return result, nil
}
func (m *UserModel) LoadVoter(ctx context.Context, voterID int64) (*User, error) {
func (m *UserModel) Voters(ctx context.Context) ([]*User, error) {
return m.InRole(ctx, RoleVoter)
}
func (m *UserModel) ByID(ctx context.Context, voterID int64) (*User, error) {
row := m.DB.QueryRowxContext(
ctx,
`SELECT DISTINCT v.id, v.name

@ -1,199 +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 (
"time"
log "github.com/sirupsen/logrus"
)
type Job interface {
Schedule()
Stop()
Run()
}
type jobIdentifier int
const (
JobIDCloseDecisions jobIdentifier = iota
JobIDRemindVotersJob
reminderDays = 3
)
var rescheduleChannel = make(chan jobIdentifier, 1)
func JobScheduler(quitChannel chan int) {
var jobs = map[jobIdentifier]Job{
JobIDCloseDecisions: NewCloseDecisionsJob(),
JobIDRemindVotersJob: NewRemindVotersJob(),
}
log.Info("started job scheduler")
for {
select {
case jobID := <-rescheduleChannel:
job := jobs[jobID]
log.Infof("reschedule job %s", job)
job.Schedule()
case <-quitChannel:
for _, job := range jobs {
job.Stop()
}
log.Info("stop job scheduler")
return
}
}
}
type CloseDecisionsJob struct {
timer *time.Timer
}
func NewCloseDecisionsJob() *CloseDecisionsJob {
job := &CloseDecisionsJob{}
job.Schedule()
return job
}
func (j *CloseDecisionsJob) Schedule() {
var (
nextDue *time.Time
err error
)
nextDue, err = GetNextPendingDecisionDue()
if err != nil {
log.Error("Could not get next pending due date")
if j.timer != nil {
j.timer.Stop()
j.timer = nil
}
return
}
if nextDue == nil {
log.Info("no next planned execution of CloseDecisionsJob")
j.Stop()
} else {
nextDue := nextDue.Add(time.Second)
log.Infof("scheduling CloseDecisionsJob for %s", nextDue)
when := time.Until(nextDue)
if j.timer != nil {
j.timer.Reset(when)
} else {
j.timer = time.AfterFunc(when, j.Run)
}
}
}
func (j *CloseDecisionsJob) Stop() {
if j.timer != nil {
j.timer.Stop()
j.timer = nil
}
}
func (j *CloseDecisionsJob) Run() {
log.Debug("running CloseDecisionsJob")
err := CloseDecisions()
if err != nil {
log.Errorf("closing decisions %v", err)
}
rescheduleChannel <- JobIDCloseDecisions
}
func (j *CloseDecisionsJob) String() string {
return "CloseDecisionsJob"
}
type RemindVotersJob struct {
timer *time.Timer
}
func NewRemindVotersJob() *RemindVotersJob {
job := &RemindVotersJob{}
job.Schedule()
return job
}
func (j *RemindVotersJob) Schedule() {
year, month, day := time.Now().UTC().Date()
nextExecution := time.Date(year, month, day, 0, 0, 0, 0, time.UTC).AddDate(0, 0, reminderDays)
log.Infof("scheduling RemindVotersJob for %s", nextExecution)
when := time.Until(nextExecution)
if j.timer != nil {
j.timer.Reset(when)
} else {
j.timer = time.AfterFunc(when, j.Run)
}
}
func (j *RemindVotersJob) Stop() {
if j.timer != nil {
j.timer.Stop()
j.timer = nil
}
}
func (j *RemindVotersJob) Run() {
log.Info("running RemindVotersJob")
defer func() { rescheduleChannel <- JobIDRemindVotersJob }()
var (
voters []Voter
err error
)
voters, err = GetReminderVoters()
if err != nil {
log.Errorf("problem getting voters %v", err)
return
}
var decisions []Decision
for i := range voters {
decisions, err = FindUnVotedDecisionsForVoter(&voters[i])
if err != nil {
log.Errorf("problem getting unvoted decisions: %v", err)
return
}
if len(decisions) > 0 {
NotifyMailChannel <- &RemindVoterNotification{voter: voters[i], decisions: decisions}
}
}
}

@ -1,876 +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 (
"database/sql"
"embed"
"errors"
"fmt"
"time"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/jmoiron/sqlx"
"github.com/johejo/golang-migrate-extra/source/iofs"
log "github.com/sirupsen/logrus"
)
type sqlKey int
const (
sqlLoadDecisions sqlKey = iota
sqlLoadUnVotedDecisions
sqlLoadDecisionByTag
sqlLoadDecisionByID
sqlLoadVoteCountsForDecision
sqlLoadVotesForDecision
sqlLoadEnabledVoterByEmail
sqlCountOlderThanDecision
sqlCountOlderThanUnVotedDecision
sqlCreateDecision
sqlUpdateDecision
sqlUpdateDecisionStatus
sqlSelectClosableDecisions
sqlGetNextPendingDecisionDue
sqlGetReminderVoters
sqlFindUnVotedDecisionsForVoter
sqlGetEnabledVoterByID
sqlCreateVote
sqlLoadVote
sqlGetVotersForProxy
)
var sqlStatements = map[sqlKey]string{
sqlLoadDecisions: `
SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
JOIN voters ON decisions.proponent=voters.id
ORDER BY proposed DESC
LIMIT 10 OFFSET 10 * $1`,
sqlLoadUnVotedDecisions: `
SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
JOIN voters ON decisions.proponent=voters.id
WHERE decisions.status = 0 AND decisions.id NOT IN (SELECT votes.decision FROM votes WHERE votes.voter = $1)
ORDER BY proposed DESC
LIMIT 10 OFFSET 10 * $2;`,
sqlLoadDecisionByTag: `
SELECT decisions.id, decisions.tag, decisions.proponent, voters.name AS proposer, decisions.proposed, decisions.title,
decisions.content, decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
JOIN voters ON decisions.proponent=voters.id
WHERE decisions.tag=$1;`,
sqlLoadDecisionByID: `
SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content,
decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
WHERE decisions.id=$1;`,
sqlLoadVoteCountsForDecision: `
SELECT vote, COUNT(vote) FROM votes WHERE decision=$1 GROUP BY vote`,
sqlLoadVotesForDecision: `
SELECT votes.decision, votes.voter, voters.name, votes.vote, votes.voted, votes.notes
FROM votes
JOIN voters ON votes.voter=voters.id
WHERE decision=$1`,
sqlLoadEnabledVoterByEmail: `
SELECT voters.id, voters.name, voters.reminder
FROM voters
JOIN emails ON voters.id=emails.voter
JOIN user_roles ON user_roles.voter_id=voters.id
WHERE emails.address=$1 AND user_roles.role='VOTER'`,
sqlGetEnabledVoterByID: `
SELECT voters.id, voters.name, voters.reminder
FROM voters
JOIN user_roles ON user_roles.voter_id=voters.id
WHERE user_roles.role='VOTER' AND voters.id=$1`,
sqlCountOlderThanDecision: `
SELECT COUNT(*) > 0 FROM decisions WHERE proposed < $1`,
sqlCountOlderThanUnVotedDecision: `
SELECT COUNT(*) > 0 FROM decisions
WHERE proposed < $1 AND status=0 AND id NOT IN (SELECT decision FROM votes WHERE votes.voter=$2)`,
sqlCreateDecision: `
INSERT INTO decisions (proposed, proponent, title, content, votetype, status, due, modified,tag)
VALUES (
:proposed, :proponent, :title, :content, :votetype, 0, :due, :proposed,
'm' || strftime('%Y%m%d', :proposed) || '.' || (
SELECT COUNT(*)+1 AS num
FROM decisions
WHERE proposed BETWEEN date(:proposed) AND date(:proposed, '1 day')
)
)`,
sqlUpdateDecision: `
UPDATE decisions
SET proponent=:proponent, title=:title, content=:content, votetype=:votetype, due=:due, modified=:modified
WHERE id=:id`,
sqlUpdateDecisionStatus: `
UPDATE decisions SET status=:status, modified=:modified WHERE id=:id`,
sqlSelectClosableDecisions: `
SELECT decisions.id, decisions.tag, decisions.proponent, decisions.proposed, decisions.title, decisions.content,
decisions.votetype, decisions.status, decisions.due, decisions.modified
FROM decisions
WHERE decisions.status=0 AND :now > due`,
sqlGetNextPendingDecisionDue: `
SELECT due FROM decisions WHERE status=0 ORDER BY due LIMIT 1`,
sqlGetVotersForProxy: `
SELECT voters.id, voters.name, voters.reminder
FROM voters
JOIN user_roles ON user_roles.voter_id=voters.id
WHERE user_roles.role='VOTER' AND voters.id != $1`,
sqlGetReminderVoters: `
SELECT voters.id, voters.name, voters.reminder
FROM voters
JOIN user_roles ON user_roles.voter_id=voters.id
WHERE user_roles.role='VOTER' AND reminder!='' AND reminder IS NOT NULL`,
sqlFindUnVotedDecisionsForVoter: `
SELECT tag, title, votetype, due
FROM decisions
WHERE status = 0 AND id NOT IN (SELECT decision FROM votes WHERE voter = $1)
ORDER BY due ASC`,
sqlCreateVote: `
INSERT OR REPLACE INTO votes (decision, voter, vote, voted, notes)
VALUES (:decision, :voter, :vote, :voted, :notes)`,
sqlLoadVote: `
SELECT decision, voter, vote, voted, notes
FROM votes
WHERE decision=$1 AND voter=$2`,
}
type VoteType uint8
type VoteStatus int8
type Decision struct {
ID int64 `db:"id"`
Proposed time.Time
ProponentID int64 `db:"proponent"`
Title string
Content string
Quorum int
Majority int
Status VoteStatus
Due time.Time
Modified time.Time
Tag string
VoteType VoteType
}
type Voter struct {
ID int64 `db:"id"`
Name string
Reminder string // reminder email address
}
type VoteChoice int
const (
voteAye = 1
voteNaye = -1
voteAbstain = 0
)
const (
voteTypeMotion = 0
voteTypeVeto = 1
)
const (
voteTypeLabelMotion = "motion"
voteTypeLabelUnknown = "unknown"
voteTypeLabelVeto = "veto"
)
func (v VoteType) String() string {
switch v {
case voteTypeMotion:
return voteTypeLabelMotion
case voteTypeVeto:
return voteTypeLabelVeto
default:
return voteTypeLabelUnknown
}
}
func (v VoteType) QuorumAndMajority() (int, float32) {
const (
majorityDefault = 0.99
majorityMotion = 0.50
quorumDefault = 1
quorumMotion = 3
)
switch v {
case voteTypeMotion:
return quorumMotion, majorityMotion
default:
return quorumDefault, majorityDefault
}
}
func (v VoteChoice) String() string {
switch v {
case voteAye:
return "aye"
case voteNaye:
return "naye"
case voteAbstain:
return "abstain"
default:
return "unknown"
}
}
var VoteValues = map[string]VoteChoice{
"aye": voteAye,
"naye": voteNaye,
"abstain": voteAbstain,
}
var VoteChoices = map[int64]VoteChoice{
1: voteAye,
0: voteAbstain,
-1: voteNaye,
}
const (
voteStatusDeclined = -1
voteStatusPending = 0
voteStatusApproved = 1
voteStatusWithdrawn = -2
)
func (v VoteStatus) String() string {
switch v {
case voteStatusDeclined:
return "declined"
case voteStatusPending:
return "pending"
case voteStatusApproved:
return "approved"
case voteStatusWithdrawn:
return "withdrawn"
default:
return "unknown"
}
}
type Vote struct {
DecisionID int64 `db:"decision"`
VoterID int64 `db:"voter"`
Vote VoteChoice
Voted time.Time
Notes string
}
type DbHandler struct {
db *sqlx.DB
}
var db *DbHandler
// go:embed boardvoting/migrations/*
var migrations embed.FS
func NewDB(database *sql.DB) *DbHandler {
handler := &DbHandler{db: sqlx.NewDb(database, "sqlite3")}
source, err := iofs.New(migrations, "boardvoting/migrations")
if err != nil {
log.Panicf("could not create migration source: %v", err)
}
driver, err := sqlite3.WithInstance(database, &sqlite3.Config{})
if err != nil {
log.Panicf("could not create migration driver: %v", err)
}
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
if err != nil {
log.Panicf("could not create migration instance: %v", err)
}
m.Log = NewLogger()
err = m.Up()
if err != nil {
if !errors.Is(err, migrate.ErrNoChange) {
log.Panicf("running database migration failed: %v", err)
}
log.Info("no database migrations required")
} else {
log.Info("applied database migrations")
}
failedStatements := make([]string, 0)
for _, sqlStatement := range sqlStatements {
var stmt *sqlx.Stmt
stmt, err := handler.db.Preparex(sqlStatement)
if err != nil {
log.Errorf("error parsing statement %s: %s", sqlStatement, err)
failedStatements = append(failedStatements, sqlStatement)
}
_ = stmt.Close()
}
if len(failedStatements) > 0 {
log.Panicf("%d statements failed to prepare", len(failedStatements))
}
return handler
}
type migrationLogger struct{}
func (m migrationLogger) Printf(format string, v ...interface{}) {
log.Printf(format, v...)
}
func (m migrationLogger) Verbose() bool {
return log.IsLevelEnabled(log.DebugLevel)
}
func NewLogger() migrate.Logger {
return &migrationLogger{}
}
func (d *DbHandler) Close() error {
if err := d.db.Close(); err != nil {
return fmt.Errorf("could not close database: %w", err)
}
return nil
}
func (d *DbHandler) getPreparedNamedStatement(statementKey sqlKey) *sqlx.NamedStmt {
statement, err := d.db.PrepareNamed(sqlStatements[statementKey])
if err != nil {
log.Panicf("Preparing statement failed: %v", err)
}
return statement
}
func (d *DbHandler) getPreparedStatement(statementKey sqlKey) *sqlx.Stmt {
statement, err := d.db.Preparex(sqlStatements[statementKey])
if err != nil {
log.Panicf("Preparing statement failed: %v", err)
}
return statement
}
func (v *Vote) Save() error {
insertVoteStmt := db.getPreparedNamedStatement(sqlCreateVote)
defer func() { _ = insertVoteStmt.Close() }()
var err error
if _, err = insertVoteStmt.Exec(v); err != nil {
return fmt.Errorf("saving vote failed: %w", err)
}
getVoteStmt := db.getPreparedStatement(sqlLoadVote)
defer func() { _ = getVoteStmt.Close() }()
if err = getVoteStmt.Get(v, v.DecisionID, v.VoterID); err != nil {
return fmt.Errorf("getting inserted vote failed: %w", err)
}
return nil
}
type VoteSums struct {
Ayes int
Nayes int
Abstains int
}
func (v *VoteSums) VoteCount() int {
return v.Ayes + v.Nayes + v.Abstains
}
func (v *VoteSums) TotalVotes() int {
return v.Ayes + v.Nayes
}
func (v *VoteSums) Percent() int {
totalVotes := v.TotalVotes()
if totalVotes == 0 {
return 0
}
return v.Ayes * 100 / totalVotes
}
func (v *VoteSums) CalculateResult(quorum int, majority float32) (VoteStatus, string) {
if v.VoteCount() < quorum {
return voteStatusDeclined, fmt.Sprintf("Needed quorum of %d has not been reached.", quorum)
}
if (float32(v.Ayes) / float32(v.TotalVotes())) < majority {
return voteStatusDeclined, fmt.Sprintf("Needed majority of %0.2f%% has not been reached.", majority)
}
return voteStatusApproved, "Quorum and majority have been reached"
}
type VoteForDisplay struct {
Vote
Name string
}
type DecisionForDisplay struct {
Decision
Proposer string `db:"proposer"`
*VoteSums
Votes []VoteForDisplay
}
func FindDecisionForDisplayByTag(tag string) (*DecisionForDisplay, error) {
decisionStmt := db.getPreparedStatement(sqlLoadDecisionByTag)
defer func() { _ = decisionStmt.Close() }()
decision := &DecisionForDisplay{}
var err error
if err = decisionStmt.Get(decision, tag); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("getting motion %s failed: %w", tag, err)
}
decision.VoteSums, err = decision.Decision.VoteSums()
return decision, err
}
// FindDecisionsForDisplayOnPage loads a set of decisions from the database.
//
// This function uses OFFSET for pagination which is not a good idea for larger data sets.
//
// TODO: migrate to timestamp base pagination
func FindDecisionsForDisplayOnPage(page int, unVoted bool, voter *Voter) ([]*DecisionForDisplay, error) {
var decisionsStmt *sqlx.Stmt
if unVoted && voter != nil {
decisionsStmt = db.getPreparedStatement(sqlLoadUnVotedDecisions)
} else {
decisionsStmt = db.getPreparedStatement(sqlLoadDecisions)
}
defer func() { _ = decisionsStmt.Close() }()
var (
rows *sqlx.Rows
err error
decisions []*DecisionForDisplay
)
if unVoted && voter != nil {
rows, err = decisionsStmt.Queryx(voter.ID, page-1)
} else {
rows, err = decisionsStmt.Queryx(page - 1)
}
if err != nil {
return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var d DecisionForDisplay
if err = rows.StructScan(&d); err != nil {
return nil, fmt.Errorf("loading motions for page %d failed: %w", page, err)
}
d.VoteSums, err = d.Decision.VoteSums()
if err != nil {
return nil, err
}
decisions = append(decisions, &d)
}
return decisions, nil
}
func (d *Decision) VoteSums() (*VoteSums, error) {
votesStmt := db.getPreparedStatement(sqlLoadVoteCountsForDecision)
defer func() { _ = votesStmt.Close() }()
voteRows, err := votesStmt.Queryx(d.ID)
if err != nil {
return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err)
}
defer func() { _ = voteRows.Close() }()
sums := &VoteSums{}
for voteRows.Next() {
var (
vote VoteChoice
count int
)
if err = voteRows.Scan(&vote, &count); err != nil {
return nil, fmt.Errorf("fetching vote sums for motion %s failed: %w", d.Tag, err)
}
switch vote {
case voteAye:
sums.Ayes = count
case voteNaye:
sums.Nayes = count
case voteAbstain:
sums.Abstains = count
}
}
return sums, nil
}
func (d *DecisionForDisplay) LoadVotes() (err error) {
votesStmt := db.getPreparedStatement(sqlLoadVotesForDecision)
defer func() { _ = votesStmt.Close() }()
err = votesStmt.Select(&d.Votes, d.ID)
if err != nil {
log.Errorf("selecting votes for motion %s failed: %v", d.Tag, err)
return
}
return
}
func (d *Decision) OlderExists(unvoted bool, voter *Voter) (bool, error) {
var result bool
if unvoted && voter != nil {
olderStmt := db.getPreparedStatement(sqlCountOlderThanUnVotedDecision)
defer func() { _ = olderStmt.Close() }()
if err := olderStmt.Get(&result, d.Proposed, voter.ID); err != nil {
return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err)
}
} else {
olderStmt := db.getPreparedStatement(sqlCountOlderThanDecision)
defer func() { _ = olderStmt.Close() }()
if err := olderStmt.Get(&result, d.Proposed); err != nil {
return false, fmt.Errorf("finding older motions than %s failed: %w", d.Tag, err)
}
}
return result, nil
}
func (d *Decision) Create() error {
insertDecisionStmt := db.getPreparedNamedStatement(sqlCreateDecision)
defer func() { _ = insertDecisionStmt.Close() }()
result, err := insertDecisionStmt.Exec(d)
if err != nil {
return fmt.Errorf("creating motion failed: %w", err)
}
decisionID, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("getting id of inserted motion failed: %w", err)
}
rescheduleChannel <- JobIDCloseDecisions
getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID)
defer func() { _ = getDecisionStmt.Close() }()
err = getDecisionStmt.Get(d, decisionID)
if err != nil {
return fmt.Errorf("getting inserted motion failed: %w", err)
}
return nil
}
func (d *Decision) LoadWithID() (err error) {
getDecisionStmt := db.getPreparedStatement(sqlLoadDecisionByID)
defer func() { _ = getDecisionStmt.Close() }()
err = getDecisionStmt.Get(d, d.ID)
if err != nil {
log.Errorf("loading updated motion failed: %v", err)
return
}
return
}
func (d *Decision) Update() (err error) {
updateDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecision)
defer func() { _ = updateDecisionStmt.Close() }()
result, err := updateDecisionStmt.Exec(d)
if err != nil {
log.Errorf("updating motion failed: %v", err)
return
}
affectedRows, err := result.RowsAffected()
if err != nil {
log.Error("Problem determining the affected rows")
return
} else if affectedRows != 1 {
log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
}
rescheduleChannel <- JobIDCloseDecisions
err = d.LoadWithID()
return
}
func (d *Decision) UpdateStatus() error {
updateStatusStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus)
defer func() { _ = updateStatusStmt.Close() }()
result, err := updateStatusStmt.Exec(d)
if err != nil {
return fmt.Errorf("setting motion status failed: %w", err)
}
affectedRows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("determining the affected rows failed: %w", err)
} else if affectedRows != 1 {
log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
}
rescheduleChannel <- JobIDCloseDecisions
err = d.LoadWithID()
return err
}
func (d *Decision) String() string {
return fmt.Sprintf("%s %s (ID %d)", d.Tag, d.Title, d.ID)
}
func FindVoterByAddress(emailAddress string) (*Voter, error) {
findVoterStmt := db.getPreparedStatement(sqlLoadEnabledVoterByEmail)
defer func() { _ = findVoterStmt.Close() }()
voter := &Voter{}
if err := findVoterStmt.Get(voter, emailAddress); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("getting voter for address %s failed: %w", emailAddress, err)
}
voter = nil
}
return voter, nil
}
func (d *Decision) Close() error {
quorum, majority := d.VoteType.QuorumAndMajority()
var (
voteSums *VoteSums
err error
)
if voteSums, err = d.VoteSums(); err != nil {
log.Errorf("getting vote sums failed: %v", err)
return err
}
var reasoning string
d.Status, reasoning = voteSums.CalculateResult(quorum, majority)
closeDecisionStmt := db.getPreparedNamedStatement(sqlUpdateDecisionStatus)
defer func() { _ = closeDecisionStmt.Close() }()
result, err := closeDecisionStmt.Exec(d)
if err != nil {
return fmt.Errorf("closing vote failed: %w", err)
}
affectedRows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("getting affected rows failed: %w", err)
}
if affectedRows != 1 {
log.Warningf("wrong number of affected rows: %d (1 expected)", affectedRows)
}
NotifyMailChannel <- NewNotificationClosedDecision(d, voteSums, reasoning)
log.Infof("decision %s closed with result %s: reasoning %s", d.Tag, d.Status, reasoning)
return nil
}
func CloseDecisions() error {
getClosableDecisionsStmt := db.getPreparedNamedStatement(sqlSelectClosableDecisions)
defer func() { _ = getClosableDecisionsStmt.Close() }()
decisions := make([]*Decision, 0)
rows, err := getClosableDecisionsStmt.Queryx(struct{ Now time.Time }{time.Now().UTC()})
if err != nil {
return fmt.Errorf("fetching closable decisions failed: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
decision := &Decision{}
if err = rows.StructScan(decision); err != nil {
return fmt.Errorf("scanning row failed: %w", err)
}
decisions = append(decisions, decision)
}
defer func() { _ = rows.Close() }()
for _, decision := range decisions {
log.Infof("found closable decision %s", decision.Tag)
if err = decision.Close(); err != nil {
return fmt.Errorf("closing decision %s failed: %w", decision.Tag, err)
}
}
return nil
}
func GetNextPendingDecisionDue() (*time.Time, error) {
getNextPendingDecisionDueStmt := db.getPreparedStatement(sqlGetNextPendingDecisionDue)
defer func() { _ = getNextPendingDecisionDueStmt.Close() }()
row := getNextPendingDecisionDueStmt.QueryRow()
due := &time.Time{}
if err := row.Scan(due); err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Debug("No pending decisions")
return nil, nil
}
return nil, fmt.Errorf("parsing result failed: %w", err)
}
return due, nil
}
func GetReminderVoters() ([]Voter, error) {
getReminderVotersStmt := db.getPreparedStatement(sqlGetReminderVoters)
defer func() { _ = getReminderVotersStmt.Close() }()
var voters []Voter
if err := getReminderVotersStmt.Select(&voters); err != nil {
return nil, fmt.Errorf("getting voters failed: %w", err)
}
return voters, nil
}
func FindUnVotedDecisionsForVoter(voter *Voter) ([]Decision, error) {
findUnVotedDecisionsForVoterStmt := db.getPreparedStatement(sqlFindUnVotedDecisionsForVoter)
defer func() { _ = findUnVotedDecisionsForVoterStmt.Close() }()
var decisions []Decision
if err := findUnVotedDecisionsForVoterStmt.Select(&decisions, voter.ID); err != nil {
return nil, fmt.Errorf("getting unvoted decisions failed: %w", err)
}
return decisions, nil
}
func GetVoterByID(id int64) (*Voter, error) {
getVoterByIDStmt := db.getPreparedStatement(sqlGetEnabledVoterByID)
defer func() { _ = getVoterByIDStmt.Close() }()
voter := &Voter{}
if err := getVoterByIDStmt.Get(voter, id); err != nil {
return nil, fmt.Errorf("getting voter failed: %w", err)
}
return voter, nil
}
func GetVotersForProxy(proxy *Voter) (voters *[]Voter, err error) {
getVotersForProxyStmt := db.getPreparedStatement(sqlGetVotersForProxy)
defer func() { _ = getVotersForProxyStmt.Close() }()
votersSlice := make([]Voter, 0)
if err = getVotersForProxyStmt.Select(&votersSlice, proxy.ID); err != nil {
log.Errorf("Error getting voters for proxy failed: %v", err)
return
}
voters = &votersSlice
return
}

@ -1,347 +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"
"embed"
"fmt"
"text/template"
"github.com/Masterminds/sprig/v3"
"gopkg.in/mail.v2"
log "github.com/sirupsen/logrus"
)
type headerData struct {
name string
value []string
}
type headerList []headerData
type recipientData struct {
field, address, name string
}
type NotificationContent struct {
template string
data interface{}
subject string
headers headerList
recipients []recipientData
}
type NotificationMail interface {
GetNotificationContent() *NotificationContent
}
var NotifyMailChannel = make(chan NotificationMail, 1)
func MailNotifier(quitMailNotifier chan int) {
log.Info("Launched mail notifier")
for {
select {
case notification := <-NotifyMailChannel:
content := notification.GetNotificationContent()
mailText, err := buildMail(content.template, content.data)
if err != nil {
log.Errorf("building mail failed: %v", err)
continue
}
m := mail.NewMessage()
m.SetAddressHeader("From", config.NotificationSenderAddress, "CAcert board voting system")
for _, recipient := range content.recipients {
m.SetAddressHeader(recipient.field, recipient.address, recipient.name)
}
m.SetHeader("Subject", content.subject)
for _, header := range content.headers {
m.SetHeader(header.name, header.value...)
}
m.SetBody("text/plain", mailText.String())
d := mail.NewDialer(config.MailServer.Host, config.MailServer.Port, "", "")
if err := d.DialAndSend(m); err != nil {
log.Errorf("sending mail failed: %v", err)
}
case <-quitMailNotifier:
log.Info("Ending mail notifier")
return
}
}
}
//go:embed boardvoting/templates
var mailTemplates embed.FS
func buildMail(templateName string, context interface{}) (mailText *bytes.Buffer, err error) {
b, err := mailTemplates.ReadFile(fmt.Sprintf("templates/%s", templateName))
if err != nil {
return
}
t, err := template.New(templateName).Funcs(sprig.GenericFuncMap()).Parse(string(b))
if err != nil {
return
}
mailText = bytes.NewBufferString("")
if err := t.Execute(mailText, context); err != nil {
return nil, fmt.Errorf(
"failed to execute template %s with context %+v: %w",
templateName, context, err,
)
}
return
}
type notificationBase struct{}
func (n *notificationBase) getRecipient() recipientData {
return recipientData{field: "To", address: config.NoticeMailAddress, name: "CAcert board mailing list"}
}
type decisionReplyBase struct {
decision Decision
}
func (n *decisionReplyBase) getHeaders() headerList {
headers := make(headerList, 0)
headers = append(headers, headerData{
name: "References", value: []string{fmt.Sprintf("<%s>", n.decision.Tag)},
})
headers = append(headers, headerData{
name: "In-Reply-To", value: []string{fmt.Sprintf("<%s>", n.decision.Tag)},
})
return headers
}
func (n *decisionReplyBase) getSubject() string {
return fmt.Sprintf("Re: %s - %s", n.decision.Tag, n.decision.Title)
}
type notificationClosedDecision struct {
notificationBase
decisionReplyBase
voteSums VoteSums
reasoning string
}
func NewNotificationClosedDecision(decision *Decision, voteSums *VoteSums, reasoning string) NotificationMail {
notification := &notificationClosedDecision{voteSums: *voteSums, reasoning: reasoning}
notification.decision = *decision
return notification
}
func (n *notificationClosedDecision) GetNotificationContent() *NotificationContent {
return &NotificationContent{
template: "closed_motion_mail.txt",
data: struct {
*Decision
*VoteSums
Reasoning string
}{&n.decision, &n.voteSums, n.reasoning},
subject: fmt.Sprintf("Re: %s - %s - finalised", n.decision.Tag, n.decision.Title),
headers: n.decisionReplyBase.getHeaders(),
recipients: []recipientData{n.notificationBase.getRecipient()},
}
}
type NotificationCreateMotion struct {
notificationBase
decision Decision
voter Voter
}
func (n *NotificationCreateMotion) GetNotificationContent() *NotificationContent {
voteURL := fmt.Sprintf("%s/vote/%s", config.BaseURL, n.decision.Tag)
unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL)
return &NotificationContent{
template: "create_motion_mail.txt",
data: struct {
*Decision
Name string
VoteURL string
UnvotedURL string
}{&n.decision, n.voter.Name, voteURL, unvotedURL},
subject: fmt.Sprintf("%s - %s", n.decision.Tag, n.decision.Title),
headers: headerList{headerData{"Message-ID", []string{fmt.Sprintf("<%s>", n.decision.Tag)}}},
recipients: []recipientData{n.notificationBase.getRecipient()},
}
}
type notificationUpdateMotion struct {
notificationBase
decisionReplyBase
voter Voter
}
func NewNotificationUpdateMotion(decision Decision, voter Voter) NotificationMail {
notification := notificationUpdateMotion{voter: voter}
notification.decision = decision
return &notification
}
func (n *notificationUpdateMotion) GetNotificationContent() *NotificationContent {
voteURL := fmt.Sprintf("%s/vote/%s", config.BaseURL, n.decision.Tag)
unvotedURL := fmt.Sprintf("%s/motions/?unvoted=1", config.BaseURL)
return &NotificationContent{
template: "update_motion_mail.txt",
data: struct {
*Decision
Name string
VoteURL string
UnvotedURL string
}{&n.decision, n.voter.Name, voteURL, unvotedURL},
subject: n.decisionReplyBase.getSubject(),
headers: n.decisionReplyBase.getHeaders(),
recipients: []recipientData{n.notificationBase.getRecipient()},
}
}
type notificationWithDrawMotion struct {
notificationBase
decisionReplyBase
voter Voter
}
func NewNotificationWithDrawMotion(decision *Decision, voter *Voter) NotificationMail {
notification := &notificationWithDrawMotion{voter: *voter}
notification.decision = *decision
return notification
}
func (n *notificationWithDrawMotion) GetNotificationContent() *NotificationContent {
return &NotificationContent{
template: "withdraw_motion_mail.txt",
data: struct {
*Decision
Name string
}{&n.decision, n.voter.Name},
subject: fmt.Sprintf("Re: %s - %s - withdrawn", n.decision.Tag, n.decision.Title),
headers: n.decisionReplyBase.getHeaders(),
recipients: []recipientData{n.notificationBase.getRecipient()},
}
}
type RemindVoterNotification struct {
voter Voter
decisions []Decision
}
func (n *RemindVoterNotification) GetNotificationContent() *NotificationContent {
return &NotificationContent{
template: "remind_voter_mail.txt",
data: struct {
Decisions []Decision
Name string
BaseURL string
}{n.decisions, n.voter.Name, config.BaseURL},
subject: "Outstanding CAcert board votes",
recipients: []recipientData{{"To", n.voter.Reminder, n.voter.Name}},
}
}
type voteNotificationBase struct{}
func (n *voteNotificationBase) getRecipient() recipientData {
return recipientData{"To", config.VoteNoticeMailAddress, "CAcert board votes mailing list"}
}
type notificationProxyVote struct {
voteNotificationBase
decisionReplyBase
proxy Voter
voter Voter
vote Vote
justification string
}
func NewNotificationProxyVote(
decision *Decision,
proxy *Voter,
voter *Voter,
vote *Vote,
justification string,
) NotificationMail {
notification := &notificationProxyVote{proxy: *proxy, voter: *voter, vote: *vote, justification: justification}
notification.decision = *decision
return notification
}
func (n *notificationProxyVote) GetNotificationContent() *NotificationContent {
return &NotificationContent{
template: "proxy_vote_mail.txt",
data: struct {
Proxy string
Vote VoteChoice
Voter string
Decision *Decision
Justification string
}{n.proxy.Name, n.vote.Vote, n.voter.Name, &n.decision, n.justification},
subject: n.decisionReplyBase.getSubject(),
headers: n.decisionReplyBase.getHeaders(),
recipients: []recipientData{n.voteNotificationBase.getRecipient()},
}
}
type notificationDirectVote struct {
voteNotificationBase
decisionReplyBase
voter Voter
vote Vote
}
func NewNotificationDirectVote(decision *Decision, voter *Voter, vote *Vote) NotificationMail {
notification := &notificationDirectVote{voter: *voter, vote: *vote}
notification.decision = *decision
return notification
}
func (n *notificationDirectVote) GetNotificationContent() *NotificationContent {
return &NotificationContent{
template: "direct_vote_mail.txt",
data: struct {
Vote VoteChoice
Voter string
Decision *Decision
}{n.vote.Vote, n.voter.Name, &n.decision},
subject: n.decisionReplyBase.getSubject(),
headers: n.decisionReplyBase.getHeaders(),
recipients: []recipientData{n.voteNotificationBase.getRecipient()},
}
}

@ -1,4 +1,4 @@
{{ define "base" }}
{{ define "base" -}}
<!doctype html>
<html lang="en">
<head>

Loading…
Cancel
Save