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
This commit is contained in:
Jan Dittberner 2022-05-29 15:36:27 +02:00
parent 28ddbd2ce6
commit 368bd8eefb
35 changed files with 416 additions and 3406 deletions

5
.gitignore vendored
View file

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

File diff suppressed because it is too large Load diff

View file

@ -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" . }}

View file

@ -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" . }}

View file

@ -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" . }}

View file

@ -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" . }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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" . }}

View file

@ -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 }}

View file

@ -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" . }}

View file

@ -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 }}

View file

@ -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" . }}

View file

@ -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" . }}

View file

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

View file

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

View file

@ -19,12 +19,16 @@ package main
import ( import (
"database/sql" "database/sql"
"fmt"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path" "path"
"testing" "testing"
"time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/lestrrat-go/tcputil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -44,7 +48,41 @@ func prepareTestDb(t *testing.T) *sqlx.DB {
return dbx 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) { func TestApplication_healthCheck(t *testing.T) {
port := StartTestTcpServer(t)
t.Run("check with valid DB", func(t *testing.T) { t.Run("check with valid DB", func(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@ -55,8 +93,11 @@ func TestApplication_healthCheck(t *testing.T) {
app := &application{ 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) app.healthCheck(rr, r)
rs := rr.Result() rs := rr.Result()
@ -81,8 +122,11 @@ func TestApplication_healthCheck(t *testing.T) {
app := &application{ 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) app.healthCheck(rr, r)
rs := rr.Result() rs := rr.Result()

View file

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

View file

@ -36,11 +36,11 @@ type RemindVotersJob struct {
timer *time.Timer timer *time.Timer
voters *models.UserModel voters *models.UserModel
decisions *models.MotionModel decisions *models.MotionModel
notify chan NotificationMail notifier *MailNotifier
reschedule chan Job
} }
func (r *RemindVotersJob) Schedule() { 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 const reminderDays = 3
year, month, day := time.Now().UTC().Date() year, month, day := time.Now().UTC().Date()
@ -65,7 +65,7 @@ func (r *RemindVotersJob) Schedule() {
func (r *RemindVotersJob) Run() { func (r *RemindVotersJob) Run() {
r.infoLog.Print("running RemindVotersJob") r.infoLog.Print("running RemindVotersJob")
defer func(r *RemindVotersJob) { r.reschedule <- r }(r) defer func(r *RemindVotersJob) { r.Schedule() }(r)
var ( var (
voters []*models.User voters []*models.User
@ -75,7 +75,7 @@ func (r *RemindVotersJob) Run() {
ctx := context.Background() ctx := context.Background()
voters, err = r.voters.GetReminderVoters(ctx) voters, err = r.voters.ReminderVoters(ctx)
if err != nil { if err != nil {
r.errorLog.Printf("problem getting voters: %v", err) r.errorLog.Printf("problem getting voters: %v", err)
@ -85,7 +85,7 @@ func (r *RemindVotersJob) Run() {
for _, voter := range voters { for _, voter := range voters {
v := voter v := voter
decisions, err = r.decisions.UnVotedDecisionsForVoter(ctx, v) decisions, err = r.decisions.UnvotedForVoter(ctx, v)
if err != nil { if err != nil {
r.errorLog.Printf("problem getting unvoted decisions: %v", err) r.errorLog.Printf("problem getting unvoted decisions: %v", err)
@ -93,7 +93,7 @@ func (r *RemindVotersJob) Run() {
} }
if len(decisions) > 0 { if len(decisions) > 0 {
r.notify <- &RemindVoterNotification{voter: voter, decisions: decisions} r.notifier.Notify(&RemindVoterNotification{voter: voter, decisions: decisions})
} }
} }
} }
@ -106,16 +106,13 @@ func (r *RemindVotersJob) Stop() {
} }
} }
func (app *application) NewRemindVotersJob( func (app *application) NewRemindVotersJob() Job {
rescheduleChannel chan Job,
) Job {
return &RemindVotersJob{ return &RemindVotersJob{
infoLog: app.infoLog, infoLog: app.infoLog,
errorLog: app.errorLog, errorLog: app.errorLog,
voters: app.users, voters: app.users,
decisions: app.motions, decisions: app.motions,
reschedule: rescheduleChannel, notifier: app.mailNotifier,
notify: app.mailNotifier.notifyChannel,
} }
} }
@ -124,8 +121,7 @@ type CloseDecisionsJob struct {
infoLog *log.Logger infoLog *log.Logger
errorLog *log.Logger errorLog *log.Logger
decisions *models.MotionModel decisions *models.MotionModel
reschedule chan Job notifier *MailNotifier
notify chan NotificationMail
} }
func (c *CloseDecisionsJob) Schedule() { func (c *CloseDecisionsJob) Schedule() {
@ -136,7 +132,7 @@ func (c *CloseDecisionsJob) Schedule() {
ctx := context.Background() ctx := context.Background()
nextDue, err = c.decisions.NextPendingDecisionDue(ctx) nextDue, err = c.decisions.NextPendingDue(ctx)
if err != nil { if err != nil {
c.errorLog.Printf("could not get next pending due date") c.errorLog.Printf("could not get next pending due date")
@ -167,16 +163,23 @@ func (c *CloseDecisionsJob) Schedule() {
func (c *CloseDecisionsJob) Run() { func (c *CloseDecisionsJob) Run() {
c.infoLog.Printf("running CloseDecisionsJob") c.infoLog.Printf("running CloseDecisionsJob")
defer func(c *CloseDecisionsJob) { c.Schedule() }(c)
results, err := c.decisions.CloseDecisions(context.Background()) results, err := c.decisions.CloseDecisions(context.Background())
if err != nil { if err != nil {
c.errorLog.Printf("closing decisions failed: %v", err) c.errorLog.Printf("closing decisions failed: %v", err)
} }
for _, res := range results { 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.reschedule <- c c.notifier.Notify(&ClosedDecisionNotification{Decision: res})
}
} }
func (c *CloseDecisionsJob) Stop() { func (c *CloseDecisionsJob) Stop() {
@ -186,39 +189,43 @@ func (c *CloseDecisionsJob) Stop() {
} }
} }
func (app *application) NewCloseDecisionsJob( func (app *application) NewCloseDecisionsJob() Job {
rescheduleChannel chan Job,
) Job {
return &CloseDecisionsJob{ return &CloseDecisionsJob{
infoLog: app.infoLog, infoLog: app.infoLog,
errorLog: app.errorLog, errorLog: app.errorLog,
decisions: app.motions, decisions: app.motions,
reschedule: rescheduleChannel, notifier: app.mailNotifier,
notify: app.mailNotifier.notifyChannel,
} }
} }
type JobIdentifier int
const (
JobIDCloseDecisions JobIdentifier = iota
JobIDRemindVoters
)
type JobScheduler struct { type JobScheduler struct {
infoLogger *log.Logger infoLogger *log.Logger
errorLogger *log.Logger errorLogger *log.Logger
jobs []Job jobs map[JobIdentifier]Job
rescheduleChannel chan Job rescheduleChannel chan JobIdentifier
quitChannel chan struct{} quitChannel chan struct{}
} }
func (app *application) NewJobScheduler() { func (app *application) NewJobScheduler() {
rescheduleChannel := make(chan Job, 1) rescheduleChannel := make(chan JobIdentifier, 1)
app.jobScheduler = &JobScheduler{ app.jobScheduler = &JobScheduler{
infoLogger: app.infoLog, infoLogger: app.infoLog,
errorLogger: app.errorLog, errorLogger: app.errorLog,
jobs: make([]Job, 0, 2), jobs: make(map[JobIdentifier]Job, 2),
rescheduleChannel: rescheduleChannel, rescheduleChannel: rescheduleChannel,
quitChannel: make(chan struct{}), quitChannel: make(chan struct{}),
} }
app.jobScheduler.addJob(app.NewCloseDecisionsJob(rescheduleChannel)) app.jobScheduler.addJob(JobIDCloseDecisions, app.NewCloseDecisionsJob())
app.jobScheduler.addJob(app.NewRemindVotersJob(rescheduleChannel)) app.jobScheduler.addJob(JobIDRemindVoters, app.NewRemindVotersJob())
} }
func (js *JobScheduler) Schedule() { func (js *JobScheduler) Schedule() {
@ -228,8 +235,8 @@ func (js *JobScheduler) Schedule() {
for { for {
select { select {
case job := <-js.rescheduleChannel: case jobId := <-js.rescheduleChannel:
job.Schedule() js.jobs[jobId].Schedule()
case <-js.quitChannel: case <-js.quitChannel:
for _, job := range js.jobs { for _, job := range js.jobs {
job.Stop() job.Stop()
@ -242,10 +249,16 @@ func (js *JobScheduler) Schedule() {
} }
} }
func (js *JobScheduler) addJob(job Job) { func (js *JobScheduler) addJob(jobID JobIdentifier, job Job) {
js.jobs = append(js.jobs, job) js.jobs[jobID] = job
} }
func (js *JobScheduler) Quit() { func (js *JobScheduler) Quit() {
js.quitChannel <- struct{}{} js.quitChannel <- struct{}{}
} }
func (js *JobScheduler) Reschedule(jobIDs ...JobIdentifier) {
for i := range jobIDs {
js.rescheduleChannel <- jobIDs[i]
}
}

View file

@ -19,6 +19,7 @@ limitations under the License.
package main package main
import ( import (
"context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"database/sql" "database/sql"
@ -30,6 +31,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@ -104,12 +106,11 @@ func main() {
app := &application{ app := &application{
errorLog: errorLog, errorLog: errorLog,
infoLog: infoLog, infoLog: infoLog,
motions: &models.MotionModel{DB: db, InfoLog: infoLog}, motions: &models.MotionModel{DB: db},
users: &models.UserModel{DB: db}, users: &models.UserModel{DB: db},
mailConfig: config.MailConfig, mailConfig: config.MailConfig,
templateCache: templateCache, templateCache: templateCache,
sessionManager: sessionManager, sessionManager: sessionManager,
formDecoder: setupFormDecoder(),
} }
err = internal.InitializeDb(db.DB, infoLog) err = internal.InitializeDb(db.DB, infoLog)
@ -117,15 +118,18 @@ func main() {
errorLog.Fatal(err) errorLog.Fatal(err)
} }
app.setupFormDecoder()
app.NewMailNotifier() app.NewMailNotifier()
defer app.mailNotifier.Quit() defer app.mailNotifier.Quit()
go app.StartMailNotifier() go app.mailNotifier.Start()
app.NewJobScheduler() app.NewJobScheduler()
defer app.jobScheduler.Quit() defer app.jobScheduler.Quit()
go app.jobScheduler.Schedule() go app.jobScheduler.Schedule()
infoLog.Printf("Starting server on %s", config.HTTPAddress) infoLog.Printf("Starting server on %s", config.HTTPAddress)
errChan := make(chan error, 1) errChan := make(chan error, 1)
@ -144,7 +148,7 @@ func main() {
} }
} }
func setupFormDecoder() *form.Decoder { func (app *application) setupFormDecoder() {
decoder := form.NewDecoder() decoder := form.NewDecoder()
decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) { decoder.RegisterCustomTypeFunc(func(values []string) (interface{}, error) {
@ -163,8 +167,21 @@ func setupFormDecoder() *form.Decoder {
return v, nil return v, nil
}, new(models.VoteChoice)) }, 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)
}
return decoder u, err := app.users.ByID(context.Background(), int64(userID))
if err != nil {
return nil, fmt.Errorf("could not convert value %s to user: %w", values[0], err)
}
return u, nil
}, new(models.User))
app.formDecoder = decoder
} }
func (app *application) startHTTPSServer(config *Config) error { func (app *application) startHTTPSServer(config *Config) error {

View file

@ -68,9 +68,24 @@ func (app *application) authenticateRequest(r *http.Request) (*models.User, *x50
} }
clientCert := r.TLS.PeerCertificates[0] 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 emails := clientCert.EmailAddresses
user, err := app.users.GetUser(r.Context(), emails) user, err := app.users.ByEmails(r.Context(), emails)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("could not get user information from database: %w", err) 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 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) user, err := app.GetUser(r)
if err != nil { if err != nil {
return false, false, err return false, false, err
@ -126,16 +141,21 @@ func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, er
return false, false, nil return false, false, nil
} }
roleMatched, err := user.HasRole(roles) roleMatched, err := user.HasRole(roles...)
if err != nil { if err != nil {
return false, true, fmt.Errorf("could not determin user role assignment: %w", err) return false, true, fmt.Errorf("could not determin user role assignment: %w", err)
} }
if !roleMatched { if !roleMatched {
roleNames := make([]string, len(roles))
for idx := range roles {
roleNames[idx] = string(roles[idx])
}
app.errorLog.Printf( app.errorLog.Printf(
"user %s does not have any of the required role(s) %s assigned", "user %s does not have any of the required role(s) %s assigned",
user.Name, user.Name,
strings.Join(roles, ", "), strings.Join(roleNames, ", "),
) )
return false, true, nil return false, true, nil
@ -144,9 +164,9 @@ func (app *application) HasRole(r *http.Request, roles []string) (bool, bool, er
return true, true, nil 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) { 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 { if err != nil {
app.serverError(w, err) 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 { 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 { 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 { 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 { func noSurf(next http.Handler) http.Handler {

View file

@ -96,7 +96,7 @@ func TestApplication_tryAuthenticate(t *testing.T) {
users := &models.UserModel{DB: db} users := &models.UserModel{DB: db}
_, err = users.CreateUser( _, err = users.Create(
context.Background(), context.Background(),
"Test User", "Test User",
"test@example.org", "test@example.org",
@ -151,7 +151,10 @@ func TestApplication_tryAuthenticate(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/", nil) r, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err) 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) app.tryAuthenticate(next).ServeHTTP(rr, r)

View file

@ -20,6 +20,8 @@ package main
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"log"
"net"
"path" "path"
"text/template" "text/template"
@ -51,6 +53,8 @@ type MailNotifier struct {
senderAddress string senderAddress string
dialer *mail.Dialer dialer *mail.Dialer
quitChannel chan struct{} quitChannel chan struct{}
infoLog, errorLog *log.Logger
mailConfig *mailConfig
} }
func (app *application) NewMailNotifier() { func (app *application) NewMailNotifier() {
@ -59,20 +63,23 @@ func (app *application) NewMailNotifier() {
senderAddress: app.mailConfig.NotificationSenderAddress, senderAddress: app.mailConfig.NotificationSenderAddress,
dialer: mail.NewDialer(app.mailConfig.SMTPHost, app.mailConfig.SMTPPort, "", ""), dialer: mail.NewDialer(app.mailConfig.SMTPHost, app.mailConfig.SMTPPort, "", ""),
quitChannel: make(chan struct{}), quitChannel: make(chan struct{}),
infoLog: app.infoLog,
errorLog: app.errorLog,
mailConfig: app.mailConfig,
} }
} }
func (app *application) StartMailNotifier() { func (mn *MailNotifier) Start() {
app.infoLog.Print("Launching mail notifier") mn.infoLog.Print("Launching mail notifier")
for { for {
select { select {
case notification := <-app.mailNotifier.notifyChannel: case notification := <-mn.notifyChannel:
content := notification.GetNotificationContent(app.mailConfig) content := notification.GetNotificationContent(mn.mailConfig)
mailText, err := content.buildMail(app.mailConfig.BaseURL) mailText, err := content.buildMail(mn.mailConfig.BaseURL)
if err != nil { if err != nil {
app.errorLog.Printf("building mail failed: %v", err) mn.errorLog.Printf("building mail failed: %v", err)
continue continue
} }
@ -80,7 +87,7 @@ func (app *application) StartMailNotifier() {
m := mail.NewMessage() m := mail.NewMessage()
m.SetHeaders(content.headers) 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 { for _, recipient := range content.recipients {
m.SetAddressHeader(recipient.field, recipient.address, recipient.name) m.SetAddressHeader(recipient.field, recipient.address, recipient.name)
@ -90,22 +97,44 @@ func (app *application) StartMailNotifier() {
m.SetBody("text/plain", mailText.String()) m.SetBody("text/plain", mailText.String())
if err = app.mailNotifier.dialer.DialAndSend(m); err != nil { if err = mn.dialer.DialAndSend(m); err != nil {
app.errorLog.Printf("sending mail failed: %v", err) mn.errorLog.Printf("sending mail failed: %v", err)
} }
case <-app.mailNotifier.quitChannel: case <-mn.quitChannel:
app.infoLog.Print("ending mail notifier") mn.infoLog.Print("ending mail notifier")
return return
} }
} }
} }
func (m *MailNotifier) Quit() { func (mn *MailNotifier) Quit() {
m.quitChannel <- struct{}{} 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) { 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)) b, err := internal.MailTemplates.ReadFile(path.Join("mailtemplates", n.template))
if err != nil { if err != nil {
return nil, fmt.Errorf("could not read mail template %s: %w", n.template, err) 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 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 { func defaultRecipient(mc *mailConfig) recipientData {
return recipientData{ return recipientData{
field: "To", 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 { type ClosedDecisionNotification struct {
Decision *models.Motion Decision *models.Motion
} }

171
forms.go
View file

@ -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
}

7
go.mod
View file

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

12
go.sum
View file

@ -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/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/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/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 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/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.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.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.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 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 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.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/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.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.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.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.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-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.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/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 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.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/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/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= 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-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-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-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-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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View file

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

View file

@ -1,16 +1,16 @@
Dear Board, 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: {{ with .Data }}Motion:
{{ .Title}} {{ .Title}}
{{ .Content}} {{ .Content}}
Vote type: {{.VoteType}}{{end}} Vote type: {{ .Type}}{{end}}
{{ with .VoteSums }} Ayes: {{ .Ayes }} {{ with .Data.Sums }} Ayes: {{ .Ayes }}
Nayes: {{ .Nayes }} Nayes: {{ .Nayes }}
Abstentions: {{ .Abstains }} Abstentions: {{ .Abstains }}

View file

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

View file

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

View file

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

199
jobs.go
View file

@ -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}
}
}
}

876
models.go
View file

@ -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
}

View file

@ -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()},
}
}

View file

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